diff --git a/AGENTS.md b/AGENTS.md index bf4048b86d5..567cf7dbed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Bun-native TypeScript with no separate server compile step. through `tests/helpers/repo-root.ts` (`repoRoot()`, `repoPath()`, `helperPath()`, `fixturePath()`), never `import.meta.dir + "/.."`. A new test file lands in its domain directory and needs an entry in both - `layout.json` `explicit` and `tests/fixtures/test-layout-expected.json` + `layout.json` `explicit` and a `tests/fixtures/test-layout-expected*.json` shard (`tests/test-layout-tooling.test.ts` names the missing one); the regex seeds in `seeds.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. @@ -268,7 +268,7 @@ fails `file-size ratchet: repository` for your branch and for every branch cut f Two branches can each stay under a cap alone and sum over it together; that is what happened in #4908, #5011 and #5018. The remedy is always a move, never a number: put the new case in a sibling file, byte for byte, and register it in **both** -`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. +`scripts/test-layout/layout.json` and a `tests/fixtures/test-layout-expected*.json` shard. `d3ca5522db` is the original precedent. A moved test is not automatically the same test. One case moved out of diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 114376d265b..84828ea69bb 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -956,6 +956,131 @@ without requiring a separate Bun installation or a source checkout. Use `ocx codex-shim status` to inspect it and `ocx codex-shim uninstall` to restore the saved Codex launcher. +## Experimental Windows desktop compatibility + +**Codex Set → Desktop compatibility** provides local controls for a bounded compatibility +trial while retaining the native Codex login. This is an experimental relay, not a guarantee +that every Codex build or exhausted-account state can recover its composer. + +1. Inspect status, prepare the encrypted certificate and review its fingerprint before + registering trust. Windows may ask for confirmation. Trust applies to the current Windows + user and allows the local relay to handle `chatgpt.com` traffic. +2. Start observation. Save drafts, close Codex, then use **Open Codex** in this panel to launch + the packaged app with the managed connection. The panel never forcibly closes an existing app. +3. Only after eligible exhaustion is observed, **Run 3-minute trial** offers a separate account-wide + consent. It adjusts two UI flags across the signed-in account, not just the selected model. + Actual usage, credits, spending restrictions and server limits remain unchanged. A produced + response alone does not establish that the composer recovered. + +An original response showing available usage or a protected state ends the trial immediately. +If eligible exhaustion appears again, another explicit trial is required; the earlier consent +does not silently reactivate correction. + +**Return to observation** disarms correction. The three-minute limit bounds response correction, +not the time until Codex refreshes its display. Expiry and returning to observation request a +fresh usage response; the app may retain its previous display until that response arrives. +The panel shows this limitation before trial consent and while awaiting the original response; +it cannot confirm the app's cache refresh. **Stop service** closes its connections and leaves +the certificate for reuse; active connections may be interrupted. For renewal or trust removal, +stop the service and close Codex first. Renewal prepares a new untrusted certificate; review the +new fingerprint before registering it. An uncertain action is not automatically retried. +Certificates are restricted to TLS server authentication. If status shows `renewal-required`, +stop the service and close Codex, then renew the older certificate before starting again. +The native API URL must match the proxy's actual listener address and port. IPv4 and IPv6 +loopback addresses are not interchangeable here; ambiguous `localhost` aliases are refused. +Codex's automatic MCP connection refresh and configuration formatting do not invalidate +observation. Model, authentication, profile and routing changes still require a fresh +observation context; the service does not silently adopt those changes during a trial. + +The service preserves its PAC address and local connection ports for reuse after a restart. +With the same trusted certificate, an already configured app can reconnect through its cached +PAC; restarting the service never resumes a correction trial. If a saved port cannot be bound +or connection metadata is invalid, startup refuses rather than silently assigning another +address. The first successful start owns `codex-desktop-compatibility/connection.json` under +the OpenCodex home. Do not delete that file as a routine restart fix: a different address +requires launching the app with a fresh managed connection. + +Current support is limited to the assessed Windows build `26.924.2738.0`, native file-based +login. HTTP/HTTPS proxies and authenticated SOCKS5 proxies are supported through the shared +outbound policy; NO_PROXY can explicitly select a direct route. Failed proxy connections do not +fall back to direct egress. OpenCodex managed client mode is not yet supported by these controls. +For this desktop relay, HTTP(S) `ALL_PROXY` also applies when `HTTPS_PROXY` is unset. An invalid +`HTTPS_PROXY` refuses the operation instead of falling back to `ALL_PROXY`. +Unknown app builds refuse correction; the integration does not patch application files or +switch to login-free mode. +The native root config and its selected profile must route `openai_base_url` to this process's +actual local data listener. A different provider, destination or unsupported override refuses +observation startup. This check does not determine a conversation's selected model or prove +that project-local overrides are absent. If routing or the installed app build changes during +a trial, correction is refused before the next usage response; periodic checks also return +an idle trial to observation. Original responses and other app traffic continue to relay. +Native login-file replacement or token rotation also invalidates the trial, even when the +same account returns. Stop and start observation to bind the current login before another +explicit trial; old responses cannot authorize the replacement session. +Changes to the root Codex config (except its MCP server table and formatting), provider configuration, model aliases, routing profiles, +fallback, compaction or memory-model routes also invalidate the observation context. Restoring the previous +settings does not restore its consent: stop and start observation, then confirm a new trial +when eligible. This detects changes to those configured routes, not which model a conversation +actually selects; an unchanged mixed-provider configuration is not proof of provider isolation. + +After certificate preparation and a successful manual start, enable **Resume observation when +OpenCodex starts** in the same panel. The optional OpenCodex configuration +`"desktopCompatibility": { "startOnProxyStart": true }` resumes **observation only** when the model +proxy starts. Omission or `false` disables this automatic start. It reuses the saved endpoints +and trusted certificate, never launches Codex or enrolls trust, and never resumes an Apply trial. +Automatic observation waits for startup configuration sync to finish. Failed sync or a wait +longer than two minutes leaves observation off; inspect its status and start it manually after +resolving the startup problem. The model proxy continues to follow its own readiness policy. +If prerequisites are missing, observation stays off and the model proxy continues running. +Saving this preference does not start or stop the current service. Use its separate service +controls for immediate changes. If a save cannot be confirmed, refresh its status before retrying. + +A running service does not establish that Codex is connected through it. A normal app launch or +an app updater can omit the managed connection argument. Save drafts, close Codex, then use +**Open Codex** in this panel to restore that connection. This control never closes the app for you. +After a Codex update, an unassessed app version refuses correction until its compatibility is +reviewed; reinstalling the certificate does not make an unassessed build supported. + +On Windows, an explicit OpenCodex full-app restart preserves an already active loopback +compatibility PAC argument only when the current OpenCodex process owns the serving compatibility +runtime. A stale or foreign endpoint is refused before stopping Codex; replacement of the runtime +during restart also prevents managed relaunch. The app launches through Windows package activation. It checks the +package identity and routing argument after launch; unreadable or conflicting main-app routing arguments cause +a refusal before the restart. This does not enable a compatibility mode, install a certificate, +or watch and restart the app automatically. Normal launches without that routing argument keep +their existing behavior. + +### Transport coverage and recovery evidence + +The compatibility panel reports identity-checked JSON/SSE usage responses and currently +bound streams separately from open connections. These are lifetime relay observations, +including requests made by diagnostic clients. They do not identify the sending native +process, prove that the app updated its authoritative cache, or prove composer recovery. +No request body, token, account ID or source IP is added to this diagnostic status. + +[Issue #6196](https://github.com/lidge-jun/opencodex/issues/6196) reports a macOS build whose +authoritative gate requests originate in the bundled Rust app-server and bypass Chromium +PAC settings. A healthy PAC, certificate or synthetic request cannot establish coverage +of that transport. This Windows experiment is not a fix for that macOS design blocker. + +Before adding another platform or transport, document and validate this sequence: + +1. Identify the actual process and transport that sends each authoritative gate request. +2. Prove that an app-origin request reaches the intended relay, separately from test clients. +3. Verify the response schema and the app's cache/update path for the exact installed build. +4. On natural account exhaustion, verify original-composer submission, independent-provider + completion, and preservation of sign-in, Chat, existing threads and remote connections. +5. Verify shutdown, ordinary launch, restart and update behavior; do not infer one from another. + +The present policy handles only `/backend-api/wham/usage` and its `/stream` endpoint. +Conversation initialization and other endpoints, including their `blocked_features` and +`limits_progress`, pass unchanged. Their presence is not permission to clear them: they may +represent unrelated restrictions. The Windows build's renderer-to-Electron fetch path does +not establish the network path of a different OS/build or every app-server RPC. App-server +routing requires its own reviewed design; changing model-provider URLs alone is not proof +that account, login or remote-control traffic follows the same route. No automatic app-server +wrapper, global proxy change or login rewrite is installed by these controls. + ## Routed models during Codex reserve mode Codex Pool can optionally protect stored pool accounts at a selected 5-hour or weekly usage @@ -974,7 +1099,7 @@ When the ChatGPT 5-hour quota is exhausted, Codex may offer a reserve fallback m **every other entry unselectable — including opencodex routed models**, even though those run on independent providers and credentials and consume none of the exhausted quota. -**This is a Codex client behavior and the proxy cannot change it.** The reserve state +**This is a Codex client behavior that ordinary model-proxy routing cannot change.** The reserve state arrives from the ChatGPT backend on the client's own authenticated connection, not through the proxy. The desktop app polls `backend-api/wham/usage` and treats reserve as active when the response carries `rate_limit_upsell.banner_type = "luna_reserve"`, the primary diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 1378a13f20e..4e83b3b8750 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -88,6 +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. | 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 /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 | @@ -736,6 +739,81 @@ provider, account, path, or credential details; clients receive only the complet an account retains its selector binding so exact routes fail closed while the account is absent and the same selector is restored if that account id is added again. +## Windows compatibility certificate setup + +`GET /api/codex/desktop-compatibility/certificate` returns public certificate status: +support, state, fingerprint, expiry, renewal notice, trust observation and any active operation. +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. + +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 +expired root. Removal refuses while Codex is running or process ownership cannot be established. +Cancellation or uncertain OS command completion is checked against the actual certificate store. +Unknown state remains an error rather than authorizing an automatic retry. Replies contain no PEM, +private keys, account data or subprocess output. + +Renewal requires the current fingerprint and an absent Codex app. It verifies removal of +the old certificate's trust before publishing a validated encrypted replacement. Refused +or uncertain removal preserves the old identity. The replacement is prepared but untrusted; +register it with a separate `trust` confirmation using its new fingerprint. A stale retry +cannot replace the new identity again. No certificate backup chain is retained. + +This setup API does not enable a relay, change login, alter usage, restart Codex or install a watcher. +CurrentUser protection does not isolate secrets from other processes running as the same OS user. + +## 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`, +`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. + +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. +HTTP/HTTPS and authenticated SOCKS5 outbound proxies use the same explicit selection for HTTP, +identity verification and upgraded sockets. NO_PROXY is honored; failures never retry directly. +An invalid or unsupported setting returns `egress_proxy_invalid`. Start never registers a certificate or changes +login. Launch preserves package identity and refuses an app that is already running. +PAC and CONNECT ports are persisted after successful first binding and reused on restart. +`connection_unavailable` means binding failed; `connection_invalid` or `connection_changed` +means stored endpoint identity could not be accepted. These failures never silently rotate +ports or overwrite the existing connection record. Restart returns to Observe, not Apply. +`native_routing_unverified` means the native root/profile routing cannot be matched to this +process's bound OpenCodex listener. Runtime status may expose `contextFailure` for this condition +or `build_unverified`. These states disable response correction without claiming an app repair. + +Apply requires a recently observed eligible exhaustion snapshot and lasts at most three minutes +at the response layer. It changes two account-UI gate flags, not usage percentages, credits, +spending restrictions or server limits. The usage response does not identify the selected model: +this trial cannot promise an effect limited to external models. An accepted activation or produced +response does not prove the app accepted the new snapshot or enabled its composer. +An available or protected original usage record returns the controller to Observe and +invalidates pending corrections. Later exhaustion requires another explicit Apply request. +Changed root TOML or configured model/provider/fallback routing invalidates the runtime's +observation context until stop/start; reverting those settings does not reactivate it. + +Observe disarms correction and refreshes only validated usage streams. Stop closes this runtime's +listeners and connections; the PAC includes `DIRECT` fallback. The certificate stays installed for +reuse. Stop the runtime and close Codex before removing trust or renewing the certificate. +Failed cleanup reports `cleanup-required`; it does not claim that the runtime is off. OS login, +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 +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 +automatic POST retry. Invalid or missing configuration is preserved rather than recreated. + ## Choosing a client For ordinary administration, the [Web Dashboard](/guides/web-dashboard/) gives the safest guided diff --git a/gui/src/App.tsx b/gui/src/App.tsx index f6b49bfffde..188eb67e834 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -580,7 +580,7 @@ export default function App() { {page === "storage" && } {page === "remote" && !remotePairingRequired && navigateToPage("remote-workspace")} />} {page === "remote-workspace" && navigateToPage("remote")} />} - {page === "codex-set" && } + {page === "codex-set" && } {page === "claude" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 398873c58b1..8fe637afc03 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -145,7 +145,7 @@ export function hashBelongsToPage(rawHash: string, page: Page): boolean { || (page === "claude" && ["claude/account", "claude/code", "claude/desktop", "claude/settings"].includes(rawHash)) || (page === "logs" && rawHash === "logs/debug") || (page === "usage" && rawHash === "usage/companion") - || (page === "codex-set" && rawHash === "codex-set/prompt") + || (page === "codex-set" && (rawHash === "codex-set/prompt" || rawHash === "codex-set/desktop")) || (page === "models" && ( (MODELS_TAB_HASHES as readonly string[]).includes(rawHash) || rawHash === JEV_AUTO_CREATE_HASH diff --git a/gui/src/desktop-compatibility-api.ts b/gui/src/desktop-compatibility-api.ts new file mode 100644 index 00000000000..1d51c90b2aa --- /dev/null +++ b/gui/src/desktop-compatibility-api.ts @@ -0,0 +1,100 @@ +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; +} +export interface CompatibilityRuntime { + supported: boolean; phase: "off" | "starting" | "running" | "stopping" | "cleanup-required"; running: boolean; + contextFailure?: "build_unverified" | "native_routing_unverified" | null; + usage?: { mode: "observe" | "apply"; phase: string; outputs: number; appCacheConfirmed: false; + observation?: { jsonSnapshots: number; streamSnapshots: number; validatedActiveStreams: number; lastSnapshotAt: number | null; + sourceProcessVerified: false; composerRecoveryVerified: false } }; +} +export interface CompatibilitySnapshot { certificate: CompatibilityCertificate; runtime: CompatibilityRuntime } +export interface CompatibilityStartupSettings { startOnProxyStart: boolean; revision: string } +export type CompatibilityAction = + | { target: "certificate"; action: "prepare" | "trust" | "remove-trust" | "renew"; fingerprint?: string } + | { target: "runtime"; action: "start" | "stop" | "observe" | "apply" | "launch" }; +const ROOT = "/api/codex/desktop-compatibility/"; +const object = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +export class CompatibilityApiError extends Error { + readonly code: string; + constructor(code: string) { super(code); this.code = code; } +} +async function reply(response: Response): Promise> { + let value: unknown; try { value = await response.json(); } catch { throw new CompatibilityApiError("invalid_response"); } + if (!object(value)) throw new CompatibilityApiError("invalid_response"); + if (!response.ok || value.ok !== true) { + const candidate = value.error ?? (object(value.activation) ? value.activation.reason : undefined) + ?? (object(value.launch) ? value.launch.reason : undefined); + throw new CompatibilityApiError(typeof candidate === "string" && /^[a-z][a-z_-]{0,79}$/.test(candidate) ? candidate : "operation_unconfirmed"); + } + return value; +} +function parseStartupSettings(value: unknown): CompatibilityStartupSettings { + if (!object(value) || typeof value.startOnProxyStart !== "boolean" || typeof value.revision !== "string" || !/^[a-f0-9]{64}$/.test(value.revision)) throw new CompatibilityApiError("invalid_startup_settings"); + return { startOnProxyStart: value.startOnProxyStart, revision: value.revision }; +} +export async function readCompatibilityStartupSettings(apiBase: string, signal: AbortSignal): Promise { + return parseStartupSettings((await reply(await fetch(apiBase + ROOT + "settings", { signal }))).settings); +} +export async function saveCompatibilityStartupSettings(apiBase: string, current: CompatibilityStartupSettings, enabled: boolean, signal: AbortSignal): Promise { + return parseStartupSettings((await reply(await fetch(apiBase + ROOT + "settings", { method: "POST", signal, + headers: { "content-type": "application/json" }, body: JSON.stringify({ startOnProxyStart: enabled, revision: current.revision, confirmed: true }) }))).settings); +} +export function parseCompatibilityCertificate(value: unknown): CompatibilityCertificate { + if (!object(value) || typeof value.supported !== "boolean" || typeof value.state !== "string" || !["missing", "invalid", "expired", "renewal-required", "prepared", "trusted", "unknown"].includes(value.state) + || !(value.busy === null || typeof value.busy === "string" && ["prepare", "trust", "remove-trust", "renew"].includes(value.busy))) throw new CompatibilityApiError("invalid_certificate_status"); + 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, + ...(value.fingerprint === undefined ? {} : { fingerprint: value.fingerprint as string }), + ...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt as number }), renewalDue: value.renewalDue === true }; +} +export function parseCompatibilityRuntime(value: unknown): CompatibilityRuntime { + if (!object(value) || typeof value.supported !== "boolean" || typeof value.running !== "boolean" + || typeof value.phase !== "string" || !["off", "starting", "running", "stopping", "cleanup-required"].includes(value.phase) + || value.phase === "running" && !value.running || (value.phase === "off" || value.phase === "starting") && value.running) throw new CompatibilityApiError("invalid_runtime_status"); + const runtime: CompatibilityRuntime = { supported: value.supported, running: value.running, phase: value.phase as CompatibilityRuntime["phase"] }; + if (value.contextFailure !== undefined) { + if (value.contextFailure !== null && value.contextFailure !== "build_unverified" && value.contextFailure !== "native_routing_unverified") throw new CompatibilityApiError("invalid_runtime_status"); + runtime.contextFailure = value.contextFailure; + } + if (value.usage !== undefined) { + if (!object(value.usage) || typeof value.usage.mode !== "string" || !["observe", "apply"].includes(value.usage.mode) || typeof value.usage.phase !== "string" + || !/^[a-z-]{1,80}$/.test(value.usage.phase) || !Number.isSafeInteger(value.usage.outputs) || Number(value.usage.outputs) < 0 + || value.usage.appCacheConfirmed !== false) throw new CompatibilityApiError("invalid_runtime_status"); + runtime.usage = { mode: value.usage.mode as "observe" | "apply", phase: value.usage.phase, outputs: value.usage.outputs as number, appCacheConfirmed: false }; + if (value.usage.observation !== undefined) { + const observed = value.usage.observation; + if (!object(observed) || ![observed.jsonSnapshots, observed.streamSnapshots, observed.validatedActiveStreams].every(n => Number.isSafeInteger(n) && Number(n) >= 0) + || !(observed.lastSnapshotAt === null || typeof observed.lastSnapshotAt === "number" && Number.isSafeInteger(observed.lastSnapshotAt) && observed.lastSnapshotAt >= 0 && observed.lastSnapshotAt <= 8.64e15) + || observed.sourceProcessVerified !== false || observed.composerRecoveryVerified !== false) throw new CompatibilityApiError("invalid_runtime_status"); + runtime.usage.observation = { jsonSnapshots: observed.jsonSnapshots as number, streamSnapshots: observed.streamSnapshots as number, + validatedActiveStreams: observed.validatedActiveStreams as number, lastSnapshotAt: observed.lastSnapshotAt as number | null, + sourceProcessVerified: false, composerRecoveryVerified: false }; + } + } + return runtime; +} +export async function readCompatibilityRuntime(apiBase: string, signal: AbortSignal): Promise { + return parseCompatibilityRuntime((await reply(await fetch(apiBase + ROOT + "runtime", { signal }))).runtime); +} +export async function readCompatibilityCertificate(apiBase: string, signal: AbortSignal): Promise { + return parseCompatibilityCertificate((await reply(await fetch(apiBase + ROOT + "certificate", { signal }))).certificate); +} +export async function readCompatibilitySnapshot(apiBase: string, signal: AbortSignal): Promise { + const [certificate, runtime] = await Promise.all([ + readCompatibilityCertificate(apiBase, signal), + readCompatibilityRuntime(apiBase, signal), + ]); + return { certificate, runtime }; +} +/** Exactly one POST. Network ambiguity is resolved by a later status read, never automatic replay. */ +export async function runCompatibilityAction(apiBase: string, action: CompatibilityAction, signal: AbortSignal): Promise { + if (action.target === "certificate" && action.action !== "prepare" && (!action.fingerprint || !/^[A-F0-9]{64}$/.test(action.fingerprint))) throw new CompatibilityApiError("certificate_fingerprint_required"); + await reply(await fetch(apiBase + ROOT + action.target, { method: "POST", signal, headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: action.action, confirmed: true, + ...(action.target === "certificate" && action.action !== "prepare" ? { fingerprint: action.fingerprint } : {}), + ...(action.target === "runtime" && action.action === "apply" ? { accountWideConsent: true } : {}) }), + })); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fc9eee0c806..9c658548272 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1,3 +1,4 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; // German — generated from en.ts. Must match TKey set (compile-checked). import type { TKey } from "./en"; @@ -5,6 +6,7 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + ...desktopCompatibilityCopy("de"), "nav.claude": "Claude", "claude.tabAccount": "Konto", "claude.tabSettings": "Einstellungen", diff --git a/gui/src/i18n/desktop-compatibility-copy.ts b/gui/src/i18n/desktop-compatibility-copy.ts new file mode 100644 index 00000000000..94c40abbe3e --- /dev/null +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -0,0 +1,45 @@ +type Locale = "en" | "ko" | "de" | "fr" | "zh" | "zh-TW" | "ru" | "ja" | "tr" | "vi" | "pt"; +const locales: readonly Locale[] = ["en", "ko", "de", "fr", "zh", "zh-TW", "ru", "ja", "tr", "vi", "pt"]; +type Row = readonly [string, string, string, string, string, string, string, string, string, string, string]; +const rows = { + observation: ["Identity-checked usage responses: JSON {json}, SSE {sse}; active bound streams: {streams}. Counts do not identify the sending process or prove composer recovery.", "신원이 확인된 사용량 응답: JSON {json}, SSE {sse}; 연결된 검증 스트림: {streams}. 이 수치로 발신 프로세스나 입력창 복구를 확인할 수는 없습니다.", "Nutzungsantworten mit geprüfter Identität: JSON {json}, SSE {sse}; aktive zugeordnete Streams: {streams}. Die Zahlen belegen weder den sendenden Prozess noch die Wiederherstellung der Eingabe.", "Réponses d’usage à identité vérifiée : JSON {json}, SSE {sse} ; flux associés actifs : {streams}. Ces nombres ne prouvent ni le processus émetteur ni le rétablissement de la saisie.", "身份已验证的用量响应:JSON {json},SSE {sse};活动绑定流:{streams}。这些计数无法证明发送进程或输入框已恢复。", "身分已驗證的用量回應:JSON {json},SSE {sse};作用中的綁定串流:{streams}。這些計數無法證明傳送程序或輸入框已恢復。", "Ответы об использовании с проверенной идентичностью: JSON {json}, SSE {sse}; активные связанные потоки: {streams}. Счётчики не подтверждают процесс-отправитель или восстановление ввода.", "本人確認済みの使用量応答: JSON {json}、SSE {sse}。接続中の確認済みストリーム: {streams}。この数値だけでは送信プロセスや入力欄の復旧を確認できません。", "Kimliği doğrulanmış kullanım yanıtları: JSON {json}, SSE {sse}; etkin bağlı akışlar: {streams}. Sayılar gönderen süreci veya giriş alanının düzeldiğini kanıtlamaz.", "Phản hồi mức dùng đã xác minh danh tính: JSON {json}, SSE {sse}; luồng liên kết đang hoạt động: {streams}. Các số này không xác nhận tiến trình gửi hay ô nhập đã phục hồi.", "Respostas de uso com identidade verificada: JSON {json}, SSE {sse}; fluxos vinculados ativos: {streams}. As contagens não identificam o processo que enviou nem comprovam a recuperação do campo de texto."], + cacheRefreshHint: ["Stopping correction does not immediately reset the Codex display. Codex must receive fresh usage data; this panel cannot confirm that refresh.", "교정을 중단해도 Codex 화면이 즉시 원복되지는 않습니다. Codex가 새 사용량 데이터를 받아야 하며, 이 패널에서는 해당 갱신을 확인할 수 없습니다.", "Das Stoppen der Korrektur setzt die Codex-Anzeige nicht sofort zurück. Codex muss neue Nutzungsdaten empfangen; dieses Panel kann die Aktualisierung nicht bestätigen.", "L’arrêt de la correction ne rétablit pas immédiatement l’affichage de Codex. Codex doit recevoir de nouvelles données d’usage ; ce panneau ne peut pas confirmer cette actualisation.", "停止修正不会立即恢复 Codex 界面。Codex 必须收到新的用量数据;此面板无法确认是否已刷新。", "停止修正不會立即還原 Codex 畫面。Codex 必須收到新的用量資料;此面板無法確認是否已更新。", "Остановка коррекции не восстанавливает отображение Codex мгновенно. Codex должен получить свежие данные об использовании; эта панель не может подтвердить обновление.", "補正を停止しても Codex の表示はすぐには戻りません。Codex が新しい使用量データを受信する必要があり、このパネルでは更新を確認できません。", "Düzeltmeyi durdurmak Codex görünümünü hemen eski haline getirmez. Codex yeni kullanım verilerini almalıdır; bu panel yenilemeyi doğrulayamaz.", "Dừng hiệu chỉnh không khôi phục ngay giao diện Codex. Codex phải nhận dữ liệu sử dụng mới; bảng này không thể xác nhận việc làm mới.", "Interromper a correção não redefine imediatamente a exibição do Codex. O Codex precisa receber dados de uso atualizados; este painel não consegue confirmar essa atualização."], + routingChanged: ["Native Codex routing no longer matches this OpenCodex process. Correction is off; review the integration before restarting observation.", "Codex 기본 연결이 이 OpenCodex 프로세스와 일치하지 않습니다. 교정이 꺼졌습니다. 연동 설정을 확인한 뒤 관찰을 다시 시작하세요.", "Die native Codex-Verbindung passt nicht mehr zu diesem OpenCodex-Prozess. Korrektur ist aus; Integration vor einem Neustart prüfen.", "La connexion native Codex ne correspond plus à ce processus OpenCodex. La correction est désactivée ; vérifiez l’intégration avant de relancer l’observation.", "原生 Codex 路由与此 OpenCodex 进程不再匹配。修正已关闭;重新观察前请检查集成。", "原生 Codex 路由與此 OpenCodex 程序不再相符。修正已關閉;重新觀察前請檢查整合。", "Маршрут Codex больше не соответствует этому процессу OpenCodex. Коррекция выключена; проверьте интеграцию перед перезапуском наблюдения.", "Codex の接続先がこの OpenCodex プロセスと一致しません。補正はオフです。連携設定を確認してから観察を再開してください。", "Codex yönlendirmesi artık bu OpenCodex süreciyle eşleşmiyor. Düzeltme kapalı; gözlemi yeniden başlatmadan önce entegrasyonu kontrol edin.", "Định tuyến Codex không còn khớp tiến trình OpenCodex này. Hiệu chỉnh đã tắt; kiểm tra tích hợp trước khi quan sát lại.", "O roteamento nativo do Codex não corresponde mais a este processo do OpenCodex. A correção está desativada; revise a integração antes de retomar a observação."], + autoStart: ["Resume observation when OpenCodex starts", "OpenCodex 시작 시 관찰 자동 재개", "Beobachtung beim Start von OpenCodex fortsetzen", "Reprendre l’observation au démarrage d’OpenCodex", "OpenCodex 启动时恢复观察", "OpenCodex 啟動時恢復觀察", "Возобновлять наблюдение при запуске OpenCodex", "OpenCodex 起動時に観察を再開", "OpenCodex başladığında gözlemi sürdür", "Tiếp tục quan sát khi OpenCodex khởi động", "Retomar a observação quando o OpenCodex iniciar"], + autoStartHint: ["Applies at the next proxy start and requires an existing trusted certificate. Does not open Codex, register trust or start a correction trial. Turning this off does not stop a running service.", "다음 프록시 시작부터 적용되며 이미 신뢰된 인증서가 필요합니다. Codex 실행·신뢰 등록·복구 시험을 자동 수행하지 않습니다. 끄더라도 실행 중인 서비스는 중지하지 않습니다.", "Gilt ab dem nächsten Proxystart und erfordert ein bereits vertrauenswürdiges Zertifikat. Öffnet Codex nicht, registriert kein Vertrauen und startet keinen Korrekturtest. Ausschalten stoppt keinen laufenden Dienst.", "S’applique au prochain démarrage du proxy et nécessite un certificat déjà approuvé. N’ouvre pas Codex, n’enregistre pas de confiance et ne lance pas d’essai. La désactivation n’arrête pas un service actif.", "从下次代理启动起生效,需要已受信任的证书。不会打开 Codex、注册信任或启动修正试验。关闭此选项不会停止正在运行的服务。", "從下次代理啟動起生效,需要已受信任的憑證。不會開啟 Codex、註冊信任或啟動修正試驗。關閉此選項不會停止執行中的服務。", "Действует со следующего запуска прокси и требует доверенного сертификата. Не открывает Codex, не добавляет доверие и не запускает тест. Отключение не останавливает работающую службу.", "次回のプロキシ起動時から適用され、信頼済み証明書が必要です。Codex の起動・信頼登録・補正テストは自動実行しません。オフにしても実行中のサービスは停止しません。", "Sonraki proxy başlangıcında uygulanır; önceden güvenilen sertifika gerekir. Codex’i açmaz, güven kaydetmez veya deneme başlatmaz. Kapatmak çalışan hizmeti durdurmaz.", "Áp dụng từ lần khởi động proxy tiếp theo và cần chứng chỉ đã tin cậy. Không mở Codex, đăng ký tin cậy hay chạy thử hiệu chỉnh. Tắt tùy chọn không dừng dịch vụ đang chạy.", "Aplica-se na próxima inicialização do proxy e requer um certificado já confiável. Não abre o Codex, registra confiança nem inicia um teste de correção. Desativar esta opção não interrompe um serviço em execução."], + connectionUnavailable: ["The saved local connection could not be opened. Its address was not changed automatically.", "저장된 로컬 연결을 열지 못했습니다. 연결 주소를 자동으로 변경하지 않았습니다.", "Die gespeicherte lokale Verbindung konnte nicht geöffnet werden. Ihre Adresse wurde nicht automatisch geändert.", "La connexion locale enregistrée n’a pas pu être ouverte. Son adresse n’a pas été modifiée automatiquement.", "无法打开保存的本地连接。未自动更改其地址。", "無法開啟儲存的本機連線。並未自動變更其位址。", "Не удалось открыть сохранённое локальное соединение. Его адрес автоматически не менялся.", "保存されたローカル接続を開けませんでした。接続先を自動変更していません。", "Kaydedilen yerel bağlantı açılamadı. Adresi otomatik değiştirilmedi.", "Không mở được kết nối cục bộ đã lưu. Địa chỉ không được tự động thay đổi.", "Não foi possível abrir a conexão local salva. O endereço não foi alterado automaticamente."], + blockedByBuild: ["This Codex build has not been assessed. Compatibility correction is unavailable; refresh alone will not enable it.", "현재 Codex 빌드는 아직 검증되지 않았습니다. 호환 교정을 사용할 수 없으며, 새로고침만으로 활성화되지는 않습니다.", "Dieser Codex-Build wurde noch nicht geprüft. Die Kompatibilitätskorrektur ist nicht verfügbar; Aktualisieren allein aktiviert sie nicht.", "Cette version de Codex n’a pas été évaluée. La correction est indisponible ; une actualisation seule ne l’activera pas.", "此 Codex 版本尚未评估。兼容修正不可用,刷新不会启用它。", "此 Codex 版本尚未評估。相容修正無法使用,重新整理不會啟用它。", "Эта сборка Codex не проверена. Коррекция недоступна; обновление состояния её не включит.", "この Codex ビルドは未検証です。互換補正は利用できず、状態更新だけでは有効になりません。", "Bu Codex derlemesi henüz değerlendirilmedi. Uyumluluk düzeltmesi kullanılamaz; yenileme bunu açmaz.", "Bản Codex này chưa được đánh giá. Chưa thể hiệu chỉnh; làm mới không tự bật tính năng.", "Esta versão do Codex ainda não foi avaliada. A correção de compatibilidade não está disponível; atualizar o status não a habilitará."], + blockedByProxy: ["The outbound proxy setting is invalid or unsupported. Correct it before starting observation; no direct fallback was used.", "외부 프록시 설정이 잘못되었거나 지원되지 않습니다. 관찰 시작 전에 설정을 확인하세요. 직접 연결로 우회하지 않았습니다.", "Die Proxy-Einstellung ist ungültig oder nicht unterstützt. Vor dem Start korrigieren; keine direkte Ersatzverbindung wurde genutzt.", "Le proxy sortant est invalide ou non pris en charge. Corrigez-le avant l’observation ; aucun repli direct n’a été utilisé.", "出站代理设置无效或不受支持。观察前请修正;未回退到直接连接。", "輸出代理設定無效或不受支援。觀察前請修正;並未改用直接連線。", "Настройка прокси неверна или не поддерживается. Исправьте её до наблюдения; прямое подключение не использовалось.", "送信プロキシ設定が無効または未対応です。観察開始前に修正してください。直接接続への切り替えは行っていません。", "Çıkış proxy ayarı geçersiz veya desteklenmiyor. Gözlemden önce düzeltin; doğrudan bağlantıya geçilmedi.", "Cấu hình proxy đầu ra không hợp lệ hoặc chưa hỗ trợ. Sửa trước khi quan sát; không chuyển sang kết nối trực tiếp.", "A configuração do proxy de saída é inválida ou não é compatível. Corrija-a antes de iniciar a observação; não foi usado acesso direto como alternativa."], + closeApp: ["Save drafts, close Codex, then refresh status before trying again.", "초안을 저장하고 Codex를 닫은 뒤 상태를 새로고침하여 재시도하세요.", "Entwürfe speichern, Codex schließen und vor dem nächsten Versuch den Status aktualisieren.", "Enregistrez les brouillons, fermez Codex, puis actualisez l’état avant de réessayer.", "保存草稿并关闭 Codex,然后刷新状态再重试。", "儲存草稿並關閉 Codex,然後重新整理狀態再重試。", "Сохраните черновики, закройте Codex и обновите состояние перед повтором.", "下書きを保存して Codex を閉じ、状態を更新してから再試行してください。", "Taslakları kaydedip Codex’i kapatın; yeniden denemeden önce durumu yenileyin.", "Lưu bản nháp, đóng Codex rồi làm mới trạng thái trước khi thử lại.", "Salve os rascunhos, feche o Codex e atualize o status antes de tentar novamente."], + connectedUnavailable: ["These local controls are unavailable in OpenCodex managed client mode.", "OpenCodex 관리형 클라이언트 모드에서는 이 로컬 제어를 사용할 수 없습니다.", "Diese lokalen Steuerelemente sind im verwalteten Clientmodus von OpenCodex nicht verfügbar.", "Ces commandes locales ne sont pas disponibles en mode client géré OpenCodex.", "OpenCodex 托管客户端模式不提供这些本地控制。", "OpenCodex 受管理用戶端模式不提供這些本機控制。", "Эти локальные действия недоступны в управляемом клиентском режиме OpenCodex.", "OpenCodex の管理対象クライアントモードでは、このローカル操作を利用できません。", "Bu yerel denetimler OpenCodex yönetilen istemci modunda kullanılamaz.", "Các điều khiển cục bộ này không có trong chế độ máy khách được quản lý của OpenCodex.", "Estes controles locais não estão disponíveis no modo de cliente gerenciado do OpenCodex."], + title: ["Desktop compatibility", "데스크톱 호환", "Desktop-Kompatibilität", "Compatibilité du bureau", "桌面兼容", "桌面相容", "Совместимость приложения", "デスクトップ互換性", "Masaüstü uyumluluğu", "Tương thích ứng dụng", "Compatibilidade com o desktop"], + description: ["Experimental Windows integration. Keeps the Codex login; setup alone does not enable correction.", "실험적인 Windows 연동입니다. Codex 로그인을 유지하며, 준비만으로 교정이 켜지지 않습니다.", "Experimentelle Windows-Integration. Die Codex-Anmeldung bleibt erhalten; die Einrichtung aktiviert keine Korrektur.", "Intégration Windows expérimentale. La connexion Codex est conservée ; la préparation seule n’active pas la correction.", "实验性 Windows 集成。保留 Codex 登录;仅完成准备不会启用修正。", "實驗性 Windows 整合。保留 Codex 登入;僅完成準備不會啟用修正。", "Экспериментальная интеграция Windows. Вход в Codex сохраняется; подготовка не включает коррекцию.", "実験的な Windows 連携です。Codex のログインを保持し、準備だけでは補正を有効にしません。", "Deneysel Windows entegrasyonu. Codex oturumu korunur; hazırlık tek başına düzeltmeyi açmaz.", "Tích hợp Windows thử nghiệm. Giữ đăng nhập Codex; chuẩn bị không tự bật hiệu chỉnh.", "Integração experimental com Windows. Mantém o login do Codex; apenas preparar não ativa a correção."], + refresh: ["Refresh status", "상태 새로고침", "Status aktualisieren", "Actualiser l’état", "刷新状态", "重新整理狀態", "Обновить состояние", "状態を更新", "Durumu yenile", "Làm mới trạng thái", "Atualizar status"], + prepare: ["Prepare certificate", "인증서 준비", "Zertifikat vorbereiten", "Préparer le certificat", "准备证书", "準備憑證", "Подготовить сертификат", "証明書を準備", "Sertifikayı hazırla", "Chuẩn bị chứng chỉ", "Preparar certificado"], + trust: ["Register trust", "신뢰 등록", "Vertrauen registrieren", "Enregistrer la confiance", "注册信任", "註冊信任", "Добавить доверие", "信頼を登録", "Güveni kaydet", "Đăng ký tin cậy", "Registrar confiança"], + remove: ["Remove trust", "신뢰 해제", "Vertrauen entfernen", "Retirer la confiance", "移除信任", "移除信任", "Удалить доверие", "信頼を解除", "Güveni kaldır", "Gỡ tin cậy", "Remover confiança"], + renew: ["Renew certificate", "인증서 갱신", "Zertifikat erneuern", "Renouveler le certificat", "更新证书", "更新憑證", "Обновить сертификат", "証明書を更新", "Sertifikayı yenile", "Gia hạn chứng chỉ", "Renovar certificado"], + start: ["Start observation", "관찰 시작", "Beobachtung starten", "Démarrer l’observation", "开始观察", "開始觀察", "Начать наблюдение", "観察を開始", "Gözlemi başlat", "Bắt đầu quan sát", "Iniciar observação"], + stop: ["Stop service", "서비스 중지", "Dienst stoppen", "Arrêter le service", "停止服务", "停止服務", "Остановить службу", "サービスを停止", "Hizmeti durdur", "Dừng dịch vụ", "Parar serviço"], + launch: ["Open Codex", "Codex 열기", "Codex öffnen", "Ouvrir Codex", "打开 Codex", "開啟 Codex", "Открыть Codex", "Codex を開く", "Codex’i aç", "Mở Codex", "Abrir Codex"], + observe: ["Return to observation", "관찰 모드로 복귀", "Zur Beobachtung zurück", "Revenir à l’observation", "返回观察", "返回觀察", "Вернуться к наблюдению", "観察に戻る", "Gözleme dön", "Trở lại quan sát", "Voltar à observação"], + apply: ["Run 3-minute trial", "3분 복구 시험", "3-Minuten-Test starten", "Essai de 3 minutes", "运行 3 分钟试验", "執行 3 分鐘試驗", "Запустить тест на 3 минуты", "3分間のテスト", "3 dakikalık deneme", "Thử nghiệm 3 phút", "Executar teste de 3 minutos"], + confirm: ["Confirm action", "동작 확인", "Aktion bestätigen", "Confirmer l’action", "确认操作", "確認操作", "Подтвердить действие", "操作を確認", "İşlemi onayla", "Xác nhận thao tác", "Confirmar ação"], + acknowledge: ["I understand the scope of this action.", "이 동작의 적용 범위를 이해했습니다.", "Ich verstehe den Umfang dieser Aktion.", "Je comprends la portée de cette action.", "我了解此操作的范围。", "我了解此操作的範圍。", "Я понимаю область действия.", "この操作の範囲を理解しました。", "Bu işlemin kapsamını anlıyorum.", "Tôi hiểu phạm vi thao tác này.", "Entendo o escopo desta ação."], + risk: ["Trust applies to this Windows user and allows the local relay to handle chatgpt.com traffic. Stop the service and close Codex before renewal or removal. Stopping may interrupt active connections.", "이 Windows 사용자에게 인증서 신뢰가 적용되어 로컬 릴레이가 chatgpt.com 트래픽을 처리합니다. 갱신·해제 전 서비스를 중지하고 Codex를 닫으세요. 중지하면 진행 중인 연결이 끊길 수 있습니다.", "Das Vertrauen gilt für diesen Windows-Benutzer und erlaubt dem lokalen Relay, chatgpt.com-Verkehr zu verarbeiten. Vor Erneuerung oder Entfernung Dienst stoppen und Codex schließen. Ein Stopp kann aktive Verbindungen unterbrechen.", "La confiance s’applique à cet utilisateur Windows et autorise le relais local à traiter le trafic chatgpt.com. Arrêtez le service et fermez Codex avant renouvellement ou suppression. L’arrêt peut interrompre les connexions actives.", "信任适用于此 Windows 用户,允许本地中继处理 chatgpt.com 流量。更新或移除前请停止服务并关闭 Codex。停止可能中断活动连接。", "信任適用於此 Windows 使用者,允許本機中繼處理 chatgpt.com 流量。更新或移除前請停止服務並關閉 Codex。停止可能中斷使用中的連線。", "Доверие действует для этого пользователя Windows и позволяет локальному ретранслятору обрабатывать трафик chatgpt.com. Перед обновлением или удалением остановите службу и закройте Codex. Остановка может прервать соединения.", "信頼はこの Windows ユーザーに適用され、ローカルリレーが chatgpt.com の通信を処理します。更新・解除前にサービスを停止し Codex を閉じてください。停止すると接続が中断される場合があります。", "Güven bu Windows kullanıcısı için geçerlidir ve yerel aktarıcının chatgpt.com trafiğini işlemesine izin verir. Yenilemeden veya kaldırmadan önce hizmeti durdurup Codex’i kapatın. Durdurma etkin bağlantıları kesebilir.", "Tin cậy áp dụng cho người dùng Windows này, cho phép bộ chuyển tiếp cục bộ xử lý lưu lượng chatgpt.com. Dừng dịch vụ và đóng Codex trước khi gia hạn hoặc gỡ. Dừng có thể ngắt kết nối đang dùng.", "A confiança se aplica a este usuário do Windows e permite que o relay local processe o tráfego de chatgpt.com. Pare o serviço e feche o Codex antes de renovar ou remover o certificado. Parar pode interromper conexões ativas."], + trialRisk: ["For up to 3 minutes, two UI flags are adjusted for the whole signed-in account, across models. Usage, credits and server limits remain unchanged. Observed exhaustion is required; a response does not prove the composer recovered.", "최대 3분 동안 모델 구분 없이 로그인 계정 전체의 UI 표시 두 항목을 교정합니다. 사용량·크레딧·서버 한도는 유지됩니다. 실제 소진 관찰이 필요하며, 응답 생성만으로 입력창 복구가 입증되지는 않습니다.", "Bis zu 3 Minuten werden zwei UI-Werte für das gesamte angemeldete Konto modellübergreifend angepasst. Nutzung, Guthaben und Serverlimits bleiben unverändert. Eine beobachtete Erschöpfung ist erforderlich; eine Antwort beweist keine Wiederherstellung der Eingabe.", "Pendant 3 minutes au maximum, deux indicateurs d’interface sont ajustés pour tout le compte connecté, tous modèles confondus. Usage, crédits et limites serveur restent inchangés. Un épuisement observé est requis ; une réponse ne prouve pas le rétablissement de la saisie.", "最多 3 分钟内,调整整个已登录账户的两个界面标志,影响所有模型。用量、余额和服务器限制不变。需要观察到额度耗尽;收到响应不代表输入框已恢复。", "最多 3 分鐘內,調整整個已登入帳戶的兩個介面旗標,影響所有模型。用量、點數和伺服器限制不變。必須觀察到額度耗盡;收到回應不代表輸入框已恢復。", "Не более 3 минут корректируются два флага интерфейса всего аккаунта для всех моделей. Использование, кредиты и лимиты сервера не меняются. Требуется наблюдаемое исчерпание; ответ не доказывает восстановление ввода.", "最大3分間、ログイン中のアカウント全体でモデルを区別せず2つのUIフラグを補正します。使用量・クレジット・サーバー制限は変更しません。上限到達の観察が必要で、応答だけでは入力欄の復旧を証明できません。", "En fazla 3 dakika boyunca tüm modellerde oturum açmış hesabın iki arayüz bayrağı düzeltilir. Kullanım, krediler ve sunucu sınırları değişmez. Kotanın tükendiğinin gözlenmesi gerekir; yanıt, giriş alanının düzeldiğini kanıtlamaz.", "Trong tối đa 3 phút, điều chỉnh hai cờ giao diện của toàn bộ tài khoản đã đăng nhập, cho mọi mô hình. Mức dùng, tín dụng và giới hạn máy chủ không đổi. Cần quan sát thấy hết hạn mức; phản hồi không chứng minh ô nhập đã phục hồi.", "Por até 3 minutos, dois indicadores da interface são ajustados para toda a conta conectada, em todos os modelos. Uso, créditos e limites do servidor permanecem inalterados. É necessário observar o esgotamento; uma resposta não comprova que o campo de texto foi recuperado."], + error: ["Completion could not be confirmed. Refresh status before retrying.", "완료 여부를 확인하지 못했습니다. 재시도 전에 상태를 새로고침하세요.", "Abschluss nicht bestätigt. Vor einem erneuten Versuch Status aktualisieren.", "Fin non confirmée. Actualisez l’état avant de réessayer.", "无法确认是否完成。重试前请刷新状态。", "無法確認是否完成。重試前請重新整理狀態。", "Завершение не подтверждено. Обновите состояние перед повтором.", "完了を確認できませんでした。再試行前に状態を更新してください。", "Tamamlandığı doğrulanamadı. Yeniden denemeden önce durumu yenileyin.", "Chưa xác nhận hoàn tất. Làm mới trạng thái trước khi thử lại.", "Não foi possível confirmar a conclusão. Atualize o status antes de tentar novamente."], + done: ["Action confirmed. Status refreshed.", "동작을 확인하고 상태를 새로고침했습니다.", "Aktion bestätigt. Status aktualisiert.", "Action confirmée. État actualisé.", "操作已确认,状态已刷新。", "操作已確認,狀態已重新整理。", "Действие подтверждено. Состояние обновлено.", "操作を確認し、状態を更新しました。", "İşlem doğrulandı. Durum yenilendi.", "Đã xác nhận thao tác và làm mới trạng thái.", "Ação confirmada. Status atualizado."], + certificate: ["Certificate", "인증서", "Zertifikat", "Certificat", "证书", "憑證", "Сертификат", "証明書", "Sertifika", "Chứng chỉ", "Certificado"], + runtime: ["Service", "서비스", "Dienst", "Service local", "服务", "服務", "Служба", "サービス", "Hizmet", "Dịch vụ", "Serviço"], + expiry: ["Expires", "만료", "Ablauf", "Expiration", "到期", "到期", "Истекает", "有効期限", "Bitiş", "Hết hạn", "Expira"], + renewalDue: ["Certificate renewal is due soon.", "인증서 갱신 시기가 다가옵니다.", "Zertifikat bald erneuern.", "Le certificat doit bientôt être renouvelé.", "证书即将需要更新。", "憑證即將需要更新。", "Скоро потребуется обновить сертификат.", "証明書の更新時期が近づいています。", "Sertifika yakında yenilenmeli.", "Chứng chỉ sắp cần gia hạn.", "O certificado precisa ser renovado em breve."], + unsupported: ["Available on supported Windows installations only.", "지원되는 Windows 설치에서만 사용할 수 있습니다.", "Nur für unterstützte Windows-Installationen.", "Disponible uniquement sur les installations Windows prises en charge.", "仅适用于受支持的 Windows 安装。", "僅適用於受支援的 Windows 安裝。", "Доступно только для поддерживаемых установок Windows.", "対応する Windows 環境でのみ利用できます。", "Yalnızca desteklenen Windows kurulumlarında kullanılabilir.", "Chỉ dùng được trên bản cài Windows được hỗ trợ.", "Disponível apenas em instalações Windows compatíveis."], + localOnly: ["Changes require this computer’s local dashboard. Save drafts and close Codex before launching it here.", "변경은 이 컴퓨터의 로컬 대시보드에서 가능합니다. 여기서 앱을 실행하기 전에 초안을 저장하고 Codex를 닫으세요.", "Änderungen erfordern das lokale Dashboard dieses Computers. Vor dem Start Entwürfe speichern und Codex schließen.", "Les modifications nécessitent le tableau de bord local de cet ordinateur. Enregistrez les brouillons et fermez Codex avant de le lancer ici.", "更改需要此计算机的本地仪表板。在此启动前,请保存草稿并关闭 Codex。", "變更需要此電腦的本機儀表板。在此啟動前,請儲存草稿並關閉 Codex。", "Изменения требуют локальной панели этого компьютера. Сохраните черновики и закройте Codex перед запуском здесь.", "変更にはこのPCのローカルダッシュボードが必要です。ここから起動する前に下書きを保存し Codex を閉じてください。", "Değişiklikler bu bilgisayarın yerel panosunu gerektirir. Buradan açmadan önce taslakları kaydedip Codex’i kapatın.", "Thay đổi cần bảng điều khiển cục bộ của máy này. Lưu bản nháp và đóng Codex trước khi mở tại đây.", "As alterações exigem o painel local deste computador. Salve os rascunhos e feche o Codex antes de abri-lo aqui."], + reconnectHint: ["A running service does not confirm that Codex is connected. After a normal app launch or update, close Codex and use Open Codex here to restore the managed connection. An unreviewed app version cannot run a correction trial.", "서비스 실행만으로 Codex 연결이 확인되지는 않습니다. 앱을 일반 실행하거나 업데이트한 뒤에는 Codex를 닫고 여기의 Codex 열기로 관리 연결을 복구하세요. 검토되지 않은 앱 버전에서는 교정 시험이 차단됩니다.", "Ein laufender Dienst bestätigt keine Codex-Verbindung. Nach normalem Start oder Update Codex schließen und hier über Codex öffnen die verwaltete Verbindung wiederherstellen. Eine ungeprüfte App-Version kann keinen Korrekturtest ausführen.", "Un service actif ne confirme pas la connexion de Codex. Après un lancement normal ou une mise à jour, fermez Codex puis ouvrez-le ici pour rétablir la connexion gérée. Une version non vérifiée ne peut pas lancer d’essai de correction.", "服务运行不代表 Codex 已连接。正常启动或更新应用后,请关闭 Codex 并使用此处的打开 Codex 恢复受管连接。未经验证的应用版本无法运行修正试验。", "服務執行不代表 Codex 已連線。一般啟動或更新應用程式後,請關閉 Codex 並使用此處的開啟 Codex 恢復受管理連線。未經驗證的應用程式版本無法執行修正試驗。", "Работа службы не подтверждает подключение Codex. После обычного запуска или обновления закройте Codex и откройте его здесь для восстановления управляемого подключения. Непроверенная версия не допускается к пробной корректировке.", "サービスの稼働だけでは Codex の接続を確認できません。通常起動や更新後は Codex を閉じ、ここから開いて管理接続を復元してください。未検証のアプリ版では補正試験を実行できません。", "Hizmetin çalışması Codex bağlantısını doğrulamaz. Normal açılış veya güncellemeden sonra Codex’i kapatıp yönetilen bağlantıyı geri getirmek için buradan açın. İncelenmemiş uygulama sürümü düzeltme denemesini çalıştıramaz.", "Dịch vụ chạy không xác nhận Codex đã kết nối. Sau khi mở ứng dụng bình thường hoặc cập nhật, hãy đóng Codex rồi mở từ đây để khôi phục kết nối được quản lý. Phiên bản chưa được kiểm tra không thể chạy thử điều chỉnh.", "Um serviço em execução não confirma que o Codex está conectado. Após abrir o app normalmente ou atualizá-lo, feche o Codex e use Abrir Codex aqui para restaurar a conexão gerenciada. Uma versão do app ainda não avaliada não pode executar um teste de correção."], +} as const satisfies Record; + +export function desktopCompatibilityCopy(locale: Locale): Record<`desktopCompat.${keyof typeof rows}`, string> { + const index = locales.indexOf(locale); + return Object.fromEntries(Object.entries(rows).map(([key, values]) => [`desktopCompat.${key}`, values[index]!])) as Record<`desktopCompat.${keyof typeof rows}`, string>; +} diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 045601993e1..2781f29bc4d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1,3 +1,4 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; // English — source of truth. Its keys define the TKey type; de/fr/ko/zh/zh-TW/ru/ja/tr must match (compile-checked). // Strings with {cmd} render a chip via ; {var} are plain interpolations. /** @@ -6,6 +7,7 @@ * `{var}` are plain interpolations. */ export const en = { + ...desktopCompatibilityCopy("en"), "nav.claude": "Claude", "claude.tabAccount": "Account", "claude.tabSettings": "Settings", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index eb629193027..249b0e63ee1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1,9 +1,11 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + ...desktopCompatibilityCopy("fr"), "nav.claude": "Claude", "claude.tabAccount": "Compte", "claude.tabSettings": "Paramètres", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a298a02b15e..9de14d34f66 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1,9 +1,11 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + ...desktopCompatibilityCopy("ja"), "nav.claude": "Claude", "claude.tabAccount": "アカウント", "claude.tabSettings": "設定", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d32503633ed..679b504bf22 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1,9 +1,11 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + ...desktopCompatibilityCopy("ko"), "nav.claude": "Claude", "claude.tabAccount": "계정", "claude.tabSettings": "기타 설정", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 8916a78ba48..b5a67f0aa03 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -1,3 +1,4 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; // Brazilian Portuguese — generated from en.ts. Must match TKey set (compile-checked). import type { TKey } from "./en"; @@ -6,6 +7,7 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const pt: Record = { + ...desktopCompatibilityCopy("pt"), "compactionRouting.sources": "Origens", "compactionRouting.sourcesAll": "Todos os modelos de conversa", "compactionRouting.sourcesSelected": "Somente origens selecionadas", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2dd8ef7f73f..0eddc3bcebc 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1,9 +1,11 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + ...desktopCompatibilityCopy("ru"), "nav.claude": "Claude", "claude.tabAccount": "Аккаунт", "claude.tabSettings": "Настройки", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 050daecfe11..ca537fa8b5c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1,3 +1,4 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; // Turkish — generated from en.ts. Must match TKey set (compile-checked). import type { TKey } from "./en"; @@ -5,6 +6,7 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + ...desktopCompatibilityCopy("tr"), "nav.claude": "Claude", "claude.tabAccount": "Hesap", "claude.tabSettings": "Ayarlar", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 3074945f070..658b3218599 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1,3 +1,4 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; // Vietnamese — generated from en.ts. Must match TKey set (compile-checked). import type { TKey } from "./en"; @@ -6,6 +7,7 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + ...desktopCompatibilityCopy("vi"), "nav.claude": "Claude", "claude.tabAccount": "Tài khoản", "claude.tabSettings": "Cài đặt", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8159f43a3bb..8a378ab75be 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1,7 +1,9 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + ...desktopCompatibilityCopy("zh-TW"), "nav.claude": "Claude", "claude.tabAccount": "帳戶", "claude.tabSettings": "設定", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 31764564d3c..46bef5b32b0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1,9 +1,11 @@ +import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; import type { TKey } from "./en"; /** * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + ...desktopCompatibilityCopy("zh"), "nav.claude": "Claude", "claude.tabAccount": "账户", "claude.tabSettings": "设置", diff --git a/gui/src/pages/CodexSet.tsx b/gui/src/pages/CodexSet.tsx index 444b6a7ad6e..7fd05fc2f5d 100644 --- a/gui/src/pages/CodexSet.tsx +++ b/gui/src/pages/CodexSet.tsx @@ -2,18 +2,19 @@ import { useEffect, useState } from "react"; import { useT } from "../i18n/shared"; import CodexSetMultiauth from "./codex-set-multiauth"; import CodexSetPrompt from "./codex-set-prompt"; +import CodexDesktopCompatibility from "./codex-desktop-compatibility"; import { codexSetTabKeyDown, readCodexSetTabFromHash, selectCodexSetTab } from "./codex-set-tab"; /** * Codex Set — the page that configures Codex as a whole, not just its accounts. * - * Two exclusive tabpanels shaped like Logs/Debug rather than the scrolling + * Exclusive tabpanels shaped like Logs/Debug rather than the scrolling * SectionTabs strip: Multi-auth and Prompt are unrelated surfaces, and Multi-auth * polls /api/codex-auth/* on a 30s timer that has no business running while the * user is editing prompts. Prompt lazy-mounts on first visit and stays mounted * afterwards, so hopping between tabs does not refetch either side. */ -export default function CodexSet({ apiBase }: { apiBase: string }) { +export default function CodexSet({ apiBase, machineApiBase = apiBase, connected = false }: { apiBase: string; machineApiBase?: string; connected?: boolean }) { const t = useT(); const [tab, setTab] = useState(readCodexSetTabFromHash); const [promptMounted, setPromptMounted] = useState(() => readCodexSetTabFromHash() === "prompt"); @@ -22,6 +23,7 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { // account poll behind a hidden panel - exactly the cost this shell was shaped to // avoid. Both panels now mount on first selection and stay mounted after. const [multiauthMounted, setMultiauthMounted] = useState(() => readCodexSetTabFromHash() === "multiauth"); + const [desktopMounted, setDesktopMounted] = useState(() => readCodexSetTabFromHash() === "desktop"); useEffect(() => { const onHash = () => setTab(readCodexSetTabFromHash()); @@ -37,6 +39,8 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { const showMultiauth = multiauthMounted || tab === "multiauth"; if (showPrompt !== promptMounted) setPromptMounted(true); if (showMultiauth !== multiauthMounted) setMultiauthMounted(true); + const showDesktop = desktopMounted || tab === "desktop"; + if (showDesktop !== desktopMounted) setDesktopMounted(true); return ( <> @@ -67,6 +71,10 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { > {t("codexSet.tab.prompt")} + {showPrompt && ( @@ -90,6 +98,9 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { )} + {showDesktop && } ); } diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx new file mode 100644 index 00000000000..83df10b9068 --- /dev/null +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -0,0 +1,126 @@ +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, + type CompatibilityAction, type CompatibilitySnapshot } from "../desktop-compatibility-api"; +import { setClientResourceData, useClientResource } from "../client-resource"; +import DesktopCompatibilityStartupSetting from "./desktop-compatibility-startup-setting"; + +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; +const errorLabel = { build_unverified: "desktopCompat.blockedByBuild", egress_proxy_invalid: "desktopCompat.blockedByProxy", + renewal_required: "desktopCompat.renew", + native_routing_unverified: "desktopCompat.routingChanged", + connection_unavailable: "desktopCompat.connectionUnavailable", connection_invalid: "desktopCompat.connectionUnavailable", connection_changed: "desktopCompat.connectionUnavailable", + app_running: "desktopCompat.closeApp", local_dashboard_confirmation_required: "desktopCompat.localOnly" } as const; + +function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: boolean }) { + const { t, locale } = useI18n(); + 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 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 }); + const runtime = useClientResource(runtimeKey, signal => readCompatibilityRuntime(apiBase, signal), { enabled: active, deadlineMs: 10000, staleAfterMs: 0, pollMs: 5000 }); + const snapshot: CompatibilitySnapshot | null = certificate.data && runtime.data ? { certificate: certificate.data, runtime: runtime.data } : null; + const fresh = !needsRefresh && certificate.lastAttemptOk && runtime.lastAttemptOk && !certificate.refreshing && !runtime.refreshing; + 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); + useEffect(() => { + const current = lifecycle.current; current.alive = true; + return () => { 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; + 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); + try { + if (action) await runCompatibilityAction(apiBase, action, op.signal); + 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); + } + } catch (cause) { + if (current.alive && current.generation === generation) { setActionError(cause instanceof CompatibilityApiError ? cause.code : "connection_unconfirmed"); setChoice(null); setAcknowledged(false); } + } finally { + current.operations.delete(op); op.clear(); + if (current.generation === generation) { current.pending = false; if (current.alive) setMutating(false); } + } + }, [apiBase, certificateKey, runtimeKey]); + + const available = !!snapshot?.certificate.supported && !!snapshot.runtime.supported; + const idle = snapshot?.runtime.phase === "off" && !snapshot.certificate.busy; + const running = snapshot?.runtime.phase === "running"; + const actions: CompatibilityAction[] = []; + if (snapshot && available) { + const cert = snapshot.certificate; + if (idle && cert.state === "missing") actions.push({ target: "certificate", action: "prepare" }); + if (idle && cert.fingerprint) { + if (cert.state === "prepared") actions.push({ target: "certificate", action: "trust", fingerprint: cert.fingerprint }); + // Unknown trust may still need fingerprint-verified cleanup; invalid keys cannot be acted on. + if (["trusted", "expired", "renewal-required", "unknown"].includes(cert.state)) actions.push({ target: "certificate", action: "remove-trust", fingerprint: cert.fingerprint }); + if (["prepared", "trusted", "expired", "renewal-required"].includes(cert.state)) actions.push({ target: "certificate", action: "renew", fingerprint: cert.fingerprint }); + } + if (idle && cert.state === "trusted") actions.push({ target: "runtime", action: "start" }); + if (running) actions.push({ target: "runtime", action: "launch" }, { target: "runtime", action: "observe" }, { target: "runtime", action: "apply" }); + if (running || snapshot.runtime.phase === "cleanup-required") actions.push({ target: "runtime", action: "stop" }); + } + return
+

{t("desktopCompat.title")}

+

{t("desktopCompat.description")}

+

{t("desktopCompat.localOnly")}

+ + + {error &&

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

{error}
} + {done &&

{t("desktopCompat.done")}

} + {snapshot && <> + {!available &&

{t("desktopCompat.unsupported")}

} +
+
{t("desktopCompat.certificate")}
{snapshot.certificate.state}
+ {snapshot.certificate.fingerprint &&
{snapshot.certificate.fingerprint}
} + {snapshot.certificate.expiresAt !== undefined && <>
{t("desktopCompat.expiry")}
{new Date(snapshot.certificate.expiresAt).toLocaleString(locale)}
} +
{t("desktopCompat.runtime")}
{snapshot.runtime.phase}{snapshot.runtime.usage && <> · {snapshot.runtime.usage.mode}}
+
+ {snapshot.certificate.renewalDue &&

{t("desktopCompat.renewalDue")}

} + {snapshot.runtime.usage?.mode === "apply" &&

{t("desktopCompat.trialRisk")}

} + {snapshot.runtime.usage?.mode === "observe" && snapshot.runtime.usage.phase.endsWith("awaiting-original-response") &&

{t("desktopCompat.cacheRefreshHint")}

} + {snapshot.runtime.contextFailure &&

{t(errorLabel[snapshot.runtime.contextFailure])}

} + {running &&

{t("desktopCompat.reconnectHint")}

} + {snapshot.runtime.usage?.observation &&

{t("desktopCompat.observation", { + json: snapshot.runtime.usage.observation.jsonSnapshots, sse: snapshot.runtime.usage.observation.streamSnapshots, + streams: snapshot.runtime.usage.observation.validatedActiveStreams, + })}

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

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

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

{t("desktopCompat.cacheRefreshHint")}

} + {choice.target === "certificate" && choice.fingerprint && {choice.fingerprint}} + +
+ + +
+
} +
; +} + +export default function CodexDesktopCompatibility(props: { apiBase: string; active: boolean; connected?: boolean }) { + const { t } = useI18n(); + if (props.connected) return

{t("desktopCompat.title")}

{t("desktopCompat.connectedUnavailable")}

; + // Never carry a certificate fingerprint or outstanding confirmation to another host. + return ; +} diff --git a/gui/src/pages/codex-set-tab.ts b/gui/src/pages/codex-set-tab.ts index 06efaa893ab..193adfb6a66 100644 --- a/gui/src/pages/codex-set-tab.ts +++ b/gui/src/pages/codex-set-tab.ts @@ -1,30 +1,28 @@ import type { KeyboardEvent } from "react"; /** - * Tab state for the Codex Set page, shaped exactly like Logs/Debug: two exclusive + * Tab state for the Codex Set page, shaped exactly like Logs/Debug: exclusive * tabpanels whose choice lives in the hash, not in component state alone. That is * what makes the tab survive a refresh, a bookmark, and back/forward — and it is * the pattern devlog 004 §A3 identifies as the one the ask actually names. */ -export type CodexSetTab = "multiauth" | "prompt"; +export type CodexSetTab = "multiauth" | "prompt" | "desktop"; +const tabs: readonly CodexSetTab[] = ["multiauth", "prompt", "desktop"]; export function readCodexSetTabFromHash(): CodexSetTab { - return window.location.hash.replace(/^#\/?/, "") === "codex-set/prompt" ? "prompt" : "multiauth"; + const hash = window.location.hash.replace(/^#\/?/, ""); + return hash === "codex-set/desktop" ? "desktop" : hash === "codex-set/prompt" ? "prompt" : "multiauth"; } export function selectCodexSetTab(next: CodexSetTab): void { - window.location.hash = next === "prompt" ? "codex-set/prompt" : "codex-set"; + window.location.hash = next === "multiauth" ? "codex-set" : `codex-set/${next}`; } export function codexSetTabKeyDown(e: KeyboardEvent): void { - if (e.key === "ArrowLeft" || e.key === "Home") { - e.preventDefault(); - selectCodexSetTab("multiauth"); - document.getElementById("codex-set-tab-multiauth")?.focus(); - } else if (e.key === "ArrowRight" || e.key === "End") { - e.preventDefault(); - selectCodexSetTab("prompt"); - document.getElementById("codex-set-tab-prompt")?.focus(); - } + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return; + e.preventDefault(); + const current = tabs.indexOf(readCodexSetTabFromHash()); + const index = e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : (current + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + const next = tabs[index]!; selectCodexSetTab(next); + document.getElementById(`codex-set-tab-${next}`)?.focus(); } - diff --git a/gui/src/pages/desktop-compatibility-startup-setting.tsx b/gui/src/pages/desktop-compatibility-startup-setting.tsx new file mode 100644 index 00000000000..82f86ed3502 --- /dev/null +++ b/gui/src/pages/desktop-compatibility-startup-setting.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +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"; + +export default function DesktopCompatibilityStartupSetting({ apiBase, active }: { apiBase: string; active: 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 }); + useEffect(() => { const owned = ownership.current; owned.alive = true; + return () => { owned.alive = false; owned.operation?.controller.abort(); owned.operation?.clear(); }; }, []); + async function run(toggle: boolean) { + const owner = ownership.current; + if (owner.pending || (toggle && !resource.data)) return; + owner.pending = true; setBusy(true); setError(null); + const op = createBoundedFetch(15000); owner.operation = op; + try { + if (toggle) await saveCompatibilityStartupSettings(apiBase, resource.data!, !resource.data!.startOnProxyStart, op.signal); + const actual = await readCompatibilityStartupSettings(apiBase, op.signal); + if (owner.alive) { 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); } + } + const readError = resource.error instanceof CompatibilityApiError ? resource.error.code : resource.error ? "connection_unconfirmed" : null; + return
+ void run(true)} /> +

{t("desktopCompat.autoStartHint")}

+ {(error || readError) &&

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

} + {(uncertain || readError) && } +
; +} diff --git a/gui/tests/codex-set-shell.test.tsx b/gui/tests/codex-set-shell.test.tsx index b105b97640a..f41d61be8e7 100644 --- a/gui/tests/codex-set-shell.test.tsx +++ b/gui/tests/codex-set-shell.test.tsx @@ -99,14 +99,14 @@ function json(value: unknown, status = 200): Response { return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); } -async function mountShell(): Promise<{ root: Root; container: HTMLElement }> { +async function mountShell(apiBase = "", machineApiBase = apiBase): Promise<{ root: Root; container: HTMLElement }> { const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); document.body.append(container); let root!: Root; await act(async () => { root = createRoot(container); - root.render(); + root.render(); }); return { root, container }; } @@ -123,7 +123,7 @@ async function mountPrompt(): Promise<{ root: Root; container: HTMLElement }> { return { root, container }; } -function panel(container: HTMLElement, name: "multiauth" | "prompt"): HTMLElement | null { +function panel(container: HTMLElement, name: "multiauth" | "prompt" | "desktop"): HTMLElement | null { return container.querySelector("#codex-set-panel-" + name); } @@ -135,6 +135,25 @@ test("1. #codex-set renders Multi-auth, and Prompt is not mounted", async () => expect(multi!.hasAttribute("hidden")).toBe(false); // Case 4: Prompt does not mount until first visited. expect(panel(container, "prompt")).toBeNull(); + expect(panel(container, "desktop")).toBeNull(); + await act(async () => { root.unmount(); }); +}); + +test("desktop compatibility deep link mounts only its read-only status surface", async () => { + testWindow.location.hash = "#codex-set/desktop"; + const calls = stubRoutes(call => json(call.url.endsWith("/settings") ? { ok: true, settings: { startOnProxyStart: false, revision: "a".repeat(64) } } : call.url.endsWith("/certificate") + ? { ok: true, certificate: { supported: true, state: "missing", busy: null } } + : { ok: true, runtime: { supported: true, phase: "off", running: false } })); + const { container, root } = await mountShell("/shared", "/machine"); + expect(hashBelongsToPage("codex-set/desktop", "codex-set")).toBe(true); + expect(resolveAppHashChange("codex-set/desktop").replaceTo).toBeNull(); + expect(panel(container, "desktop")?.hasAttribute("hidden")).toBe(false); + expect(panel(container, "multiauth")).toBeNull(); expect(panel(container, "prompt")).toBeNull(); + expect(new Set(calls.map(call => call.url))).toEqual(new Set([ + "/machine/api/codex/desktop-compatibility/settings", "/machine/api/codex/desktop-compatibility/certificate", + "/machine/api/codex/desktop-compatibility/runtime", + ])); + expect(calls.every(call => call.method === "GET" && call.url.startsWith("/machine/api/codex/desktop-compatibility/"))).toBe(true); await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/desktop-compatibility-api.test.ts b/gui/tests/desktop-compatibility-api.test.ts new file mode 100644 index 00000000000..e6735a77a2e --- /dev/null +++ b/gui/tests/desktop-compatibility-api.test.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, test } from "bun:test"; +import { parseCompatibilityCertificate, parseCompatibilityRuntime, runCompatibilityAction } from "../src/desktop-compatibility-api"; +import { DICTS } from "../src/i18n/catalogs"; +const originalFetch = globalThis.fetch; +test("observation counters never upgrade response observations into native recovery proof", () => { + const observation = { jsonSnapshots: 1, streamSnapshots: 3, validatedActiveStreams: 1, lastSnapshotAt: 1000, + sourceProcessVerified: false, composerRecoveryVerified: false }; + const status = { supported: true, phase: "running", running: true, + usage: { mode: "observe", phase: "observing", outputs: 0, appCacheConfirmed: false, observation } }; + expect(parseCompatibilityRuntime(status).usage?.observation).toEqual(observation); + for (const invalid of [{ ...observation, sourceProcessVerified: true }, { ...observation, composerRecoveryVerified: true }, + { ...observation, streamSnapshots: -1 }, { ...observation, jsonSnapshots: "1" }, { ...observation, lastSnapshotAt: Infinity }]) { + expect(() => parseCompatibilityRuntime({ ...status, usage: { ...status.usage, observation: invalid } })).toThrow("invalid_runtime_status"); + } +}); +afterEach(() => { globalThis.fetch = originalFetch; }); +test("status projection excludes private/unknown data and rejects coerced states", () => { + const certificate = parseCompatibilityCertificate({ supported: true, state: "trusted", busy: null, fingerprint: "A".repeat(64), privateKey: "fixture-secret" }); + expect(JSON.stringify(certificate)).not.toContain("fixture-secret"); + expect(() => parseCompatibilityCertificate({ supported: true, state: ["trusted"], busy: null })).toThrow(); + expect(() => parseCompatibilityRuntime({ supported: true, phase: "off", running: true })).toThrow(); + expect(() => parseCompatibilityRuntime({ supported: true, phase: ["running"], running: true })).toThrow(); + expect(parseCompatibilityRuntime({ supported: true, phase: "running", running: true, contextFailure: "native_routing_unverified" }).contextFailure).toBe("native_routing_unverified"); + expect(() => parseCompatibilityRuntime({ supported: true, phase: "running", running: true, contextFailure: "untrusted-arbitrary-message" })).toThrow(); +}); +test("certificate actions bind the displayed fingerprint and never retry uncertain writes", async () => { + const calls: RequestInit[] = []; + globalThis.fetch = (async (_input, init) => { calls.push(init!); throw new TypeError("connection lost"); }) as typeof fetch; + await expect(runCompatibilityAction("", { target: "certificate", action: "trust", fingerprint: "A".repeat(64) }, new AbortController().signal)).rejects.toThrow(); + expect(calls).toHaveLength(1); + expect(JSON.parse(String(calls[0]!.body))).toEqual({ action: "trust", confirmed: true, fingerprint: "A".repeat(64) }); + await expect(runCompatibilityAction("", { target: "certificate", action: "renew" }, new AbortController().signal)).rejects.toThrow("certificate_fingerprint_required"); + expect(calls).toHaveLength(1); +}); +test("only the deliberate apply action carries account-wide consent", async () => { + const bodies: unknown[] = []; + globalThis.fetch = (async (_input, init) => { bodies.push(JSON.parse(String(init!.body))); return Response.json({ ok: true }); }) as typeof fetch; + await runCompatibilityAction("", { target: "runtime", action: "start" }, new AbortController().signal); + await runCompatibilityAction("", { target: "runtime", action: "apply" }, new AbortController().signal); + expect(bodies).toEqual([{ action: "start", confirmed: true }, { action: "apply", confirmed: true, accountWideConsent: true }]); +}); +test("every supported locale contains the complete compatibility consent namespace", () => { + const keys = Object.keys(DICTS.en).filter(key => key.startsWith("desktopCompat.")); + expect(keys.length).toBeGreaterThan(20); + for (const [locale, catalog] of Object.entries(DICTS)) { + expect(Object.keys(catalog).filter(key => key.startsWith("desktopCompat."))).toEqual(keys); + for (const key of keys) expect((catalog as Record)[key]?.length).toBeGreaterThan(0); + if (locale !== "en") expect(catalog["desktopCompat.trialRisk"]).not.toBe(DICTS.en["desktopCompat.trialRisk"]); + } +}); diff --git a/gui/tests/desktop-compatibility-panel.test.tsx b/gui/tests/desktop-compatibility-panel.test.tsx new file mode 100644 index 00000000000..3f0d3485a4a --- /dev/null +++ b/gui/tests/desktop-compatibility-panel.test.tsx @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, StrictMode } from "react"; +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"; + +const keys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record, page: Window, root: Root | undefined; +const originalFetch = globalThis.fetch; +beforeEach(() => { + clearClientResourceStoresForTests(); + 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"); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: key === "IS_REACT_ACT_ENVIRONMENT" ? true : Reflect.get(page, key) }); +}); +afterEach(async () => { + if (root) await act(async () => root!.unmount()); root = undefined; + 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) { + requests.length = 0; let trusted = false, startup = false; + globalThis.fetch = (async (input, init) => { + const url = String(input), method = init?.method ?? "GET"; + requests.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (url.endsWith("/settings")) { + if (method === "POST") { startup = JSON.parse(String(init!.body)).startOnProxyStart; if (uncertain) throw new TypeError("uncertain response"); } + 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, + fingerprint: (url.startsWith("/second") ? "B" : "A").repeat(64) } } : { ok: true, runtime: { supported: true, phase: usagePhase ? "running" : "off", running: !!usagePhase, + ...(usagePhase ? { usage: { mode: "observe", phase: usagePhase, outputs: 0, appCacheConfirmed: false, ...(observation ? { observation } : {}) } } : {}) } }); + }) as typeof fetch; +} +async function mount(apiBase = "") { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); document.body.append(container); root = createRoot(container); + await render(apiBase); return container; +} +async function render(apiBase: string, connected = false) { + await act(async () => { root!.render(); }); +} +function button(container: HTMLElement, text: string): HTMLButtonElement { + const value = [...container.querySelectorAll("button")].find(node => node.textContent === text); + if (!value) throw new Error("Missing button: " + text); return value; +} +async function chooseAndConfirm(container: HTMLElement) { + await act(async () => button(container, "Register trust").click()); + expect(requests.filter(req => req.method === "POST")).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()); +} +test("StrictMode status reads are inert and a fingerprint-bound action needs explicit consent", async () => { + server(); const container = await mount(); + expect(button(container, "Register trust").disabled).toBe(false); + expect(requests.every(req => req.method === "GET")).toBe(true); + await chooseAndConfirm(container); + const posts = requests.filter(req => req.method === "POST"); expect(posts).toHaveLength(1); + expect(posts[0]!.body).toEqual({ action: "trust", confirmed: true, fingerprint: "A".repeat(64) }); + expect(button(container, "Start observation").disabled).toBe(false); + await act(async () => button(container, "Start observation").click()); + expect(requests.filter(req => req.method === "POST").at(-1)?.body).toEqual({ action: "start", confirmed: true }); + expect(container.querySelector("fieldset")).toBeNull(); +}); + +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."); + expect(container.textContent).toContain("this panel cannot confirm that refresh."); + expect(requests.every(req => req.method === "GET")).toBe(true); +}); + +test("observed response counts are displayed without claiming a native process or recovered composer", async () => { + server(false, "trusted", "observing", { jsonSnapshots: 2, streamSnapshots: 3, validatedActiveStreams: 1, lastSnapshotAt: 1000, + sourceProcessVerified: false, composerRecoveryVerified: false }); + const container = await mount(); + expect(container.textContent).toContain("JSON 2, SSE 3; active bound streams: 1"); + expect(container.textContent).toContain("Counts do not identify the sending process or prove composer recovery."); + expect(requests.every(req => req.method === "GET")).toBe(true); +}); + +test("trial confirmation explains delayed native refresh before any correction request", async () => { + server(false, "trusted", "observing"); const container = await mount(); + expect(container.textContent).not.toContain("Stopping correction does not immediately reset the Codex display."); + await act(async () => button(container, "Run 3-minute trial").click()); + expect(container.querySelector("fieldset")?.textContent).toContain("Codex must receive fresh usage data"); + expect(button(container, "Confirm action").disabled).toBe(true); + expect(requests.every(req => req.method === "GET")).toBe(true); +}); + +test.each([ + ["prepared", false, true], ["trusted", true, true], ["expired", true, true], ["renewal-required", true, true], ["unknown", true, false], ["invalid", false, false], +] as const)("certificate state %s exposes only supported cleanup and renewal actions", async (state, remove, renew) => { + server(false, state); const container = await mount(); + const labels = [...container.querySelectorAll("button")].map(value => value.textContent); + expect(labels.includes("Remove trust")).toBe(remove); expect(labels.includes("Renew certificate")).toBe(renew); + if (state === "renewal-required") expect(labels.includes("Start observation")).toBe(false); + expect(requests.filter(value => value.method === "POST")).toHaveLength(0); +}); +test("a lost write response disables replay until a fresh read proves the actual state", async () => { + server(true); const container = await mount(); await chooseAndConfirm(container); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + expect(button(container, "Register trust").disabled).toBe(true); + expect(requests.filter(req => req.method === "POST")).toHaveLength(1); + await act(async () => button(container, "Refresh status").click()); + expect(button(container, "Start observation").disabled).toBe(false); + expect(requests.filter(req => req.method === "POST")).toHaveLength(1); +}); +test("changing target drops an outstanding certificate confirmation", 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 render("/second"); + expect(container.querySelector("fieldset")).toBeNull(); expect(container.textContent).toContain("B".repeat(64)); + expect(requests.filter(req => req.method === "POST")).toHaveLength(0); +}); +test("managed client mode never falls back to mutating the shared hub", async () => { + server(); const container = await mount(); requests.length = 0; + await render("/machine", true); + expect(container.textContent).toContain("unavailable in OpenCodex managed client mode"); + expect(requests).toHaveLength(0); expect(container.querySelector("button")).toBeNull(); +}); +test("auto-start toggle writes once with the displayed revision and rechecks the saved state", async () => { + server(); const container = await mount(); + const toggle = button(container, "Resume observation when OpenCodex starts"); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + const posts = requests.filter(req => req.method === "POST"); expect(posts).toHaveLength(1); + expect(posts[0]!.url).toEndWith("/settings"); expect(posts[0]!.body).toEqual({ confirmed: true, startOnProxyStart: true, revision: "a".repeat(64) }); + expect(requests.some(req => req.method === "POST" && req.url.endsWith("/runtime"))).toBe(false); +}); +test("an uncertain auto-start write is not replayed and refresh recovers its committed state", async () => { + server(true); const container = await mount(); + const toggle = button(container, "Resume observation when OpenCodex starts"); + await act(async () => toggle.click()); expect(toggle.disabled).toBe(true); + const setting = toggle.closest(".panel")!; + await act(async () => button(setting as HTMLElement, "Refresh status").click()); + expect(toggle.disabled).toBe(false); expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(requests.filter(req => req.method === "POST")).toHaveLength(1); +}); diff --git a/gui/tests/sidebar-codex-set.test.ts b/gui/tests/sidebar-codex-set.test.ts index 1d7c8b4217b..ec6f3963ad8 100644 --- a/gui/tests/sidebar-codex-set.test.ts +++ b/gui/tests/sidebar-codex-set.test.ts @@ -32,7 +32,7 @@ test("Codex Set is always present in the sidebar, never filtered by view mode", * written to catch. The entry's identity is its id and its label key. */ expect(src).toContain('{ id: "codex-set", tkey: "nav.codexSet", Icon:'); - expect(src).toContain('{page === "codex-set" && }'); + expect(src).toContain('{page === "codex-set" && }'); }); test("the shipped #codex-auth bookmark still resolves", async () => { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 39c908bd9fd..0eaef2c6f9e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -802,6 +802,10 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", + "desktop-compatibility-authority.test.ts": "clients", "desktop-compatibility-certificate-service.test.ts": "clients", "desktop-compatibility-trust.test.ts": "clients", "desktop-compatibility-build-probe.test.ts": "clients", "desktop-compatibility-native-identity.test.ts": "clients", + "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-routing.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "desktop-compatibility-launch.test.ts": "clients", + "management-desktop-compatibility-routes.test.ts": "server", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", + "optional-desktop-upstream.test.ts": "lib", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index 168e2d1c439..8e6e186743c 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -198,7 +198,9 @@ export interface AuthorityOptions { permittedDnsNames?: readonly string[]; /** With permittedDnsNames: also exclude every IP address (default true). */ excludeAllIpAddresses?: boolean; - } + /** Restrict this authority to TLS server authentication; legacy callers keep their existing scope. */ + serverAuthOnly?: boolean; +} export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { const validityDays = options.validityDays ?? CA_VALIDITY_DAYS; @@ -218,6 +220,7 @@ export function createCertificateAuthority(options: AuthorityOptions): LocalInte // keyCertSign | cRLSign extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), + ...(options.serverAuthOnly ? [extension(OID.extendedKeyUsage, true, sequence(objectIdentifier(OID.serverAuth)))] : []), ...(options.permittedDnsNames?.length ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, options.excludeAllIpAddresses !== false))] : []), @@ -235,6 +238,10 @@ export function createLocalInterceptCa(): LocalInterceptCa { return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME }); } +export function isServerAuthOnlyCertificate(certificate: X509Certificate): boolean { + return certificate.keyUsage?.length === 1 && certificate.keyUsage[0] === OID.serverAuth; +} + /** * Test hook for trust-boundary suites: mint a self-signed authority carrying an arbitrary list of * DER-encoded Extension items so adversarial profiles still bear a valid signature. Production diff --git a/src/cli/restart-scope.ts b/src/cli/restart-scope.ts index 2dd14c94214..58b5264606e 100644 --- a/src/cli/restart-scope.ts +++ b/src/cli/restart-scope.ts @@ -173,6 +173,9 @@ export async function handleDesktopAppRestart( + "Quit and relaunch the desktop app manually to refresh the model picker.", ); return result; + case "relaunch_context_failed": + log.error("Codex desktop launch options could not be preserved safely, so the app was not stopped. Review its current launch options before retrying."); + return result; case "no_targets": log.log("Codex desktop app is not running; nothing to restart."); return result; @@ -189,4 +192,3 @@ export async function handleDesktopAppRestart( return result; } } - diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 6de8220ad0b..18c1e1a8470 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -89,6 +89,7 @@ export type DesktopAppRestartReason = | "self_ancestry" | "restart_in_flight" | "handoff_started" + | "relaunch_context_failed" | "targets_survived" | "relaunch_failed"; @@ -263,7 +264,9 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp // graphical session variables, and after termination there is nothing to read them // from. Ordering this wrongly works on macOS and Windows and produces a Linux app // that cannot reach the compositor. - const context = adapter.captureRelaunchContext(exec, install, processes); + let context: Record; + try { context = adapter.captureRelaunchContext(exec, install, processes); } + catch { return skipped("relaunch_context_failed"); } const isAlive = io.isAlive ?? defaultIsAlive; const sleep = io.sleep ?? defaultSleep; @@ -345,4 +348,3 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp if (!handedOff) releaseDesktopRestartLock(io.lock); } } - diff --git a/src/codex/desktop-app/types.ts b/src/codex/desktop-app/types.ts index c90862f329b..07884dd9cd9 100644 --- a/src/codex/desktop-app/types.ts +++ b/src/codex/desktop-app/types.ts @@ -51,6 +51,8 @@ export interface DesktopProcess { createdAt: string; /** Absolute executable path, used for membership and the shell predicate. */ executable: string; + /** Windows-only private launch context; never included in restart results or logs. */ + commandLine?: string; } export interface DesktopAppAdapter { diff --git a/src/codex/desktop-app/windows.ts b/src/codex/desktop-app/windows.ts index c73e067e5b6..ab125fd46c8 100644 --- a/src/codex/desktop-app/windows.ts +++ b/src/codex/desktop-app/windows.ts @@ -13,6 +13,7 @@ import { execFileSync } from "node:child_process"; import { sep, win32 } from "node:path"; import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../../lib/windows-elevation"; +import { activateWindowsCodexCompatibility, assertWindowsCompatibilityContext, captureWindowsCompatibilityContext } from "../desktop-compatibility/windows-package-command"; import { isUnderRoot, type DesktopAppAdapter, @@ -50,8 +51,7 @@ function isMemberExecutable(executable: string, root: string): boolean { * changes between builds, so a literal AUMID would silently stop matching and * then either do nothing or — worse — match a package we did not mean. */ -function discoverPackage(exec: DesktopExec): DesktopAppInstall | null { - const script = [ +export const WINDOWS_PACKAGE_DISCOVERY_SCRIPT = [ "$ErrorActionPreference='SilentlyContinue'", "Import-Module Appx -ErrorAction SilentlyContinue", "$p = Get-AppxPackage -Name OpenAI.Codex", @@ -60,12 +60,8 @@ function discoverPackage(exec: DesktopExec): DesktopAppInstall | null { " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"", "}", ].join("; "); - let stdout: string; - try { - stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], POWERSHELL_PROBE_OPTIONS); - } catch { - return null; - } + +export function parseWindowsDesktopPackage(stdout: string): DesktopAppInstall | null { const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); if (lines.length < 3 || lines[0] === "MISS") return null; const [family, installLocation, aumid] = lines; @@ -73,6 +69,16 @@ function discoverPackage(exec: DesktopExec): DesktopAppInstall | null { return { id: family, root: installLocation, relaunch: aumid }; } +function discoverPackage(exec: DesktopExec): DesktopAppInstall | null { + let stdout: string; + try { + stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PACKAGE_DISCOVERY_SCRIPT], POWERSHELL_PROBE_OPTIONS); + } catch { + return null; + } + return parseWindowsDesktopPackage(stdout); +} + /** * Only `ChatGPT.exe` processes whose image lives under the discovered install * location AND owned by the current user. The install location alone is not @@ -100,7 +106,8 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {", " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }", " if ($owner -ieq $me) {", - " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o')) $($_.ExecutablePath)\"", + " $encoded=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$_.CommandLine))", + " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o')) $($_.ExecutablePath)`t$encoded\"", " }", " }", " }", @@ -126,7 +133,9 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De } function parseProcessLine(line: string, root: string): DesktopProcess | null { - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/.exec(line); + const [listing, encoded, ...extra] = line.split("\t"); + if (extra.length || (encoded && (encoded.length > 65_536 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)))) return null; + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/.exec(listing ?? ""); if (!match) return null; const pid = Number(match[1]); const parentPid = Number(match[2]); @@ -143,7 +152,8 @@ function parseProcessLine(line: string, root: string): DesktopProcess | null { // Authoritative membership. PowerShell StartsWith already cheap-filtered, but // that test is a string prefix and is how a sibling install would sneak in. if (!isMemberExecutable(executable, root)) return null; - return { pid, parentPid, createdAt, executable }; + return { pid, parentPid, createdAt, executable, + ...(encoded !== undefined ? { commandLine: Buffer.from(encoded, "base64").toString("utf8") } : {}) }; } /** @@ -215,13 +225,16 @@ export const windowsDesktopAppAdapter: DesktopAppAdapter = { exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(root.pid), "/T", "/F"], POWERSHELL_PROBE_OPTIONS); }, - captureRelaunchContext(): Record { - // The session is supplied by the shell:AppsFolder launch, so nothing needs - // carrying forward. - return {}; + captureRelaunchContext(_exec, _install, processes): Record { + return captureWindowsCompatibilityContext(processes); }, - relaunch(exec, install): void { + relaunch(exec, install, context): void { + if (context.codexCompatibilityPacUrl) { + assertWindowsCompatibilityContext(context); + activateWindowsCodexCompatibility(exec, install, context.codexCompatibilityPacUrl); + return; + } // Throws on failure so the ladder reports relaunch_failed. The old code // returned targets_survived here, which was dishonest: everything HAD died // and it was the relaunch that failed. diff --git a/src/codex/desktop-compatibility/certificate-service.ts b/src/codex/desktop-compatibility/certificate-service.ts new file mode 100644 index 00000000000..289fbb2af50 --- /dev/null +++ b/src/codex/desktop-compatibility/certificate-service.ts @@ -0,0 +1,99 @@ +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows"; +import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, renewDesktopCompatibilityAuthority, type DesktopAuthorityInspection, type StoredDesktopAuthority } from "./certificate-store"; +import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust } from "./windows-certificate-trust"; +import { acquireDesktopCertificateMutation, desktopCompatibilityRuntimeActive } from "./runtime-ownership"; + +export interface DesktopCertificateStatus { + supported: boolean; + state: "missing" | "invalid" | "expired" | "renewal-required" | "prepared" | "trusted" | "unknown"; + fingerprint?: string; + expiresAt?: number; + renewalDue?: boolean; + trust?: DesktopCertificateTrust; + busy: "prepare" | "trust" | "remove-trust" | "renew" | null; +} + +export interface DesktopCertificateServiceIo { + platform?: string; + inspect?: () => DesktopAuthorityInspection; + prepare?: () => Promise; + load?: (allowExpiredForRemoval: boolean) => Promise; + renew?: (fingerprint: string) => Promise; + readTrust?: (authority: Extract) => Promise; + changeTrust?: (authority: StoredDesktopAuthority, action: "trust" | "remove") => Promise; + appRunning?: () => Promise; +} + +export class DesktopCertificateServiceError extends Error { + constructor(readonly code: "unsupported" | "busy" | "not_prepared" | "fingerprint_changed" | "app_running" | "runtime_running" | "app_state_unknown" | "trust_unknown" | "trust_not_applied") { + super(`desktop_compatibility_${code}`); this.name = "DesktopCertificateServiceError"; + } +} + +/** This service owns setup only: no proxy listeners, app restart, account or quota writes. */ +export function createDesktopCertificateService(directory = join(getConfigDir(), "codex-desktop-compatibility"), io: DesktopCertificateServiceIo = {}) { + const platform = io.platform ?? process.platform; + const inspect = io.inspect ?? (() => inspectDesktopCompatibilityAuthority(directory)); + const prepare = io.prepare ?? (() => ensureDesktopCompatibilityAuthority({ directory })); + const load = io.load ?? (allowExpired => loadDesktopCompatibilityAuthority({ directory }, allowExpired)); + const renew = io.renew ?? (fingerprint => renewDesktopCompatibilityAuthority({ directory }, fingerprint)); + const readTrust = io.readTrust ?? (value => inspectWindowsCertificateTrust(value.certPem, value.fingerprint)); + const changeTrust = io.changeTrust ?? ((authority, action) => createWindowsCertificateTrust(authority, authority.fingerprint)[action]()); + const appRunning = io.appRunning ?? (async () => { + const install = windowsDesktopAppAdapter.discover(windowsDefaultExec); + if (!install) return null; // Unknown installation is not proof that no app uses the authority. + const processes = windowsDesktopAppAdapter.listProcesses(windowsDefaultExec, install); + return processes === null ? null : processes.length > 0; + }); + let busy: DesktopCertificateStatus["busy"] = null; + async function status(): Promise { + if (platform !== "win32") return { supported: false, state: "missing", busy }; + const stored = inspect(); + if (!("fingerprint" in stored)) return { supported: true, state: stored.status, busy }; + const trusted = await readTrust(stored).catch(() => "unknown" as const); + return { supported: true, state: stored.status === "expired" ? "expired" + : stored.status === "renewal-required" ? "renewal-required" + : trusted === "trusted" ? "trusted" : trusted === "not-trusted" ? "prepared" : "unknown", + fingerprint: stored.fingerprint, expiresAt: stored.expiresAt, renewalDue: stored.renewalDue, trust: trusted, busy }; + } + async function action(kind: NonNullable, expectedFingerprint?: string): Promise { + if (platform !== "win32") throw new DesktopCertificateServiceError("unsupported"); + if (busy !== null) throw new DesktopCertificateServiceError("busy"); + if (desktopCompatibilityRuntimeActive()) throw new DesktopCertificateServiceError("runtime_running"); + let release: () => void; + try { release = acquireDesktopCertificateMutation(); } catch { throw new DesktopCertificateServiceError("busy"); } + busy = kind; + try { + if (kind !== "prepare") { + const before = inspect(); + if (!("fingerprint" in before)) throw new DesktopCertificateServiceError("not_prepared"); + if (before.fingerprint !== expectedFingerprint) throw new DesktopCertificateServiceError("fingerprint_changed"); + if (kind === "remove-trust" || kind === "renew") { + const running = await appRunning(); + if (running === null) throw new DesktopCertificateServiceError("app_state_unknown"); + if (running) throw new DesktopCertificateServiceError("app_running"); + } + } + const authority = kind === "prepare" ? await prepare() : await load(kind !== "trust"); + if (kind !== "prepare") { + if (authority.fingerprint !== expectedFingerprint) throw new DesktopCertificateServiceError("fingerprint_changed"); + const current = inspect(); + if (!("fingerprint" in current) || current.fingerprint !== authority.fingerprint) throw new DesktopCertificateServiceError("fingerprint_changed"); + const result = await changeTrust(authority, kind === "trust" ? "trust" : "remove"); + if (result === "unknown") throw new DesktopCertificateServiceError("trust_unknown"); + if (result !== (kind === "trust" ? "trusted" : "not-trusted")) throw new DesktopCertificateServiceError("trust_not_applied"); + // Never discard the only removable old identity until OS trust removal is proven. + // Publication failure leaves its protected envelope intact for an explicit retry. + if (kind === "renew") await renew(authority.fingerprint); + } + busy = null; + return status(); + } finally { busy = null; release(); } + } + return { status, prepare: () => action("prepare"), trust: (fingerprint: string) => action("trust", fingerprint), + removeTrust: (fingerprint: string) => action("remove-trust", fingerprint), renew: (fingerprint: string) => action("renew", fingerprint) }; +} + +export type DesktopCertificateService = ReturnType; diff --git a/src/codex/desktop-compatibility/certificate-store.ts b/src/codex/desktop-compatibility/certificate-store.ts new file mode 100644 index 00000000000..26af38df442 --- /dev/null +++ b/src/codex/desktop-compatibility/certificate-store.ts @@ -0,0 +1,193 @@ +import { createHash, createPrivateKey, createPublicKey, randomUUID, X509Certificate } from "node:crypto"; +import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { createCertificateAuthority, isServerAuthOnlyCertificate, type LocalInterceptCa } from "../../claude/intercept/local-ca"; +import { withClientLifecycle } from "../../client/lifecycle-lock"; +import { hardenSecretDirAsync, hardenSecretPathAsync } from "../../lib/windows-secret-acl"; +import { windowsAuthorityKeyProtection, type AuthorityKeyProtection } from "./windows-key-protection"; + +const POLICY = "codex-desktop-chatgpt-only/v1"; +const FILE = "authority.json"; +const MAX_FILE = 131_072; +const VALIDITY_DAYS = 30; +const DAY = 86_400_000; +const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + +export class DesktopAuthorityError extends Error { + constructor(readonly code: "unsafe_path" | "unreadable" | "expired" | "renewal_required" | "protection_failed" | "fingerprint_changed") { + super(`desktop_compatibility_authority_${code}`); this.name = "DesktopAuthorityError"; + } +} + +export interface DesktopAuthorityStoreOptions { + /** Explicit feature-owned directory; never an OS trust store or existing Claude CA. */ + directory: string; + now?: () => number; + /** Test seam; production always uses Windows CurrentUser DPAPI. */ + protection?: AuthorityKeyProtection; +} + +export interface StoredDesktopAuthority { + authority: LocalInterceptCa; + commonName: string; + fingerprint: string; + expiresAt: number; + renewalDue: boolean; + reused: boolean; +} + +export type DesktopAuthorityInspection = + | { status: "missing" | "invalid" } + | { status: "present" | "expired" | "renewal-required"; certPem: string; fingerprint: string; expiresAt: number; renewalDue: boolean }; + +/** Read-only public metadata for status: no key decryption, ACL write, lock or generation. */ +export function inspectDesktopCompatibilityAuthority(directory: string, now = Date.now()): DesktopAuthorityInspection { + if (!isAbsolute(directory) || !Number.isFinite(now)) return { status: "invalid" }; + const path = join(directory, FILE); + try { + if (!pathPresent(directory)) return { status: "missing" }; + assertPath(directory, true); + if (!pathPresent(path)) return { status: "missing" }; + assertPath(path, false); + const saved = JSON.parse(readFileSync(path, "utf8")); + if (saved.version !== 1 || saved.protection !== "windows-current-user-dpapi" || typeof saved.certPem !== "string" + || typeof saved.sealed !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(saved.sealed) || saved.sealed.length > MAX_FILE) return { status: "invalid" }; + const certificate = new X509Certificate(saved.certPem), expiresAt = Date.parse(certificate.validTo); + if (!certificate.ca || !Number.isFinite(expiresAt)) return { status: "invalid" }; + return { status: expiresAt <= now ? "expired" : isServerAuthOnlyCertificate(certificate) ? "present" : "renewal-required", certPem: saved.certPem, + fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt, renewalDue: expiresAt - now <= 7 * DAY }; + } catch { return { status: "invalid" }; } +} + +function assertPath(path: string, directory: boolean): void { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_FILE)) { + throw new DesktopAuthorityError("unsafe_path"); + } +} + +function pathPresent(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false; + throw new DesktopAuthorityError("unsafe_path"); + } +} + +function validatedAuthority(certPem: string, keyPem: string, commonName: string, now: number, reused: boolean, allowExpired = false): StoredDesktopAuthority { + try { + const certificate = new X509Certificate(certPem); + const privateKey = createPrivateKey(keyPem), publicKey = createPublicKey(keyPem); + if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey) + || certificate.subject !== certificate.issuer + || !commonName.startsWith("OpenCodex Codex Desktop ") || !certificate.subject.split("\n").includes(`CN=${commonName}`)) { + throw new Error("invalid"); + } + const expiresAt = Date.parse(certificate.validTo), startsAt = Date.parse(certificate.validFrom); + if (!Number.isFinite(expiresAt) || !Number.isFinite(startsAt) || startsAt > now + || expiresAt - startsAt > (VALIDITY_DAYS + 1) * DAY) throw new Error("invalid"); + if (expiresAt <= now && !allowExpired) throw new DesktopAuthorityError("expired"); + if (!allowExpired && !isServerAuthOnlyCertificate(certificate)) throw new DesktopAuthorityError("renewal_required"); + return { authority: { certPem, keyPem, privateKey, publicKey }, commonName, + fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt, + renewalDue: expiresAt - now <= 7 * DAY, reused }; + } catch (error) { + if (error instanceof DesktopAuthorityError) throw error; + throw new DesktopAuthorityError("unreadable"); + } +} + +async function readAuthority(path: string, protection: AuthorityKeyProtection, now: number, allowExpired = false): Promise { + assertPath(path, false); + await hardenSecretPathAsync(path, { required: true }); + assertPath(path, false); + let cleartext: Uint8Array | undefined; + try { + const saved = JSON.parse(readFileSync(path, "utf8")); + if (saved.version !== 1 || saved.protection !== "windows-current-user-dpapi" + || typeof saved.certPem !== "string" || typeof saved.sealed !== "string" + || !/^[A-Za-z0-9+/]+={0,2}$/.test(saved.sealed) || saved.sealed.length > MAX_FILE) throw new Error("invalid"); + cleartext = await protection.unprotect(Buffer.from(saved.sealed, "base64")); + const secret = JSON.parse(Buffer.from(cleartext).toString("utf8")); + if (secret.policy !== POLICY || secret.certSha256 !== digest(saved.certPem) + || typeof secret.keyPem !== "string" || typeof secret.commonName !== "string") throw new Error("invalid"); + return validatedAuthority(saved.certPem, secret.keyPem, secret.commonName, now, true, allowExpired); + } catch (error) { + if (error instanceof DesktopAuthorityError) throw error; + // A corrupt or foreign-user key never silently regenerates a new root or trust prompt. + throw new DesktopAuthorityError("unreadable"); + } finally { cleartext?.fill(0); } +} + +/** Loads only existing state. Expired or legacy-purpose keys may be loaded solely for trust removal/renewal. */ +export async function loadDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions, allowExpiredForRemoval = false): Promise { + if (!isAbsolute(options.directory)) throw new DesktopAuthorityError("unsafe_path"); + const now = options.now?.() ?? Date.now(); + if (!Number.isFinite(now)) throw new DesktopAuthorityError("unreadable"); + assertPath(options.directory, true); + const path = join(options.directory, FILE); + if (!pathPresent(path)) throw new DesktopAuthorityError("unreadable"); + return readAuthority(path, options.protection ?? windowsAuthorityKeyProtection, now, allowExpiredForRemoval); +} + +/** Reuses one user-protected root across restarts; never installs, rotates or removes trust. */ +export async function ensureDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions): Promise { + return publishAuthority(options); +} + +/** Deliberate replacement after the caller has removed OS trust and stopped consumers. */ +export async function renewDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions, expectedFingerprint: string): Promise { + if (!/^[A-F0-9]{64}$/.test(expectedFingerprint)) throw new DesktopAuthorityError("fingerprint_changed"); + return publishAuthority(options, expectedFingerprint); +} + +async function publishAuthority(options: DesktopAuthorityStoreOptions, expectedFingerprint?: string): Promise { + if (!isAbsolute(options.directory)) throw new DesktopAuthorityError("unsafe_path"); + if (!options.protection && process.platform !== "win32") throw new Error("desktop_compatibility_windows_required"); + const now = options.now?.() ?? Date.now(); + if (!Number.isFinite(now)) throw new DesktopAuthorityError("unreadable"); + const protection = options.protection ?? windowsAuthorityKeyProtection; + mkdirSync(options.directory, { recursive: true, mode: 0o700 }); + assertPath(options.directory, true); + await hardenSecretDirAsync(options.directory, { required: true }); + return withClientLifecycle(async () => { + assertPath(options.directory, true); + const path = join(options.directory, FILE); + let original: string | undefined; + if (expectedFingerprint !== undefined) { + if (!pathPresent(path)) throw new DesktopAuthorityError("fingerprint_changed"); + const previous = await readAuthority(path, protection, now, true); + if (previous.fingerprint !== expectedFingerprint) throw new DesktopAuthorityError("fingerprint_changed"); + original = readFileSync(path, "utf8"); + } else if (pathPresent(path)) return readAuthority(path, protection, now); + const commonName = `OpenCodex Codex Desktop ${randomUUID()}`; + const authority = createCertificateAuthority({ commonName, validityDays: VALIDITY_DAYS, + permittedDnsNames: ["chatgpt.com"], excludeAllIpAddresses: true, serverAuthOnly: true }); + const cleartext = Buffer.from(JSON.stringify({ policy: POLICY, certSha256: digest(authority.certPem), commonName, keyPem: authority.keyPem })); + let sealed: Uint8Array; + try { sealed = await protection.protect(cleartext); } + catch { throw new DesktopAuthorityError("protection_failed"); } + finally { cleartext.fill(0); } + if (sealed.byteLength === 0 || sealed.byteLength > 65_536) throw new DesktopAuthorityError("protection_failed"); + const contents = JSON.stringify({ version: 1, protection: "windows-current-user-dpapi", certPem: authority.certPem, + sealed: Buffer.from(sealed).toString("base64") }); + const temporary = join(options.directory, `authority-${randomUUID()}.tmp`); + let created = false; + try { + const fd = openSync(temporary, "wx", 0o600); created = true; + try { writeFileSync(fd, contents); fsyncSync(fd); } finally { closeSync(fd); } + await hardenSecretPathAsync(temporary, { required: true }); + assertPath(temporary, false); assertPath(options.directory, true); + // Verify the new encrypted envelope before replacing the only recovery source. + await readAuthority(temporary, protection, now); + if (original !== undefined) { + assertPath(path, false); + if (readFileSync(path, "utf8") !== original) throw new DesktopAuthorityError("fingerprint_changed"); + } else if (pathPresent(path)) throw new DesktopAuthorityError("unsafe_path"); + renameSync(temporary, path); created = false; + // Read back the actual persisted pair before reporting a successful setup. + const persisted = await readAuthority(path, protection, now); + return { ...persisted, reused: false }; + } finally { if (created) unlinkSync(temporary); } + }, { lockPath: join(options.directory, "authority-publication.sqlite") }); +} diff --git a/src/codex/desktop-compatibility/connection-store.ts b/src/codex/desktop-compatibility/connection-store.ts new file mode 100644 index 00000000000..d6512d8bd71 --- /dev/null +++ b/src/codex/desktop-compatibility/connection-store.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import { closeSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { withClientLifecycle } from "../../client/lifecycle-lock"; +import { hardenSecretDirAsync, hardenSecretPathAsync } from "../../lib/windows-secret-acl"; + +export interface DesktopConnectionIdentity { + version: 1; + id: string; + connectPort: number; + pacPort: number; +} +export interface DesktopConnectionStore { + read(): DesktopConnectionIdentity | null; + publish(value: DesktopConnectionIdentity): Promise; +} +const FILE = "connection.json"; +const fail = (reason: "invalid" | "changed" | "cleanup_required", cause?: unknown) => new Error(`desktop_compatibility_connection_${reason}`, { cause }); +const port = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 1024 && Number(value) <= 65535; +function validate(value: unknown): DesktopConnectionIdentity { + if (!value || typeof value !== "object" || Array.isArray(value)) throw fail("invalid"); + const row = value as Record; + if (Object.keys(row).length !== 4 || row.version !== 1 || typeof row.id !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(row.id) + || !port(row.connectPort) || !port(row.pacPort) || row.connectPort === row.pacPort) throw fail("invalid"); + return { version: 1, id: row.id, connectPort: row.connectPort, pacPort: row.pacPort }; +} +function assertPath(path: string, directory: boolean): void { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > 4096)) throw fail("invalid"); +} +function present(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false; throw fail("invalid"); } +} +function same(left: DesktopConnectionIdentity, right: DesktopConnectionIdentity): boolean { + return left.version === right.version && left.id === right.id && left.connectPort === right.connectPort && left.pacPort === right.pacPort; +} + +/** Public endpoint identity only. No token, account, private key or expiry is persisted here. */ +export function createDesktopConnectionStore(directory: string): DesktopConnectionStore { + if (!isAbsolute(directory)) throw fail("invalid"); + const path = join(directory, FILE); + const read = (): DesktopConnectionIdentity | null => { + try { + if (!present(directory)) return null; + assertPath(directory, true); + if (!present(path)) return null; + assertPath(path, false); + return validate(JSON.parse(readFileSync(path, "utf8"))); + } catch { throw fail("invalid"); } + }; + const verifyExisting = (value: DesktopConnectionIdentity): DesktopConnectionIdentity | null => { + const existing = read(); + if (existing && !same(existing, value)) throw fail("changed"); + return existing; + }; + return { read, async publish(input) { + const value = validate(input); + const existing = verifyExisting(value); if (existing) return existing; + // A trusted authority must already own this directory. Never create a replacement home. + assertPath(directory, true); await hardenSecretDirAsync(directory, { required: true }); + return withClientLifecycle(async () => { + const raced = verifyExisting(value); if (raced) return raced; + const temporary = join(directory, `connection-${randomUUID()}.tmp`); + let created = false; + try { + const fd = openSync(temporary, "wx", 0o600); created = true; + try { writeFileSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); } + await hardenSecretPathAsync(temporary, { required: true }); + assertPath(directory, true); assertPath(temporary, false); + // Create-only publication cannot overwrite another process's endpoint identity. + try { linkSync(temporary, path); } + catch (error) { + const winner = verifyExisting(value); if (!winner) throw error; + } + unlinkSync(temporary); created = false; + const published = verifyExisting(value); if (!published) throw fail("changed"); + return published; + } catch (error) { + try { if (created && present(temporary)) unlinkSync(temporary); } + catch (cleanupError) { throw fail("cleanup_required", new AggregateError([error, cleanupError], "Endpoint publication and cleanup failed")); } + throw error; + } + }, { lockPath: join(directory, "connection-publication.sqlite") }); + } }; +} diff --git a/src/codex/desktop-compatibility/installed-build.ts b/src/codex/desktop-compatibility/installed-build.ts new file mode 100644 index 00000000000..36dbf3bc5b5 --- /dev/null +++ b/src/codex/desktop-compatibility/installed-build.ts @@ -0,0 +1,55 @@ +import { execFile } from "node:child_process"; +import { win32 } from "node:path"; +import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import { parseWindowsDesktopPackage, WINDOWS_PACKAGE_DISCOVERY_SCRIPT } from "../desktop-app/windows"; +import type { DesktopAppInstall } from "../desktop-app/types"; + +export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; +export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0"; + +function matchesAssessedPackage(install: DesktopAppInstall): boolean { + const name = win32.basename(install.root); + const parts = /^OpenAI\.Codex_(\d+\.\d+\.\d+\.\d+)_(x64|arm64|x86|neutral)_([A-Za-z0-9.-]*)_([A-Za-z0-9]+)$/.exec(name); + return install.id === DESKTOP_COMPATIBILITY_ASSESSED_FAMILY && install.relaunch === `${install.id}!App` + && !!parts && parts[1] === DESKTOP_COMPATIBILITY_ASSESSED_VERSION && `OpenAI.Codex_${parts[4]}` === install.id; +} + +/** Resolve only after the exact asynchronous child has closed, including abort/timeout. */ +function queryInstalledPackage(signal: AbortSignal): Promise { + if (process.platform !== "win32" || isTestHomeGuardArmed() || signal.aborted) return Promise.resolve(null); + return new Promise(resolve => { + let completed = false, closed = false, result: DesktopAppInstall | null = null; + const finish = () => { if (completed && closed) { signal.removeEventListener("abort", abort); resolve(result); } }; + const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PACKAGE_DISCOVERY_SCRIPT], { + timeout: 10000, maxBuffer: 65536, encoding: "utf8", windowsHide: true, + }, (error, stdout) => { result = error ? null : parseWindowsDesktopPackage(stdout); completed = true; finish(); }); + const abort = () => { try { child.kill(); } catch { /* Keep ownership until execFile reports close. */ } }; + child.once("close", () => { closed = true; finish(); }); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); +} + +/** Coalesce concurrent fresh probes; never cache a positive verdict across requests. */ +export function createInstalledBuildProbe(query: (signal: AbortSignal) => Promise = queryInstalledPackage) { + let pending: Promise | null = null, controller: AbortController | null = null, closed = false; + return { + check(): Promise { + if (closed) return Promise.resolve(false); + if (pending) return pending; + const current = new AbortController(); controller = current; + pending = Promise.resolve().then(() => current.signal.aborted ? null : query(current.signal)) + .then(install => !closed && !current.signal.aborted && !!install + && matchesAssessedPackage(install)) + .catch(() => false).finally(() => { pending = null; controller = null; }); + return pending; + }, + async close(): Promise { closed = true; controller?.abort(); await pending; }, + }; +} + +export async function isAssessedDesktopInstalled(): Promise { + const probe = createInstalledBuildProbe(); + try { return await probe.check(); } finally { await probe.close(); } +} diff --git a/src/codex/desktop-compatibility/json-body.ts b/src/codex/desktop-compatibility/json-body.ts new file mode 100644 index 00000000000..d51e38ca0b5 --- /dev/null +++ b/src/codex/desktop-compatibility/json-body.ts @@ -0,0 +1,29 @@ +/** Buffer small JSON only; large bodies pass through without collecting the remainder. */ +export async function boundedJsonBody(body: ReadableStream | null, maxBytes = 262144): Promise<{ bytes: Buffer } | { stream: ReadableStream }> { + if (!body) return { bytes: Buffer.alloc(0) }; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let count = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { reader.releaseLock(); return { bytes: Buffer.concat(chunks, count) }; } + chunks.push(next.value); count += next.value.byteLength; + if (count <= maxBytes && chunks.length < 1024) continue; + let index = 0; + return { stream: new ReadableStream({ + async pull(controller) { + if (index < chunks.length) { + const value = chunks[index]!; chunks[index++] = new Uint8Array(0); controller.enqueue(value); return; + } + try { + const part = await reader.read(); + if (part.done) { reader.releaseLock(); controller.close(); } + else controller.enqueue(part.value); + } catch (error) { reader.releaseLock(); controller.error(error); } + }, + async cancel(reason) { try { await reader.cancel(reason); } finally { reader.releaseLock(); } }, + }) }; + } + } catch (error) { reader.releaseLock(); throw error; } +} diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts new file mode 100644 index 00000000000..e3f97d59e86 --- /dev/null +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -0,0 +1,62 @@ +import { readFileSync, statSync, type BigIntStats } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import type { UsageIdentity } from './usage-controller'; + +type Credential = { identity: UsageIdentity; accessToken: string }; +const equal = (a: UsageIdentity, b: UsageIdentity) => a.id === b.id && a.userId === b.userId && a.plan === b.plan + && a.credentialGeneration === b.credentialGeneration; + +/** Local native credentials remain in memory and are never returned by the public reader. */ +export function createNativeIdentityReader(authPath: string, upstreamFetch: typeof fetch = fetch) { + const readerId = randomUUID(); let generation = 0, previousSnapshot: string | undefined; + const stamp = (value: BigIntStats) => + `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; + const readCredential = (): Credential => { + const before = statSync(authPath, { bigint: true }); + if (before.size > 1048576n) throw new Error('Unexpected native auth size'); + const text = readFileSync(authPath, 'utf8'), after = statSync(authPath, { bigint: true }); + if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read'); + const snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`; + if (snapshot !== previousSnapshot) { previousSnapshot = snapshot; generation++; } + const auth = JSON.parse(text); + if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' + || typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token + || typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required'); + // These local claims are only a binding hint. Activation additionally verifies upstream. + const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth']; + if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id + || typeof claims.chatgpt_user_id !== 'string' || !claims.chatgpt_user_id + || !['plus', 'pro'].includes(claims.chatgpt_plan_type)) throw new Error('Unsupported identity'); + return { identity: { id: auth.tokens.account_id, userId: claims.chatgpt_user_id, + plan: claims.chatgpt_plan_type, structure: 'personal', credentialGeneration: `${readerId}:${generation}` }, accessToken: auth.tokens.access_token }; + }; + const readCurrentIdentity = async (): Promise => { + try { return readCredential().identity; } catch { return null; } + }; + const verifyFreshIdentity = async (): Promise => { + try { + const before = readCredential(); + const response = await upstreamFetch('https://chatgpt.com/backend-api/wham/usage', { + headers: { authorization: 'Bearer ' + before.accessToken, 'ChatGPT-Account-ID': before.identity.id }, + redirect: 'manual', signal: AbortSignal.timeout(10000), + }); + if (response.status !== 200 || !response.body) { await response.body?.cancel(); return null; } + const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; + try { + for (;;) { + const part = await reader.read(); if (part.done) break; + size += part.value.length; + if (size > 65536) { await reader.cancel(); return null; } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + const usage = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const after = readCredential(); + if (!equal(before.identity, after.identity) || before.accessToken !== after.accessToken + || usage.account_id !== before.identity.id || usage.user_id !== before.identity.userId + || usage.plan_type !== before.identity.plan) return null; + return before.identity; + } catch { return null; } + }; + return { readCurrentIdentity, verifyFreshIdentity }; +} diff --git a/src/codex/desktop-compatibility/relay-listener.ts b/src/codex/desktop-compatibility/relay-listener.ts new file mode 100644 index 00000000000..72fb1e3a2ed --- /dev/null +++ b/src/codex/desktop-compatibility/relay-listener.ts @@ -0,0 +1,93 @@ +import { createServer } from "node:https"; +import { Readable, type Duplex } from "node:stream"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { dialDesktopUpstream, type DesktopTunnelOptions } from "../../lib/desktop-upstream-tunnel"; + +const ORIGIN = "https://chatgpt.com"; +const STRIP = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "alt-svc", "proxy-authenticate"]); +export interface DesktopRelayOptions { + leaf: PemKeyPair; + fetchImpl: typeof fetch; + /** Test-only TLS peer; the production destination is fixed, never taken from a request. */ + websocketPeer?: { host: string; port: number; ca: string }; + websocketTransport?: Pick; +} + +/** Transparent HTTP and raw upgraded-socket relay. Only fetchImpl owns usage policy. */ +export async function startDesktopRelay(options: DesktopRelayOptions) { + const sockets = new Set(), requests = new Set(); + const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] }); + server.on("connection", socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); + const valid = (host: string | undefined, path: string | undefined) => + (host?.toLowerCase() === "chatgpt.com" || host?.toLowerCase() === "chatgpt.com:443") && !!path && path.startsWith("/") && !path.startsWith("//"); + server.on("request", (req, res) => { + if (!valid(req.headers.host, req.url)) { res.writeHead(421); res.end(); return; } + const abort = new AbortController(); requests.add(abort); + const finish = () => { requests.delete(abort); abort.abort(); }; + res.once("close", finish); req.once("error", finish); + void (async () => { + const headers = new Headers(); + for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i]!, req.rawHeaders[i + 1]!); + const method = req.method ?? "GET"; + const response = await options.fetchImpl(ORIGIN + req.url, { + method, headers: forwardHeadersForUpstream(headers), redirect: "manual", signal: abort.signal, + // Node's and Bun's declarations disagree on BYOB overloads; both implement Web streams. + body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req) as unknown as ReadableStream, + // @ts-expect-error Node-compatible streaming fetch requires half duplex. + duplex: "half", + }); + if (res.destroyed) { await response.body?.cancel(); return; } + const output: Record = {}; + response.headers.forEach((value, key) => { if (!STRIP.has(key) && key !== "set-cookie") output[key] = value; }); + const cookies = response.headers.getSetCookie(); if (cookies.length) output["set-cookie"] = cookies; + res.writeHead(response.status, output); + if (!response.body || method === "HEAD") { await response.body?.cancel(); res.end(); return; } + const body = Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream); + body.once("error", () => res.destroy()); res.once("close", () => body.destroy()); body.pipe(res); + })().catch(() => { + if (res.headersSent) res.destroy(); else { res.writeHead(502, { "Content-Length": "0" }); res.end(); } + }); + }); + server.on("upgrade", (req, client, head) => { + if (!valid(req.headers.host, req.url)) { client.end("HTTP/1.1 421 Misdirected Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } + const peer = options.websocketPeer; + const abort = new AbortController(); requests.add(abort); + client.once("close", () => { requests.delete(abort); abort.abort(); }); + client.once("error", () => abort.abort()); + void dialDesktopUpstream({ ...options.websocketTransport, ...(peer ? { target: { host: peer.host, port: peer.port }, ca: peer.ca } : {}), signal: abort.signal }).then(upstream => { + requests.delete(abort); + if (client.destroyed || abort.signal.aborted) { upstream.destroy(); return; } + sockets.add(upstream); + upstream.once("error", () => client.destroy()); + upstream.once("close", () => { sockets.delete(upstream); client.destroy(); }); + client.once("close", () => upstream.destroy()); + const lines = [`${req.method ?? "GET"} ${req.url} HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (!/^proxy-(?:authorization|connection)$/i.test(req.rawHeaders[i]!)) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`); + } + upstream.write(lines.join("\r\n") + "\r\n\r\n"); + if (head.length) upstream.write(head); + client.pipe(upstream).pipe(client); + }).catch(() => { + requests.delete(abort); + if (!client.destroyed) client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); + }); + } catch (error) { server.close(); throw error; } + const address = server.address(); + if (!address || typeof address === "string") throw new Error("desktop_compatibility_listener_unavailable"); + let closing: Promise | null = null; + return { port: address.port, close() { + if (closing) return closing; + for (const abort of requests) abort.abort(); + for (const socket of sockets) socket.destroy(); + closing = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + server.closeAllConnections(); return closing; + } }; +} diff --git a/src/codex/desktop-compatibility/routing-binding.ts b/src/codex/desktop-compatibility/routing-binding.ts new file mode 100644 index 00000000000..f29ef80fd3f --- /dev/null +++ b/src/codex/desktop-compatibility/routing-binding.ts @@ -0,0 +1,10 @@ +import type { OcxConfig } from "../../types"; + +export interface NativeCompatibilityOwner { hostname: string; port: number; loopbackPort?: number; config: OcxConfig } +let owner: NativeCompatibilityOwner | null = null; +/** Bound sockets, not persisted desired ports, identify this process's native routing target. */ +export function bindNativeCompatibilityOwner(value: NativeCompatibilityOwner): () => void { + owner = value; + return () => { if (owner === value) owner = null; }; +} +export function nativeCompatibilityOwner(): NativeCompatibilityOwner | null { return owner; } diff --git a/src/codex/desktop-compatibility/routing-preflight.ts b/src/codex/desktop-compatibility/routing-preflight.ts new file mode 100644 index 00000000000..3e2f16df774 --- /dev/null +++ b/src/codex/desktop-compatibility/routing-preflight.ts @@ -0,0 +1,69 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import type { OcxConfig } from "../../types"; +import { nativeCompatibilityOwner, type NativeCompatibilityOwner } from "./routing-binding"; + +const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +// These are routing inputs, not proof of a conversation's final provider. Their +// changes revoke the observed context even if the loopback URL stays the same. +const ROUTING_KEYS = ["providers", "defaultProvider", "defaultModelAliases", "customModels", "combos", "routingProfiles", + "subagentModelFallback", "subagentModelFallbackByModel", "injectionModel", "compactionRouting", "compactionRecovery", + "blockedModelRedirects", "shadowCallIntercept", "protocols", "memoryModels"] as const satisfies readonly (keyof OcxConfig)[]; +function routingDigest(text: string, owner: NativeCompatibilityOwner): string { + const native = Bun.TOML.parse(text) as Record; + // Desktop refreshes MCP endpoints after launch. Tool transport metadata and TOML + // formatting do not select the model transport. Keep all other (including unknown) + // native fields bound, rather than an allowlist that could miss a new routing key. + const { mcp_servers: _mcpServers, ...nativeContext } = native; + const projection = Object.fromEntries(ROUTING_KEYS.map(key => [key, owner.config[key]])); + const serialized = JSON.stringify([nativeContext, owner.hostname, owner.port, owner.loopbackPort, projection], (_key, value) => + record(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value); + if (Buffer.byteLength(serialized) > 1048576) throw new Error("Routing snapshot too large"); + return createHash("sha256").update(serialized).digest("hex"); +} +/** Validates the effective root/profile routing only; it does not prove a thread's selected provider. */ +export function matchesNativeCompatibilityRouting(text: string, owner: NativeCompatibilityOwner | null): boolean { + if (!owner || owner.config.codexDesktopAuthless === true || owner.config.runtimeRole === "client") return false; + try { + const root: unknown = Bun.TOML.parse(text); + if (!record(root)) return false; + let effective = root; + if (root.profile !== undefined) { + if (typeof root.profile !== "string" || !record(root.profiles)) return false; + const profile = root.profiles[root.profile]; + if (!record(profile)) return false; + effective = { ...root, ...profile }; + } + if ((effective.model_provider ?? "openai") !== "openai" || effective.forced_login_method === "api") return false; + if (typeof effective.openai_base_url !== "string") return false; + const url = new URL(effective.openai_base_url); + if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password + || url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname) || !url.port) return false; + const port = Number(url.port), matchesPort = (value: number | undefined) => Number.isInteger(value) && Number(value) > 0 && value === port; + // The companion always binds IPv4. localhost can resolve to another address family. + if (matchesPort(owner.loopbackPort) && url.hostname === "127.0.0.1") return true; + if (!matchesPort(owner.port)) return false; + return url.hostname === "127.0.0.1" ? ["127.0.0.1", "0.0.0.0"].includes(owner.hostname) + : ["::1", "[::1]", "::", "[::]"].includes(owner.hostname); + } catch { return false; } +} +export function createNativeRoutingVerifier(codexHome: string, readOwner = nativeCompatibilityOwner) { + const path = join(codexHome, "config.toml"); + let baseline: string | undefined, invalidated = false; + return () => { + if (invalidated) return false; + try { + // Process-level overrides could put the app on another transport despite its TOML. + if (["CODEX_API_BASE_URL", "CODEX_APP_SERVER_WS_URL", "CODEX_ELECTRON_USER_DATA_PATH", "ELECTRON_RUN_AS_NODE"].some(key => process.env[key]?.trim())) { invalidated = true; return false; } + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) { invalidated = true; return false; } + const text = readFileSync(path, "utf8"), owner = readOwner(); + if (!owner || !matchesNativeCompatibilityRouting(text, owner)) { invalidated = true; return false; } + const current = routingDigest(text, owner); + if (baseline !== undefined && current !== baseline) { invalidated = true; return false; } + baseline ??= current; + return true; + } catch { invalidated = true; return false; } + }; +} diff --git a/src/codex/desktop-compatibility/runtime-ownership.ts b/src/codex/desktop-compatibility/runtime-ownership.ts new file mode 100644 index 00000000000..3bf8082eeaf --- /dev/null +++ b/src/codex/desktop-compatibility/runtime-ownership.ts @@ -0,0 +1,28 @@ +import { randomUUID } from "node:crypto"; + +interface LaunchContext { pacUrl: string; generation: string; current(): boolean } +/** Process-local ownership; the management sibling guard owns the cross-instance boundary. */ +let owner: { token: symbol; kind: "runtime" | "certificate"; launch?: LaunchContext } | null = null; +export function desktopCompatibilityRuntimeActive(): boolean { return owner?.kind === "runtime"; } +function acquire(kind: "runtime" | "certificate"): () => void { + if (owner !== null) throw new Error("desktop_compatibility_busy"); + const token = Symbol(); owner = { token, kind }; + return () => { if (owner?.token === token) owner = null; }; +} +export const acquireDesktopCompatibilityRuntime = () => acquire("runtime"); +export const acquireDesktopCertificateMutation = () => acquire("certificate"); + +/** Published only by a successfully started runtime, never reconstructed from disk or app arguments. */ +export function bindDesktopCompatibilityLaunch(pacUrl: string, current: () => boolean): () => void { + if (owner?.kind !== "runtime" || owner.launch) throw new Error("desktop_compatibility_launch_owner_unverified"); + const lease = owner, launch = { pacUrl, generation: randomUUID(), current }; + lease.launch = launch; + return () => { if (lease.launch === launch) delete lease.launch; }; +} + +/** No key loading, socket discovery, or optional-runtime construction on the ordinary restart path. */ +export function readDesktopCompatibilityLaunch(): { pacUrl: string; generation: string } | null { + const launch = owner?.kind === "runtime" ? owner.launch : undefined; + try { return launch?.current() ? { pacUrl: launch.pacUrl, generation: launch.generation } : null; } + catch { return null; } +} diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts new file mode 100644 index 00000000000..439db5d4276 --- /dev/null +++ b/src/codex/desktop-compatibility/runtime.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { issueServerLeaf } from "../../claude/intercept/local-ca"; +import { startConnectProxy, type ConnectProxyHandle } from "../../claude/intercept/connect-proxy"; +import { didRunOptionalShutdownHooks, registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"; +import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; +import { desktopOutboundFetch, desktopProxyFor } from "../../lib/desktop-proxy-route"; +import { inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, type StoredDesktopAuthority } from "./certificate-store"; +import { inspectWindowsCertificateTrust } from "./windows-certificate-trust"; +import { createNativeIdentityReader } from "./native-identity"; +import { UsageRelayController, type UsageIdentity } from "./usage-controller"; +import { createUsageControlledFetch } from "./usage-controlled-fetch"; +import { startDesktopRelay } from "./relay-listener"; +import { launchWindowsCodexCompatibility } from "./windows-package-launch"; +import { acquireDesktopCompatibilityRuntime, bindDesktopCompatibilityLaunch } from "./runtime-ownership"; +import { createDesktopConnectionStore, type DesktopConnectionStore } from "./connection-store"; +import { createNativeRoutingVerifier } from "./routing-preflight"; +import { createInstalledBuildProbe } from "./installed-build"; + +export { DESKTOP_COMPATIBILITY_ASSESSED_VERSION, isAssessedDesktopInstalled } from "./installed-build"; +export interface DesktopRuntimeIo { + platform?: string; + loadAuthority?: () => Promise; + trust?: (authority: StoredDesktopAuthority) => Promise<"trusted" | "not-trusted" | "unknown">; + identity?: ReturnType; + buildSupported?: () => boolean | Promise; + upstreamFetch?: typeof fetch; + connectionStore?: DesktopConnectionStore; + routingSupported?: () => boolean; + /** Synthetic-test execution is admitted only with explicit fixture identity and CA seams. */ + testOnly?: boolean; +} + +/** Optional runtime. Import/construction/status do not start listeners or touch credentials. */ +export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { + const platform = io.platform ?? process.platform; + let buildProbe: ReturnType | undefined; + const buildSupported = io.buildSupported ?? (() => buildProbe?.check() ?? Promise.resolve(false)); + let routingSupported = io.routingSupported ?? (() => false); + let contextFailure: "build_unverified" | "native_routing_unverified" | null = null; + const contextValid = async () => { + try { + if (!routingSupported()) { contextFailure = "native_routing_unverified"; return false; } + const supported = await buildSupported(); + contextFailure = !routingSupported() ? "native_routing_unverified" : !supported ? "build_unverified" : null; + } + catch { contextFailure = "build_unverified"; } + return contextFailure === null; + }; + let phase: "off" | "starting" | "running" | "stopping" | "cleanup-required" = "off"; + let owned: { controller: UsageRelayController; close(): Promise; pacUrl: string; fingerprint: string; deadline: number } | null = null; + let closing: Promise | null = null; + let pendingCleanup: (() => Promise) | null = null; + let releaseOwner: (() => void) | null = null; + const status = () => ({ phase, supported: platform === "win32", running: owned !== null, contextFailure, + ...(owned ? { fingerprint: owned.fingerprint, safetyDeadline: owned.deadline, usage: owned.controller.snapshot() } : {}) }); + async function stop(): Promise> { + if (phase === "starting") throw new Error("desktop_compatibility_busy"); + if (closing) { await closing; return status(); } + if (!pendingCleanup) return status(); + phase = "stopping"; + closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; }) + .catch(error => { phase = "cleanup-required"; throw error; }).finally(() => { closing = null; }); + await closing; return status(); + } + async function start() { + if (phase !== "off") throw new Error("desktop_compatibility_busy"); + if (platform !== "win32") throw new Error("desktop_compatibility_unsupported"); + if (isTestHomeGuardArmed() && !(io.testOnly && io.identity && io.loadAuthority && io.trust && io.buildSupported && io.connectionStore && io.routingSupported)) throw new Error("desktop_compatibility_test_environment"); + if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); + try { desktopProxyFor(new URL("https://chatgpt.com")); } catch { throw new Error("desktop_compatibility_egress_proxy_invalid"); } + releaseOwner = acquireDesktopCompatibilityRuntime(); phase = "starting"; + if (!io.buildSupported) buildProbe = createInstalledBuildProbe(); + let relay: Awaited> | undefined, proxy: ConnectProxyHandle | undefined; + let pac: ReturnType | undefined, timer: ReturnType | undefined; + let detach: (() => void) | undefined, unbindLaunch: (() => void) | undefined, controller: UsageRelayController | undefined; + const cleanup = async () => { + unbindLaunch?.(); + if (timer) clearInterval(timer); detach?.(); + // Abort transport before awaiting stream refresh so a stuck reader cannot retain sockets. + const results = await Promise.allSettled([pac?.stop(true), proxy?.close(), relay?.close(), controller?.observeOnly(), buildProbe?.close()]); + if (results.some(result => result.status === "rejected")) throw new Error("desktop_compatibility_cleanup_incomplete"); + }; + pendingCleanup = cleanup; + try { + if (!io.routingSupported) routingSupported = createNativeRoutingVerifier((await import("../paths")).getCodexHome()); + if (!await contextValid()) throw new Error(`desktop_compatibility_${contextFailure}`); + const directory = join(getConfigDir(), "codex-desktop-compatibility"); + if (!io.loadAuthority) { + const saved = inspectDesktopCompatibilityAuthority(directory); + if (saved.status === "missing") throw new Error("desktop_compatibility_certificate_not_prepared"); + if (saved.status === "invalid") throw new Error("desktop_compatibility_certificate_invalid"); + if (saved.status === "expired") throw new Error("desktop_compatibility_certificate_expired"); + } + const authority = await (io.loadAuthority?.() ?? loadDesktopCompatibilityAuthority({ directory })); + const trust = await (io.trust?.(authority) ?? inspectWindowsCertificateTrust(authority.authority.certPem, authority.fingerprint)); + if (trust !== "trusted") throw new Error("desktop_compatibility_trust_required"); + const connections = io.connectionStore ?? createDesktopConnectionStore(directory); + const previousConnection = connections.read(); + const deadline = authority.expiresAt - 300_000; + if (deadline <= Date.now()) throw new Error("desktop_compatibility_certificate_expiring"); + const identity = io.identity ?? createNativeIdentityReader(join((await import("../paths")).getCodexHome(), "auth.json"), desktopOutboundFetch as typeof fetch); + const account: UsageIdentity | null = await identity.verifyFreshIdentity(); + if (!account) throw new Error("desktop_compatibility_native_identity_unverified"); + if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); + controller = new UsageRelayController(account, identity.readCurrentIdentity, identity.verifyFreshIdentity, Date.now, deadline, 180000, contextValid); + relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]), + fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? desktopOutboundFetch as typeof fetch) }); + try { proxy = await startConnectProxy(previousConnection?.connectPort ?? 0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); } + catch { throw new Error("desktop_compatibility_connection_unavailable"); } + const runId = previousConnection?.id ?? randomUUID(), proxyPort = proxy.port; + try { pac = Bun.serve({ hostname: "127.0.0.1", port: previousConnection?.pacPort ?? 0, fetch(req) { + const url = new URL(req.url); + if (req.method !== "GET" || req.headers.has("origin") || url.origin !== `http://127.0.0.1:${pac!.port}` || url.pathname !== `/${runId}/proxy.pac`) return new Response(null, { status: 404 }); + return new Response(`function FindProxyForURL(url, host) { return Date.now() < ${deadline} && host.toLowerCase() === "chatgpt.com" && url.indexOf("https:") === 0 ? "PROXY 127.0.0.1:${proxyPort}; DIRECT" : "DIRECT"; }`, + { headers: { "content-type": "application/x-ns-proxy-autoconfig", "cache-control": "no-store" } }); + } }); } catch { throw new Error("desktop_compatibility_connection_unavailable"); } + const connection = await connections.publish({ version: 1, id: runId, connectPort: proxyPort, pacPort: pac.port! }); + if (connection.id !== runId || connection.connectPort !== proxyPort || connection.pacPort !== pac.port) throw new Error("desktop_compatibility_connection_changed"); + if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); + const active = controller; let ticking = false, deadlineHandled = false, lastContextCheck = Date.now(); + timer = setInterval(() => { + if (ticking) return; ticking = true; + const expired = Date.now() >= deadline; + const work = (async () => { + const contextCadence = active.snapshot().mode === "apply" ? 10000 : 60000; + if (!expired && Date.now() - lastContextCheck >= contextCadence) { + lastContextCheck = Date.now(); + if (!await contextValid() && active.snapshot().mode === "apply") { await active.observeOnly(); return; } + } + await (expired && !deadlineHandled ? (deadlineHandled = true, active.observeOnly()) : active.expireIfNeeded()); + })(); + void work.catch(() => {}).finally(() => { ticking = false; }); + }, 1000); timer.unref(); + owned = { controller, close: cleanup, fingerprint: authority.fingerprint, deadline, pacUrl: `http://127.0.0.1:${pac.port}/${runId}/proxy.pac` }; + phase = "running"; + const launchedBy = owned; + unbindLaunch = bindDesktopCompatibilityLaunch(owned.pacUrl, + () => owned === launchedBy && phase === "running" && Date.now() < deadline); + detach = registerOptionalShutdownHook("codex-desktop-compatibility", () => { void stop().catch(() => {}); }); + return status(); + } catch (error) { + try { await cleanup(); pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; } + catch { phase = "cleanup-required"; throw new Error("desktop_compatibility_cleanup_incomplete"); } + throw error; + } + } + async function apply(accountWideConsent: boolean) { + const current = owned; + if (!current || phase !== "running") throw new Error("desktop_compatibility_not_running"); + const generation = current.controller.snapshot().generation, valid = await contextValid(); + if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running"); + if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() }; + if (!valid) { await current.controller.observeOnly(); throw new Error(`desktop_compatibility_${contextFailure}`); } + const result = await current.controller.activate({ scope: "account-ui-compatibility", accountWideConsent }); + if (owned !== current || phase !== "running") { await current.controller.observeOnly(); return { ...result, accepted: false, reason: "runtime-stopped" }; } + return result; + } + return { status, start, stop, apply, + async observe() { if (owned) await owned.controller.observeOnly(); return status(); }, + async launch() { + if (!owned || phase !== "running") throw new Error("desktop_compatibility_not_running"); + const current = owned; + if (!await contextValid()) throw new Error(`desktop_compatibility_${contextFailure}`); + if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running"); + return launchWindowsCodexCompatibility(current.pacUrl); + }, + /** Internal native launch context; management status never returns the control endpoint. */ + getPacUrl: () => owned?.pacUrl ?? null, + }; +} +export type DesktopCompatibilityRuntime = ReturnType; diff --git a/src/codex/desktop-compatibility/service.ts b/src/codex/desktop-compatibility/service.ts new file mode 100644 index 00000000000..64ada008bff --- /dev/null +++ b/src/codex/desktop-compatibility/service.ts @@ -0,0 +1,30 @@ +import { createDesktopCompatibilityRuntime, type DesktopCompatibilityRuntime } from "./runtime"; + +/** One runtime for management and automatic startup; shutdown never constructs a new instance. */ +export function createDesktopCompatibilityService(factory: () => DesktopCompatibilityRuntime = createDesktopCompatibilityRuntime) { + let runtime: DesktopCompatibilityRuntime | undefined; + let starting: Promise> | null = null; + let shuttingDown = false; + return { + get(): DesktopCompatibilityRuntime { + if (!runtime) { + const raw = factory(); + runtime = { ...raw, start() { + if (shuttingDown) return Promise.reject(new Error("desktop_compatibility_stopping")); + if (starting) return Promise.reject(new Error("desktop_compatibility_busy")); + starting = raw.start().finally(() => { starting = null; }); + return starting; + } }; + } + return runtime; + }, + async shutdown(): Promise { + shuttingDown = true; + try { await starting; } catch { /* failed start owns its cleanup */ } + if (runtime) await runtime.stop(); + }, + }; +} +const service = createDesktopCompatibilityService(); +export const getDesktopCompatibilityRuntime = () => service.get(); +export const shutdownDesktopCompatibility = () => service.shutdown(); diff --git a/src/codex/desktop-compatibility/startup-settings.ts b/src/codex/desktop-compatibility/startup-settings.ts new file mode 100644 index 00000000000..4ffba406b89 --- /dev/null +++ b/src/codex/desktop-compatibility/startup-settings.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import { readConfigFileSnapshot } from "../../config/diagnostics"; +import { mutatePersistedConfig } from "../../config/persisted-mutation"; +import { desktopCompatibilityConfigError } from "../../config/schema/desktop-compatibility"; +import { adoptPersistedDesktopCompatibility } from "../../config/live-reconcile"; +import type { OcxConfig } from "../../types"; + +export interface DesktopStartupSettings { startOnProxyStart: boolean; revision: string } +interface SettingsIo { read?: typeof readConfigFileSnapshot; mutate?: typeof mutatePersistedConfig; liveConfig?: OcxConfig } +const failure = (code: string) => new Error(`desktop_compatibility_settings_${code}`); +/** A next-process preference: never changes live runtime, trust, app or login state. */ +export function createDesktopStartupSettings(io: SettingsIo = {}) { + const read = io.read ?? readConfigFileSnapshot, mutate = io.mutate ?? mutatePersistedConfig; + function inspect() { + const snapshot = read(); + if (snapshot.diagnostics.source !== "file" || snapshot.raw === undefined) throw failure("unavailable"); + let raw: Record; + try { raw = JSON.parse(snapshot.raw.replace(/^\uFEFF/, "")); } catch { throw failure("unavailable"); } + if (desktopCompatibilityConfigError(raw)) throw failure("invalid"); + const setting = raw.desktopCompatibility as { startOnProxyStart: boolean } | undefined; + return { setting, status: { startOnProxyStart: setting?.startOnProxyStart === true, + revision: createHash("sha256").update(JSON.stringify(setting ?? null)).digest("hex") } }; + } + const status = (): DesktopStartupSettings => inspect().status; + function set(startOnProxyStart: boolean, expectedRevision: string): DesktopStartupSettings { + if (typeof startOnProxyStart !== "boolean" || !/^[a-f0-9]{64}$/.test(expectedRevision)) throw failure("invalid_request"); + let outcome; + try { outcome = mutate(config => { + // Runs under the existing config lock, including each rebase retry. Unrelated fields + // may change, but a changed preference must be shown again before this write. + const current = status(); + if (current.revision !== expectedRevision) throw failure("changed"); + if (current.startOnProxyStart === startOnProxyStart) return { changed: false, value: startOnProxyStart }; + config.desktopCompatibility = { startOnProxyStart }; + return { changed: true, value: startOnProxyStart }; + }); } catch (error) { + // Publication can succeed before bookkeeping fails. Reconcile a verified disk + // field without claiming success or replaying the uncertain write. + if (io.liveConfig) { try { adoptPersistedDesktopCompatibility(io.liveConfig, inspect().setting); } catch { /* unresolved state remains an error */ } } + throw error; + } + if (outcome.status === "unavailable") throw failure("unavailable"); + const actual = inspect(); + if (io.liveConfig) adoptPersistedDesktopCompatibility(io.liveConfig, actual.setting); + if (actual.status.startOnProxyStart !== startOnProxyStart) throw failure("changed"); + return actual.status; + } + return { status, set }; +} +export type DesktopStartupSettingsService = ReturnType; diff --git a/src/codex/desktop-compatibility/usage-activation.ts b/src/codex/desktop-compatibility/usage-activation.ts new file mode 100644 index 00000000000..cdd92e363fb --- /dev/null +++ b/src/codex/desktop-compatibility/usage-activation.ts @@ -0,0 +1,86 @@ +/** Account-bound, time-limited compatibility activation; no persistent settings or timers. */ +export type Observation = 'exhausted' | 'available' | 'protected'; +export type ApplyScope = 'selected-external-model' | 'account-ui-compatibility'; +type RefreshResult = {requested:number;closed:number;failed:number}; +export class UsageActivation { + private mode: 'observe' | 'apply' = 'observe'; + private phase = 'observing'; + private generation = 0; + private latest: { kind:Observation; at:number } | null = null; + private startedAt: number | null = null; + private outputs = 0; + constructor(private binding:string, private refresh:(binding:string)=>Promise, + private verifyIdentity:()=>Promise, + private clock:()=>number=Date.now, private timeoutMs=180000) { + if(!binding || !Number.isSafeInteger(timeoutMs) || timeoutMs<1 || timeoutMs>180000) throw new Error('Invalid activation limits'); + } + snapshot() { return {mode:this.mode,phase:this.phase,generation:this.generation,outputs:this.outputs, + appCacheConfirmed:false,requestedUiScope:'account-wide',providerIsolationAvailable:false}; } + invalidateIdentity() { + this.mode='observe';this.phase='identity-invalidated';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation; + } + observe(binding:string,kind:Observation) { + if(binding!==this.binding)return false; + this.latest={kind,at:this.clock()}; + // A recovered or protected account must not silently re-enter a previous trial + // if a later snapshot becomes exhausted again. A new trial requires consent. + if(this.mode==='apply'&&kind!=='exhausted'){ + this.mode='observe';this.phase=kind==='available'?'usage-recovered':'protected-observation'; + this.startedAt=null;this.outputs=0;++this.generation; + } + return true; + } + private async reset(phase:string) { + this.mode='observe';this.phase=phase;this.startedAt=null;this.latest=null;this.outputs=0;++this.generation; + return this.refresh(this.binding); + } + async activate(options:{scope:ApplyScope;accountWideConsent:boolean}) { + if(options.scope!=='account-ui-compatibility')return{accepted:false,reason:'selected-model-scope-unavailable',...this.snapshot()}; + if(!options.accountWideConsent)return{accepted:false,reason:'account-wide-scope-not-accepted',...this.snapshot()}; + const beforeVerification=this.generation,binding=this.binding; + let verified:string|null; + try{verified=await this.verifyIdentity();}catch{verified=null;} + if(verified!==binding||this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()}; + if(this.mode==='apply')return{accepted:false,reason:'activation-already-pending',...this.snapshot()}; + const age=this.latest?this.clock()-this.latest.at:Infinity; + if(!this.latest||this.latest.kind!=='exhausted'||age<0||age>300000)return{accepted:false,reason:'no-fresh-eligible-exhaustion',...this.snapshot()}; + // An exhausted observation authorizes one trial, not every retry within its TTL. + this.latest=null; + this.mode='apply';this.phase='awaiting-fresh-usage';this.startedAt=this.clock();this.outputs=0; + const generation=++this.generation; + let refreshed:RefreshResult; + try { refreshed=await this.refresh(this.binding); } + catch { + if(this.generation===generation){this.mode='observe';this.phase='refresh-failed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;} + return{accepted:false,reason:'refresh-failed',...this.snapshot()}; + } + // Account/mode changes while a close callback awaited invalidate this activation. + if(this.generation!==generation)return{accepted:false,reason:'superseded',...this.snapshot()}; + // The registry settles every close and reports failures; it does not reject the batch. + // Partial closure cannot leave rewriting enabled while an old app cache may survive. + if(!Number.isSafeInteger(refreshed.requested)||!Number.isSafeInteger(refreshed.closed) + ||!Number.isSafeInteger(refreshed.failed)||refreshed.requested<0||refreshed.closed<0 + ||refreshed.failed!==0||refreshed.closed!==refreshed.requested){ + this.mode='observe';this.phase='refresh-failed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation; + return{accepted:false,reason:'refresh-failed',refresh:refreshed,...this.snapshot()}; + } + return{accepted:true,reason:'awaiting-new-response',refresh:refreshed,...this.snapshot()}; + } + recordOutput(binding:string,generation:number) { + if(binding!==this.binding||generation!==this.generation||this.mode!=='apply')return false; + if(this.startedAt===null||this.clock()-this.startedAt<0||this.clock()-this.startedAt>=this.timeoutMs)return false; + ++this.outputs;this.phase='response-produced-app-confirmation-needed';return true; + } + async expireIfNeeded() { + if(this.startedAt===null||this.clock()-this.startedAt=0)return false; + await this.reset('expired-awaiting-original-response');return true; + } + async observeOnly() { return this.reset('observing-awaiting-original-response'); } + async changeBinding(binding:string) { + if(!binding)throw new Error('Verified account binding is required'); + const previous=this.binding; + this.mode='observe';this.phase='account-changed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation; + this.binding=binding; + return this.refresh(previous); + } +} diff --git a/src/codex/desktop-compatibility/usage-controlled-fetch.ts b/src/codex/desktop-compatibility/usage-controlled-fetch.ts new file mode 100644 index 00000000000..f0f0e6d1fb8 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-controlled-fetch.ts @@ -0,0 +1,59 @@ +import { UsageRelayController } from './usage-controller'; +import { controlledUsageSse } from './usage-sse-controller'; +import { boundedJsonBody } from './json-body'; + +/** Inject as the listener's fetchImpl. Existing auth forwarding/TLS/WS code stays unchanged. */ +export function createUsageControlledFetch(controller: UsageRelayController, upstreamFetch: typeof fetch = fetch): typeof fetch { + return (async (input: Parameters[0], init?: Parameters[1]) => { + const upstream = await upstreamFetch(input, init); + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + const exchange = { pathname, method, status: upstream.status }; + if (method !== 'GET' || upstream.status !== 200 || !upstream.body + || !['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(pathname)) return upstream; + const contentType = upstream.headers.get('content-type') ?? ''; + const headers = new Headers(upstream.headers); + const output = (body: BodyInit, transformed = false) => { + headers.delete('content-length'); headers.delete('content-encoding'); + if (transformed) { + for (const name of ['etag', 'last-modified', 'digest', 'content-md5']) headers.delete(name); + headers.set('cache-control', 'no-store'); + } + return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers }); + }; + if (contentType.includes('application/json') || contentType.endsWith('+json')) { + const body = await boundedJsonBody(upstream.body); + if ('stream' in body) return output(body.stream); + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(body.bytes); } + catch { return output(new Uint8Array(body.bytes)); } + const changed = await controller.rewriteJson(text, exchange); + return output(changed ?? new Uint8Array(body.bytes), changed !== null); + } + if (!contentType.includes('text/event-stream') || pathname !== '/backend-api/wham/usage/stream') return upstream; + let registration: ReturnType = null; + const reader = upstream.body.pipeThrough(controlledUsageSse(text => controller.rewriteJson(text, exchange, registration))).getReader(); + let ended = false, sink: ReadableStreamDefaultController; + const close = async () => { + if (ended) return; + ended = true; registration?.release(); + const cancelled = reader.cancel(); + try { sink.close(); } catch { /* caller already cancelled */ } + await cancelled; + }; + const body = new ReadableStream({ + start(value) { sink = value; }, + async pull(value) { + try { + const next = await reader.read(); + if (ended) return; + if (next.done) { ended = true; registration?.release(); value.close(); } + else value.enqueue(next.value); + } catch (error) { if (!ended) { ended = true; registration?.release(); value.error(error); } } + }, + cancel: close, + }); + registration = controller.registerStream(exchange, close); + return output(body, true); + }) as typeof fetch; +} diff --git a/src/codex/desktop-compatibility/usage-controller.ts b/src/codex/desktop-compatibility/usage-controller.ts new file mode 100644 index 00000000000..9e9c6403fb4 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-controller.ts @@ -0,0 +1,107 @@ +import { UsageActivation, type ApplyScope } from './usage-activation'; +import { UsageRefreshRegistry } from './usage-refresh'; +import { evaluateUsageRewrite, type UsageRewriteContext } from './usage-policy'; + +export type UsageIdentity = NonNullable & { credentialGeneration?: string }; +type Registration = NonNullable>; +type Exchange = { method: string; pathname: string; status: number }; +const same = (a: UsageIdentity | null, b: UsageIdentity) => a !== null + && a.id === b.id && a.userId === b.userId && a.plan === b.plan && a.structure === b.structure + && a.credentialGeneration === b.credentialGeneration; +const object = (value: unknown): value is Record => value !== null + && typeof value === 'object' && !Array.isArray(value); + +/** Response-level controller. Construction never starts a listener, timer or trust operation. */ +export class UsageRelayController { + private readonly account: UsageIdentity; + private readonly key: string; + private readonly refresh = new UsageRefreshRegistry(); + private readonly activation: UsageActivation; + private jsonSnapshots = 0; + private streamSnapshots = 0; + private lastSnapshotAt: number | null = null; + constructor(account: UsageIdentity, private readonly readCurrentIdentity: () => Promise, + verifyFreshIdentity: () => Promise, private readonly clock: () => number, + private readonly expiresAt: number, timeoutMs = 180000, private readonly contextValid: () => boolean | Promise = () => true) { + if (!account.id || !account.userId || !['plus', 'pro'].includes(account.plan) + || account.structure !== 'personal' || !Number.isFinite(expiresAt) || expiresAt <= clock()) { + throw new Error('A verified supported identity and finite safety deadline are required'); + } + this.account = { ...account }; + this.key = JSON.stringify([account.id, account.userId, account.plan, account.credentialGeneration ?? null]); + this.activation = new UsageActivation(this.key, key => this.refresh.refresh(key), async () => { + return same(await verifyFreshIdentity(), this.account) ? this.key : null; + }, clock, timeoutMs); + } + snapshot() { return { ...this.activation.snapshot(), trackedStreams: this.refresh.size, expired: this.clock() >= this.expiresAt, + observation: { jsonSnapshots: this.jsonSnapshots, streamSnapshots: this.streamSnapshots, + validatedActiveStreams: this.refresh.boundSize, lastSnapshotAt: this.lastSnapshotAt, + sourceProcessVerified: false as const, composerRecoveryVerified: false as const } }; } + private async checkContext() { try { return await this.contextValid(); } catch { return false; } } + async activate(options: { scope: ApplyScope; accountWideConsent: boolean }) { + const generation = this.activation.snapshot().generation, valid = await this.checkContext(); + if (generation !== this.activation.snapshot().generation) return { accepted: false, reason: 'superseded', ...this.snapshot() }; + if (!valid) { this.activation.invalidateIdentity(); return { accepted: false, reason: 'native-context-changed', ...this.snapshot() }; } + if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); return { accepted: false, reason: 'safety-deadline', ...this.snapshot() }; } + return this.activation.activate(options); + } + observeOnly() { return this.activation.observeOnly(); } + expireIfNeeded() { return this.activation.expireIfNeeded(); } + registerStream(exchange: Exchange, close: () => Promise): Registration | null { + if (exchange.status !== 200) return null; + return this.refresh.register(exchange.method, exchange.pathname, close); + } + /** Only a complete original usage record may establish the stream's account binding. */ + async rewriteJson(text: string, exchange: Exchange, stream?: Registration | null): Promise { + if (exchange.method !== 'GET' || exchange.status !== 200 + || !['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(exchange.pathname)) return null; + if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + const observedGeneration = this.activation.snapshot().generation; + let current: UsageIdentity | null; + try { current = await this.readCurrentIdentity(); } catch { current = null; } + if (!same(current, this.account)) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + // Do not apply an activation that raced this response's identity read. + if (observedGeneration !== this.activation.snapshot().generation) return null; + if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + if (Buffer.byteLength(text, 'utf8') > 262144) { stream?.exclude(); return null; } + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { stream?.exclude(); return null; } + const envelope = object(parsed) && 'usage' in parsed ? parsed : null; + if ((exchange.pathname.endsWith('/stream') && !envelope) + || (envelope && (envelope.version !== 1 || typeof envelope.stream_id !== 'string' || !envelope.stream_id + || !Number.isSafeInteger(envelope.sequence) || Number(envelope.sequence) <= 0))) { stream?.exclude(); return null; } + const original = envelope ? envelope.usage : parsed; + if (!object(original) || original.account_id !== this.account.id || original.user_id !== this.account.userId + || original.plan_type !== this.account.plan) { stream?.exclude(); return null; } + const rate = original.rate_limit; + if (!object(rate) || typeof rate.allowed !== 'boolean' || typeof rate.limit_reached !== 'boolean') { + stream?.exclude(); return null; + } + if (stream && !stream.bind(this.key)) return null; + // Identity-checked responses received by this relay do not establish the + // sending process or prove that an authoritative UI cache consumed them. + if (exchange.pathname.endsWith('/stream')) this.streamSnapshots = Math.min(Number.MAX_SAFE_INTEGER, this.streamSnapshots + 1); + else this.jsonSnapshots = Math.min(Number.MAX_SAFE_INTEGER, this.jsonSnapshots + 1); + this.lastSnapshotAt = this.clock(); + const context: UsageRewriteContext = { enabled: true, mode: 'observe', status: exchange.status, + pathname: exchange.pathname, account: this.account }; + const observed = evaluateUsageRewrite(original, context); + this.activation.observe(this.key, observed.eligible ? 'exhausted' + : rate.allowed === true && rate.limit_reached === false ? 'available' : 'protected'); + const state = this.activation.snapshot(); + if (state.mode !== 'apply') return null; + const result = evaluateUsageRewrite(original, { ...context, mode: 'apply' }); + if (!result.changed) return null; + // Observe and unchanged responses never spawn an installed-package probe. + const valid = await this.checkContext(); + if (state.generation !== this.activation.snapshot().generation) return null; + if (!valid) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + // A native account or trial can change while the asynchronous build check runs. + try { current = await this.readCurrentIdentity(); } catch { current = null; } + if (state.generation !== this.activation.snapshot().generation) return null; + if (!same(current, this.account)) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; } + if (!this.activation.recordOutput(this.key, state.generation)) return null; + return JSON.stringify(envelope ? { ...envelope, usage: result.value } : result.value); + } +} diff --git a/src/codex/desktop-compatibility/usage-policy.ts b/src/codex/desktop-compatibility/usage-policy.ts new file mode 100644 index 00000000000..7a87d10f0ff --- /dev/null +++ b/src/codex/desktop-compatibility/usage-policy.ts @@ -0,0 +1,49 @@ +/** Pure response policy. Real quota windows, credits and spending restrictions stay unchanged. */ +export interface UsageRewriteContext { + enabled: boolean; + mode: 'observe' | 'apply'; + status: number; + pathname: string; + account: { + id: string; + userId: string; + plan: 'plus' | 'pro'; + structure: 'personal'; + } | null; +} + +type RecordValue = Record; +function record(value: unknown): value is RecordValue { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Transform only a complete, matched personal-account usage snapshot. + * Account context must come from a trusted live integration; this module does not + * discover it or prove provider isolation. Never persist the adjusted snapshot as + * actual provider/account capacity. Unknown or protected states pass unchanged. + */ +export function evaluateUsageRewrite(value: unknown, context: UsageRewriteContext) { + const unchanged = (reason: string) => ({ value, changed: false, eligible: false, reason }); + if (!context.enabled) return unchanged('off'); + if (context.status !== 200) return unchanged('non-success-response'); + if (!['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(context.pathname)) return unchanged('unowned-path'); + const account = context.account; + if (!account || !account.id || !account.userId || account.structure !== 'personal' || !['plus', 'pro'].includes(account.plan)) return unchanged('unsupported-account'); + if (!record(value) || value.account_id !== account.id || value.user_id !== account.userId || value.plan_type !== account.plan) return unchanged('identity-mismatch'); + const rate = value.rate_limit; + if (!record(rate) || rate.allowed !== false || rate.limit_reached !== true) return unchanged('not-explicitly-exhausted'); + const spend = value.spend_control; + // Absence is unknown, not evidence that no protected spending restriction exists. + if (!record(spend) || spend.reached !== false) return unchanged('protected-or-unknown-spend'); + const reason = value.rate_limit_reached_type; + if (reason != null && (!record(reason) || reason.type !== 'rate_limit_reached')) return unchanged('protected-or-unknown-reason'); + const credits = value.credits; + if (!record(credits) || typeof credits.has_credits !== 'boolean' || typeof credits.unlimited !== 'boolean') return unchanged('unknown-credit-schema'); + if (credits.overage_limit_reached != null && credits.overage_limit_reached !== false) return unchanged('overage-restriction'); + if (context.mode !== 'apply') return { value, changed: false, eligible: true, reason: 'observe-only' }; + return { + value: { ...value, rate_limit: { ...rate, allowed: true, limit_reached: false } }, + changed: true, eligible: true, reason: 'personal-usage-only', + }; +} diff --git a/src/codex/desktop-compatibility/usage-refresh.ts b/src/codex/desktop-compatibility/usage-refresh.ts new file mode 100644 index 00000000000..2eea2aa1e1c --- /dev/null +++ b/src/codex/desktop-compatibility/usage-refresh.ts @@ -0,0 +1,33 @@ +/** Exact usage-stream refresh registry. No timers, sockets or core-path imports. */ +export class UsageRefreshRegistry { + private entries = new Set<{ binding: string | null; tainted: boolean; close: () => Promise }>(); + register(method: string, pathname: string, close: () => Promise) { + if (method !== 'GET' || pathname !== '/backend-api/wham/usage/stream') return null; + const entry = { binding: null as string | null, tainted: false, close }; + this.entries.add(entry); + return { + // Caller must invoke only after validating a complete upstream snapshot's identity. + // A connection that changes account identity never becomes a refresh target again. + bind: (verifiedBinding: string) => { + if (!verifiedBinding || entry.tainted || !this.entries.has(entry)) return false; + if (entry.binding !== null && entry.binding !== verifiedBinding) { + entry.tainted = true; entry.binding = null; return false; + } + entry.binding = verifiedBinding; return true; + }, + exclude: () => { entry.tainted = true; entry.binding = null; }, + release: () => { this.entries.delete(entry); }, + }; + } + async refresh(binding: string) { + if (!binding) throw new Error('Verified account binding is required'); + const selected = [...this.entries].filter(e => !e.tainted && e.binding === binding); + // Detach before awaiting; concurrent refreshes cannot close the same stream twice. + for (const entry of selected) this.entries.delete(entry); + const results = await Promise.allSettled(selected.map(entry => Promise.resolve().then(entry.close))); + return { requested: selected.length, closed: results.filter(r => r.status === 'fulfilled').length, + failed: results.filter(r => r.status === 'rejected').length }; + } + get size() { return this.entries.size; } + get boundSize() { return [...this.entries].filter(entry => !entry.tainted && entry.binding !== null).length; } +} diff --git a/src/codex/desktop-compatibility/usage-sse-controller.ts b/src/codex/desktop-compatibility/usage-sse-controller.ts new file mode 100644 index 00000000000..d99d458e3e6 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-sse-controller.ts @@ -0,0 +1,86 @@ +type Rewrite = (text: string) => Promise; + +async function rewriteRecord(bytes: Buffer, first: boolean, rewrite: Rewrite): Promise { + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } + catch { return Buffer.from(bytes); } + const lines = [...text.matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/g)].filter(m => m[0].length > 0); + const data: string[] = [], indexes = new Set(); let event = ''; + lines.forEach((line, i) => { + const content = first && i === 0 ? line[1]!.replace(/^\uFEFF/, '') : line[1]!; + if (content.startsWith(':')) return; + const colon = content.indexOf(':'); + const key = colon < 0 ? content : content.slice(0, colon); + const value = colon < 0 ? '' : content.slice(colon + 1).replace(/^ /, ''); + if (key === 'event') event = value; + if (key === 'data') { data.push(value); indexes.add(i); } + }); + if (event !== 'usage.snapshot' || !data.length) return Buffer.from(bytes); + const changed = await rewrite(data.join('\n')); + if (changed === null) return Buffer.from(bytes); + let written = false; + return Buffer.from(lines.map((line, i) => { + if (!indexes.has(i)) return line[0]; + if (written) return ''; + written = true; + const bom = first && i === 0 && line[1]!.startsWith('\uFEFF') ? '\uFEFF' : ''; + return `${bom}data: ${changed}${line[2]}`; + }).join('')); +} + +/** Byte-bounded SSE framing; never fabricates events or changes stream sequence numbers. */ +export function controlledUsageSse(rewrite: Rewrite, cap = 262144): TransformStream { + if (!Number.isSafeInteger(cap) || cap < 1 || cap > 1048576) throw new Error('Invalid record limit'); + const buffer = Buffer.alloc(cap); + let used = 0, lineHasBytes = false, pendingCR = false, first = true, passthrough = false; + return new TransformStream({ + async transform(chunk, controller) { + let rawStart = passthrough ? 0 : -1; + const enterPassthrough = (start: number) => { + controller.enqueue(Buffer.from(buffer.subarray(0, used))); + used = 0; passthrough = true; rawStart = start; + }; + const endRecord = async (rawEnd: number) => { + if (passthrough) { + if (rawEnd > rawStart) controller.enqueue(chunk.subarray(rawStart, rawEnd)); + passthrough = false; rawStart = -1; + } else { + controller.enqueue(await rewriteRecord(buffer.subarray(0, used), first, rewrite)); + } + first = false; used = 0; lineHasBytes = false; + }; + for (let i = 0; i < chunk.length; i++) { + const byte = chunk[i]!; + if (pendingCR) { + pendingCR = false; + if (byte !== 10) { + if (!lineHasBytes) await endRecord(i); + lineHasBytes = false; + } + else { + if (!passthrough && used === cap) enterPassthrough(i); + if (!passthrough) buffer[used++] = byte; + const blankLine = !lineHasBytes; + lineHasBytes = false; + if (blankLine) await endRecord(i + 1); + continue; + } + } + if (!passthrough && used === cap) enterPassthrough(i); + if (!passthrough) buffer[used++] = byte; + if (byte === 13) pendingCR = true; + else if (byte === 10) { + const blankLine = !lineHasBytes; + lineHasBytes = false; + if (blankLine) await endRecord(i + 1); + } else lineHasBytes = true; + } + if (passthrough && rawStart < chunk.length) controller.enqueue(chunk.subarray(rawStart)); + }, + async flush(controller) { + if (passthrough) return; + if (pendingCR && !lineHasBytes) { controller.enqueue(await rewriteRecord(buffer.subarray(0, used), first, rewrite)); used = 0; } + if (used > 0) controller.enqueue(Buffer.from(buffer.subarray(0, used))); + }, + }); +} diff --git a/src/codex/desktop-compatibility/windows-activation-source.ts b/src/codex/desktop-compatibility/windows-activation-source.ts new file mode 100644 index 00000000000..6d4c1497273 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-activation-source.ts @@ -0,0 +1,44 @@ +// Kept inline so source and standalone Bun distributions carry the same COM adapter. +export const WINDOWS_ACTIVATION_SOURCE = String.raw`using System; +using System.Runtime.InteropServices; +using System.Text; + +// Windows.Launch preserves package identity and forwards explicit application arguments. +public static class OpenCodexPackageActivation { + [ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IApplicationActivationManager { + [PreserveSig] int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appId, [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId); + [PreserveSig] int ActivateForFile([MarshalAs(UnmanagedType.LPWStr)] string appId, IntPtr items, [MarshalAs(UnmanagedType.LPWStr)] string verb, out uint processId); + [PreserveSig] int ActivateForProtocol([MarshalAs(UnmanagedType.LPWStr)] string appId, IntPtr items, out uint processId); + } + [DllImport("ole32.dll", PreserveSig=true)] + private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer, uint context, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out IApplicationActivationManager manager); + [DllImport("kernel32.dll", SetLastError=true)] private static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] private static extern int GetPackageFullName(IntPtr process, ref uint length, StringBuilder name); + private static IApplicationActivationManager Manager() { + var clsid=new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C"); + var iid=new Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"); + IApplicationActivationManager manager; + // LOCAL_SERVER preserves activation argument lifetime after the short launcher exits. + Marshal.ThrowExceptionForHR(CoCreateInstance(ref clsid,IntPtr.Zero,4,ref iid,out manager)); + return manager; + } + public static void ValidateActivationService() {var manager=Manager();Marshal.FinalReleaseComObject(manager);} + public static uint Activate(string appId,string arguments) { + var manager=Manager(); + try {uint pid;Marshal.ThrowExceptionForHR(manager.ActivateApplication(appId,arguments,0,out pid));return pid;} + finally {Marshal.FinalReleaseComObject(manager);} + } + public static string PackageOf(uint pid) { + var handle=OpenProcess(0x1000,false,pid); + if(handle==IntPtr.Zero)throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + try {uint length=0;int status=GetPackageFullName(handle,ref length,null); + if(status==15700)return null; + if(status!=122)throw new System.ComponentModel.Win32Exception(status); + var value=new StringBuilder((int)length);status=GetPackageFullName(handle,ref length,value); + if(status!=0)throw new System.ComponentModel.Win32Exception(status); + return value.ToString(); + }finally{CloseHandle(handle);} + } +}`; diff --git a/src/codex/desktop-compatibility/windows-certificate-trust.ts b/src/codex/desktop-compatibility/windows-certificate-trust.ts new file mode 100644 index 00000000000..d26e2f828c3 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-certificate-trust.ts @@ -0,0 +1,81 @@ +import { execFile } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import type { StoredDesktopAuthority } from "./certificate-store"; +import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; +import { isServerAuthOnlyCertificate } from "../../claude/intercept/local-ca"; + +export type DesktopCertificateTrust = "trusted" | "not-trusted" | "unknown"; +export type TrustOperation = "inspect" | "trust" | "remove"; +export type CertificateTrustRunner = (operation: TrustOperation, certificateDer: string, fingerprint: string) => Promise; + +const SCRIPT = [ + "$ErrorActionPreference='Stop'", + "$inputData=[Console]::In.ReadToEnd()|ConvertFrom-Json", + "$bytes=[Convert]::FromBase64String($inputData.certificate)", + "$cert=[Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)", + "$sha=[Security.Cryptography.SHA256]::Create()", + "try {$fingerprint=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','')}finally{$sha.Dispose()}", + "if($fingerprint -cne $inputData.fingerprint){throw 'Certificate changed'}", + "$store=[Security.Cryptography.X509Certificates.X509Store]::new('Root','CurrentUser')", + "$mode=if($inputData.operation -eq 'inspect'){[Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly}else{[Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite}", + "try {", + " $store.Open($mode)", + " $foundCertificates=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$cert.Thumbprint,$false))", + " foreach($certificateMatch in $foundCertificates){if([Convert]::ToBase64String($certificateMatch.RawData) -cne [Convert]::ToBase64String($bytes)){throw 'Certificate mismatch'}}", + " if($inputData.operation -eq 'trust'){if($foundCertificates.Count -eq 0){$store.Add($cert)}}", + " elseif($inputData.operation -eq 'remove'){foreach($certificateMatch in $foundCertificates){$store.Remove($certificateMatch)}}", + " elseif($inputData.operation -ne 'inspect'){throw 'Unknown operation'}", + " $present=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$cert.Thumbprint,$false)).Count -gt 0", + " if($present){[Console]::Out.Write('trusted')}else{[Console]::Out.Write('not-trusted')}", + "}finally{$store.Close();$store.Dispose();$cert.Dispose()}", +].join("\n"); + +const run: CertificateTrustRunner = (operation, certificateDer, fingerprint) => { + if (process.platform !== "win32") return Promise.resolve("unknown"); + if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown"); + return new Promise(resolve => { + const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { + timeout: operation === "inspect" ? 10_000 : 120_000, maxBuffer: 4096, windowsHide: true, encoding: "utf8", + }, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted") + ? stdout.trim() as DesktopCertificateTrust : "unknown")); + child.stdin?.on("error", () => { /* completion and readback decide the result */ }); + child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint })); + }); +}; + +/** Public status cannot create a certificate, decrypt a key or alter OS trust. */ +export async function inspectWindowsCertificateTrust(certPem: string, expectedFingerprint: string, runner: CertificateTrustRunner = run): Promise { + const certificate = new X509Certificate(certPem); + const fingerprint = certificate.fingerprint256.replaceAll(":", ""); + if (!certificate.ca || fingerprint !== expectedFingerprint || !/^[A-F0-9]{64}$/.test(expectedFingerprint)) { + throw new Error("desktop_compatibility_certificate_mismatch"); + } + return runner("inspect", certificate.raw.toString("base64"), fingerprint).catch(() => "unknown" as const); +} + +/** Mutations require the store-validated private key, not unverified public status metadata. */ +export function createWindowsCertificateTrust(authority: StoredDesktopAuthority, expectedFingerprint: string, runner: CertificateTrustRunner = run) { + const certificate = new X509Certificate(authority.authority.certPem); + const fingerprint = certificate.fingerprint256.replaceAll(":", ""); + if (fingerprint !== expectedFingerprint || !certificate.ca || !certificate.checkPrivateKey(authority.authority.privateKey) + || !certificate.verify(authority.authority.publicKey) || !/^OpenCodex Codex Desktop [0-9a-f-]{36}$/.test(authority.commonName) + || !certificate.subject.split("\n").includes(`CN=${authority.commonName}`)) throw new Error("desktop_compatibility_certificate_mismatch"); + const encoded = certificate.raw.toString("base64"); + const inspect = () => runner("inspect", encoded, fingerprint).catch(() => "unknown" as const); + let pending: Promise | null = null; + const change = (operation: "trust" | "remove") => { + if (pending) return Promise.reject(new Error("desktop_compatibility_trust_busy")); + if (operation === "trust" && Date.parse(certificate.validTo) <= Date.now()) return Promise.reject(new Error("desktop_compatibility_authority_expired")); + if (operation === "trust" && !isServerAuthOnlyCertificate(certificate)) return Promise.reject(new Error("desktop_compatibility_authority_renewal_required")); + pending = (async () => { + const before = await inspect(); + if (before === "unknown") return before; + if (before === (operation === "trust" ? "trusted" : "not-trusted")) return before; + try { await runner(operation, encoded, fingerprint); } catch { /* read the exact store after an uncertain action */ } + return inspect(); + })().finally(() => { pending = null; }); + return pending; + }; + return { inspect, trust: () => change("trust"), remove: () => change("remove") }; +} diff --git a/src/codex/desktop-compatibility/windows-key-protection.ts b/src/codex/desktop-compatibility/windows-key-protection.ts new file mode 100644 index 00000000000..6c12cce52a7 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-key-protection.ts @@ -0,0 +1,55 @@ +import { execFile } from "node:child_process"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; + +const MAX_INPUT = 65_536; +const MAX_OUTPUT = 131_072; +const PURPOSE = "opencodex/codex-desktop-compatibility/authority/v1"; +const SCRIPT = [ + "$ErrorActionPreference='Stop'", + "Add-Type -AssemblyName System.Security", + "$r=[Console]::In.ReadToEnd()|ConvertFrom-Json", + "$data=[Convert]::FromBase64String($r.data)", + `if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`, + `$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`, + "$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser", + "if($r.operation -eq 'protect'){", + " $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)", + "}elseif($r.operation -eq 'unprotect'){", + " $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)", + "}else{throw 'Invalid operation'}", + "[Console]::Out.Write([Convert]::ToBase64String($out))", +].join("\n"); + +export interface AuthorityKeyProtection { + protect(cleartext: Uint8Array): Promise; + unprotect(ciphertext: Uint8Array): Promise; +} + +/** The payload travels on stdin, never in process arguments, environment or logs. */ +function runDpapi(operation: "protect" | "unprotect", data: Uint8Array): Promise { + if (process.platform !== "win32") return Promise.reject(new Error("desktop_compatibility_windows_required")); + if (data.byteLength === 0 || data.byteLength > MAX_INPUT) return Promise.reject(new Error("desktop_compatibility_key_size")); + return new Promise((resolve, reject) => { + const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { + timeout: 10_000, maxBuffer: MAX_OUTPUT, windowsHide: true, encoding: "utf8", + }, (error, stdout) => { + // Do not include PowerShell stderr, command output or the input in a propagated error. + if (error || !/^[A-Za-z0-9+/]+={0,2}$/.test(stdout.trim())) { + reject(new Error("desktop_compatibility_key_protection_failed")); return; + } + const bytes = Buffer.from(stdout.trim(), "base64"); + if (bytes.length === 0 || bytes.length > MAX_INPUT) { + reject(new Error("desktop_compatibility_key_size")); return; + } + resolve(bytes); + }); + child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ }); + child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") })); + }); +} + +/** CurrentUser protects against other OS identities, not other processes of the same user. */ +export const windowsAuthorityKeyProtection: AuthorityKeyProtection = { + protect: bytes => runDpapi("protect", bytes), + unprotect: bytes => runDpapi("unprotect", bytes), +}; diff --git a/src/codex/desktop-compatibility/windows-package-command.ts b/src/codex/desktop-compatibility/windows-package-command.ts new file mode 100644 index 00000000000..e7909583784 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-package-command.ts @@ -0,0 +1,86 @@ +import type { DesktopAppInstall, DesktopExec, DesktopProcess } from "../desktop-app/types"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import { WINDOWS_ACTIVATION_SOURCE } from "./windows-activation-source"; +import { readDesktopCompatibilityLaunch } from "./runtime-ownership"; + +/** Only the feature's loopback PAC endpoint may become a launch argument. */ +export function validatedCompatibilityPacUrl(value: string): string { + let url: URL; + try { url = new URL(value); } catch { throw new Error("desktop_compatibility_invalid_pac_url"); } + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || Number(url.port) < 1 + || url.username || url.password || url.search || url.hash + || !/^\/[a-zA-Z0-9-]{16,80}\/proxy\.pac$/.test(url.pathname) + || value !== url.href) throw new Error("desktop_compatibility_invalid_pac_url"); + return url.href; +} + +function literal(value: string): string { return `'${value.replaceAll("'", "''")}'`; } + +export function compatibilityActivationScript(install: DesktopAppInstall, pacUrl: string): string { + const pac = validatedCompatibilityPacUrl(pacUrl); + if (!/^OpenAI\.Codex(?:Beta)?_[A-Za-z0-9]+$/.test(install.id) || install.relaunch !== `${install.id}!App`) { + throw new Error("desktop_compatibility_invalid_package"); + } + return [ + "$ErrorActionPreference='Stop'", + `$family=${literal(install.id)}; $root=${literal(install.root)}; $pac=${literal(pac)}`, + `$p=Get-AppxPackage -Name ${literal(install.id.split("_")[0]!)}`, + "$p=@($p|Where-Object {$_.PackageFamilyName -eq $family -and $_.InstallLocation -ieq $root})", + "if($p.Count -ne 1){throw 'Package changed'}; $p=$p[0]", + "$manifest=Get-AppxPackageManifest -Package $p.PackageFullName", + "$entry=@($manifest.Package.Applications.Application|Where-Object {$_.Id -eq 'App' -and $_.Executable.Replace('/','\\') -ieq 'app\\ChatGPT.exe'})", + "if($entry.Count -ne 1){throw 'Application entry changed'}", + "Add-Type -TypeDefinition @'", WINDOWS_ACTIVATION_SOURCE, "'@", + "$pidValue=[OpenCodexPackageActivation]::Activate($family+'!App','--proxy-pac-url='+$pac)", + "if([OpenCodexPackageActivation]::PackageOf($pidValue) -ne $p.PackageFullName){throw 'Package identity missing'}", + "$running=Get-CimInstance Win32_Process -Filter ('ProcessId='+$pidValue)", + "$expected=Join-Path $p.InstallLocation 'app\\ChatGPT.exe'", + "if(-not $running -or $running.ExecutablePath -ine $expected -or $running.CommandLine -notmatch ('(?:^|[\\s\"])--proxy-pac-url='+[regex]::Escape($pac)+'(?:$|[\\s\"])')){throw 'Launch arguments not observed'}", + "[pscustomobject]@{pid=[int]$pidValue;packageFullName=$p.PackageFullName;verified=$true}|ConvertTo-Json -Compress", + ].join("\n"); +} + + +/** Preserve only the current runtime's PAC and lifetime, never trust a URL shape alone. */ +export function captureWindowsCompatibilityContext(processes: readonly DesktopProcess[]): Record { + const members = new Set(processes.map(entry => entry.pid)); + const urls = new Set(); + for (const entry of processes.filter(value => !members.has(value.parentPid))) { + if (entry.commandLine !== undefined && entry.commandLine.trim() === "") { + throw new Error("desktop_compatibility_launch_context_unavailable"); + } + for (const match of (entry.commandLine ?? "").matchAll(/(?:^|\s)"?--proxy-pac-url=([^"\s]+)"?/g)) { + const url = match[1]!; + if (url.startsWith("http://127.0.0.1:")) urls.add(validatedCompatibilityPacUrl(url)); + } + } + if (urls.size > 1) throw new Error("desktop_compatibility_conflicting_launch_context"); + if (urls.size === 0) return {}; + const pacUrl = [...urls][0]!, owner = readDesktopCompatibilityLaunch(); + if (!owner || owner.pacUrl !== pacUrl) throw new Error("desktop_compatibility_launch_owner_unverified"); + return { codexCompatibilityPacUrl: pacUrl, codexCompatibilityGeneration: owner.generation }; +} + +/** Recheck after the asynchronous stop ladder: identical endpoints can belong to a new runtime. */ +export function assertWindowsCompatibilityContext(context: Record): void { + const owner = readDesktopCompatibilityLaunch(); + if (!owner || owner.pacUrl !== context.codexCompatibilityPacUrl || owner.generation !== context.codexCompatibilityGeneration) { + throw new Error("desktop_compatibility_launch_owner_unverified"); + } +} + +export function activateWindowsCodexCompatibility(exec: DesktopExec, install: DesktopAppInstall, pacUrl: string): { pid: number; packageFullName: string } { + const script = compatibilityActivationScript(install, pacUrl); + let text: string; + try { text = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 15_000, windowsHide: true }); } + catch { throw new Error("desktop_compatibility_activation_unverified"); } + let result: unknown; + try { result = JSON.parse(text.trim().split(/\r?\n/).at(-1) ?? ""); } + catch { throw new Error("desktop_compatibility_activation_unverified"); } + if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("desktop_compatibility_activation_unverified"); + const value = result as Record; + if (value.verified !== true || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0 + || typeof value.packageFullName !== "string" || !value.packageFullName.startsWith(install.id.split("_")[0]! + "_") + || !value.packageFullName.endsWith("_" + install.id.split("_")[1]!)) throw new Error("desktop_compatibility_activation_unverified"); + return { pid: Number(value.pid), packageFullName: value.packageFullName }; +} diff --git a/src/codex/desktop-compatibility/windows-package-launch.ts b/src/codex/desktop-compatibility/windows-package-launch.ts new file mode 100644 index 00000000000..64074f3eeb3 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-package-launch.ts @@ -0,0 +1,28 @@ +import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows"; +import type { DesktopExec } from "../desktop-app/types"; +import { activateWindowsCodexCompatibility, validatedCompatibilityPacUrl } from "./windows-package-command"; +export { compatibilityActivationScript, validatedCompatibilityPacUrl } from "./windows-package-command"; + +export interface CompatibilityLaunchIo { + exec?: DesktopExec; + platform?: string; +} +export type CompatibilityLaunchResult = + | { status: "started"; pid: number; packageFullName: string } + | { status: "refused"; reason: "unsupported_platform" | "test_environment" | "package_unavailable" | "app_running" | "process_probe_failed" | "activation_failed" }; + +/** Never quits an app, launches the raw EXE, changes login or installs a watcher. */ +export function launchWindowsCodexCompatibility(pacUrl: string, io: CompatibilityLaunchIo = {}): CompatibilityLaunchResult { + validatedCompatibilityPacUrl(pacUrl); + if ((io.platform ?? process.platform) !== "win32") return { status: "refused", reason: "unsupported_platform" }; + if (process.env.OCX_TEST_HOME_GUARD === "1" && !io.exec) return { status: "refused", reason: "test_environment" }; + const exec = io.exec ?? windowsDefaultExec; + const install = windowsDesktopAppAdapter.discover(exec); + if (!install) return { status: "refused", reason: "package_unavailable" }; + const processes = windowsDesktopAppAdapter.listProcesses(exec, install); + if (processes === null) return { status: "refused", reason: "process_probe_failed" }; + if (processes.length > 0) return { status: "refused", reason: "app_running" }; + try { + return { status: "started", ...activateWindowsCodexCompatibility(exec, install, pacUrl) }; + } catch { return { status: "refused", reason: "activation_failed" }; } +} diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 8037724c950..31539977831 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -4,6 +4,7 @@ import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import * as z from "zod/v4"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; +import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import type { OcxConfig } from "../types"; import { parseAnthropicModelRoutes } from "../oauth/anthropic-model-routes"; @@ -653,7 +654,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` }; } const boundaryError = blockedModelRedirectsError(value) - ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value) + ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) ?? blankHostnameError(value) ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null) ?? claudeSubagentEffortError(value) diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index d33aa9746b0..b67f70c023b 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -118,6 +118,19 @@ export function adoptPersistedProviderIntoLiveConfig( if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig); } +/** Adopt only the committed next-start preference; preserve unrelated pending live edits. */ +export function adoptPersistedDesktopCompatibility(config: OcxConfig, persisted: OcxConfig["desktopCompatibility"]): void { + const baseline = liveConfigBaseline.get(config); + const merged = reconcileConfigValue(baseline?.desktopCompatibility ?? MISSING_CONFIG_VALUE, + config.desktopCompatibility ?? MISSING_CONFIG_VALUE, persisted ?? MISSING_CONFIG_VALUE); + if (merged === MISSING_CONFIG_VALUE) delete config.desktopCompatibility; + else config.desktopCompatibility = merged as OcxConfig["desktopCompatibility"]; + if (baseline) { + if (persisted === undefined) delete baseline.desktopCompatibility; + else baseline.desktopCompatibility = structuredClone(persisted); + } +} + /** Test seam only: is this instance armed? */ export function claudeCodeBaselineArmed(config: OcxConfig): boolean { return claudeCodeBaseline.has(config); diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 2c6f160c367..060220f1432 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -2,6 +2,7 @@ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models"; import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; +import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { modelPinnedEffortsConfigError, @@ -123,6 +124,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx */ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved"); + if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically."); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 0ac3a0c3ed8..23c939c938d 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -1,6 +1,7 @@ import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types"; import * as z from "zod/v4"; import { compactionRecoverySchema } from "./compaction-recovery"; +import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { agentTaskRecoverySchema, @@ -170,6 +171,7 @@ export const configSchema = z.object({ modelPinnedEfforts: modelPinnedEffortsSchema.optional(), compactionRouting: compactionRoutingSchema.optional().catch(undefined), compactionRecovery: compactionRecoverySchema.optional().catch(undefined), + desktopCompatibility: desktopCompatibilitySchema.optional().catch(undefined), // A hand-edited malformed phase disables only that phase instead of rejecting // providers/apiKeys, matching the load-time degradation notice; the management write // boundary (validateConfigCandidate) still refuses the bad value through the shared, diff --git a/src/config/schema/desktop-compatibility.ts b/src/config/schema/desktop-compatibility.ts new file mode 100644 index 00000000000..492404de717 --- /dev/null +++ b/src/config/schema/desktop-compatibility.ts @@ -0,0 +1,9 @@ +import * as z from "zod/v4"; +/** Startup resumes observation only. Certificate trust, app launch and Apply are never saved here. */ +export const desktopCompatibilitySchema = z.object({ startOnProxyStart: z.boolean() }).strict(); +export function desktopCompatibilityConfigError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const setting = (value as Record).desktopCompatibility; + return setting === undefined || desktopCompatibilitySchema.safeParse(setting).success + ? null : "schema_invalid: desktopCompatibility: requires only boolean startOnProxyStart"; +} diff --git a/src/lib/desktop-proxy-route.ts b/src/lib/desktop-proxy-route.ts new file mode 100644 index 00000000000..fbb84fd5a65 --- /dev/null +++ b/src/lib/desktop-proxy-route.ts @@ -0,0 +1,30 @@ +import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, type ProxyEnvMap, type ProxyCapableRequestInit } from "./proxy-env"; + +/** Bind both native desktop transports to one explicit route; never fall back after failure. */ +export function desktopProxyFor(url: URL, env: ProxyEnvMap = process.env): string | false { + if (noProxyMatches(url, env)) return false; + const key = url.protocol === "https:" ? "HTTPS_PROXY" : "HTTP_PROXY"; + let selected = effectiveProxyFor(url, env); + // Unlike an implicit Bun route, this transport explicitly binds HTTP(S) CONNECT. + // A malformed scheme-specific setting still refuses instead of falling through. + if (!selected && url.protocol === "https:" && !(env[key]?.trim() || env[key.toLowerCase()]?.trim())) { + const all = env.ALL_PROXY?.trim() || env.all_proxy?.trim(); + if (all) { + let parsed: URL; try { parsed = new URL(all); } catch { throw new Error("desktop_egress_proxy_invalid"); } + if (["http:", "https:"].includes(parsed.protocol)) selected = all; + } + } + if (selected) { + const proxy = new URL(selected); + if (!["http:", "https:", "socks5:", "socks5h:"].includes(proxy.protocol) || !proxy.hostname + || proxy.search || proxy.hash || proxy.pathname !== "" && proxy.pathname !== "/") throw new Error("desktop_egress_proxy_invalid"); + return selected; + } + // An explicit but unsupported route must not quietly become direct desktop traffic. + if ([env[key], env[key.toLowerCase()], env.ALL_PROXY, env.all_proxy].some(value => value?.trim())) throw new Error("desktop_egress_proxy_invalid"); + return false; +} +export function desktopOutboundFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = new URL(input instanceof Request ? input.url : String(input)); + return configuredOutboundFetch(input, { ...init, proxy: desktopProxyFor(url) } as ProxyCapableRequestInit); +} diff --git a/src/lib/desktop-upstream-tunnel.ts b/src/lib/desktop-upstream-tunnel.ts new file mode 100644 index 00000000000..936ccb0b2f3 --- /dev/null +++ b/src/lib/desktop-upstream-tunnel.ts @@ -0,0 +1,107 @@ +import { connect as tcpConnect, isIP, type Socket } from "node:net"; +import { checkServerIdentity, connect as tlsConnect, type TLSSocket } from "node:tls"; +import { desktopProxyFor } from "./desktop-proxy-route"; +import { socks5Credentials, socks5Handshake } from "./socks5-handshake"; + +// Shares the SOCKS exchange extracted by lcxhh521 in PR #5947 (efdccdbfac3f). +// Lifecycle here additionally bounds the entire dial and handles close/abort/header limits. +export interface DesktopTunnelOptions { + proxy?: string | false; + signal?: AbortSignal; + timeoutMs?: number; + /** Isolated test peers; production always uses verified TLS to chatgpt.com:443. */ + target?: { host: string; port: number }; + ca?: string; + proxyCa?: string; +} +const fail = () => new Error("desktop_upstream_connection_failed"); +class HandshakeReader { + private buffer: Buffer = Buffer.alloc(0); + private failure: Error | null = null; + private waiting: { ready: () => boolean; resolve: () => void; reject: (error: Error) => void } | null = null; + private onData = (chunk: Buffer) => { + if (this.buffer.length + chunk.length > 65536) { this.onFailure(); return; } + this.buffer = Buffer.concat([this.buffer, chunk]); + if (this.waiting?.ready()) { const current = this.waiting; this.waiting = null; current.resolve(); } + }; + private onFailure = () => { this.failure = fail(); const current = this.waiting; this.waiting = null; current?.reject(this.failure); }; + constructor(private socket: Socket, private signal: AbortSignal) { + socket.on("data", this.onData); socket.once("error", this.onFailure); socket.once("end", this.onFailure); socket.once("close", this.onFailure); + signal.addEventListener("abort", this.onFailure, { once: true }); if (signal.aborted) this.onFailure(); socket.resume(); + } + write(bytes: Uint8Array | string): void { if (this.failure) throw this.failure; this.socket.write(bytes); } + private async until(ready: () => boolean): Promise { + if (this.failure) throw this.failure; + if (ready()) return; + if (this.waiting) throw fail(); + await new Promise((resolve, reject) => { this.waiting = { ready, resolve, reject }; }); + } + private take(length: number): Buffer { const out = this.buffer.subarray(0, length); this.buffer = this.buffer.subarray(length); return out; } + async readExact(length: number): Promise { await this.until(() => this.buffer.length >= length); return this.take(length); } + async readHead(): Promise { await this.until(() => this.buffer.indexOf("\r\n\r\n") >= 0); return this.take(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"); } + dispose(): void { + this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onFailure); + this.socket.removeListener("end", this.onFailure); this.socket.removeListener("close", this.onFailure); + this.signal.removeEventListener("abort", this.onFailure); + if (this.buffer.length) this.socket.unshift(this.buffer); this.buffer = Buffer.alloc(0); this.onFailure(); + } +} +function waitConnected(socket: Socket, event: "connect" | "secureConnect", signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const dispose = () => { socket.off(event, done); socket.off("error", failure); socket.off("close", failure); signal.removeEventListener("abort", failure); }; + const done = () => { dispose(); resolve(); }; + const failure = () => { dispose(); reject(fail()); }; + socket.once(event, done); socket.once("error", failure); socket.once("close", failure); + signal.addEventListener("abort", failure, { once: true }); if (signal.aborted) failure(); + }); +} +/** Fixed-destination TLS dial for native desktop upgraded sockets. No logs or direct fallback. */ +export async function dialDesktopUpstream(options: DesktopTunnelOptions = {}): Promise { + const timeout = options.timeoutMs ?? 10000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30000) throw fail(); + const cancel = new AbortController(), timer = setTimeout(() => cancel.abort(), timeout); + const signal = options.signal ? AbortSignal.any([options.signal, cancel.signal]) : cancel.signal; + const sockets = new Set(); + const own = (socket: T): T => { sockets.add(socket); socket.on("error", () => {}); return socket; }; + const destroy = () => { for (const socket of sockets) socket.destroy(); }; + signal.addEventListener("abort", destroy, { once: true }); + try { + if (signal.aborted) throw fail(); + const proxy = options.proxy === undefined ? desktopProxyFor(new URL("https://chatgpt.com")) : options.proxy; + const target = options.target ?? { host: "chatgpt.com", port: 443 }; + let raw: Socket; + if (proxy === false) { + raw = own(tcpConnect(target)); await waitConnected(raw, "connect", signal); + } else { + const url = new URL(proxy), protocol = url.protocol; + if (!["http:", "https:", "socks5:", "socks5h:"].includes(protocol) || !url.hostname || url.search || url.hash || url.pathname !== "" && url.pathname !== "/") throw fail(); + const host = url.hostname.replace(/^\[|\]$/g, ""), port = Number(url.port) || (protocol === "https:" ? 443 : protocol.startsWith("socks") ? 1080 : 80); + raw = own(tcpConnect({ host, port })); await waitConnected(raw, "connect", signal); + if (protocol === "https:") { + raw = own(tlsConnect({ socket: raw, servername: isIP(host) ? undefined : host, ca: options.proxyCa, + rejectUnauthorized: true, checkServerIdentity: (_name, certificate) => checkServerIdentity(host, certificate), ALPNProtocols: ["http/1.1"] })); + await waitConnected(raw, "secureConnect", signal); + } + const reader = new HandshakeReader(raw, signal); + try { + if (protocol.startsWith("socks")) await socks5Handshake(reader, target, socks5Credentials(url)); + else { + const authority = `${target.host}:${target.port}`; + const lines = [`CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`]; + if (url.username || url.password) lines.push(`Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString("base64")}`); + reader.write(lines.join("\r\n") + "\r\n\r\n"); + if (!/^HTTP\/1\.[01] 2\d\d(?: |\r)/.test(await reader.readHead())) throw fail(); + } + } finally { reader.dispose(); } + } + if (signal.aborted || raw.destroyed) throw fail(); + const secured = own(tlsConnect({ socket: raw, servername: "chatgpt.com", ca: options.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"] })); + // The CONNECT reader paused its stream before handing buffered bytes back. Bun's + // TLS-over-TLS path needs the outer TLS stream explicitly resumed for the new handshake. + raw.resume(); + await waitConnected(secured, "secureConnect", signal); + if (signal.aborted) throw fail(); + return secured; + } catch { destroy(); throw fail(); } + finally { clearTimeout(timer); signal.removeEventListener("abort", destroy); } +} diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 52bdc3d3b50..3caec90bb16 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -1,6 +1,7 @@ import net, { type Socket } from "node:net"; import tls, { type TLSSocket } from "node:tls"; import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics"; +import { socks5Credentials, socks5Handshake, Socks5HandshakeError } from "./socks5-handshake"; const DEFAULT_SOCKS5_PORT = 1080; const SOCKS5_CONNECT_TIMEOUT_MS = 30_000; @@ -10,11 +11,6 @@ const MAX_BODY_SLICE_BYTES = 64 * 1024; const MAX_DECODED_BODY_BYTES = 32 * 1024 * 1024; const MAX_STREAM_DECODE_EXPANSION_RATIO = 128; const SOCKS5_VERSION = 0x05; -const SOCKS5_NO_AUTH = 0x00; -const SOCKS5_USER_PASS = 0x02; -const SOCKS5_CONNECT = 0x01; -const SOCKS5_DOMAIN = 0x03; -const SOCKS5_SUCCESS = 0x00; const CRLF = Buffer.from("\r\n"); const HEADER_END = Buffer.from("\r\n\r\n"); @@ -22,24 +18,6 @@ export class Socks5FetchError extends Error { override readonly name = "Socks5FetchError"; } -function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } { - if (!proxy.username && !proxy.password) return {}; - let username: string; - let password: string; - try { - username = decodeURIComponent(proxy.username); - password = decodeURIComponent(proxy.password); - } catch { - throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding"); - } - const usernameBytes = new TextEncoder().encode(username); - const passwordBytes = new TextEncoder().encode(password); - if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { - throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); - } - return { username: usernameBytes, password: passwordBytes }; -} - function validateProxy(proxy: string): URL { let parsed: URL; try { @@ -55,7 +33,10 @@ function validateProxy(proxy: string): URL { throw new Socks5FetchError("SOCKS5 proxy port is invalid"); } if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); - proxyCredentials(parsed); + try { socks5Credentials(parsed); } catch (error) { + if (error instanceof Socks5HandshakeError) throw new Socks5FetchError(error.message); + throw error; + } return parsed; } @@ -130,6 +111,14 @@ class SocketReader { socket.resume(); } + write(bytes: Uint8Array): void { + this.socket.write(bytes); + } + + readExact(bytes: number, signal?: AbortSignal): Promise { + return this.read(bytes, signal); + } + private readonly onData = (chunk: Buffer | string): void => { const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; if (this.anyWaiters.length > 0) { @@ -263,7 +252,7 @@ class SocketReader { async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { const parsedProxy = validateProxy(proxy); - const credentials = proxyCredentials(parsedProxy); + const credentials = socks5Credentials(parsedProxy); const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, ""); const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { @@ -271,49 +260,12 @@ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): }); const reader = new SocketReader(socket); try { - const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]); - socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods])); - const greeting = await reader.read(2, signal); - if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting"); - if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { - socket.write(Buffer.from([ - 0x01, - credentials.username.byteLength, - ...credentials.username, - credentials.password.byteLength, - ...credentials.password, - ])); - const auth = await reader.read(2, signal); - if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed"); - } else if (greeting[1] !== SOCKS5_NO_AUTH) { - throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method"); - } - - const hostname = new TextEncoder().encode(target.hostname); - if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long"); - const port = targetPort(target); - socket.write(Buffer.from([ - SOCKS5_VERSION, - SOCKS5_CONNECT, - 0x00, - SOCKS5_DOMAIN, - hostname.byteLength, - ...hostname, - port >> 8, - port & 0xff, - ])); - const reply = await reader.read(4, signal); - if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); - if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); - if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { - throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte"); - } - const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; - await reader.read(addressLength + 2, signal); + await socks5Handshake(reader, { host: target.hostname, port: targetPort(target) }, credentials, signal); socket.setTimeout(0); return socket; } catch (error) { socket.destroy(); + if (error instanceof Socks5HandshakeError) throw new Socks5FetchError(error.message); throw error; } finally { reader.dispose(); diff --git a/src/lib/socks5-handshake.ts b/src/lib/socks5-handshake.ts new file mode 100644 index 00000000000..a917af02e00 --- /dev/null +++ b/src/lib/socks5-handshake.ts @@ -0,0 +1,112 @@ +/** + * The SOCKS5 handshake both raw outbound transports share: method negotiation + * (RFC 1928), the optional RFC 1929 username/password subnegotiation, and the + * CONNECT exchange. + * + * `src/lib/socks5-fetch.ts` (the HTTP tunnel) and + * `src/lib/desktop-upstream-tunnel.ts` (the native desktop relay's raw + * dial) each own their socket, their timeouts, and what happens after CONNECT; + * this module only frames bytes through the reader each hands it, so a + * credentialed `socks5://` proxy URL authenticates identically whether the caller + * is a fetch or a raw WebSocket upgrade. When the URL carries credentials the + * greeting offers NO-AUTH alongside USER-PASS, which keeps an unauthenticated + * proxy working for a caller that configured more than it needed. + */ + +export class Socks5HandshakeError extends Error { + override readonly name = "Socks5HandshakeError"; +} + +const SOCKS5_VERSION = 0x05; +const SOCKS5_NO_AUTH = 0x00; +const SOCKS5_USER_PASS = 0x02; +const SOCKS5_CONNECT = 0x01; +const SOCKS5_DOMAIN = 0x03; +const SOCKS5_SUCCESS = 0x00; + +/** RFC 1929 credentials decoded from the proxy URL, each bounded to one length byte. */ +export interface Socks5Credentials { + username?: Uint8Array; + password?: Uint8Array; +} + +export function socks5Credentials(proxy: URL): Socks5Credentials { + if (!proxy.username && !proxy.password) return {}; + let username: string; + let password: string; + try { + username = decodeURIComponent(proxy.username); + password = decodeURIComponent(proxy.password); + } catch { + throw new Socks5HandshakeError("SOCKS5 proxy credentials contain invalid percent encoding"); + } + const usernameBytes = new TextEncoder().encode(username); + const passwordBytes = new TextEncoder().encode(password); + if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { + throw new Socks5HandshakeError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); + } + return { username: usernameBytes, password: passwordBytes }; +} + +/** + * The slice of a transport's socket reader the handshake needs: exact-length reads + * that reject on close, on error, or on the transport's own timeout, plus writes. + * Bytes read past a step stay queued in the transport's reader; the handshake never + * touches the socket itself. + */ +export interface Socks5HandshakeReader { + write(bytes: Uint8Array): void; + readExact(bytes: number, signal?: AbortSignal): Promise; +} + +export interface Socks5Target { + host: string; + port: number; +} + +/** Negotiate methods, authenticate when the proxy picks USER-PASS, and CONNECT to `target`. */ +export async function socks5Handshake( + reader: Socks5HandshakeReader, + target: Socks5Target, + credentials: Socks5Credentials, + signal?: AbortSignal, +): Promise { + const hostBytes = Buffer.from(target.host, "utf8"); + if (hostBytes.byteLength > 255) throw new Socks5HandshakeError("SOCKS5 target hostname is too long"); + const methods = credentials.username ? [SOCKS5_NO_AUTH, SOCKS5_USER_PASS] : [SOCKS5_NO_AUTH]; + reader.write(Buffer.from([SOCKS5_VERSION, methods.length, ...methods])); + const greeting = await reader.readExact(2, signal); + if (greeting[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid greeting"); + if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { + reader.write(Buffer.from([ + 0x01, + credentials.username.byteLength, + ...credentials.username, + credentials.password.byteLength, + ...credentials.password, + ])); + const auth = await reader.readExact(2, signal); + if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5HandshakeError("SOCKS5 proxy authentication failed"); + } else if (greeting[1] !== SOCKS5_NO_AUTH) { + throw new Socks5HandshakeError("SOCKS5 proxy does not accept an offered authentication method"); + } + + reader.write(Buffer.from([ + SOCKS5_VERSION, + SOCKS5_CONNECT, + 0x00, + SOCKS5_DOMAIN, + hostBytes.byteLength, + ...hostBytes, + target.port >> 8, + target.port & 0xff, + ])); + const reply = await reader.readExact(4, signal); + if (reply[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid connect response"); + if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5HandshakeError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { + throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid address type or reserved byte"); + } + const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.readExact(1, signal))[0]! : 16; + await reader.readExact(addressLength + 2, signal); +} diff --git a/src/server/index.ts b/src/server/index.ts index bc827af06fe..f9632634e85 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation"; +import { scheduleDesktopCompatibilityStartup } from "./index/desktop-compatibility-startup"; import { AuxiliaryListenerBindError } from "./ports"; import { runAdmittedBodyWork } from "./inbound-body-admission"; import { @@ -198,7 +199,7 @@ import { createReadinessGate, type ReadinessGate } from "./readiness"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; import { createOptionalListenerSet, LINK_INGRESS_HOSTNAME } from "./index/optional-listeners"; import { createPackageTreeIntegrityGuardForServer } from "./index/package-tree-guard"; -import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; +import { inspectStartupOwnership, logProxyEndpoints, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle"; @@ -638,11 +639,14 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let unregisterQuotaAutoRefresh: (() => void) | null = null; let remoteWorkspaceStopping = false; let remoteWorkspaceShutdown: (() => Promise) | undefined; + let desktopCompatibilityStartup: ReturnType | undefined; + let desktopCompatibilityShutdown: (() => Promise) | undefined; const managementApiDeps: ManagementApiDeps = { ...deps.managementApi, listLowQuotaEvents: limit => backgroundLifecycle?.listLowQuotaEvents(limit) ?? [], remoteWorkspaceStopping: () => remoteWorkspaceStopping, onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; }, linkSupervisor: () => optionalListeners.linkSupervisor(), linkListener: () => optionalListeners, + onDesktopCompatibilityShutdown: shutdown => { desktopCompatibilityShutdown = shutdown; }, }; let workspaceRuntimeFlight: Promise | undefined; const loadRemoteWorkspaceRuntime = () => { @@ -771,6 +775,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe : []), () => optionalListeners.stop(), async () => { await remoteWorkspaceShutdown?.(); }, + async () => { await desktopCompatibilityStartup?.shutdown(); await desktopCompatibilityShutdown?.(); }, async () => { try { userCostOverlayReconciler?.stop(); @@ -800,13 +805,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe boundPort = actualPort; setCorsOrigin(actualPort); - console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`); - console.log(` POST /v1/responses → provider translation`); - console.log(` POST /v1/chat/completions → OpenAI-compatible clients`); - console.log(` GET /healthz → health check`); - console.log(` GET /api/* → management API`); - console.log(` GET / → GUI dashboard`); - + logProxyEndpoints(actualPort); if (loopbackServer) { // Loud on every start, not once at enable time. An operator who inherits a config, or // who forgot, has to be able to see that an unauthenticated surface is live without @@ -878,6 +877,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } startPackageRefresh(); + desktopCompatibilityStartup = scheduleDesktopCompatibilityStartup(config, { boundHostname: bindHost, boundPort: actualPort, loopbackPort: loopbackServer?.port ?? undefined, readiness: deps.readinessGate }); return server; } diff --git a/src/server/index/desktop-compatibility-startup.ts b/src/server/index/desktop-compatibility-startup.ts new file mode 100644 index 00000000000..a80bf1f5d58 --- /dev/null +++ b/src/server/index/desktop-compatibility-startup.ts @@ -0,0 +1,56 @@ +import type { OcxConfig } from "../../types"; +import { siblingOfLivePort } from "../../codex/sibling-start"; +import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; +import type { DesktopCompatibilityRuntime } from "../../codex/desktop-compatibility/runtime"; +import { bindNativeCompatibilityOwner } from "../../codex/desktop-compatibility/routing-binding"; +import type { ReadinessGate } from "../readiness"; + +type RuntimeModule = { getDesktopCompatibilityRuntime(): DesktopCompatibilityRuntime; shutdownDesktopCompatibility(): Promise }; +interface StartupIo { + platform?: string; + testGuard?: boolean; + sibling?: boolean; + load?: () => Promise; + warn?: (message: string) => void; + boundPort?: number; + boundHostname?: string; + loopbackPort?: number; + readiness?: Pick; + readinessTimeoutMs?: number; +} +/** Core-safe gate: off installs do not load the optional runtime, read credentials or start timers. */ +export function scheduleDesktopCompatibilityStartup(config: OcxConfig, io: StartupIo = {}): { shutdown(): Promise } { + let stopped = false, module: RuntimeModule | undefined; + let cancelReadiness: (() => void) | undefined; + const waitForReadiness = () => !io.readiness ? Promise.resolve(true) : new Promise(resolve => { + const deadline = Date.now() + (io.readinessTimeoutMs ?? 120000); + let timer: ReturnType | undefined; + const finish = (ready: boolean) => { if (timer) clearTimeout(timer); cancelReadiness = undefined; resolve(ready); }; + cancelReadiness = () => finish(false); + const check = () => { + const status = io.readiness!.getStatus(); + if (stopped || status !== "pending" || Date.now() >= deadline) { finish(!stopped && status === "ready"); return; } + timer = setTimeout(check, 100); timer.unref(); + }; + check(); + }); + const unbind = io.boundPort === undefined || io.boundHostname === undefined ? () => {} : bindNativeCompatibilityOwner({ config, hostname: io.boundHostname, port: io.boundPort, loopbackPort: io.loopbackPort }); + const enabled = config.desktopCompatibility?.startOnProxyStart === true && (io.platform ?? process.platform) === "win32" + && !(io.testGuard ?? isTestHomeGuardArmed()) && !(io.sibling ?? siblingOfLivePort() !== null) + && config.runtimeRole !== "client"; + const pending = enabled ? Promise.resolve().then(async () => { + if (stopped) return; + if (io.readiness) { + const ready = await waitForReadiness(); + if (stopped) return; + if (!ready) throw new Error("desktop_compatibility_startup_not_ready"); + } + module = await (io.load?.() ?? import("../../codex/desktop-compatibility/service")); + if (stopped) return; + await module.getDesktopCompatibilityRuntime().start(); + }).catch(() => { (io.warn ?? console.warn)("Desktop compatibility observation did not start. Inspect Desktop compatibility status; no automatic trust or correction was applied."); }) : Promise.resolve(); + return { async shutdown() { + stopped = true; cancelReadiness?.(); unbind(); await pending; + if (module) await module.shutdownDesktopCompatibility(); + } }; +} diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 5ebbb04805e..ec49b184bc4 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -258,3 +258,12 @@ export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMe console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state."); console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks."); } + +export function logProxyEndpoints(actualPort: number): void { + console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`); + console.log(` POST /v1/responses → provider translation`); + console.log(` POST /v1/chat/completions → OpenAI-compatible clients`); + console.log(` GET /healthz → health check`); + console.log(` GET /api/* → management API`); + console.log(` GET / → GUI dashboard`); +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 1f6b28e427d..7dfa9ddd786 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -79,6 +79,9 @@ import { handleCodexPromptRoutes } from "./management/codex-prompt-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; import { handleClaudeDesktopPickerRoutes } from "./management/claude-desktop-picker-routes"; +import { handleDesktopCompatibilityRoutes } from "./management/desktop-compatibility-routes"; +import { handleDesktopCompatibilityRuntimeRoutes } from "./management/desktop-compatibility-runtime-routes"; +import { handleDesktopCompatibilitySettingsRoutes } from "./management/desktop-compatibility-settings-routes"; import { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -358,6 +361,9 @@ export async function handleManagementAPI( ?? (await handleNativeIntegrationRoutes(ctx)) ?? (await handleCursorIntegrationRoutes(ctx)) ?? (await handleClaudeDesktopPickerRoutes(ctx)) + ?? (await handleDesktopCompatibilityRoutes(ctx)) + ?? (await handleDesktopCompatibilityRuntimeRoutes(ctx)) + ?? (await handleDesktopCompatibilitySettingsRoutes(ctx)) ?? (await handleAgentSettingsRoutes(ctx)) ?? (await handleCodexPromptRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 9162b855034..39ee5b63cd5 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -45,6 +45,10 @@ export interface ManagementRequestIngress { } export interface ManagementApiDeps { + desktopStartupSettings?: import("../../codex/desktop-compatibility/startup-settings").DesktopStartupSettingsService; + desktopCertificateService?: import("../../codex/desktop-compatibility/certificate-service").DesktopCertificateService; + desktopCompatibilityRuntime?: import("../../codex/desktop-compatibility/runtime").DesktopCompatibilityRuntime; + onDesktopCompatibilityShutdown?: (shutdown: () => Promise) => void; /** Bound to this server's lifecycle owner; absent in direct route tests. */ listLowQuotaEvents?: (limit?: number) => LowQuotaEvent[]; /** Bound Claude intercept state, injectable for isolated management-route tests. */ diff --git a/src/server/management/desktop-compatibility-routes.ts b/src/server/management/desktop-compatibility-routes.ts new file mode 100644 index 00000000000..af67e429a51 --- /dev/null +++ b/src/server/management/desktop-compatibility-routes.ts @@ -0,0 +1,46 @@ +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; +import type { DesktopCertificateService } from "../../codex/desktop-compatibility/certificate-service"; + +const PATH = "/api/codex/desktop-compatibility/certificate"; +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)) { + return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); + } + let action: "prepare" | "trust" | "remove-trust" | "renew" | undefined, fingerprint: string | undefined; + if (ctx.req.method === "POST") { + let body: unknown; + try { body = await readManagementJsonBody(ctx.req); } + catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid_request" }, 400); + const value = body as Record; + if (Object.keys(value).some(key => !["action", "fingerprint", "confirmed"].includes(key)) || value.confirmed !== true + || typeof value.action !== "string" || !["prepare", "trust", "remove-trust", "renew"].includes(value.action)) return jsonResponse({ error: "invalid_request" }, 400); + action = value.action as typeof action; + if (action !== "prepare" && (typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint))) { + return jsonResponse({ error: "certificate_fingerprint_required" }, 400); + } + if (action === "prepare" && value.fingerprint !== undefined) return jsonResponse({ error: "unexpected_fingerprint" }, 400); + fingerprint = value.fingerprint as string | undefined; + } + const controller = ctx.deps.desktopCertificateService ?? (service ??= (await import("../../codex/desktop-compatibility/certificate-service")).createDesktopCertificateService()); + try { + const status = action === "prepare" ? await controller.prepare() + : 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 }); + } 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", + "unsafe_path", "unreadable", "expired", "renewal_required", "protection_failed"]); + return jsonResponse({ ok: false, error: allowed.has(code) ? code : "operation_failed" }, 409); + } +} diff --git a/src/server/management/desktop-compatibility-runtime-routes.ts b/src/server/management/desktop-compatibility-runtime-routes.ts new file mode 100644 index 00000000000..eeb4bbe2b47 --- /dev/null +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -0,0 +1,46 @@ +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; +import type { DesktopCompatibilityRuntime } from "../../codex/desktop-compatibility/runtime"; + +const PATH = "/api/codex/desktop-compatibility/runtime"; +const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stopping", "build_unverified", "egress_proxy_invalid", + "trust_required", "certificate_expiring", "certificate_not_prepared", "certificate_invalid", "certificate_expired", + "native_identity_unverified", "native_routing_unverified", "cleanup_incomplete", "not_running", "connection_invalid", "connection_changed", "connection_cleanup_required", "connection_unavailable"]); + +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); + let action: "start" | "stop" | "observe" | "apply" | "launch" | undefined; + if (ctx.req.method === "POST") { + let body: unknown; + try { body = await readManagementJsonBody(ctx.req); } + catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid_request" }, 400); + const value = body as Record; + if (Object.keys(value).some(key => !["action", "confirmed", "accountWideConsent"].includes(key)) || value.confirmed !== true + || typeof value.action !== "string" || !["start", "stop", "observe", "apply", "launch"].includes(value.action)) return jsonResponse({ error: "invalid_request" }, 400); + if (value.action === "apply" ? value.accountWideConsent !== true : value.accountWideConsent !== undefined) return jsonResponse({ error: "invalid_consent_scope" }, 400); + action = value.action as typeof action; + } + const module = ctx.deps.desktopCompatibilityRuntime ? null : await import("../../codex/desktop-compatibility/service"); + const service: DesktopCompatibilityRuntime = ctx.deps.desktopCompatibilityRuntime ?? module!.getDesktopCompatibilityRuntime(); + if (module) ctx.deps.onDesktopCompatibilityShutdown?.(module.shutdownDesktopCompatibility); + try { + if (action === "apply") { + const result = await service.apply(true); + return jsonResponse({ ok: result.accepted, activation: result, runtime: service.status() }, result.accepted ? 202 : 409); + } + if (action === "launch") { + const result = await service.launch(); + return jsonResponse({ ok: result.status === "started", launch: result, runtime: service.status() }, result.status === "started" ? 200 : 409); + } + const result = action === "start" ? await service.start() : action === "stop" ? await service.stop() + : action === "observe" ? await service.observe() : service.status(); + return jsonResponse({ ok: true, runtime: result }); + } catch (error) { + const code = error instanceof Error ? error.message.replace(/^desktop_compatibility_/, "") : "operation_failed"; + return jsonResponse({ ok: false, error: ERROR_CODES.has(code) ? code : "operation_failed", runtime: service.status() }, 409); + } +} diff --git a/src/server/management/desktop-compatibility-settings-routes.ts b/src/server/management/desktop-compatibility-settings-routes.ts new file mode 100644 index 00000000000..b051292673f --- /dev/null +++ b/src/server/management/desktop-compatibility-settings-routes.ts @@ -0,0 +1,27 @@ +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +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); + let update: { startOnProxyStart: boolean; revision: string } | undefined; + if (ctx.req.method === "POST") { + let body: unknown; + try { body = await readManagementJsonBody(ctx.req); } + catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid_request" }, 400); + const value = body as Record; + if (Object.keys(value).some(key => !["startOnProxyStart", "revision", "confirmed"].includes(key)) || value.confirmed !== true + || typeof value.startOnProxyStart !== "boolean" || typeof value.revision !== "string" || !/^[a-f0-9]{64}$/.test(value.revision)) return jsonResponse({ error: "invalid_request" }, 400); + update = { startOnProxyStart: value.startOnProxyStart, revision: value.revision }; + } + const service = ctx.deps.desktopStartupSettings ?? (await import("../../codex/desktop-compatibility/startup-settings")).createDesktopStartupSettings({ liveConfig: ctx.config }); + try { return jsonResponse({ ok: true, settings: update ? service.set(update.startOnProxyStart, update.revision) : service.status() }); } + catch (error) { + const code = error instanceof Error ? error.message.replace(/^desktop_compatibility_settings_/, "") : "operation_unconfirmed"; + return jsonResponse({ ok: false, error: ["unavailable", "invalid", "changed", "invalid_request"].includes(code) ? `settings_${code}` : "settings_operation_unconfirmed" }, 409); + } +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index f2441b824da..240e4a3c0b4 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -163,6 +163,12 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/claude-desktop-picker-routes { 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: "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: "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: "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/src/server/management/sibling-guard.ts b/src/server/management/sibling-guard.ts index a03bb52e740..5f1754eb4f5 100644 --- a/src/server/management/sibling-guard.ts +++ b/src/server/management/sibling-guard.ts @@ -30,6 +30,7 @@ export const SIBLING_REFUSED_MANAGEMENT_PATHS: readonly { readonly path: string; { path: "/api/client-integrations", children: true }, { path: "/api/native-integrations", children: true }, { path: "/api/claude-desktop", children: true }, + { path: "/api/codex/desktop-compatibility", children: true }, { path: "/api/claude-code", children: false }, { path: "/api/grok/apply", children: false }, { path: "/api/grok/selection", children: false }, diff --git a/src/types/config.ts b/src/types/config.ts index c771c6299ef..138a9e992ea 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -760,6 +760,8 @@ export interface OcxConfig { }; /** Opt-in failure-only recovery; never replaces the initial compaction model. */ compactionRecovery?: { enabled: boolean; model: string; allowDevinInvalidArgument?: boolean }; + /** Explicit opt-in; resumes Observe only using an existing trusted certificate and endpoints. */ + desktopCompatibility?: { startOnProxyStart: boolean }; /** * Destination model for Codex's own memory pipeline, per phase * (src/server/responses/memory-models.ts). diff --git a/structure/INDEX.md b/structure/INDEX.md index 2f7e3eea32c..42a3db7f04a 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -81,6 +81,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | +| [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | @@ -121,10 +122,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | -| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | -| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md new file mode 100644 index 00000000000..7d45b91b916 --- /dev/null +++ b/structure/clients/codex-desktop.md @@ -0,0 +1,217 @@ +# Codex Desktop compatibility + +## Explicit restart + +`ocx system codex-restart` requests a full Codex app and app-server restart through +the management endpoint. `src/cli/capabilities.ts` and `src/cli/system-command.ts` +warn that unsaved drafts, picker selections and pending approvals may be lost. +The unconfirmed path sends no restart request; JSON output preserves refused outcomes. + +Under the test preload's `OCX_TEST_HOME_GUARD=1`, +`src/codex/desktop-app-restart.ts` skips real restart without an injected executor. +`src/cli/restart-scope.ts` reports that skip. `NODE_ENV=test` alone does not control +this boundary. `tests/clients/desktop-app-restart.test.ts` covers the contract. + +## Windows launch context + +`src/codex/desktop-app/windows.ts` captures an already active loopback compatibility +PAC from the main package process only when its exact URL is registered by the current +process-local serving runtime. The runtime publishes a fresh generation after its listeners +bind and revokes it before cleanup. Disk state and URL shape cannot establish this ownership; +stopped, expired, foreign or unregistered runtimes refuse capture before termination. +The adapter rechecks the captured generation immediately before package activation, so even +a replacement runtime serving identical ports cannot inherit the old restart approval. +Helpers cannot override it and conflicting main +processes refuse before termination. No other process arguments are carried forward. +Captured command lines stay internal, outside restart results and diagnostic logs. +Capture failures return `relaunch_context_failed` and release the restart lock before any process is signalled; the CLI reports that the app was not stopped. +An explicitly empty or whitespace-only root command line remains unknown after CIM parsing and refuses context capture; an unreadable helper cannot erase a known root PAC. + +`src/codex/desktop-compatibility/windows-package-command.ts` validates the canonical +loopback URL shape, rechecks the discovered package manifest, activates with +`IApplicationActivationManager`, and verifies the package identity and actual PAC +argument. Normal launches keep the existing AppsFolder route. A standalone compatibility +launch refuses an already running app; it never quits an app or installs a watcher. +Activation reads the final non-empty JSON line after trimming a BOM or preceding warnings. Invalid output and executor failures remain unverified, without automatic relaunch retries. +`tests/clients/desktop-compatibility-launch.test.ts` covers restart integration and the +real Windows parser/COM service without launching the user's app. + +## Certificate persistence + +`src/codex/desktop-compatibility/certificate-store.ts` is a separately callable store +for a 30-day authority constrained to `chatgpt.com`, with IP exclusions and a TLS-server-only EKU. An explicit +feature-owned directory and lifecycle lease protect one atomic envelope. Its public +certificate is bound to a CurrentUser-DPAPI-protected private payload. + +Reopening reuses the same key and fingerprint. Corrupt, foreign-user and expired state +refuses instead of silently replacing a trusted identity. Renewal is reported within +the last seven days. The store does not register certificates, start listeners, enable +compatibility settings or change the running app. A deliberate renewal validates the +replacement envelope before atomic publication and compares the existing identity again. +Failure before publication preserves the original removable identity; staging is cleaned. +An older authority without the server-only EKU reports `renewal-required`: serving and trust +registration refuse it, while exact trust removal and deliberate renewal remain available. + +`src/codex/desktop-compatibility/windows-key-protection.ts` uses trusted PowerShell +and bounded stdin/stdout, never command-line secrets or plaintext fallback. CurrentUser +protects against other OS identities, not another process using the same credentials. +`tests/clients/desktop-compatibility-authority.test.ts` covers persistence, refusal, +and a real Windows DPAPI round trip with synthetic data. + +## Certificate setup API + +`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. +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. + +`src/codex/desktop-compatibility/certificate-service.ts` serializes setup and refuses +busy operations. Only prepare may generate a key. Trust/removal load existing validated +state, recheck its fingerprint, and never repair missing state by creating a new root. +Removal refuses while the app is running or its process state cannot be established. +An expired key is loadable only for removal, not for renewed trust. +Explicit renewal first proves the app absent and verifies old trust removal, then replaces +the encrypted envelope. Unknown or refused removal never loses the old key. The new root +remains untrusted until a separate fingerprint-bound confirmation; renewal never creates +an automatic trust prompt or accumulates old trusted roots. + +`src/codex/desktop-compatibility/windows-certificate-trust.ts` uses the CurrentUser Root +store and exact certificate bytes. Mutations require the matching private key from the +protected store, not status metadata. Idempotent actions skip repeated OS changes; +uncertain command completion is resolved by an independent readback. Unknown readback +remains unknown. Public responses contain no PEM, private-key objects or subprocess output. + +Sibling instances refuse certificate mutations because OS trust is shared user state. +The registry declares the certificate-status CLI verb as deferred to the desktop +compatibility integration owner; it currently has an authenticated HTTP contract only. + +## Optional compatibility runtime + +`src/codex/desktop-compatibility/runtime.ts` owns an explicit, default-off runtime. +Construction and status do not start listeners, load credentials or enroll trust. Start +loads an existing DPAPI key, verifies CurrentUser trust and a fresh native file-login +identity, then creates only loopback TLS/CONNECT/PAC listeners. The currently assessed +Windows package version is declared in the module. Unknown builds refuse activation. +The assessed family/publisher, full package basename and App entry must also agree; +matching the version prefix alone does not qualify a foreign package identity. +HTTP, identity verification and upgraded sockets use the explicit desktop egress policy +described in the [transport inventory](../transports/inventory.md#native-desktop-proxy-egress). +Invalid proxy routes refuse rather than falling back to direct egress. +Native routing verification binds both the actual listener address family and port; the companion +is IPv4-only. Ambiguous localhost aliases cannot qualify, and listener identity changes revoke observation. + +`relay-listener.ts` forwards HTTP with the existing upstream-header filter, cookies and +streaming bodies, and pipes upgraded TLS sockets without decoding their frames. The +upstream is fixed to chatgpt.com; request Host cannot select another destination. CONNECT +allows only chatgpt.com:443. PAC has a certificate-relative deadline and `DIRECT` fallback. +HTTP and upgraded requests accept the case-insensitive DNS spelling of that exact Host, +with optional port443, while other hosts, ports and non-origin request targets remain refused. +The package launcher uses only the runtime-owned PAC and never kills an existing app. + +`connection-store.ts` preserves the PAC nonce and two public loopback ports in a bounded, +strictly validated `connection.json` beside the protected authority. No account, credential, +key or expiry is stored there. First publication is create-only under a lifecycle lease; +another identity cannot be overwritten. Startup binds the recorded ports and revalidates +publication before exposing a launch URL. A conflict or malformed file fails without new +port allocation. Existing cached PACs can reconnect after a service restart using the same +authority and endpoints; correction always restarts in Observe. Certificate renewal still +requires a closed app, so its next launch fetches the new certificate-relative PAC deadline. +If both publication and temporary-file cleanup fail, the cleanup-required error retains both causes; cleanup never throws from a `finally` block or reports the residue as removed. + +`usage-controller.ts`, `usage-activation.ts` and `usage-policy.ts` implement a maximum +three-minute, explicitly confirmed account-UI trial after a fresh supported exhaustion +snapshot. They cannot assert selected-provider isolation. Two usage gate booleans may +change; quota windows, credits, spending limits and other responses remain original. +An original available or protected usage record disarms Apply and advances its generation. +Later exhaustion does not resume that trial; it requires another explicit activation. +Pending response checks from the previous generation cannot emit a correction after recovery. +Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises delayed verification and build-check races with synthetic auth files. +Fresh identity checks, generation changes, unknown schemas and elapsed deadlines refuse +correction. `usage-sse-controller.ts` preserves event metadata and original sequence IDs; +records over its default 256 KiB cap pass through unchanged until the record delimiter, +after which framing resumes for later records. +`usage-refresh.ts` closes only usage streams bound by validated original account records. +`usage-controlled-fetch.ts` removes stale validators from changed JSON and controlled SSE. +Response production is reported separately from app-cache or UI confirmation. +Observation counters distinguish validated JSON/SSE snapshots from merely registered streams; +only identity-bound, untainted active streams count as `validatedActiveStreams`. Counts and the +last snapshot time describe this relay's lifetime, including responses sent by diagnostic clients. +They never establish a source PID, authoritative gate coverage or composer recovery. Both +`sourceProcessVerified` and `composerRecoveryVerified` remain false. Invalid/foreign snapshots do +not advance these counters. Conversation initialization and other non-WHAM endpoints retain +their original response bytes, including `blocked_features` and `limits_progress`. + +`routing-binding.ts` records the server's actual bound data/companion ports. Shutdown +unregisters only its matching owner. `routing-preflight.ts` verifies bounded native root +TOML and any selected root profile against those ports, rejecting foreign providers, +remote destinations, authless mode, unknown profiles and process-level app overrides. +The verifier binds parsed root TOML values, actual listener ports and the configured provider/model/fallback +routing inputs to its first valid observation. A change or failed check invalidates that runtime +even if the old settings return; stop/start creates a fresh observation context. Unrelated +OpenCodex preferences, TOML formatting, object-key order and the native root `mcp_servers` +table do not invalidate the routing snapshot. Desktop refreshes that tool-transport table +after launch; all other native fields, including unknown ones and selected profiles, remain bound. +It does not claim knowledge of project-local overrides or a conversation's selected model. +Each record that would be corrected checks routing and the assessed installed build asynchronously, +then rechecks account identity and trial generation before emitting it. `installed-build.ts` shares +the Windows adapter's discovery parser, coalesces concurrent probes without caching a positive +result across requests, and aborts/reaps its bounded child before shutdown completes. Observe and +unchanged responses do not query Windows. Background refresh runs once a minute in Observe, +every ten seconds during Apply, and stops probing after the safety deadline. Failed checks +disarm Apply. Native update/routing failures are +public diagnostic codes; originals continue to relay and no app/config repair is automatic. + +`runtime-ownership.ts` serializes certificate mutations against active/starting runtimes. +The existing sibling guard blocks all runtime mutations in sibling instances. Core shutdown +registration occurs only after successful startup. Cleanup stops owned listeners and streams; +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 +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. + +## Dashboard controls + +`gui/src/pages/codex-desktop-compatibility.tsx` is a lazy Codex Set tab at +`#codex-set/desktop`. It uses the machine API base, never the shared hub base. Managed +OpenCodex client mode does not offer these controls because its local listener deliberately +does not admit durable machine mutations through a dashboard bootstrap session. + +`gui/src/desktop-compatibility-api.ts` projects public status and issues one POST per action. +Fingerprint-bound trust/renew/removal and the account-wide trial require separate acknowledgement. +Observe/start/stop/launch follow explicit button actions. No action is replayed after an uncertain +response; a fresh status read is required. Changing the API target remounts the panel and discards +pending consent. `useClientResource` owns bounded, visibility-aware reads and invalidates earlier +reads when a mutation result is published. Certificate status is not repeatedly polled; runtime +status polls only while the tab is active. Consent copy exists in all ten locale catalogs. +Trial consent and pending withdrawal distinguish stopped response correction from unconfirmed native cache refresh; the panel never reports cache rollback as confirmed. +The panel offers renewal only for prepared, trusted, expired or renewal-required identities. Unknown trust permits fingerprint-verified removal but not renewal; invalid keys expose neither action. +Running-state help distinguishes a listening service from a connected native app: ordinary app launches or updates can omit the managed PAC argument. It points to explicit package launch after the user closes Codex and states that a certificate reinstall cannot authorize an unassessed build. + +## Proxy startup preference + +`desktopCompatibility.startOnProxyStart` is opt-in and defaults absent/off. The strict schema +rejects candidate writes containing unknown options, while invalid hand edits disable startup. +`src/server/index/desktop-compatibility-startup.ts` gates optional imports on this intent, +Windows, non-test execution and non-sibling/non-client ownership. It keeps `startServer` +synchronous and does not await before the Lab activation boundary. Unsupported prerequisites +warn without stopping the model proxy or installing trust. +When the CLI supplies its readiness gate, observation waits for post-startup native configuration +sync before loading its runtime. A failed sync or a two-minute pending limit leaves observation +off with a generic diagnostic; shutdown cancels this optional wait without delaying proxy exit. + +`service.ts` shares one runtime between startup and management. Server shutdown retains +the asynchronous teardown, waits for in-flight startup, and prevents a late start after stop. +The saved preference does not launch Codex, enroll or renew certificates, or resume a trial. +The dashboard's startup toggle changes only this next-process preference. Its settings API +requires local GUI provenance, explicit boolean intent and a revision of the displayed field. +`startup-settings.ts` uses the existing config mutation lock/rebase writer, then reads back +the real file. Unrelated concurrent fields survive. `adoptPersistedDesktopCompatibility` +updates only this field and its live comparison baseline, so a later unrelated save cannot +undo the committed preference or overwrite newer disk edits. Publication-side errors remain +errors, with verified disk state adopted rather than speculative rollback or request replay. +Invalid/missing configuration cannot be recreated by toggling this setting. diff --git a/structure/config.md b/structure/config.md index d51f33020c4..d25a0af2d89 100644 --- a/structure/config.md +++ b/structure/config.md @@ -28,7 +28,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i ## Config surface -Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. +Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9ae2019fe9b..bddf4344805 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,13 @@ # GUI And Management API +The optional Windows compatibility runtime uses confirmed local GUI-session commands; raw admin-token and remote ingress mutations are +refused. Its start/stop/launch and three-minute account-UI trial follow the [native compatibility contract](clients/codex-desktop.md#optional-compatibility-runtime). +The lazy Codex Set desktop tab uses the machine API target and follows the +[dashboard consent and stale-response contract](clients/codex-desktop.md#dashboard-controls). +Runtime management and proxy startup share a single owner and register awaited shutdown; the desktop compatibility +settings endpoint binds each local GUI write to the displayed field revision. Saving the next-start preference +triggers no runtime action. + Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), `autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; @@ -96,6 +104,8 @@ unsupported; deployments that previously relied on such embedding must open it a ## Authentication boundaries +Codex compatibility certificate setup follows the [certificate setup API](clients/codex-desktop.md#certificate-setup-api): status is read-only; key/trust mutations require the actual local GUI-session principal, explicit confirmation and an exact fingerprint for trust changes. The endpoint does not enable a relay, change Codex login or restart the app. + Kiro management login starts the native device flow only when `POST /api/oauth/login` supplies `method: "builder-id"`, `"google"`, or `"github"`. A method-less request retains the Kiro CLI flow used by the dashboard chooser. The KiroDeviceLoginDialog and useKiroDeviceLogin GUI modules own the native chooser and polling. The kiro-device-login-finalizer GUI module continues terminal status reads after dialog unmount; a provisional cancel result is never treated as confirmed success. Hook and finalizer share one status-read operation that owns fetch, bounded body consumption, parsing and cancellation. The complete read has a 45-second ceiling, the detached loop remains bounded by flow expiry plus 60 seconds (and 16 minutes maximum), and closing the dialog transfers rather than clones an in-flight reader so an already-observed terminal reply can still win. Native status and cancellation require `flowId`; diff --git a/structure/manifest.json b/structure/manifest.json index 48d655727f8..0f8000dcfa6 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -459,6 +459,13 @@ "src/lib/" ] }, + { + "path": "clients/codex-desktop.md", + "tier": 5, + "title": "Codex Desktop Compatibility", + "scope": "Windows package activation, restart context, and protected compatibility authority persistence.", + "documents": ["src/codex/", "src/cli/"] + }, { "path": "clients/chatgpt-desktop.md", "tier": 5, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index ff43997aa44..1435778c235 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -422,9 +422,9 @@ The `package-standalone` job in `.github/workflows/release.yml` also builds Bun `ocx` archives for Linux, macOS, and Windows, bundles `gui/dist`, smoke-tests `/healthz`, and publishes SHA-256 sidecars for the attach job. Each archive also carries the target-matching `@napi-rs/keyring` native addon under `keyring/`; the macOS release installs both optional Darwin -packages so its separate arm64 and x64 builds cannot silently reuse the hosted runner's -architecture. Desktop preparation copies those same pinned assets into Tauri resources. The loader -and packaged-app proof are owned by the [desktop keyring contract](../desktop-shell.md#packaged-native-keyring-binding). +packages so its separate arm64 and x64 builds cannot silently reuse the hosted runner's architecture. Desktop preparation +copies those same pinned assets into Tauri resources. The loader and packaged-app proof are owned by the [desktop keyring contract](../desktop-shell.md#packaged-native-keyring-binding). +`src/lib/standalone.ts` recognizes Bun's file-URL virtual roots after one URL decode, including Windows `%7EBUN`, and resolves runtime assets beside the real executable. Invalid/non-file URLs and double-encoded markers remain source paths; `tests/lib/standalone.test.ts` and `tests/service/standalone-service.test.ts` cover this distinction. Opening a release starts with the `dev` pre-move. Dispatch `.github/workflows/dev-version-bump.yml` with the intended version, merge the pull request it opens, diff --git a/structure/runtime.md b/structure/runtime.md index eaaef86bb72..df56e9f61d3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). ## Resolved static model policy @@ -67,7 +67,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI Codex restart scope -`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope and warns that unsaved composer drafts, model-picker selections, and pending approval prompts may be discarded. `src/cli/system-command.ts` repeats that concrete state-loss warning both when confirmation is missing and after a confirmed human-readable request; the unconfirmed path sends no restart request. `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. +The explicit restart, Windows package activation and compatibility authority contracts are documented in [Codex Desktop compatibility](clients/codex-desktop.md). After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's start time with the written catalog mtime. It reports only processes proven stale; a fresh or diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0de552888a..41a3eeeefbc 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -307,6 +307,10 @@ admission or account-snapshot pairing. The forwarding contract is covered in ## SOCKS5 dispatch boundary +`src/lib/socks5-handshake.ts` shares RFC1928/RFC1929 framing between the fetch tunnel and +native desktop raw TLS dial. Credential decoding is bounded and failures retain the fetch +transport's public `Socks5FetchError` contract. This extraction reuses lcxhh521's PR5947 work. + `src/config/proxy-env.ts` activates configured SOCKS5 through `src/lib/proxy-env.ts`; the compatibility config facade does not own a second activation path. `src/server/responses/fetch-helpers.ts` routes the built-in HTTP executor through @@ -379,3 +383,21 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows The [compaction routing override](responses-failover.md#compaction-routing-overrides) selects a target before the existing native compact or routed Responses transport is resolved. First-party managed native Messages retain the [serving UUID and observed CLI identity contract](../data-planes/protocol-paths.md#managed-native-messages). Identity headers do not authorize credentials or extend their destination scope. + +## Native desktop proxy egress + +`src/lib/desktop-proxy-route.ts` binds desktop HTTP fetch and raw upgraded sockets to the +same explicit proxy decision. NO_PROXY may choose direct; otherwise HTTPS_PROXY or a +selected SOCKS5 ALL_PROXY is carried explicitly. With no HTTPS_PROXY, the desktop relay also +binds HTTP(S) ALL_PROXY explicitly to its supported CONNECT transport. An invalid HTTPS_PROXY +never falls through to ALL_PROXY. Present but unsupported proxy configuration +refuses. A failed selected route never retries directly. Identity verification uses this same +HTTP path, while provider inference retains its separate existing provider-egress policy. + +`src/lib/desktop-upstream-tunnel.ts` dials fixed chatgpt.com TLS through HTTP/HTTPS CONNECT +or authenticated SOCKS5. It bounds the whole connection setup, rejects proxy EOF, abort and +oversized handshake heads, and verifies both proxy and upstream certificate identities. +Only the proxy handshake carries proxy credentials. The raw upgraded socket remains opaque; +the relay does not decode WebSocket payloads. After CONNECT the paused outer stream resumes +before TLS-over-TLS negotiation. Test fixture CA trust is confined to child processes or +injected test socket options, never the user's OS trust store. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c613ab3d3c9..a2560098049 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -572,6 +572,9 @@ describe("headless GUI parity CLI", () => { // Claude reset grants: reading is an owed CLI verb (deferred-verb in the route // registry) and spending is dashboard-session-only by design. ["/api/anthropic/reset-grants", "(none — GUI reset-grant dialog; spend requires a dashboard session)"], + // The registry records read-only status as an owed CLI verb; trust, launch, + // settings and the account-wide trial intentionally require local GUI consent. + ["/api/codex/desktop-compatibility", "(none — local GUI confirmation; status CLI deferred in clients/codex-desktop.md)"], ["/api/protocols", "ocx api protocols/explain/policy"], ["/api/settings", "ocx system"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by @@ -1163,13 +1166,17 @@ describe("headless GUI parity CLI", () => { test("remote connect status is headless and revoke refuses disconnected state before hub traffic", async () => { let requests = 0; + const lockRoot = mkdtempSync(join(tmpdir(), "ocx-connect-parity-lock-")); + const lifecycleLockDeps = { lockPath: join(lockRoot, "client-lifecycle.sqlite") }; const logSpy = spyOn(console, "log").mockImplementation(() => {}); const errorSpy = spyOn(console, "error").mockImplementation(() => {}); try { expect(await handleConnectCommand(["status", "--json"], { + lifecycleLockDeps, fetchImpl: async () => { requests += 1; return new Response(); }, })).toBe(0); expect(await handleConnectCommand(["revoke", "--admin-token-stdin", "--json"], { + lifecycleLockDeps, stdinImpl: Readable.from(["ocx_admin_test\n"]), fetchImpl: async () => { requests += 1; return new Response(); }, })).toBe(1); @@ -1177,6 +1184,7 @@ describe("headless GUI parity CLI", () => { } finally { logSpy.mockRestore(); errorSpy.mockRestore(); + removeTreeWithRetry(lockRoot); } }); diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 1fd3de3c26e..8bbfcec3503 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -171,6 +171,28 @@ describe("the test-runner guard follows the test preload, not NODE_ENV", () => { }); describe("Codex desktop app restart (#2292)", () => { + test("an unreadable Windows root command line refuses before stop and leaves the restart lock reusable", () => { + const calls: Call[] = []; + const io = scriptedIo({ discovery: DISCOVERY, processes: `1000 900 2026-01-01T00:00:00Z ${INSTALL}\\app\\ChatGPT.exe\t`, calls }); + withTrustedExes(() => { + for (let attempt = 0; attempt < 2; attempt++) { + expect(restartCodexDesktopApp(io)).toEqual({ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "relaunch_context_failed" }); + } + }); + expect(calls.some(call => call.file === TASKKILL || /CloseMainWindow|Start-Process|OpenCodexPackageActivation/.test(call.args.join(" ")))).toBe(false); + }); + + test("a relaunch-context failure refuses before any process is signalled and releases the lock", () => { + const calls: Call[] = []; + const io = scriptedIo({ discovery: DISCOVERY, processes: `1000 900 2026-01-01T00:00:00Z ${INSTALL}\\app\\ChatGPT.exe`, calls }); + io.adapter = { ...windowsDesktopAppAdapter, captureRelaunchContext: () => { throw new Error("conflicting context"); } }; + withTrustedExes(() => { + const first = restartCodexDesktopApp(io), second = restartCodexDesktopApp(io); + expect(first).toEqual({ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "relaunch_context_failed" }); + expect(second.reason).toBe("relaunch_context_failed"); + }); + expect(calls.some(call => call.file === TASKKILL || call.args.join(" ").includes("CloseMainWindow") || call.args.join(" ").includes("Start-Process"))).toBe(false); + }); // macOS and Linux are no longer no-ops: they have real adapters. What survives from the // original assertion is that a platform with NO adapter still refuses without execing // anything, which is the fail-closed property the old windows_only case was really @@ -504,4 +526,3 @@ describe("#2557 a failed probe is not an absent app", () => { expect(script).not.toContain("SilentlyContinue' $root"); }); }); - diff --git a/tests/clients/desktop-compatibility-authority.test.ts b/tests/clients/desktop-compatibility-authority.test.ts new file mode 100644 index 00000000000..f23e2ae70a0 --- /dev/null +++ b/tests/clients/desktop-compatibility-authority.test.ts @@ -0,0 +1,174 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, X509Certificate } from "node:crypto"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, renewDesktopCompatibilityAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; +import { windowsAuthorityKeyProtection, type AuthorityKeyProtection } from "../../src/codex/desktop-compatibility/windows-key-protection"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; + +const roots: string[] = []; +const success = () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" }); +// These temporary-store cases exercise publication semantics, not the separate ACL implementation. +setIcaclsRunnerForTests(success); +setAsyncIcaclsRunnerForTests(async () => success()); +afterAll(() => { + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + for (const root of roots) rmSync(root, { recursive: true }); +}); +const directory = () => { const root = mkdtempSync(join(tmpdir(), "ocx-compat-authority-")); roots.push(root); return root; }; +function protector(): AuthorityKeyProtection { + const key = randomBytes(32); + return { + async protect(cleartext) { + const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(cleartext), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]); + }, + async unprotect(ciphertext) { + const data = Buffer.from(ciphertext), cipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)); + cipher.setAuthTag(data.subarray(12, 28)); + return Buffer.concat([cipher.update(data.subarray(28)), cipher.final()]); + }, + }; +} + +describe("Codex Desktop compatibility authority lifecycle", () => { + test("legacy-purpose authority stays removable but must be deliberately renewed before reuse", async () => { + const root = directory(), protection = protector(), commonName = `OpenCodex Codex Desktop ${randomUUID()}`; + const legacy = createCertificateAuthority({ commonName, validityDays: 30, permittedDnsNames: ["chatgpt.com"] }); + expect(new X509Certificate(legacy.certPem).keyUsage).toBeUndefined(); + const clear = Buffer.from(JSON.stringify({ policy: "codex-desktop-chatgpt-only/v1", commonName, + certSha256: createHash("sha256").update(legacy.certPem).digest("hex"), keyPem: legacy.keyPem })); + const path = join(root, "authority.json"); + writeFileSync(path, JSON.stringify({ version: 1, protection: "windows-current-user-dpapi", certPem: legacy.certPem, + sealed: Buffer.from(await protection.protect(clear)).toString("base64") })); clear.fill(0); + const before = readFileSync(path, "utf8"); + expect(inspectDesktopCompatibilityAuthority(root).status).toBe("renewal-required"); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection })).rejects.toMatchObject({ code: "renewal_required" }); + await expect(loadDesktopCompatibilityAuthority({ directory: root, protection })).rejects.toMatchObject({ code: "renewal_required" }); + const removable = await loadDesktopCompatibilityAuthority({ directory: root, protection }, true); + expect(readFileSync(path, "utf8")).toBe(before); + const renewed = await renewDesktopCompatibilityAuthority({ directory: root, protection }, removable.fingerprint); + expect(renewed.fingerprint).not.toBe(removable.fingerprint); + expect(new X509Certificate(renewed.authority.certPem).keyUsage).toEqual(["1.3.6.1.5.5.7.3.1"]); + expect(inspectDesktopCompatibilityAuthority(root).status).toBe("present"); + }); + + test("deliberate renewal replaces one encrypted identity and refuses a stale fingerprint", async () => { + const root = directory(), protection = protector(); + const first = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const second = await renewDesktopCompatibilityAuthority({ directory: root, protection }, first.fingerprint); + expect(second.fingerprint).not.toBe(first.fingerprint); + expect(second.reused).toBe(false); + expect((await ensureDesktopCompatibilityAuthority({ directory: root, protection })).fingerprint).toBe(second.fingerprint); + const contents = readFileSync(join(root, "authority.json"), "utf8"); + await expect(renewDesktopCompatibilityAuthority({ directory: root, protection }, first.fingerprint)).rejects.toMatchObject({ code: "fingerprint_changed" }); + expect(readFileSync(join(root, "authority.json"), "utf8")).toBe(contents); + expect(readdirSync(root).filter(name => /authority.*\.(json|tmp)$/.test(name))).toEqual(["authority.json"]); + }); + + test("failed renewal preserves the old removable envelope and cleans unpublished staging", async () => { + const root = directory(), protection = protector(); + const first = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const original = readFileSync(join(root, "authority.json"), "utf8"); + const broken = { ...protection, protect: async () => Buffer.from("unreadable replacement") }; + await expect(renewDesktopCompatibilityAuthority({ directory: root, protection: broken }, first.fingerprint)).rejects.toMatchObject({ code: "unreadable" }); + expect(readFileSync(join(root, "authority.json"), "utf8")).toBe(original); + expect((await loadDesktopCompatibilityAuthority({ directory: root, protection }, true)).fingerprint).toBe(first.fingerprint); + expect(readdirSync(root).some(name => name.endsWith(".tmp"))).toBe(false); + }); + + test("public status neither creates missing state nor repairs a malformed envelope", async () => { + const root = directory(), absent = join(root, "not-created"); + expect(inspectDesktopCompatibilityAuthority(absent)).toEqual({ status: "missing" }); expect(existsSync(absent)).toBe(false); + const prepared = await ensureDesktopCompatibilityAuthority({ directory: root, protection: protector() }); + const path = join(root, "authority.json"), original = readFileSync(path, "utf8"); + expect(inspectDesktopCompatibilityAuthority(root)).toMatchObject({ status: "present", fingerprint: prepared.fingerprint }); + expect(readFileSync(path, "utf8")).toBe(original); + const malformed = JSON.parse(original); delete malformed.sealed; writeFileSync(path, JSON.stringify(malformed)); + const before = readFileSync(path, "utf8"); expect(inspectDesktopCompatibilityAuthority(root)).toEqual({ status: "invalid" }); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + test("an expired key can be loaded for exact trust removal without minting a replacement", async () => { + const root = directory(), protection = protector(); + const prepared = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const options = { directory: root, protection, now: () => prepared.expiresAt + 1 }; + await expect(loadDesktopCompatibilityAuthority(options)).rejects.toMatchObject({ code: "expired" }); + const loaded = await loadDesktopCompatibilityAuthority(options, true); + expect(loaded.fingerprint).toBe(prepared.fingerprint); expect(loaded.authority.keyPem).toBe(prepared.authority.keyPem); + }); + + test("reuses the exact certificate and key after reopening; disk never contains the private PEM", async () => { + const root = directory(), protection = protector(); + const first = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const contents = readFileSync(join(root, "authority.json"), "utf8"); + expect(contents).not.toContain("PRIVATE KEY"); + expect(contents).not.toContain(first.authority.keyPem); + const second = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + expect(first.reused).toBe(false); expect(second.reused).toBe(true); + expect(second.fingerprint).toBe(first.fingerprint); + expect(second.authority.keyPem).toBe(first.authority.keyPem); + expect(readFileSync(join(root, "authority.json"), "utf8")).toBe(contents); + }); + + test("a foreign user or corrupt protected key refuses without replacing the trusted identity", async () => { + const root = directory(), protection = protector(); + await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const path = join(root, "authority.json"), original = readFileSync(path, "utf8"); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection: protector() })) + .rejects.toMatchObject({ code: "unreadable" }); + expect(readFileSync(path, "utf8")).toBe(original); + const saved = JSON.parse(original); saved.sealed = "invalid-ciphertext"; writeFileSync(path, JSON.stringify(saved)); + const corrupted = readFileSync(path, "utf8"); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection })).rejects.toMatchObject({ code: "unreadable" }); + expect(readFileSync(path, "utf8")).toBe(corrupted); + }); + + test("swapping only the public certificate cannot reuse an unrelated protected key", async () => { + const protection = protector(), root = directory(), foreign = directory(); + await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + await ensureDesktopCompatibilityAuthority({ directory: foreign, protection }); + const path = join(root, "authority.json"), saved = JSON.parse(readFileSync(path, "utf8")); + saved.certPem = JSON.parse(readFileSync(join(foreign, "authority.json"), "utf8")).certPem; + writeFileSync(path, JSON.stringify(saved)); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection })).rejects.toMatchObject({ code: "unreadable" }); + }); + + test("expiry requires deliberate renewal and never silently creates a new trust prompt", async () => { + const root = directory(), protection = protector(); + const first = await ensureDesktopCompatibilityAuthority({ directory: root, protection }); + const original = readFileSync(join(root, "authority.json"), "utf8"); + const near = await ensureDesktopCompatibilityAuthority({ directory: root, protection, now: () => first.expiresAt - 60_000 }); + expect(near.renewalDue).toBe(true); expect(near.fingerprint).toBe(first.fingerprint); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection, now: () => first.expiresAt + 1 })) + .rejects.toMatchObject({ code: "expired" }); + expect(readFileSync(join(root, "authority.json"), "utf8")).toBe(original); + }); + + test("protection failure publishes no certificate or plaintext fallback", async () => { + const root = directory(); + const protection: AuthorityKeyProtection = { protect: async () => { throw new Error("fixture secret"); }, unprotect: async () => { throw new Error(); } }; + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection })).rejects.toMatchObject({ code: "protection_failed" }); + expect(existsSync(join(root, "authority.json"))).toBe(false); + expect(readdirSync(root).some(name => name.endsWith(".tmp") || name.endsWith(".pem") || name.endsWith(".key"))).toBe(false); + }); + + test("malformed existing state is preserved for recovery rather than regenerated", async () => { + const root = directory(), path = join(root, "authority.json"); + writeFileSync(path, "broken state"); + await expect(ensureDesktopCompatibilityAuthority({ directory: root, protection: protector() })).rejects.toMatchObject({ code: "unreadable" }); + expect(readFileSync(path, "utf8")).toBe("broken state"); + }); + + test.skipIf(process.platform !== "win32")("real CurrentUser DPAPI survives separate helper invocations and rejects corruption", async () => { + const original = Buffer.from("synthetic authority secret; no real credentials"); + const encrypted = await windowsAuthorityKeyProtection.protect(original); + expect(Buffer.from(encrypted).includes(original)).toBe(false); + expect(Buffer.from(await windowsAuthorityKeyProtection.unprotect(encrypted))).toEqual(original); + const corrupted = Uint8Array.from(encrypted); corrupted[corrupted.length - 1] ^= 1; + await expect(windowsAuthorityKeyProtection.unprotect(corrupted)).rejects.toThrow("desktop_compatibility_key_protection_failed"); + }, 35_000); +}); diff --git a/tests/clients/desktop-compatibility-build-probe.test.ts b/tests/clients/desktop-compatibility-build-probe.test.ts new file mode 100644 index 00000000000..a412e914592 --- /dev/null +++ b/tests/clients/desktop-compatibility-build-probe.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_COMPATIBILITY_ASSESSED_FAMILY } from "../../src/codex/desktop-compatibility/installed-build"; +import type { DesktopAppInstall } from "../../src/codex/desktop-app/types"; + +const installed = (version = DESKTOP_COMPATIBILITY_ASSESSED_VERSION): DesktopAppInstall => ({ + id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, root: `C:\\Program Files\\WindowsApps\\OpenAI.Codex_${version}_x64__2p2nqsd0c76g0`, relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App`, +}); + +test("matching version text cannot qualify a foreign package family, publisher or app entry", async () => { + const original = installed(); + for (const candidate of [{ ...original, id: "Foreign.Codex_2p2nqsd0c76g0" }, + { ...original, relaunch: `${original.id}!Other` }, + { ...original, root: original.root.replace("__2p2nqsd0c76g0", "__foreign") }, + { ...original, id: "OpenAI.Codex_foreign", relaunch: "OpenAI.Codex_foreign!App", root: original.root.replace("__2p2nqsd0c76g0", "__foreign") }, + { ...original, root: original.root + "_extra" }]) { + const probe = createInstalledBuildProbe(async () => candidate); + expect(await probe.check()).toBe(false); await probe.close(); + } +}); + +test("concurrent build checks share only the pending query and refresh after it settles", async () => { + let calls = 0, release!: (value: DesktopAppInstall | null) => void; + const probe = createInstalledBuildProbe(async () => { calls++; return new Promise(resolve => { release = resolve; }); }); + const first = probe.check(), second = probe.check(); + await Bun.sleep(0); expect(calls).toBe(1); + release(installed()); expect(await first).toBe(true); expect(await second).toBe(true); + const changed = probe.check(); await Bun.sleep(0); expect(calls).toBe(2); + release(installed("99.1.1.0")); expect(await changed).toBe(false); + await probe.close(); +}); + +test("shutdown aborts the owned query and waits for its cleanup before settling", async () => { + let aborted = false, reaped!: () => void, closed = false; + const probe = createInstalledBuildProbe(signal => new Promise(resolve => { + reaped = () => resolve(null); + signal.addEventListener("abort", () => { aborted = true; }, { once: true }); + })); + const pending = probe.check(); await Bun.sleep(0); + const closing = probe.close().then(() => { closed = true; }); + await Bun.sleep(0); expect(aborted).toBe(true); expect(closed).toBe(false); + reaped(); await closing; expect(await pending).toBe(false); expect(await probe.check()).toBe(false); +}); + +test("a failed probe is closed and the next fresh query can recover", async () => { + let calls = 0; + const probe = createInstalledBuildProbe(async () => { if (++calls === 1) throw new Error("fixture failure"); return installed(); }); + expect(await probe.check()).toBe(false); expect(await probe.check()).toBe(true); + await probe.close(); +}); diff --git a/tests/clients/desktop-compatibility-certificate-service.test.ts b/tests/clients/desktop-compatibility-certificate-service.test.ts new file mode 100644 index 00000000000..638e2a3d30a --- /dev/null +++ b/tests/clients/desktop-compatibility-certificate-service.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { createDesktopCertificateService } from "../../src/codex/desktop-compatibility/certificate-service"; +import type { DesktopAuthorityInspection, StoredDesktopAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; + +const fingerprint = "A".repeat(64); +const authority = { fingerprint } as StoredDesktopAuthority; +function fixture() { + const calls: string[] = []; + let stored: DesktopAuthorityInspection = { status: "missing" }, trusted = false; + let appRunning: boolean | null = false, removalResult: "not-trusted" | "unknown" | "trusted" = "not-trusted"; + const present = () => { stored = { status: "present", fingerprint, certPem: "public-fixture", expiresAt: 123456, renewalDue: false }; }; + const service = createDesktopCertificateService("fixture-only", { platform: "win32", + inspect: () => { calls.push("inspect"); return stored; }, + prepare: async () => { calls.push("prepare"); present(); return authority; }, + load: async expired => { calls.push(expired ? "load-removal" : "load"); return authority; }, + renew: async () => { calls.push("renew"); stored = { status: "present", fingerprint: "B".repeat(64), certPem: "renewed-public-fixture", expiresAt: 234567, renewalDue: false }; return { fingerprint: "B".repeat(64) } as StoredDesktopAuthority; }, + readTrust: async () => { calls.push("read-trust"); return trusted ? "trusted" : "not-trusted"; }, + changeTrust: async (_value, action) => { calls.push(action); trusted = action === "trust"; return trusted ? "trusted" : removalResult; }, + appRunning: async () => appRunning, + }); + return { calls, service, present, setAppRunning: (value: boolean | null) => { appRunning = value; }, setRemovalResult: (value: typeof removalResult) => { removalResult = value; }, setStored: (value: DesktopAuthorityInspection) => { stored = value; } }; +} + +describe("compatibility certificate setup service", () => { + test("legacy-purpose status stays renewal-required even when its old OS root is trusted", async () => { + const io = fixture(); io.present(); await io.service.trust(fingerprint); + io.setStored({ status: "renewal-required", fingerprint, certPem: "public-fixture", expiresAt: 123456, renewalDue: false }); + expect((await io.service.status()).state).toBe("renewal-required"); + expect((await io.service.renew(fingerprint)).state).toBe("prepared"); + expect(io.calls).toContain("load-removal"); + }); + + test("renewal verifies removal before replacement and leaves the new root untrusted", async () => { + const io = fixture(); io.present(); + const result = await io.service.renew(fingerprint); + expect(result).toMatchObject({ state: "prepared", fingerprint: "B".repeat(64) }); + expect(io.calls.indexOf("remove")).toBeLessThan(io.calls.indexOf("renew")); + expect(io.calls).toContain("load-removal"); expect(io.calls).not.toContain("trust"); + expect(io.calls).not.toContain("prepare"); + }); + test("renewal never loses an old trust identity after refusal, uncertainty or a running app", async () => { + for (const result of ["unknown", "trusted"] as const) { + const io = fixture(); io.present(); io.setRemovalResult(result); + await expect(io.service.renew(fingerprint)).rejects.toMatchObject({ code: result === "unknown" ? "trust_unknown" : "trust_not_applied" }); + expect(io.calls).not.toContain("renew"); + } + const io = fixture(); io.present(); io.setAppRunning(true); + await expect(io.service.renew(fingerprint)).rejects.toMatchObject({ code: "app_running" }); + expect(io.calls).not.toContain("remove"); expect(io.calls).not.toContain("renew"); + }); + + test("status and unsupported hosts never create a key or inspect trust unnecessarily", async () => { + const io = fixture(); expect((await io.service.status()).state).toBe("missing"); expect(io.calls).toEqual(["inspect"]); + const disabled = createDesktopCertificateService("unused", { platform: "linux", inspect: () => { throw new Error("must not run"); } }); + expect(await disabled.status()).toEqual({ supported: false, state: "missing", busy: null }); + await expect(disabled.prepare()).rejects.toMatchObject({ code: "unsupported" }); + }); + test("prepare does not register trust and trust loads only an already prepared key", async () => { + const io = fixture(); + expect((await io.service.prepare()).state).toBe("prepared"); expect(io.calls).not.toContain("trust"); + io.calls.length = 0; + expect((await io.service.trust(fingerprint)).state).toBe("trusted"); + expect(io.calls).toContain("load"); expect(io.calls).not.toContain("prepare"); + }); + test("missing or changed certificates cannot silently generate replacements during trust", async () => { + const io = fixture(); + await expect(io.service.trust(fingerprint)).rejects.toMatchObject({ code: "not_prepared" }); + io.present(); await expect(io.service.trust("B".repeat(64))).rejects.toMatchObject({ code: "fingerprint_changed" }); + expect(io.calls).not.toContain("prepare"); expect(io.calls).not.toContain("trust"); + }); + test("removal waits until the app is absent and uses the removal-only existing-key load", async () => { + const io = fixture(); io.present(); io.setAppRunning(true); + await expect(io.service.removeTrust(fingerprint)).rejects.toMatchObject({ code: "app_running" }); + expect(io.calls).not.toContain("remove"); io.setAppRunning(false); + expect((await io.service.removeTrust(fingerprint)).state).toBe("prepared"); + expect(io.calls).toContain("load-removal"); expect(io.calls).not.toContain("prepare"); + }); + test("the public status never includes the certificate body or private-key object", async () => { + const io = fixture(); io.present(); + const json = JSON.stringify(await io.service.status()); + expect(json).not.toContain("certPem"); expect(json).not.toContain("public-fixture"); expect(json).not.toContain("authority"); + }); + test("unknown app state is reported distinctly and cannot remove trust", async () => { + const io = fixture(); io.present(); io.setAppRunning(null); + await expect(io.service.removeTrust(fingerprint)).rejects.toMatchObject({ code: "app_state_unknown" }); + expect(io.calls).not.toContain("remove"); expect(io.calls).not.toContain("load-removal"); + }); +}); diff --git a/tests/clients/desktop-compatibility-connection-store.test.ts b/tests/clients/desktop-compatibility-connection-store.test.ts new file mode 100644 index 00000000000..c9bc172cef3 --- /dev/null +++ b/tests/clients/desktop-compatibility-connection-store.test.ts @@ -0,0 +1,88 @@ +import { afterAll, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { X509Certificate } from "node:crypto"; +import { createDesktopConnectionStore } from "../../src/codex/desktop-compatibility/connection-store"; +import { createDesktopCompatibilityRuntime } from "../../src/codex/desktop-compatibility/runtime"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +const roots: string[] = []; +const success = () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" }); +setIcaclsRunnerForTests(success); setAsyncIcaclsRunnerForTests(async () => success()); +afterAll(() => { setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); for (const root of roots) rmSync(root, { recursive: true }); }); +function fixture() { const path = mkdtempSync(join(tmpdir(), "ocx-desktop-endpoint-")); roots.push(path); return { path, store: createDesktopConnectionStore(path) }; } +const identity = () => ({ version: 1 as const, id: randomUUID(), connectPort: 40001, pacPort: 40002 }); + +test("failed temporary cleanup preserves the publication error as well as the residue", async () => { + const { path, store } = fixture(), primary = new Error("fixture publication failure"), cleanup = new Error("fixture cleanup failure"); + const realLink = fs.linkSync, realUnlink = fs.unlinkSync; + const link = spyOn(fs, "linkSync").mockImplementation((source, destination) => { + if (String(destination) === join(path, "connection.json")) throw primary; + return realLink(source, destination); + }); + const unlink = spyOn(fs, "unlinkSync").mockImplementation(target => { + if (String(target).startsWith(join(path, "connection-")) && String(target).endsWith(".tmp")) throw cleanup; + return realUnlink(target); + }); + try { + const error = await store.publish(identity()).catch(value => value); + expect(error).toBeInstanceOf(Error); expect(error.message).toBe("desktop_compatibility_connection_cleanup_required"); + expect(error.cause).toBeInstanceOf(AggregateError); expect(error.cause.errors).toEqual([primary, cleanup]); + expect(store.read()).toBeNull(); expect(readdirSync(path).some(value => value.endsWith(".tmp"))).toBe(true); + } finally { link.mockRestore(); unlink.mockRestore(); } +}); + +test("one public endpoint identity survives reopening without rewriting the file", async () => { + const { path, store } = fixture(), value = identity(); expect(store.read()).toBeNull(); + await store.publish(value); + const saved = readFileSync(join(path, "connection.json"), "utf8"); + const reopened = createDesktopConnectionStore(path); + expect(reopened.read()).toEqual(value); expect(await reopened.publish(value)).toEqual(value); + expect(readFileSync(join(path, "connection.json"), "utf8")).toBe(saved); + expect(Object.keys(JSON.parse(saved)).sort()).toEqual(["connectPort", "id", "pacPort", "version"]); + expect(readdirSync(path).some(file => file.endsWith(".tmp"))).toBe(false); +}); +test("changed, invalid or unsupported state is preserved rather than assigned new endpoints", async () => { + const { path, store } = fixture(), value = identity(); await store.publish(value); + await expect(store.publish({ ...value, id: randomUUID() })).rejects.toThrow("connection_changed"); + for (const content of ["broken", JSON.stringify({ ...value, connectPort: 80 }), JSON.stringify({ ...value, version: 2 }), JSON.stringify({ ...value, token: "fixture" })]) { + writeFileSync(join(path, "connection.json"), content); + expect(() => store.read()).toThrow("connection_invalid"); + await expect(store.publish(value)).rejects.toThrow("connection_invalid"); + expect(readFileSync(join(path, "connection.json"), "utf8")).toBe(content); + } +}); +test("a competing publication cannot replace the first endpoint identity", async () => { + const { path, store } = fixture(), first = identity(), second = { ...identity(), connectPort: 40003, pacPort: 40004 }; + const results = await Promise.allSettled([store.publish(first), createDesktopConnectionStore(path).publish(second)]); + expect(results.filter(value => value.status === "fulfilled")).toHaveLength(1); + expect([first.id, second.id]).toContain(store.read()!.id); + expect(readdirSync(path).some(file => file.endsWith(".tmp"))).toBe(false); +}); + +test("a fresh runtime reopens the persisted endpoints and serves an already cached PAC", async () => { + const { path } = fixture(); + const authority = createCertificateAuthority({ commonName: "persisted-runtime-fixture", validityDays: 1 }), cert = new X509Certificate(authority.certPem); + const account = { id: "fixture", userId: "fixture-user", plan: "pro" as const, structure: "personal" as const }; + const makeRuntime = () => createDesktopCompatibilityRuntime({ platform: "win32", testOnly: true, + connectionStore: createDesktopConnectionStore(path), identity: { readCurrentIdentity: async () => account, verifyFreshIdentity: async () => account }, + loadAuthority: async () => ({ authority, commonName: "persisted-runtime-fixture", fingerprint: cert.fingerprint256.replaceAll(":", ""), expiresAt: Date.parse(cert.validTo), reused: true, renewalDue: false }), + trust: async () => "trusted", buildSupported: () => true, routingSupported: () => true, + upstreamFetch: (async () => Response.json({ fixture: "original-response" })) as typeof fetch, + }); + const first = makeRuntime(), second = makeRuntime(); + try { + await first.start(); const url = first.getPacUrl()!, pac = await fetch(url).then(res => res.text()); + const saved = readFileSync(join(path, "connection.json"), "utf8"); + const proxy = `http://127.0.0.1:${/PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]}`; + const request = () => fetch("https://chatgpt.com/backend-api/conversation", { proxy, tls: { ca: authority.certPem } }).then(res => res.json()); + expect(await request()).toEqual({ fixture: "original-response" }); + await first.stop(); await second.start(); + expect(second.getPacUrl()).toBe(url); expect(await fetch(url).then(res => res.text())).toBe(pac); + expect(await request()).toEqual({ fixture: "original-response" }); + expect(readFileSync(join(path, "connection.json"), "utf8")).toBe(saved); + } finally { await first.stop(); await second.stop(); } +}, 10000); diff --git a/tests/clients/desktop-compatibility-launch.test.ts b/tests/clients/desktop-compatibility-launch.test.ts new file mode 100644 index 00000000000..769b2971852 --- /dev/null +++ b/tests/clients/desktop-compatibility-launch.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { compatibilityActivationScript, launchWindowsCodexCompatibility, validatedCompatibilityPacUrl } from "../../src/codex/desktop-compatibility/windows-package-launch"; +import { WINDOWS_ACTIVATION_SOURCE } from "../../src/codex/desktop-compatibility/windows-activation-source"; +import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; +import type { DesktopExec } from "../../src/codex/desktop-app/types"; +import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; +import { activateWindowsCodexCompatibility, captureWindowsCompatibilityContext } from "../../src/codex/desktop-compatibility/windows-package-command"; +import { acquireDesktopCompatibilityRuntime, bindDesktopCompatibilityLaunch, readDesktopCompatibilityLaunch } from "../../src/codex/desktop-compatibility/runtime-ownership"; + +const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; +const install = { id: "OpenAI.Codex_fixture", root: "C:\\Program Files\\WindowsApps\\OpenAI.Codex_fixture", relaunch: "OpenAI.Codex_fixture!App" }; +const pac = "http://127.0.0.1:10102/fixture-0123456789/proxy.pac"; +const packageFullName = "OpenAI.Codex_1.2.3.0_x64__fixture"; +const releases: (() => void)[] = []; +afterEach(() => { for (const release of releases.splice(0).reverse()) release(); setTrustedWindowsElevationExecutablesForTests(null); }); +function own(pacUrl = pac, current = () => true) { + const release = acquireDesktopCompatibilityRuntime(); releases.push(release); + releases.push(bindDesktopCompatibilityLaunch(pacUrl, current)); + return { release, generation: readDesktopCompatibilityLaunch()!.generation }; +} +function executor(options: { running?: string; probeFailure?: boolean; output?: string } = {}) { + const calls: string[] = []; + setTrustedWindowsElevationExecutablesForTests({ powershell }); + const exec: DesktopExec = (_file, args) => { + const script = args.at(-1)!; calls.push(script); + if (script.includes("OpenCodexPackageActivation")) return options.output ?? JSON.stringify({ pid: 123, packageFullName, verified: true }); + if (script.includes("Get-AppxPackage")) return `${install.id}\n${install.root}\n${install.relaunch}`; + if (options.probeFailure) throw new Error("probe refused"); + return options.running ?? ""; + }; + return { calls, exec }; +} + +describe("Codex Desktop compatibility package launch", () => { + test("a same-shape PAC without a live runtime owner refuses before restart", () => { + const root = { pid: 100, parentPid: 50, createdAt: "fixture", executable: "ChatGPT.exe", commandLine: `ChatGPT.exe --proxy-pac-url=${pac}` }; + expect(() => captureWindowsCompatibilityContext([root])).toThrow("desktop_compatibility_launch_owner_unverified"); + }); + test("activation tolerates a BOM and preceding warnings but normalizes invalid or timed-out output", () => { + const io = executor({ output: `\uFEFFwarning: fixture\r\n${JSON.stringify({ pid: 123, packageFullName, verified: true })}\r\n` }); + expect(activateWindowsCodexCompatibility(io.exec, install, pac)).toEqual({ pid: 123, packageFullName }); + for (const output of ["", "null", "[]", "warning only"]) { + expect(() => activateWindowsCodexCompatibility(executor({ output }).exec, install, pac)).toThrow("desktop_compatibility_activation_unverified"); + } + expect(() => activateWindowsCodexCompatibility(() => { throw new Error("fixture timeout"); }, install, pac)).toThrow("desktop_compatibility_activation_unverified"); + }); + test("the shipped Windows restart adapter captures and reapplies an active compatibility PAC", () => { + const { generation } = own(); + const commandLine = `"${install.root}\\app\\ChatGPT.exe" --proxy-pac-url=${pac}`; + const io = executor({ running: `100 50 2026-01-01T00:00:00Z ${install.root}\\app\\ChatGPT.exe\t${Buffer.from(commandLine).toString("base64")}` }); + const processes = windowsDesktopAppAdapter.listProcesses(io.exec, install)!; + expect(processes[0]?.commandLine).toBe(commandLine); + const context = windowsDesktopAppAdapter.captureRelaunchContext(io.exec, install, processes); + expect(context).toEqual({ codexCompatibilityPacUrl: pac, codexCompatibilityGeneration: generation }); + windowsDesktopAppAdapter.relaunch(io.exec, install, context); + expect(io.calls.at(-1)).toContain("OpenCodexPackageActivation"); + expect(io.calls.at(-1)).toContain(pac); + }); + + test("an explicitly unreadable root command line survives parsing and refuses context capture", () => { + for (const commandLine of ["", " "]) { + const io = executor({ running: `100 50 2026-01-01T00:00:00Z ${install.root}\\app\\ChatGPT.exe\t${Buffer.from(commandLine).toString("base64")}` }); + const processes = windowsDesktopAppAdapter.listProcesses(io.exec, install)!; + expect(processes).toHaveLength(1); + expect(processes[0]?.commandLine).toBe(commandLine); + expect(() => windowsDesktopAppAdapter.captureRelaunchContext(io.exec, install, processes)) + .toThrow("desktop_compatibility_launch_context_unavailable"); + } + }); + + test("helper arguments cannot override the main app and conflicting roots refuse before a stop", () => { + const { generation } = own(); + const root = { pid: 100, parentPid: 50, createdAt: "fixture", executable: "ChatGPT.exe", commandLine: `ChatGPT.exe --proxy-pac-url=${pac}` }; + const other = pac.replace(":10102", ":10103"); + expect(captureWindowsCompatibilityContext([root, { ...root, pid: 101, parentPid: 100, commandLine: `ChatGPT.exe --proxy-pac-url=${other}` }])) + .toEqual({ codexCompatibilityPacUrl: pac, codexCompatibilityGeneration: generation }); + expect(() => captureWindowsCompatibilityContext([root, { ...root, pid: 200, commandLine: `ChatGPT.exe --proxy-pac-url=${other}` }])) + .toThrow("desktop_compatibility_conflicting_launch_context"); + expect(captureWindowsCompatibilityContext([{ ...root, commandLine: "ChatGPT.exe" }])).toEqual({}); + expect(captureWindowsCompatibilityContext([root, { ...root, pid: 101, parentPid: 100, commandLine: "" }])) + .toEqual({ codexCompatibilityPacUrl: pac, codexCompatibilityGeneration: generation }); + }); + + test("a foreign same-shape endpoint and an invalidated owner refuse capture", () => { + let current = true; + own(pac, () => current); + const root = { pid: 100, parentPid: 50, createdAt: "fixture", executable: "ChatGPT.exe", commandLine: `ChatGPT.exe --proxy-pac-url=${pac.replace(":10102", ":10103")}` }; + expect(() => captureWindowsCompatibilityContext([root])).toThrow("desktop_compatibility_launch_owner_unverified"); + current = false; + expect(() => captureWindowsCompatibilityContext([{ ...root, commandLine: `ChatGPT.exe --proxy-pac-url=${pac}` }])) + .toThrow("desktop_compatibility_launch_owner_unverified"); + }); + + test("a stopped or replaced runtime cannot reuse a previously captured restart context", () => { + const first = own(); + const root = { pid: 100, parentPid: 50, createdAt: "fixture", executable: "ChatGPT.exe", commandLine: `ChatGPT.exe --proxy-pac-url=${pac}` }; + const context = captureWindowsCompatibilityContext([root]), io = executor(); + first.release(); + expect(() => windowsDesktopAppAdapter.relaunch(io.exec, install, context)).toThrow("desktop_compatibility_launch_owner_unverified"); + const second = own(); + expect(second.generation).not.toBe(first.generation); + expect(() => windowsDesktopAppAdapter.relaunch(io.exec, install, context)).toThrow("desktop_compatibility_launch_owner_unverified"); + expect(io.calls).toEqual([]); + windowsDesktopAppAdapter.relaunch(io.exec, install, captureWindowsCompatibilityContext([root])); + expect(io.calls).toHaveLength(1); + }); + + test("accepts only canonical owned-shape loopback PAC URLs", () => { + expect(validatedCompatibilityPacUrl(pac)).toBe(pac); + for (const value of ["https://127.0.0.1:10102/fixture-0123456789/proxy.pac", pac.replace("127.0.0.1", "localhost"), + pac + "?other=1", pac + "#fragment", pac.replace("127.0.0.1", "user@127.0.0.1"), pac.replace("proxy.pac", "other.pac"), + pac.replace("fixture-0123456789", "short"), pac + " --disable-web-security", pac.replace(":10102", "")]) { + expect(() => validatedCompatibilityPacUrl(value)).toThrow("desktop_compatibility_invalid_pac_url"); + } + }); + + test("uses Windows package activation with PAC arguments and requires package readback", () => { + const io = executor(); + expect(launchWindowsCodexCompatibility(pac, { exec: io.exec, platform: "win32" })).toEqual({ status: "started", pid: 123, packageFullName }); + const script = io.calls.at(-1)!; + expect(script).toContain("::Activate($family+'!App','--proxy-pac-url='+$pac)"); + expect(script).toContain("::PackageOf($pidValue)"); + expect(script).toContain("Get-AppxPackageManifest"); + expect(script).toContain("Launch arguments not observed"); + expect(script).not.toContain("Start-Process"); + expect(script).not.toContain("taskkill"); + }); + + test("an existing app or failed process discovery refuses before activation", () => { + const running = executor({ running: `100 50 2026-01-01T00:00:00Z ${install.root}\\app\\ChatGPT.exe` }); + expect(launchWindowsCodexCompatibility(pac, { exec: running.exec, platform: "win32" })).toEqual({ status: "refused", reason: "app_running" }); + expect(running.calls.some(script => script.includes("OpenCodexPackageActivation"))).toBe(false); + const failed = executor({ probeFailure: true }); + expect(launchWindowsCodexCompatibility(pac, { exec: failed.exec, platform: "win32" })).toEqual({ status: "refused", reason: "process_probe_failed" }); + }); + + test("unverified activation or the wrong package publisher cannot report success", () => { + for (const output of ["broken", JSON.stringify({ pid: 123, packageFullName, verified: false }), + JSON.stringify({ pid: 123, packageFullName: "OpenAI.Codex_1.2.3.0_x64__foreign", verified: true })]) { + const io = executor({ output }); + expect(launchWindowsCodexCompatibility(pac, { exec: io.exec, platform: "win32" })).toEqual({ status: "refused", reason: "activation_failed" }); + } + }); + + test("non-Windows and armed tests never reach the real native app", () => { + expect(launchWindowsCodexCompatibility(pac, { platform: "linux" })).toEqual({ status: "refused", reason: "unsupported_platform" }); + expect(launchWindowsCodexCompatibility(pac, { platform: "win32" })).toEqual({ status: "refused", reason: "test_environment" }); + }); + + test.skipIf(process.platform !== "win32")("the real Windows parser and COM service accept the adapter without launching an app", () => { + const script = compatibilityActivationScript(install, pac); + const encoded = Buffer.from(script, "utf8").toString("base64"); + const validation = ["$ErrorActionPreference='Stop'", + `$null=[scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))`, + "Add-Type -TypeDefinition @'", WINDOWS_ACTIVATION_SOURCE, "'@", + "[OpenCodexPackageActivation]::ValidateActivationService()", "'validated-without-launch'"].join("\n"); + const output = execFileSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", validation], { encoding: "utf8", timeout: 10_000, windowsHide: true }); + expect(output.trim()).toBe("validated-without-launch"); + }, 15_000); +}); diff --git a/tests/clients/desktop-compatibility-native-identity.test.ts b/tests/clients/desktop-compatibility-native-identity.test.ts new file mode 100644 index 00000000000..aecdc2b2da9 --- /dev/null +++ b/tests/clients/desktop-compatibility-native-identity.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmdirSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createNativeIdentityReader } from "../../src/codex/desktop-compatibility/native-identity"; +import { UsageRelayController } from "../../src/codex/desktop-compatibility/usage-controller"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) { unlinkSync(join(root, "auth.json")); rmdirSync(root); } }); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "desktop-identity-")); roots.push(root); + const path = join(root, "auth.json"); let revision = 0; + const write = (id = "fixture-A", access = "synthetic-access-A") => { + const claims = { "https://api.openai.com/auth": { chatgpt_account_id: id, chatgpt_user_id: "fixture-user", chatgpt_plan_type: "pro" } }; + writeFileSync(path, JSON.stringify({ auth_mode: "chatgpt", tokens: { account_id: id, access_token: access, + id_token: `fixture.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.fixture` } })); + const timestamp = new Date(Date.now() + ++revision * 2000); utimesSync(path, timestamp, timestamp); + }; + write(); return { path, write }; +} +const usage = { account_id: "fixture-A", user_id: "fixture-user", plan_type: "pro", + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } }, + spend_control: { reached: false }, credits: { has_credits: false, unlimited: false } }; +function upstreamFixture(beforeResponse?: () => Promise) { + const requests: Request[] = []; + return { + fetch: (async (input, init) => { + requests.push(new Request(input, init)); await beforeResponse?.(); return Response.json(usage); + }) as typeof fetch, + verify(tokens: string[]) { + // Assert outside the production reader's catch: a bad request must not look + // like the expected null result of a negative identity test. + expect(requests).toHaveLength(tokens.length); + requests.forEach((request, index) => { + expect(request.url).toBe("https://chatgpt.com/backend-api/wham/usage"); + expect(request.method).toBe("GET"); expect(request.redirect).toBe("manual"); + expect(request.headers.get("authorization")).toBe(`Bearer ${tokens[index]}`); + expect(request.headers.get("ChatGPT-Account-ID")).toBe("fixture-A"); + }); + }, + }; +} +const exchange = { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }; +const consent = { scope: "account-ui-compatibility" as const, accountWideConsent: true }; + +test("unchanged native credentials keep one opaque generation and verify without exposing tokens", async () => { + const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); + const first = await reader.readCurrentIdentity(); + expect(first?.credentialGeneration).toBeDefined(); + expect(await reader.readCurrentIdentity()).toEqual(first); + expect(await reader.verifyFreshIdentity()).toEqual(first); + expect(JSON.stringify(first)).not.toContain("synthetic-access"); + expect(JSON.stringify(first)).not.toContain("id_token"); + upstream.verify(["synthetic-access-A"]); +}); + +for (const change of ["token rotation", "A to B to A"] as const) test(`a delayed identity response cannot cross ${change}`, async () => { + const io = fixture(); let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + const upstream = upstreamFixture(async () => { entered(); await pending; }); + const reader = createNativeIdentityReader(io.path, upstream.fetch); + const before = await reader.readCurrentIdentity(), verifying = reader.verifyFreshIdentity(); await started; + if (change === "token rotation") io.write("fixture-A", "synthetic-access-new"); + else { io.write("fixture-B", "synthetic-access-B"); io.write(); } + release(); expect(await verifying).toBeNull(); + expect((await reader.readCurrentIdentity())?.credentialGeneration).not.toBe(before?.credentialGeneration); + upstream.verify(["synthetic-access-A"]); +}); + +test("an unreadable intermediate login cannot recover an old credential generation", async () => { + const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch), before = await reader.readCurrentIdentity(); + writeFileSync(io.path, "{}"); expect(await reader.readCurrentIdentity()).toBeNull(); io.write(); + expect((await reader.readCurrentIdentity())?.credentialGeneration).not.toBe(before?.credentialGeneration); + upstream.verify([]); +}); + +test("a trial disarms on native credential replacement and a fresh runtime can bind the replacement", async () => { + const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch), account = (await reader.verifyFreshIdentity())!; + const controller = new UsageRelayController(account, reader.readCurrentIdentity, reader.verifyFreshIdentity, Date.now, Date.now() + 600000); + await controller.rewriteJson(JSON.stringify(usage), exchange); expect((await controller.activate(consent)).accepted).toBe(true); + io.write("fixture-A", "synthetic-access-new"); + expect(await controller.rewriteJson(JSON.stringify(usage), exchange)).toBeNull(); + expect(controller.snapshot().mode).toBe("observe"); expect(controller.snapshot().outputs).toBe(0); + const replacement = new UsageRelayController((await reader.verifyFreshIdentity())!, reader.readCurrentIdentity, reader.verifyFreshIdentity, Date.now, Date.now() + 600000); + await replacement.rewriteJson(JSON.stringify(usage), exchange); expect((await replacement.activate(consent)).accepted).toBe(true); + upstream.verify(["synthetic-access-A", "synthetic-access-A", "synthetic-access-new", "synthetic-access-new"]); +}); + +test("A to B to A during the async build check cannot publish a corrected old response", async () => { + const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); let waiting = false, entered!: () => void, release!: (value: boolean) => void; + const started = new Promise(resolve => { entered = resolve; }); + const controller = new UsageRelayController((await reader.verifyFreshIdentity())!, reader.readCurrentIdentity, reader.verifyFreshIdentity, Date.now, Date.now() + 600000, 180000, + () => waiting ? (entered(), new Promise(resolve => { release = resolve; })) : true); + await controller.rewriteJson(JSON.stringify(usage), exchange); await controller.activate(consent); + waiting = true; const correcting = controller.rewriteJson(JSON.stringify(usage), exchange); await started; + io.write("fixture-B", "synthetic-access-B"); io.write(); release(true); + expect(await correcting).toBeNull(); expect(controller.snapshot().mode).toBe("observe"); expect(controller.snapshot().outputs).toBe(0); + upstream.verify(["synthetic-access-A", "synthetic-access-A"]); +}); diff --git a/tests/clients/desktop-compatibility-relay.test.ts b/tests/clients/desktop-compatibility-relay.test.ts new file mode 100644 index 00000000000..0fb765666f6 --- /dev/null +++ b/tests/clients/desktop-compatibility-relay.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from "bun:test"; +import { createServer, request } from "node:https"; +import { connect } from "node:tls"; +import type { Duplex } from "node:stream"; +import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; +import { startDesktopRelay } from "../../src/codex/desktop-compatibility/relay-listener"; +import { forwardProxy } from "../helpers/desktop-egress-fixture"; +import { UsageRelayController } from "../../src/codex/desktop-compatibility/usage-controller"; +import { createUsageControlledFetch } from "../../src/codex/desktop-compatibility/usage-controlled-fetch"; +import { controlledUsageSse } from "../../src/codex/desktop-compatibility/usage-sse-controller"; + +test("oversized SSE records pass through byte-for-byte and framing resumes at the next record", async () => { + for (const lineEnding of ["\n", "\r", "\r\n"]) { + const oversized = Buffer.from(`event: usage.snapshot${lineEnding}data: {"noise":"${"x".repeat(160)}"}${lineEnding}${lineEnding}`); + const later = Buffer.from(`event: usage.snapshot${lineEnding}data: {"marker":"normal"}${lineEnding}${lineEnding}`); + const input = Buffer.concat([oversized, later]); + const calls: string[] = []; + const source = new ReadableStream({ + start(controller) { + for (const byte of input) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }, + }); + const output = await new Response(source.pipeThrough(controlledUsageSse(async text => { + calls.push(text); + return text === '{"marker":"normal"}' ? '{"marker":"rewritten"}' : null; + }, 64))).arrayBuffer(); + const bytes = Buffer.from(output); + + expect(bytes.subarray(0, oversized.length)).toEqual(oversized); + expect(bytes.subarray(oversized.length)).toEqual(Buffer.from( + `event: usage.snapshot${lineEnding}data: {"marker":"rewritten"}${lineEnding}${lineEnding}`)); + expect(calls).toEqual(['{"marker":"normal"}']); + } +}); + +test("an oversized usage record is preserved through the 200 relay and a later record is still rewritten", async () => { + const account = { id: "fixture-account", userId: "fixture-user", plan: "pro", structure: "personal" } as const; + const usage = { account_id: account.id, user_id: account.userId, plan_type: account.plan, + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100, reset_at: 123456 } }, + spend_control: { reached: false }, credits: { has_credits: false, unlimited: false } }; + const controller = new UsageRelayController(account, async () => account, async () => account, () => 1000, 100000); + await controller.rewriteJson(JSON.stringify(usage), { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }); + expect((await controller.activate({ scope: "account-ui-compatibility", accountWideConsent: true })).accepted).toBe(true); + + const oversizedUsage = { ...usage, observation_noise: "x".repeat(262200) }; + const oversized = Buffer.from(`event: usage.snapshot\r\ndata: ${JSON.stringify({ version: 1, stream_id: "fixture", sequence: 1, usage: oversizedUsage })}\r\n\r\n`); + const later = Buffer.from(`event: usage.snapshot\r\ndata: ${JSON.stringify({ version: 1, stream_id: "fixture", sequence: 2, usage })}\r\n\r\n`); + const corrected = Buffer.from(`event: usage.snapshot\r\ndata: ${JSON.stringify({ version: 1, stream_id: "fixture", sequence: 2, + usage: { ...usage, rate_limit: { ...usage.rate_limit, allowed: true, limit_reached: false } } })}\r\n\r\n`); + const body = Buffer.concat([oversized, later]); + const chunkSizes = [65533, 19, 131071, 2, 7, 16383, 1, 4093]; + const source = new ReadableStream({ + start(stream) { + let offset = 0, index = 0; + while (offset < body.length) { + const end = Math.min(body.length, offset + chunkSizes[index++ % chunkSizes.length]!); + stream.enqueue(body.subarray(offset, end)); + offset = end; + } + stream.close(); + }, + }); + const ca = createCertificateAuthority({ commonName: "oversized-usage-fixture", validityDays: 1 }); + const relay = await startDesktopRelay({ leaf: issueServerLeaf(ca, "oversized-usage-fixture", ["chatgpt.com"]), + fetchImpl: createUsageControlledFetch(controller, (async () => new Response(source, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch) }); + try { + const response = await new Promise<{ status?: number; complete: boolean; bytes: Buffer }>((resolve, reject) => { + const client = request({ hostname: "127.0.0.1", port: relay.port, servername: "chatgpt.com", ca: ca.certPem, + path: "/backend-api/wham/usage/stream", headers: { host: "chatgpt.com" } }, incoming => { + const chunks: Buffer[] = []; + incoming.on("data", chunk => chunks.push(Buffer.from(chunk))); + incoming.once("error", reject); + incoming.once("end", () => resolve({ status: incoming.statusCode, complete: incoming.complete, bytes: Buffer.concat(chunks) })); + }); + client.once("error", reject); + client.setTimeout(10000, () => client.destroy(new Error("fixture timeout"))); + client.end(); + }); + expect(response.status).toBe(200); + expect(response.complete).toBe(true); + expect(response.bytes).toEqual(Buffer.concat([oversized, corrected])); + expect(controller.snapshot().observation.streamSnapshots).toBe(1); + expect(controller.snapshot().outputs).toBe(1); + } finally { await relay.close(); await controller.observeOnly(); } +}); + +test("HTTP Host is case-insensitive while foreign hosts and ports cannot reach upstream", async () => { + const ca = createCertificateAuthority({ commonName: "host-fixture", validityDays: 1 }); + let forwarded = 0; + const relay = await startDesktopRelay({ leaf: issueServerLeaf(ca, "host-fixture", ["chatgpt.com"]), + fetchImpl: (async input => { expect(String(input)).toBe("https://chatgpt.com/fixture"); forwarded++; return new Response("fixture"); }) as typeof fetch }); + try { + for (const [host, status] of [["CHATGPT.COM", 200], ["ChatGPT.Com:443", 200], ["chatgpt.com:444", 421], + ["chatgpt.com.evil", 421], ["chatgpt.com.", 421]] as const) { + const actual = await new Promise((resolve, reject) => { + const client = request({ hostname: "127.0.0.1", port: relay.port, servername: "chatgpt.com", ca: ca.certPem, + path: "/fixture", headers: { host } }, response => { response.resume(); response.once("end", () => resolve(response.statusCode)); response.once("error", reject); }); + client.once("error", reject); client.setTimeout(5000, () => client.destroy(new Error("fixture timeout"))); client.end(); + }); + expect(actual).toBe(status); + } + expect(forwarded).toBe(2); + } finally { await relay.close(); } +}, 10000); + +for (const route of ["direct", "http", "https", "socks5"] as const) test(`upgraded native app traffic preserves handshake and raw frames through ${route}`, async () => { + const ca = createCertificateAuthority({ commonName: "relay-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "relay-fixture", ["chatgpt.com"]); + const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }); + const sockets = new Set(); + let cookie: string | undefined, protocol: string | undefined; + const frame = Buffer.from([0x82, 0x83, 0x01, 0x02, 0x03, 0x04, 0x7a, 0x00, 0xff]); + upstream.on("connection", socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); + upstream.on("upgrade", (req, client, head) => { + cookie = req.headers.cookie; protocol = req.headers["sec-websocket-protocol"] as string | undefined; + client.on("error", () => {}); + client.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Protocol: fixture.v1\r\n\r\n"); + if (head.length) client.write(head); + client.on("data", data => client.write(data)); + }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); + const port = (upstream.address() as { port: number }).port; + const proxy = route === "direct" ? null : await forwardProxy(route, port, issueServerLeaf(ca, "relay-fixture", ["localhost"])); + const relay = await startDesktopRelay({ leaf, fetchImpl: (async () => { throw new Error("Upgrade must not use HTTP fetch"); }) as typeof fetch, + websocketPeer: { host: "127.0.0.1", port, ca: ca.certPem }, websocketTransport: { proxy: proxy?.url ?? false, proxyCa: ca.certPem } }); + const client = connect({ host: "127.0.0.1", port: relay.port, servername: "chatgpt.com", ca: ca.certPem }); + try { + const bytes = await new Promise((resolve, reject) => { + let result = Buffer.alloc(0); + client.setTimeout(5000, () => reject(new Error("fixture timeout"))); client.once("error", reject); + client.on("data", chunk => { + result = Buffer.concat([result, chunk]); const end = result.indexOf("\r\n\r\n"); + if (end !== -1 && result.length >= end + 4 + frame.length) resolve(result); + }); + client.once("secureConnect", () => { + client.write("GET /dictation/stream HTTP/1.1\r\nHost: CHATGPT.COM\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: Zml4dHVyZQ==\r\nSec-WebSocket-Protocol: fixture.v1\r\nCookie: fixture=session\r\n\r\n"); + client.write(frame); + }); + }); + expect(bytes.toString("latin1")).toContain("101 Switching Protocols"); + expect(bytes.subarray(bytes.indexOf("\r\n\r\n") + 4)).toEqual(frame); + expect(cookie).toBe("fixture=session"); expect(protocol).toBe("fixture.v1"); + if (proxy) expect(proxy.seen).toHaveLength(1); + } finally { + client.destroy(); await relay.close(); + await proxy?.close(); + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => upstream.close(() => resolve())); + } +}, 10000); + +test("conversation initialization restrictions remain untouched even during a usage trial", async () => { + const account = { id: "fixture-account", userId: "fixture-user", plan: "pro", structure: "personal" } as const; + const controller = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000); + await controller.rewriteJson(JSON.stringify({ account_id: account.id, user_id: account.userId, plan_type: "pro", + rate_limit: { allowed: false, limit_reached: true }, spend_control: { reached: false }, credits: { has_credits: false, unlimited: false } }), + { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }); + expect((await controller.activate({ scope: "account-ui-compatibility", accountWideConsent: true })).accepted).toBe(true); + const payload = JSON.stringify({ blocked_features: ["fixture-policy"], limits_progress: { fixture_limit: 100 }, + rate_limit: { allowed: false, limit_reached: true } }); + for (const method of ["GET", "POST"]) { + const upstream = new Response(payload, { headers: { "content-type": "application/json", etag: '"original"' } }); + const forward = createUsageControlledFetch(controller, (async () => upstream) as typeof fetch); + const response = await forward("https://chatgpt.com/backend-api/conversation/init", { method }); + expect(response).toBe(upstream); expect(await response.text()).toBe(payload); expect(response.headers.get("etag")).toBe('"original"'); + } + expect(controller.snapshot().outputs).toBe(0); +}); + +for (const mode of ["observe", "apply"] as const) test(`attachment upload stays byte-identical in ${mode} mode`, async () => { + const account = { id: "fixture-account", userId: "fixture-user", plan: "pro", structure: "personal" } as const; + const controller = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000); + if (mode === "apply") { + await controller.rewriteJson(JSON.stringify({ account_id: account.id, user_id: account.userId, plan_type: "pro", + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100, reset_at: 123456 } }, + spend_control: { reached: false }, credits: { has_credits: false, unlimited: false } }), + { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }); + expect((await controller.activate({ scope: "account-ui-compatibility", accountWideConsent: true })).accepted).toBe(true); + } + const ca = createCertificateAuthority({ commonName: "attachment-fixture", validityDays: 1 }); + const contentType = "multipart/form-data; boundary=fixture-upload"; + const upload = Buffer.concat([Buffer.from('--fixture-upload\r\nContent-Disposition: form-data; name="file"; filename="fixture.bin"\r\nContent-Type: application/octet-stream\r\n\r\n'), + Buffer.from([0, 0xff, 0x80, 0x0d, 0x0a]), Buffer.from('첨부 UTF-8 {"rate_limit":{"allowed":false}}\r\n--fixture-upload--\r\n')]); + const download = Buffer.concat([Buffer.from([0, 0xff, 0x80]), Buffer.from('{"rate_limit":{"allowed":false,"limit_reached":true}}')]); + let captured: { url: string; method?: string; headers: Headers; bytes: Buffer } | undefined; + const upstream = (async (input, init) => { + captured = { url: String(input), method: init?.method, headers: new Headers(init?.headers), bytes: Buffer.from(await new Response(init?.body).arrayBuffer()) }; + const headers = new Headers({ "content-type": "application/octet-stream" }); + headers.append("set-cookie", "fixture-a=one; Secure"); headers.append("set-cookie", "fixture-b=two; Secure"); + return new Response(download, { status: 201, headers }); + }) as typeof fetch; + const relay = await startDesktopRelay({ leaf: issueServerLeaf(ca, "attachment-fixture", ["chatgpt.com"]), fetchImpl: createUsageControlledFetch(controller, upstream) }); + try { + const response = await new Promise<{ status?: number; cookies?: string[]; bytes: Buffer }>((resolve, reject) => { + const client = request({ hostname: "127.0.0.1", port: relay.port, servername: "chatgpt.com", ca: ca.certPem, + path: "/backend-api/files", method: "POST", headers: { host: "chatgpt.com", "content-type": contentType, + "content-length": upload.length, authorization: "Bearer fixture-token", cookie: "fixture=session" } }, incoming => { + const chunks: Buffer[] = []; incoming.on("data", chunk => chunks.push(Buffer.from(chunk))); incoming.once("error", reject); + incoming.once("end", () => resolve({ status: incoming.statusCode, cookies: incoming.headers["set-cookie"], bytes: Buffer.concat(chunks) })); + }); + client.once("error", reject); client.setTimeout(5000, () => client.destroy(new Error("fixture timeout"))); client.end(upload); + }); + expect(captured?.url).toBe("https://chatgpt.com/backend-api/files"); expect(captured?.method).toBe("POST"); + expect(captured?.bytes).toEqual(upload); expect(captured?.headers.get("content-type")).toBe(contentType); + expect(captured?.headers.get("authorization")).toBe("Bearer fixture-token"); expect(captured?.headers.get("cookie")).toBe("fixture=session"); + expect(response.status).toBe(201); expect(response.bytes).toEqual(download); + expect(response.cookies).toEqual(["fixture-a=one; Secure", "fixture-b=two; Secure"]); + expect(controller.snapshot().mode).toBe(mode); expect(controller.snapshot().outputs).toBe(0); + } finally { await relay.close(); await controller.observeOnly(); } +}, 10000); diff --git a/tests/clients/desktop-compatibility-routing.test.ts b/tests/clients/desktop-compatibility-routing.test.ts new file mode 100644 index 00000000000..547d982f70a --- /dev/null +++ b/tests/clients/desktop-compatibility-routing.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createNativeRoutingVerifier, matchesNativeCompatibilityRouting } from "../../src/codex/desktop-compatibility/routing-preflight"; +import { bindNativeCompatibilityOwner, nativeCompatibilityOwner, type NativeCompatibilityOwner } from "../../src/codex/desktop-compatibility/routing-binding"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +const owner: NativeCompatibilityOwner = { hostname: "127.0.0.1", port: 12001, loopbackPort: 12002, config: { port: 10100, providers: {} } }; +const text = 'model_provider = "openai"\nopenai_base_url = "http://127.0.0.1:12001/v1"\n'; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +test("address family matches the bound listener instead of any loopback alias", () => { + const ipv6 = text.replace("127.0.0.1", "[::1]"); + expect(matchesNativeCompatibilityRouting(ipv6, owner)).toBe(false); + expect(matchesNativeCompatibilityRouting(text.replace("127.0.0.1", "localhost"), owner)).toBe(false); + expect(matchesNativeCompatibilityRouting(text, { ...owner, hostname: "0.0.0.0" })).toBe(true); + expect(matchesNativeCompatibilityRouting(text, { ...owner, hostname: "192.0.2.10" })).toBe(false); + const v6Owner = { ...owner, hostname: "::1" }; + expect(matchesNativeCompatibilityRouting(ipv6, v6Owner)).toBe(true); + expect(matchesNativeCompatibilityRouting(text, v6Owner)).toBe(false); + expect(matchesNativeCompatibilityRouting(text.replace(":12001", ":12002"), v6Owner)).toBe(true); + expect(matchesNativeCompatibilityRouting(ipv6.replace(":12001", ":12002"), v6Owner)).toBe(false); + // Mutable desired config does not replace the socket's captured address. + expect(matchesNativeCompatibilityRouting(ipv6, { ...owner, config: { ...owner.config, hostname: "::1" } })).toBe(false); +}); + +test("changing the actual listener identity revokes a prior observation", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-listener-change-")); roots.push(root); + writeFileSync(join(root, "config.toml"), text); + const live = { ...owner }, verify = createNativeRoutingVerifier(root, () => live); + expect(verify()).toBe(true); + live.hostname = "0.0.0.0"; + expect(matchesNativeCompatibilityRouting(text, live)).toBe(true); + expect(verify()).toBe(false); +}); +test("the actual bound listener and companion port are accepted, not a stale configured port", () => { + expect(matchesNativeCompatibilityRouting(text, owner)).toBe(true); + expect(matchesNativeCompatibilityRouting(text.replace(":12001", ":12002"), owner)).toBe(true); + expect(matchesNativeCompatibilityRouting(text.replace(":12001", ":10100"), owner)).toBe(false); + expect(matchesNativeCompatibilityRouting(text, null)).toBe(false); + expect(matchesNativeCompatibilityRouting(text, { ...owner, config: { ...owner.config, codexDesktopAuthless: true } })).toBe(false); + expect(matchesNativeCompatibilityRouting(text, { ...owner, config: { ...owner.config, runtimeRole: "client" } })).toBe(false); +}); +test("foreign providers, destinations, credentials and unknown profiles cannot qualify", () => { + for (const candidate of [text.replace('"openai"', '"custom"'), text.replace("127.0.0.1", "example.test"), + text.replace("http://", "https://"), text.replace("127.0.0.1", "user:pass@127.0.0.1"), text.replace("/v1", "/v1?x=1"), + text + 'profile = "missing"', 'invalid toml [', text + 'forced_login_method = "api"']) { + expect(matchesNativeCompatibilityRouting(candidate, owner)).toBe(false); + } + expect(matchesNativeCompatibilityRouting(text + 'profile = "work"\n[profiles.work]\nmodel_provider = "custom"', owner)).toBe(false); + expect(matchesNativeCompatibilityRouting(text + 'profile = "work"\n[profiles.work]\nmodel_provider = "openai"', owner)).toBe(true); +}); +test("read-only verifier rereads changed config and owner detach cannot clear a newer owner", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-routing-")); roots.push(root); + const path = join(root, "config.toml"); writeFileSync(path, text); + const detach = bindNativeCompatibilityOwner(owner), second = { ...owner }; + const detachSecond = bindNativeCompatibilityOwner(second); detach(); expect(nativeCompatibilityOwner()).toBe(second); + try { + const verify = createNativeRoutingVerifier(root); expect(verify()).toBe(true); + writeFileSync(path, text.replace(":12001", ":13000")); expect(verify()).toBe(false); + } finally { detachSecond(); } + expect(nativeCompatibilityOwner()).toBeNull(); +}); + +test("model and fallback edits revoke routing until a new observation context is created", () => { + const changes = [ + { defaultProvider: "openai" }, { subagentModelFallback: ["openai/gpt-6-luna"] }, + { subagentModelFallbackByModel: { "external/model": ["openai/gpt-6-luna"] } }, + { compactionRecovery: { enabled: true, model: "openai/gpt-6-luna" } }, + { blockedModelRedirects: { "external/model": "openai/gpt-6-luna" } }, + { providers: { external: { baseUrl: "https://changed.example.test/v1" } } }, + { combos: {} }, { routingProfiles: {} }, + { memoryModels: { extract: { model: "openai/gpt-6-luna" } } }, + ]; + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-route-change-")); roots.push(root); + writeFileSync(join(root, "config.toml"), text); + for (const change of changes) { + const live = { ...owner, config: { ...owner.config } }; + const verify = createNativeRoutingVerifier(root, () => live); expect(verify()).toBe(true); + Object.assign(live.config, change); expect(verify()).toBe(false); + live.config = { ...owner.config }; expect(verify()).toBe(false); + expect(createNativeRoutingVerifier(root, () => live)()).toBe(true); + } +}); + +test("root model edits revoke context but unrelated preference and object order do not", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-route-baseline-")); roots.push(root); + const path = join(root, "config.toml"); writeFileSync(path, text); + const live = { ...owner, config: { ...owner.config, blockedModelRedirects: { a: "external/a", b: "external/b" } } }; + const verify = createNativeRoutingVerifier(root, () => live); expect(verify()).toBe(true); + live.config.blockedModelRedirects = { b: "external/b", a: "external/a" }; + Object.assign(live.config, { desktopCompatibility: { startOnProxyStart: true } }); expect(verify()).toBe(true); + writeFileSync(path, text + 'model = "external/other"\n'); expect(verify()).toBe(false); + writeFileSync(path, text); expect(verify()).toBe(false); +}); + +test("desktop MCP rewrites and TOML formatting preserve the observation while model edits still revoke it", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-mcp-refresh-")); roots.push(root); + const path = join(root, "config.toml"); + writeFileSync(path, text + 'model = "external/model"\n[mcp_servers.desktop]\nurl = "http://127.0.0.1:21001/mcp"\n'); + const verify = createNativeRoutingVerifier(root, () => owner); + expect(verify()).toBe(true); + const rewritten = '# Desktop rewrites its MCP endpoint after launch.\n' + text + + 'model = "external/model"\n[mcp_servers.desktop]\nurl = "http://127.0.0.1:21002/mcp"\n'; + writeFileSync(path, rewritten); expect(verify()).toBe(true); + writeFileSync(path, text + 'model = "external/model"\n'); expect(verify()).toBe(true); + writeFileSync(path, rewritten.replace('model = "external/model"', 'model = "openai/model"')); + expect(verify()).toBe(false); + writeFileSync(path, rewritten); expect(verify()).toBe(false); +}); diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts new file mode 100644 index 00000000000..34693897bcb --- /dev/null +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -0,0 +1,352 @@ +import { UsageActivation } from "../../src/codex/desktop-compatibility/usage-activation"; +import { afterEach, describe, expect, test } from "bun:test"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import { X509Certificate } from "node:crypto"; +import { createDesktopCompatibilityRuntime } from "../../src/codex/desktop-compatibility/runtime"; +import { UsageRelayController, type UsageIdentity } from "../../src/codex/desktop-compatibility/usage-controller"; +import { desktopCompatibilityRuntimeActive, readDesktopCompatibilityLaunch } from "../../src/codex/desktop-compatibility/runtime-ownership"; +import { captureWindowsCompatibilityContext, assertWindowsCompatibilityContext } from "../../src/codex/desktop-compatibility/windows-package-command"; +import { createDesktopCertificateService } from "../../src/codex/desktop-compatibility/certificate-service"; +import { resetOptionalShutdownHooksForTests, runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; +import type { DesktopConnectionIdentity, DesktopConnectionStore } from "../../src/codex/desktop-compatibility/connection-store"; +import { createServer as createTcpServer } from "node:net"; + +const account: UsageIdentity = { id: "fixture-account", userId: "fixture-user", plan: "pro", structure: "personal" }; +const usage = { account_id: account.id, user_id: account.userId, plan_type: "pro", rate_limit: { allowed: false, limit_reached: true, + primary_window: { used_percent: 100, reset_at: 123456 } }, spend_control: { reached: false }, credits: { has_credits: false, unlimited: false } }; +const exchange = { method: "GET", pathname: "/backend-api/wham/usage/stream", status: 200 }; +const frame = (data = usage, sequence = 7) => JSON.stringify({ version: 1, stream_id: "fixture", sequence, usage: data }); +const consent = { scope: "account-ui-compatibility" as const, accountWideConsent: true }; +const stopped: (() => Promise)[] = []; +afterEach(async () => { for (const stop of stopped.splice(0)) await stop(); resetOptionalShutdownHooksForTests(); }); + +describe("bounded compatibility usage controller", () => { + test("an open stream is not an observed account snapshot or proof of native composer recovery", async () => { + const ctl = new UsageRelayController(account, async () => account, async () => account, () => 1000, 100000); + const stream = ctl.registerStream(exchange, async () => {})!; + expect(ctl.snapshot().trackedStreams).toBe(1); + expect(ctl.snapshot().observation).toEqual({ jsonSnapshots: 0, streamSnapshots: 0, validatedActiveStreams: 0, + lastSnapshotAt: null, sourceProcessVerified: false, composerRecoveryVerified: false }); + await ctl.rewriteJson(frame({ ...usage, account_id: "foreign" }), exchange, stream); + expect(ctl.snapshot().observation.streamSnapshots).toBe(0); + const validStream = ctl.registerStream(exchange, async () => {})!; + await ctl.rewriteJson(frame(), exchange, validStream); + await ctl.rewriteJson(JSON.stringify(usage), { ...exchange, pathname: "/backend-api/wham/usage" }); + expect(ctl.snapshot().observation).toEqual({ jsonSnapshots: 1, streamSnapshots: 1, validatedActiveStreams: 1, + lastSnapshotAt: 1000, sourceProcessVerified: false, composerRecoveryVerified: false }); + validStream.release(); + expect(ctl.snapshot().observation.validatedActiveStreams).toBe(0); + expect(ctl.snapshot().observation.streamSnapshots).toBe(1); + }); + for (const [label, replacement] of [ + ["available", { ...usage, rate_limit: { ...usage.rate_limit, allowed: true, limit_reached: false } }], + ["protected", { ...usage, spend_control: { reached: true } }], + ] as const) test(`${label} usage ends the trial and later exhaustion cannot silently rearm it`, async () => { + const ctl = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000); + await ctl.rewriteJson(frame(), exchange); expect((await ctl.activate(consent)).accepted).toBe(true); + expect(await ctl.rewriteJson(frame(replacement), exchange)).toBeNull(); + expect(ctl.snapshot().mode).toBe("observe"); expect(ctl.snapshot().outputs).toBe(0); + expect(await ctl.rewriteJson(frame(), exchange)).toBeNull(); + expect(ctl.snapshot().mode).toBe("observe"); + expect((await ctl.activate(consent)).accepted).toBe(true); + }); + test("recovery supersedes an exhausted response waiting for its build check", async () => { + let waiting = false, entered!: () => void, release!: (value: boolean) => void; + const started = new Promise(resolve => { entered = resolve; }); + const ctl = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000, 180000, + () => waiting ? (entered(), new Promise(resolve => { release = resolve; })) : true); + await ctl.rewriteJson(frame(), exchange); await ctl.activate(consent); + waiting = true; const pending = ctl.rewriteJson(frame(), exchange); await started; + await ctl.rewriteJson(frame({ ...usage, rate_limit: { ...usage.rate_limit, allowed: true, limit_reached: false } }), exchange); + release(true); expect(await pending).toBeNull(); expect(ctl.snapshot().mode).toBe("observe"); + expect(ctl.snapshot().outputs).toBe(0); + }); + test("returning to observation supersedes activation during its asynchronous context check", async () => { + let release!: (value: boolean) => void, entered!: () => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const ctl = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000, 180000, + () => { entered(); return new Promise(resolve => { release = resolve; }); }); + await ctl.rewriteJson(frame(), exchange); + const activation = ctl.activate(consent); await waiting; await ctl.observeOnly(); release(true); + expect((await activation).accepted).toBe(false); expect(ctl.snapshot().mode).toBe("observe"); + }); + test("observation avoids build probes and pending async checks cannot cross a trial or account change", async () => { + for (const change of ["observe", "account", "expiry"] as const) { + let now = 1000, current: UsageIdentity | null = account, checks = 0, waiting = false; + let entered!: () => void, release!: (value: boolean) => void; + const enteredPromise = new Promise(resolve => { entered = resolve; }); + const ctl = new UsageRelayController(account, async () => current, async () => current, () => now, 100000, 180000, () => { + checks++; if (!waiting) return true; + entered(); return new Promise(resolve => { release = resolve; }); + }); + expect(await ctl.rewriteJson(frame(), exchange)).toBeNull(); expect(checks).toBe(0); + expect((await ctl.activate(consent)).accepted).toBe(true); expect(checks).toBe(1); + waiting = true; const response = ctl.rewriteJson(frame(), exchange); + await enteredPromise; + // The event loop remains usable while a package query is pending. + let ticked = false; await new Promise(resolve => setTimeout(() => { ticked = true; resolve(); }, 0)); expect(ticked).toBe(true); + if (change === "observe") await ctl.observeOnly(); + if (change === "account") current = null; + if (change === "expiry") now = 100001; + release(true); expect(await response).toBeNull(); expect(ctl.snapshot().outputs).toBe(0); + } + }); + test("observes first, refreshes only bound streams and preserves actual quota values", async () => { + let closed = 0; + const ctl = new UsageRelayController(account, async () => account, async () => account, Date.now, Date.now() + 600000); + const stream = ctl.registerStream(exchange, async () => { closed++; }); + expect((await ctl.activate(consent)).accepted).toBe(false); + expect(await ctl.rewriteJson(frame(), exchange, stream)).toBeNull(); + expect((await ctl.activate({ ...consent, accountWideConsent: false })).accepted).toBe(false); + expect((await ctl.activate(consent)).accepted).toBe(true); expect(closed).toBe(1); + const result = JSON.parse((await ctl.rewriteJson(frame(), exchange))!); + expect(result).toEqual({ version: 1, stream_id: "fixture", sequence: 7, usage: { ...usage, rate_limit: { ...usage.rate_limit, allowed: true, limit_reached: false } } }); + expect(ctl.snapshot().appCacheConfirmed).toBe(false); + await ctl.observeOnly(); expect(await ctl.rewriteJson(frame(), exchange)).toBeNull(); + }); + test("identity, spending restrictions, app responses and unknown stream schemas stay protected", async () => { + let identity: UsageIdentity | null = account; + const ctl = new UsageRelayController(account, async () => identity, async () => identity, Date.now, Date.now() + 600000); + await ctl.rewriteJson(frame(), exchange); await ctl.activate(consent); + for (const context of [{ ...exchange, method: "POST" }, { ...exchange, status: 401 }, { ...exchange, pathname: "/backend-api/conversation" }]) { + expect(await ctl.rewriteJson(frame(), context)).toBeNull(); + } + expect(await ctl.rewriteJson(frame({ ...usage, spend_control: { reached: true } }), exchange)).toBeNull(); + expect(await ctl.rewriteJson(frame(usage, 0), exchange)).toBeNull(); + identity = { ...account, id: "changed" }; + expect(await ctl.rewriteJson(frame(), exchange)).toBeNull(); expect(ctl.snapshot().mode).toBe("observe"); + }); + test("an async identity check cannot carry correction across the safety deadline", async () => { + let now = 1000, advance = false; + const ctl = new UsageRelayController(account, async () => { if (advance) now = 2000; return account; }, async () => account, () => now, 2000); + await ctl.rewriteJson(frame(), exchange); await ctl.activate(consent); advance = true; + expect(await ctl.rewriteJson(frame(), exchange)).toBeNull(); expect(ctl.snapshot().mode).toBe("observe"); + }); +}); + +function fixture(trusted = true, connectionStore?: DesktopConnectionStore, buildProbe?: () => boolean | Promise) { + const authority = createCertificateAuthority({ commonName: "runtime-fixture", validityDays: 1 }); + const cert = new X509Certificate(authority.certPem); + const identity = { readCurrentIdentity: async () => account, verifyFreshIdentity: async () => account }; + const calls: { path: string; method: string; bytes: number; cookie: string | null }[] = []; + let streamSequence = 0, cancelledStreams = 0, buildSupported = true, routingSupported = true; + let connection: DesktopConnectionIdentity | null = null; + const runtime = createDesktopCompatibilityRuntime({ platform: "win32", testOnly: true, identity, buildSupported: () => buildProbe ? buildProbe() : buildSupported, + routingSupported: () => routingSupported, + connectionStore: connectionStore ?? { read: () => connection, publish: async value => (connection ??= value) }, + loadAuthority: async () => ({ authority, commonName: "runtime-fixture", fingerprint: cert.fingerprint256.replaceAll(":", ""), + expiresAt: Date.parse(cert.validTo), renewalDue: false, reused: true }), trust: async () => trusted ? "trusted" : "not-trusted", + upstreamFetch: (async (input, init) => { + const request = new Request(input, init); + const data = new Uint8Array(await request.arrayBuffer()); + const path = new URL(request.url).pathname; + calls.push({ path, method: request.method, bytes: data.length, cookie: request.headers.get("cookie") }); + if (path === "/backend-api/wham/usage") return Response.json(usage, { headers: { etag: '"original-fixture"' } }); + if (path === "/backend-api/wham/usage/stream") return new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode("event: usage.snapshot\ndata: " + frame(usage, ++streamSequence) + "\n\n")); }, + cancel() { cancelledStreams++; }, + }), { headers: { "content-type": "text/event-stream" } }); + return new Response(data, { headers: { "content-type": "application/octet-stream", "set-cookie": "fixture=kept; Secure" } }); + }) as typeof fetch, + }); + stopped.push(() => runtime.stop()); + return { runtime, authority, calls, cancelledStreams: () => cancelledStreams, setBuildSupported: (value: boolean) => { buildSupported = value; }, setRoutingSupported: (value: boolean) => { routingSupported = value; } }; +} + +describe("optional native compatibility runtime", () => { + test("only a serving runtime attests PAC preservation and stop/start invalidates the captured generation", async () => { + const io = fixture(); + expect(readDesktopCompatibilityLaunch()).toBeNull(); + await io.runtime.start(); + const pacUrl = io.runtime.getPacUrl()!; + expect((await fetch(pacUrl)).status).toBe(200); + const root = { pid: 100, parentPid: 50, createdAt: "fixture", executable: "ChatGPT.exe", commandLine: `ChatGPT.exe --proxy-pac-url=${pacUrl}` }; + const captured = captureWindowsCompatibilityContext([root]); + expect(captured.codexCompatibilityGeneration).toBe(readDesktopCompatibilityLaunch()!.generation); + expect(() => assertWindowsCompatibilityContext(captured)).not.toThrow(); + const stopping = io.runtime.stop(); + expect(readDesktopCompatibilityLaunch()).toBeNull(); + await stopping; + await io.runtime.start(); + expect(io.runtime.getPacUrl()).toBe(pacUrl); + expect(() => assertWindowsCompatibilityContext(captured)).toThrow("desktop_compatibility_launch_owner_unverified"); + expect(() => assertWindowsCompatibilityContext(captureWindowsCompatibilityContext([root]))).not.toThrow(); + }); + test("observation cancels a pending apply request before its build preflight can finish", async () => { + let pending = false, entered!: () => void, release!: (value: boolean) => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const io = fixture(true, undefined, () => pending ? (entered(), new Promise(resolve => { release = resolve; })) : true); + await io.runtime.start(); + const pac = await fetch(io.runtime.getPacUrl()!).then(res => res.text()), port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + await fetch("https://chatgpt.com/backend-api/wham/usage", { proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem } }).then(res => res.json()); + pending = true; const applying = io.runtime.apply(true); await waiting; await io.runtime.observe(); release(true); + expect((await applying).accepted).toBe(false); expect(io.runtime.status().usage?.mode).toBe("observe"); + }); + test("construction/status are inert and untrusted certificates cannot start listeners", async () => { + let effects = 0; + const dormant = createDesktopCompatibilityRuntime({ loadAuthority: async () => { effects++; throw new Error(); } }); + expect(dormant.status().running).toBe(false); expect(dormant.getPacUrl()).toBeNull(); expect(effects).toBe(0); + const io = fixture(false); await expect(io.runtime.start()).rejects.toThrow("trust_required"); + expect(io.runtime.status().phase).toBe("off"); expect(desktopCompatibilityRuntimeActive()).toBe(false); + }); + test("real loopback CONNECT/TLS preserves requests and restricts correction to an explicit trial", async () => { + const io = fixture(); + await io.runtime.start(); expect(io.runtime.status().phase).toBe("running"); expect(io.runtime.status().usage?.mode).toBe("observe"); + const pac = await fetch(io.runtime.getPacUrl()!).then(res => res.text()); + expect(pac).toContain("; DIRECT"); expect(pac).toContain('host.toLowerCase() === "chatgpt.com"'); + const port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + const send = (path: string, init: RequestInit = {}) => fetch("https://chatgpt.com" + path, { + ...init, proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem }, + }); + expect(await send("/backend-api/wham/usage").then(res => res.json())).toEqual(usage); + const initial = await send("/backend-api/wham/usage/stream"), originalStream = initial.body!.getReader(); + expect(new TextDecoder().decode((await originalStream.read()).value)).toContain('"allowed":false'); + expect((await io.runtime.apply(true)).accepted).toBe(true); + expect((await originalStream.read()).done).toBe(true); originalStream.releaseLock(); expect(io.cancelledStreams()).toBe(1); + const next = await send("/backend-api/wham/usage/stream"), correctedStream = next.body!.getReader(); + const event = new TextDecoder().decode((await correctedStream.read()).value); + expect(event).toContain('"sequence":2'); expect(event).toContain('"allowed":true'); + await correctedStream.cancel(); correctedStream.releaseLock(); + const changedResponse = await send("/backend-api/wham/usage"); + expect(changedResponse.headers.get("etag")).toBeNull(); expect(changedResponse.headers.get("cache-control")).toBe("no-store"); + const adjusted = await changedResponse.json(); + expect(adjusted.rate_limit.allowed).toBe(true); expect(adjusted.rate_limit.primary_window.used_percent).toBe(100); expect(adjusted.credits.has_credits).toBe(false); + const bytes = new Uint8Array([0, 255, 1, 128, 42]); + const uploaded = await send("/backend-api/files", { method: "POST", body: bytes, headers: { cookie: "fixture=session" } }); + expect(new Uint8Array(await uploaded.arrayBuffer())).toEqual(bytes); expect(uploaded.headers.get("set-cookie")).toContain("fixture=kept; Secure"); + expect(io.calls.at(-1)).toEqual({ path: "/backend-api/files", method: "POST", bytes: 5, cookie: "fixture=session" }); + await io.runtime.observe(); expect(await send("/backend-api/wham/usage").then(res => res.json())).toEqual(usage); + const cert = createDesktopCertificateService("unused", { platform: "win32" }); + await expect(cert.renew("A".repeat(64))).rejects.toMatchObject({ code: "runtime_running" }); + await io.runtime.stop(); expect(io.runtime.status().phase).toBe("off"); expect(desktopCompatibilityRuntimeActive()).toBe(false); + expect(await io.runtime.stop()).toMatchObject({ phase: "off" }); + await expect(fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(1000) })).rejects.toThrow(); + }, 15000); + test("core shutdown owns teardown and prevents reactivation in a draining process", async () => { + const io = fixture(); await io.runtime.start(); + runOptionalShutdownHooks(); await io.runtime.stop(); + expect(io.runtime.status().running).toBe(false); + await expect(io.runtime.start()).rejects.toThrow("stopping"); + }); + test("an unassessed app update cannot start or rearm the response correction", async () => { + const io = fixture(); io.setBuildSupported(false); + await expect(io.runtime.start()).rejects.toThrow("build_unverified"); expect(desktopCompatibilityRuntimeActive()).toBe(false); + io.setBuildSupported(true); await io.runtime.start(); io.setBuildSupported(false); + await expect(io.runtime.apply(true)).rejects.toThrow("build_unverified"); expect(io.runtime.status().usage?.mode).toBe("observe"); + }); + test("an active trial disarms on an app update or routing change before returning the next usage response", async () => { + for (const failure of ["build", "routing"] as const) { + const io = fixture(); await io.runtime.start(); + const pac = await fetch(io.runtime.getPacUrl()!).then(res => res.text()), port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + const read = () => fetch("https://chatgpt.com/backend-api/wham/usage", { proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem } }).then(res => res.json()); + await read(); expect((await io.runtime.apply(true)).accepted).toBe(true); expect((await read()).rate_limit.allowed).toBe(true); + if (failure === "build") io.setBuildSupported(false); else io.setRoutingSupported(false); + expect(await read()).toEqual(usage); expect(io.runtime.status().usage?.mode).toBe("observe"); + expect(io.runtime.status().contextFailure).toBe(failure === "build" ? "build_unverified" : "native_routing_unverified"); + await io.runtime.stop(); + } + }); + test("routing preflight refuses a start before opening listeners", async () => { + const io = fixture(); io.setRoutingSupported(false); + await expect(io.runtime.start()).rejects.toThrow("native_routing_unverified"); + expect(io.runtime.status().running).toBe(false); expect(io.runtime.getPacUrl()).toBeNull(); expect(desktopCompatibilityRuntimeActive()).toBe(false); + }); + test("a cached PAC reconnects to the same ports after restart and Apply is never resumed", async () => { + const io = fixture(); await io.runtime.start(); + const originalUrl = io.runtime.getPacUrl()!, pac = await fetch(originalUrl).then(res => res.text()); + const port = Number(/PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]); + const request = () => fetch("https://chatgpt.com/backend-api/wham/usage", { proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem } }).then(res => res.json()); + expect((await request()).rate_limit.allowed).toBe(false); await io.runtime.apply(true); + expect((await request()).rate_limit.allowed).toBe(true); + await io.runtime.stop(); await io.runtime.start(); + expect(io.runtime.getPacUrl()).toBe(originalUrl); expect(await fetch(originalUrl).then(res => res.text())).toBe(pac); + expect((await request()).rate_limit.allowed).toBe(false); expect(io.runtime.status().usage?.mode).toBe("observe"); + }); + test("a reused port conflict refuses startup and never replaces the cached connection identity", async () => { + let stored: DesktopConnectionIdentity | null = null; + const io = fixture(true, { read: () => stored, publish: async value => (stored ??= value) }); + await io.runtime.start(); const originalUrl = io.runtime.getPacUrl(); await io.runtime.stop(); + const original = { ...stored! }; + for (const occupied of ["connectPort", "pacPort"] as const) { + const blocker = createTcpServer(); + await new Promise(resolve => blocker.listen(original[occupied], "127.0.0.1", resolve)); + try { + await expect(io.runtime.start()).rejects.toThrow("connection_unavailable"); + expect(stored).toEqual(original); expect(io.runtime.status().phase).toBe("off"); expect(desktopCompatibilityRuntimeActive()).toBe(false); + // The other port must be bindable even when startup had already opened it. + const probe = createTcpServer(); + try { await new Promise((resolve, reject) => { probe.once("error", reject); probe.listen(original[occupied === "connectPort" ? "pacPort" : "connectPort"], "127.0.0.1", resolve); }); } + finally { await new Promise(resolve => probe.close(() => resolve())); } + } finally { await new Promise(resolve => blocker.close(() => resolve())); } + await io.runtime.start(); expect(io.runtime.getPacUrl()).toBe(originalUrl); await io.runtime.stop(); + } + }); +}); + +describe("compatibility trials consume their exhausted observation", () => { + for (const ending of ["cancel", "timeout"] as const) { + for (const observeDuringTrial of [false, true]) { + test(`${ending} needs a new observation (observed during trial: ${observeDuringTrial})`, async () => { + let now = 1000; + const activation = new UsageActivation("account", async () => ({ requested: 0, closed: 0, failed: 0 }), + async () => "account", () => now); + activation.observe("account", "exhausted"); + expect((await activation.activate(consent)).accepted).toBe(true); + expect((await activation.activate(consent)).reason).toBe("activation-already-pending"); + const oldGeneration = activation.snapshot().generation; + if (observeDuringTrial) activation.observe("account", "exhausted"); + if (ending === "cancel") await activation.observeOnly(); + else { now += 180000; expect(await activation.expireIfNeeded()).toBe(true); } + expect(activation.snapshot().mode).toBe("observe"); + expect(activation.recordOutput("account", oldGeneration)).toBe(false); + // Still inside the old observation's five-minute TTL: consent is not enough. + expect(await activation.activate(consent)).toMatchObject({ accepted: false, reason: "no-fresh-eligible-exhaustion" }); + expect(activation.observe("another-account", "exhausted")).toBe(false); + expect((await activation.activate(consent)).accepted).toBe(false); + activation.observe("account", "exhausted"); + expect((await activation.activate({ ...consent, accountWideConsent: false })).accepted).toBe(false); + expect((await activation.activate(consent)).accepted).toBe(true); + }); + } + } + for (const failure of ["throw", "partial", "invalid-count"] as const) { + test(`${failure} clears observations and outputs produced while refresh was pending`, async () => { + let fail = true; + const activation = new UsageActivation("account", async () => { + if (!fail) return { requested: 0, closed: 0, failed: 0 }; + activation.observe("account", "exhausted"); + activation.recordOutput("account", activation.snapshot().generation); + if (failure === "throw") throw new Error("synthetic refresh failure"); + return failure === "partial" ? { requested: 1, closed: 0, failed: 1 } + : { requested: Number.NaN, closed: 0, failed: 0 }; + }, async () => "account", () => 1000); + activation.observe("account", "exhausted"); + expect(await activation.activate(consent)).toMatchObject({ accepted: false, reason: "refresh-failed", mode: "observe", outputs: 0 }); + fail = false; + expect(await activation.activate(consent)).toMatchObject({ accepted: false, reason: "no-fresh-eligible-exhaustion" }); + activation.observe("account", "exhausted"); + expect((await activation.activate(consent)).accepted).toBe(true); + }); + } + test("a superseded refresh failure cannot erase a newer trial", async () => { + let calls = 0, entered!: () => void, rejectOld!: (error: Error) => void; + const enteredPromise = new Promise(resolve => { entered = resolve; }); + const activation = new UsageActivation("account", async () => { + if (++calls === 1) { + entered(); + return new Promise<{ requested: number; closed: number; failed: number }>((_, reject) => { rejectOld = reject; }); + } + return { requested: 0, closed: 0, failed: 0 }; + }, async () => "account", () => 1000); + activation.observe("account", "exhausted"); + const old = activation.activate(consent); await enteredPromise; + await activation.observeOnly(); + activation.observe("account", "exhausted"); + expect((await activation.activate(consent)).accepted).toBe(true); + const generation = activation.snapshot().generation; + rejectOld(new Error("superseded refresh")); + expect((await old).accepted).toBe(false); + expect(activation.snapshot()).toMatchObject({ mode: "apply", generation }); + expect(activation.recordOutput("account", generation)).toBe(true); + }); +}); diff --git a/tests/clients/desktop-compatibility-trust.test.ts b/tests/clients/desktop-compatibility-trust.test.ts new file mode 100644 index 00000000000..db580c72246 --- /dev/null +++ b/tests/clients/desktop-compatibility-trust.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID, X509Certificate } from "node:crypto"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import type { StoredDesktopAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; +import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust, type TrustOperation } from "../../src/codex/desktop-compatibility/windows-certificate-trust"; + +function fixture(serverAuthOnly = true): StoredDesktopAuthority { + const commonName = `OpenCodex Codex Desktop ${randomUUID()}`; + const authority = createCertificateAuthority({ commonName, validityDays: 1, permittedDnsNames: ["chatgpt.com"], excludeAllIpAddresses: true, serverAuthOnly }); + const certificate = new X509Certificate(authority.certPem); + return { authority, commonName, fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt: Date.parse(certificate.validTo), renewalDue: true, reused: false }; +} + +describe("exact Windows compatibility certificate trust", () => { + test("legacy purpose refuses trust before an OS write but allows exact removal", async () => { + const value = fixture(false), calls: TrustOperation[] = []; let state: DesktopCertificateTrust = "trusted"; + const controller = createWindowsCertificateTrust(value, value.fingerprint, async operation => { + calls.push(operation); if (operation === "remove") state = "not-trusted"; return state; + }); + await expect(controller.trust()).rejects.toThrow("desktop_compatibility_authority_renewal_required"); + expect(calls).toHaveLength(0); + expect(await controller.remove()).toBe("not-trusted"); + expect(calls).toEqual(["inspect", "remove", "inspect"]); + }); + + test("repeated trust and removal do not repeat OS mutations", async () => { + const value = fixture(), calls: TrustOperation[] = []; let state: DesktopCertificateTrust = "not-trusted"; + const controller = createWindowsCertificateTrust(value, value.fingerprint, async (operation, publicDer, fingerprint) => { + calls.push(operation); expect(fingerprint).toBe(value.fingerprint); + expect(Buffer.from(publicDer, "base64")).toEqual(new X509Certificate(value.authority.certPem).raw); + if (operation === "trust") state = "trusted"; + if (operation === "remove") state = "not-trusted"; + return state; + }); + expect(await controller.trust()).toBe("trusted"); expect(await controller.trust()).toBe("trusted"); + expect(await controller.remove()).toBe("not-trusted"); expect(await controller.remove()).toBe("not-trusted"); + expect(calls.filter(call => call === "trust")).toHaveLength(1); + expect(calls.filter(call => call === "remove")).toHaveLength(1); + }); + + test("an uncertain command result is settled by store readback, not a second mutation", async () => { + const value = fixture(), calls: TrustOperation[] = []; let state: DesktopCertificateTrust = "not-trusted"; + const controller = createWindowsCertificateTrust(value, value.fingerprint, async operation => { + calls.push(operation); + if (operation === "trust") { state = "trusted"; throw new Error("lost acknowledgement"); } + return state; + }); + expect(await controller.trust()).toBe("trusted"); + expect(calls).toEqual(["inspect", "trust", "inspect"]); + }); + + test("unknown trust state never authorizes a write", async () => { + const value = fixture(), calls: TrustOperation[] = []; + const controller = createWindowsCertificateTrust(value, value.fingerprint, async operation => { calls.push(operation); return "unknown"; }); + expect(await controller.trust()).toBe("unknown"); expect(await controller.remove()).toBe("unknown"); + expect(calls).toEqual(["inspect", "inspect"]); + }); + + test("a stale fingerprint or an unrelated private key is refused before any OS request", () => { + const value = fixture(), other = fixture(); + expect(() => createWindowsCertificateTrust(value, other.fingerprint)).toThrow("desktop_compatibility_certificate_mismatch"); + expect(() => createWindowsCertificateTrust({ ...value, authority: { ...value.authority, privateKey: other.authority.privateKey } }, value.fingerprint)) + .toThrow("desktop_compatibility_certificate_mismatch"); + }); + + test("concurrent mutations are not queued into duplicate certificate prompts", async () => { + const value = fixture(); let release!: (state: DesktopCertificateTrust) => void; + let reads = 0; + const controller = createWindowsCertificateTrust(value, value.fingerprint, async () => ++reads === 1 + ? await new Promise(resolve => { release = resolve; }) : "trusted"); + const first = controller.trust(); + await expect(controller.remove()).rejects.toThrow("desktop_compatibility_trust_busy"); + release("trusted"); expect(await first).toBe("trusted"); + }); + + test.skipIf(process.platform !== "win32")("real CurrentUser Root inspection remains read-only for an unregistered synthetic CA", async () => { + const value = fixture(); + expect(await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint)).toBe("not-trusted"); + }, 15_000); +}); diff --git a/tests/fixtures/test-layout-expected-additional.json b/tests/fixtures/test-layout-expected-additional.json new file mode 100644 index 00000000000..844b1071928 --- /dev/null +++ b/tests/fixtures/test-layout-expected-additional.json @@ -0,0 +1,40 @@ +{ + "provider-antigravity-wire-snapshot.test.ts": "providers", + "antigravity-discovered-families.test.ts": "adapters/google", + "provider-antigravity-effort-families.test.ts": "providers", + "provider-antigravity-family-catalog.test.ts": "providers", + "claude-subagent-model-force.test.ts": "claude-integration", + "subagent-model-force-api.test.ts": "server", + "agent-subagent-force.test.ts": "cli", + "compaction-event-ownership.test.ts": "responses", + "zed-retained-budget.test.ts": "providers", + "jev-decision-scope.test.ts": "server", + "codex-shim-path-readiness.test.ts": "codex-integration", + "codex-shim-fnm.test.ts": "codex-integration", + "claude-intercept-cleanup.test.ts": "claude-integration", + "droid-managed-reasoning-defaults.test.ts": "clients", + "management-droid-reasoning-defaults.test.ts": "server", + "droid-reasoning-defaults.test.ts": "responses", + "minimax-combo-image-input.test.ts": "providers", + "web-search-forced-declaration.test.ts": "web-search", + "responses-hosted-image-limits.test.ts": "responses", + "antigravity-refusal.test.ts": "adapters/google", + "catalog-custom-ordering.test.ts": "codex-integration", + "claude-first-party-agent-sync.test.ts": "claude-integration", + "cli-account-verify-status.test.ts": "cli", + "opencode-kilo-refresh.test.ts": "clients", + "client-export-reasoning-metadata.test.ts": "clients", + "client-export-reasoning-controls.test.ts": "clients", + "client-export-live-wire.test.ts": "clients", + "client-export-effective-metadata.test.ts": "clients", + "grok-session-identity.test.ts": "providers/xai", + "responses-forward-client-headers.test.ts": "responses", + "opengateway-provider.test.ts": "providers", + "opengateway-key-validation.test.ts": "providers", + "request-log-generation-window.test.ts": "usage", + "responses-chatgpt-web-replay-metadata.test.ts": "responses", + "server-antigravity-verify-replay-scope.test.ts": "server", + "sse-rewrite-line-endings.test.ts": "responses", + "chat-sse-framing-guard.test.ts": "responses", + "usage-throughput.test.ts": "usage" +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c74522309a9..b1fc83e828f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,26 +1,11 @@ { - "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", - "antigravity-discovered-families.test.ts": "adapters/google", - "provider-antigravity-effort-families.test.ts": "providers", - "provider-antigravity-family-catalog.test.ts": "providers", - "claude-subagent-model-force.test.ts": "claude-integration", - "subagent-model-force-api.test.ts": "server", + "usage-antigravity-55.test.ts": "usage", "caller-session-identity.test.ts": "server", "caller-session-ingress.test.ts": "server", - "agent-subagent-force.test.ts": "cli", - "compaction-event-ownership.test.ts": "responses", - "zed-retained-budget.test.ts": "providers", - "jev-decision-scope.test.ts": "server", "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", - "codex-shim-path-readiness.test.ts": "codex-integration", - "codex-shim-fnm.test.ts": "codex-integration", - "claude-intercept-cleanup.test.ts": "claude-integration", - "droid-managed-reasoning-defaults.test.ts": "clients", - "management-droid-reasoning-defaults.test.ts": "server", - "droid-reasoning-defaults.test.ts": "responses", "claude-intercept-on-demand.test.ts": "claude-integration", "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", @@ -30,24 +15,21 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", - "minimax-combo-image-input.test.ts": "providers", - "web-search-forced-declaration.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", - "codex-credits.test.ts": "codex-integration", - "codex-credits-settings.test.ts": "codex-integration", - "codex-credits-probes.test.ts": "codex-integration", - "codex-credits-after-limit.test.ts": "codex-integration", - "codex-credits-after-limit-main.test.ts": "codex-integration", - "service-desktop-startup-health.test.ts": "service", - "service-desktop-startup.test.ts": "service", - "startup-health-packaged-probe.test.ts": "server", + "codex-credits.test.ts": "codex-integration", + "codex-credits-settings.test.ts": "codex-integration", + "codex-credits-probes.test.ts": "codex-integration", + "codex-credits-after-limit.test.ts": "codex-integration", + "codex-credits-after-limit-main.test.ts": "codex-integration", + "service-desktop-startup-health.test.ts": "service", + "service-desktop-startup.test.ts": "service", + "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", - "responses-hosted-image-delivery.test.ts": "responses", - "responses-hosted-image-display.test.ts": "responses", - "responses-hosted-image-limits.test.ts": "responses", + "responses-hosted-image-delivery.test.ts": "responses", + "responses-hosted-image-display.test.ts": "responses", "cursor-local-installer.test.ts": "providers/cursor", - "codex-quota-query-backoff.test.ts": "codex-integration", + "codex-quota-query-backoff.test.ts": "codex-integration", "link-relay-bound-transport.test.ts": "clients", "pnpm-command-isolation.test.ts": "update", "project-config-warning-snapshot.test.ts": "codex-integration", @@ -133,7 +115,7 @@ "anthropic-combo-account-cooldown.test.ts": "adapters/anthropic", "cli-alias-json.test.ts": "cli", "cli-anthropic-account-threshold.test.ts": "cli", - "anthropic-model-routes.test.ts": "adapters/anthropic", + "anthropic-model-routes.test.ts": "adapters/anthropic", "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", "anthropic-baseurl-override.test.ts": "adapters/anthropic", "anthropic-compatible-stream.test.ts": "adapters/anthropic", @@ -172,7 +154,6 @@ "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "anthropic-tool-schema.test.ts": "adapters/anthropic", "antigravity-baseurl-override.test.ts": "adapters/google", - "antigravity-refusal.test.ts": "adapters/google", "antigravity-static-catalog.test.ts": "adapters/google", "api-access-endpoints.test.ts": "server", "api-catalog-route.test.ts": "server", @@ -239,7 +220,6 @@ "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", - "catalog-custom-ordering.test.ts": "codex-integration", "catalog-duplicate-slug-dedup.test.ts": "codex-integration", "catalog-free-pricing-status.test.ts": "codex-integration", "catalog-full-picker-order.test.ts": "codex-integration", @@ -355,7 +335,6 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-estimate-projection.test.ts": "claude-integration", - "claude-first-party-agent-sync.test.ts": "claude-integration", "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", @@ -398,7 +377,6 @@ "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", "cli-account-threshold.test.ts": "cli", - "cli-account-verify-status.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities-arguments.test.ts": "cli", "cli-capabilities.test.ts": "cli", @@ -433,7 +411,8 @@ "cli-models-free-only.test.ts": "cli", "cli-models-price.test.ts": "cli", "cli-models-reasoning.test.ts": "cli", - "cli-models-runtime-dispatch.test.ts": "cli", "cli-models-set.test.ts": "cli", + "cli-models-runtime-dispatch.test.ts": "cli", + "cli-models-set.test.ts": "cli", "cli-models.test.ts": "cli", "cli-native-profile.test.ts": "cli", "cli-observe-logs.test.ts": "cli", @@ -462,11 +441,6 @@ "client-catalog-compatibility.test.ts": "clients", "client-config-export-new-clients.test.ts": "config", "client-config-export.test.ts": "config", - "opencode-kilo-refresh.test.ts": "clients", - "client-export-reasoning-metadata.test.ts": "clients", - "client-export-reasoning-controls.test.ts": "clients", - "client-export-live-wire.test.ts": "clients", - "client-export-effective-metadata.test.ts": "clients", "client-config-new-clients.test.ts": "config", "client-connect.test.ts": "clients", "client-export-modality-enum.test.ts": "clients", @@ -601,7 +575,7 @@ "codex-pool-request-owned-main.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-priority-failback.test.ts": "codex-integration", - "codex-idle-window.test.ts": "codex-integration", + "codex-idle-window.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", "codex-prompt-base-variants.test.ts": "codex-integration", "codex-prompt-journal.test.ts": "codex-integration", @@ -943,7 +917,6 @@ "grok-reset-coupon-cli.test.ts": "providers/xai", "grok-reset-coupons.test.ts": "providers/xai", "grok-selection.test.ts": "providers/xai", - "grok-session-identity.test.ts": "providers/xai", "grok-status.test.ts": "providers/xai", "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", @@ -1182,7 +1155,8 @@ "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", "model-roster-seed-repair.test.ts": "providers", - "model-selection-guidance.test.ts": "cli", "model-settings-management-api.test.ts": "server", + "model-selection-guidance.test.ts": "cli", + "model-settings-management-api.test.ts": "server", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", "models-free-filter.test.ts": "gui", @@ -1300,7 +1274,6 @@ "openai-provider-option-startup.test.ts": "adapters/openai", "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", - "responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses", "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", @@ -1316,8 +1289,6 @@ "opencode-management-transport.test.ts": "providers", "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", - "opengateway-provider.test.ts": "providers", - "opengateway-key-validation.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", "opper-provider.test.ts": "providers", @@ -1479,7 +1450,6 @@ "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", - "request-log-generation-window.test.ts": "usage", "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", "request-pacing.test.ts": "usage", @@ -1505,7 +1475,6 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", - "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", @@ -1617,7 +1586,6 @@ "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", "server-agent-task-recovery-replay.test.ts": "server", - "server-antigravity-verify-replay-scope.test.ts": "server", "server-auth-localhost-bind.test.ts": "server", "server-auth-scoped-quota.test.ts": "server", "server-auth.test.ts": "server", @@ -1714,8 +1682,6 @@ "sse-inspector-bounds.test.ts": "responses", "sse-null-data-frame.test.ts": "responses", "sse-payload-rewrite.test.ts": "responses", - "sse-rewrite-line-endings.test.ts": "responses", - "chat-sse-framing-guard.test.ts": "responses", "sse-unspaced-data-fields.test.ts": "responses", "stale-state-purge.test.ts": "service", "stall-subprocess-exit.test.ts": "lib", @@ -1843,7 +1809,6 @@ "usage-shape-extraction.test.ts": "usage", "usage-spend-cache-provenance.test.ts": "usage", "usage-summary.test.ts": "usage", - "usage-throughput.test.ts": "usage", "usage-surfaces.test.ts": "usage", "usage-time-range.test.ts": "usage", "usage-timeline.test.ts": "usage", @@ -1974,5 +1939,20 @@ "injection-routing-healer.test.ts": "codex-integration", "injection-routing-heal-apply.test.ts": "codex-integration", "cli-start-routing-heal-wiring.test.ts": "cli", - "cli-status-codex-routing-drift.test.ts": "cli" + "cli-status-codex-routing-drift.test.ts": "cli", + "desktop-compatibility-authority.test.ts": "clients", + "desktop-compatibility-native-identity.test.ts": "clients", + "desktop-compatibility-build-probe.test.ts": "clients", + "desktop-compatibility-connection-store.test.ts": "clients", + "desktop-compatibility-routing.test.ts": "clients", + "optional-desktop-upstream.test.ts": "lib", + "server-desktop-compatibility-startup.test.ts": "server", + "management-desktop-compatibility-settings.test.ts": "server", + "desktop-compatibility-runtime.test.ts": "clients", + "desktop-compatibility-relay.test.ts": "clients", + "management-desktop-compatibility-runtime-routes.test.ts": "server", + "desktop-compatibility-certificate-service.test.ts": "clients", + "desktop-compatibility-launch.test.ts": "clients", + "management-desktop-compatibility-routes.test.ts": "server", + "desktop-compatibility-trust.test.ts": "clients" } diff --git a/tests/helpers/desktop-egress-fixture.ts b/tests/helpers/desktop-egress-fixture.ts new file mode 100644 index 00000000000..fd2e60d083c --- /dev/null +++ b/tests/helpers/desktop-egress-fixture.ts @@ -0,0 +1,52 @@ +import { createServer as createHttp } from "node:http"; +import { createServer as createHttps } from "node:https"; +import { createServer as createTcp, connect, type Socket, type Server } from "node:net"; +import type { PemKeyPair } from "../../src/claude/intercept/local-ca"; +export async function listenFixture(server: Server, host = "127.0.0.1") { + const sockets = new Set(); + server.on("connection", socket => { sockets.add(socket); socket.on("error", () => {}); socket.once("close", () => sockets.delete(socket)); }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, host, resolve); }); + return { port: (server.address() as { port: number }).port, + async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); } }; +} +export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort: number, leaf: PemKeyPair) { + const sockets = new Set(), seen: { authority: string; auth: string }[] = []; + const pipe = (client: Socket, head = Buffer.alloc(0)) => { + const target = connect({ host: "127.0.0.1", port: targetPort }); sockets.add(target); + target.on("error", () => client.destroy()); client.on("error", () => target.destroy()); + target.on("close", () => { sockets.delete(target); client.destroy(); }); client.on("close", () => target.destroy()); + target.once("connect", () => { if (head.length) target.write(head); client.pipe(target).pipe(client); }); + }; + const server = kind === "socks5" ? createTcp(client => { + let held = Buffer.alloc(0), stage = 0, auth = ""; + const onData = (chunk: Buffer) => { + held = Buffer.concat([held, chunk]); + for (;;) { + if (stage === 0) { + if (held.length < 2 || held.length < 2 + held[1]!) return; + const end = 2 + held[1]!; const supportsAuth = held.subarray(2, end).includes(2); + held = held.subarray(end); client.write(Buffer.from([5, supportsAuth ? 2 : 255])); stage = 1; + } else if (stage === 1) { + if (held.length < 2 || held.length < 3 + held[1]!) return; + const n = held[1]!, m = held[2 + n]!; if (held.length < 3 + n + m) return; + auth = held.subarray(2, 2 + n).toString() + ":" + held.subarray(3 + n, 3 + n + m).toString(); + held = held.subarray(3 + n + m); client.write(Buffer.from([1, auth === "fixture:secret" ? 0 : 1])); stage = 2; + } else { + if (held.length < 5 || held.length < 7 + held[4]!) return; + const n = held[4]!, authority = held.subarray(5, 5 + n).toString() + ":" + held.readUInt16BE(5 + n); + seen.push({ authority, auth }); held = held.subarray(7 + n); client.off("data", onData); + client.write(Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0])); pipe(client, held); return; + } + } + }; + client.on("data", onData); + }) : kind === "https" ? createHttps({ cert: leaf.certPem, key: leaf.keyPem }) : createHttp(); + if (kind !== "socks5") server.on("connect", (req, client, head) => { + const auth = String(req.headers["proxy-authorization"] ?? ""); seen.push({ authority: req.url ?? "", auth }); + if (auth !== "Basic " + Buffer.from("fixture:secret").toString("base64")) { client.end("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); return; } + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); pipe(client as Socket, head); + }); + const listening = await listenFixture(server, kind === "https" ? "localhost" : "127.0.0.1"); + return { ...listening, seen, url: `${kind}://fixture:secret@${kind === "https" ? "localhost" : "127.0.0.1"}:${listening.port}`, + async close() { for (const socket of sockets) socket.destroy(); await listening.close(); } }; +} diff --git a/tests/helpers/desktop-egress-worker.ts b/tests/helpers/desktop-egress-worker.ts new file mode 100644 index 00000000000..44ef9d1e579 --- /dev/null +++ b/tests/helpers/desktop-egress-worker.ts @@ -0,0 +1,5 @@ +import { desktopOutboundFetch } from "../../src/lib/desktop-proxy-route"; +const response = await desktopOutboundFetch("https://chatgpt.com/fixture", { + method: "POST", body: new Uint8Array([0, 255, 42]), signal: AbortSignal.timeout(5000), headers: { cookie: "fixture=session" }, +}); +console.log(JSON.stringify({ status: response.status, body: await response.text() })); diff --git a/tests/lib/optional-desktop-upstream.test.ts b/tests/lib/optional-desktop-upstream.test.ts new file mode 100644 index 00000000000..19d181b1649 --- /dev/null +++ b/tests/lib/optional-desktop-upstream.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { createServer as createHttps } from "node:https"; +import { createServer as createTcp } from "node:net"; +import { dialDesktopUpstream } from "../../src/lib/desktop-upstream-tunnel"; +import { desktopProxyFor } from "../../src/lib/desktop-proxy-route"; +import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; +import { forwardProxy, listenFixture } from "../helpers/desktop-egress-fixture"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("desktop HTTP and WS choose explicit NO_PROXY, supported proxies, or a closed failure", () => { + const url = new URL("https://chatgpt.com/"); + expect(desktopProxyFor(url, {})).toBe(false); + expect(desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444" })).toBe("http://127.0.0.1:4444"); + expect(desktopProxyFor(url, { ALL_PROXY: "http://127.0.0.1:4444" })).toBe("http://127.0.0.1:4444"); + expect(desktopProxyFor(url, { all_proxy: "https://127.0.0.1:4444" })).toBe("https://127.0.0.1:4444"); + expect(desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444", ALL_PROXY: "http://127.0.0.1:5555" })).toBe("http://127.0.0.1:4444"); + expect(() => desktopProxyFor(url, { HTTPS_PROXY: "bad", ALL_PROXY: "http://127.0.0.1:5555" })).toThrow("proxy_invalid"); + expect(desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444", NO_PROXY: "chatgpt.com" })).toBe(false); + expect(desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444", ALL_PROXY: "socks5://127.0.0.1:5555" })).toBe("socks5://127.0.0.1:5555"); + expect(() => desktopProxyFor(url, { HTTPS_PROXY: "ftp://127.0.0.1:4444" })).toThrow("proxy_invalid"); + expect(() => desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444/path" })).toThrow("proxy_invalid"); +}); + +for (const kind of ["http", "https", "socks5"] as const) test(`verified TLS through authenticated ${kind} proxy preserves raw request and response`, async () => { + const ca = createCertificateAuthority({ commonName: "egress-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "egress-fixture", ["chatgpt.com"]), proxyLeaf = issueServerLeaf(ca, "egress-fixture", ["localhost"]); + let cookie: string | undefined, leaked: string | undefined; + const upstream = createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { + cookie = req.headers.cookie; leaked = req.headers["proxy-authorization"] as string | undefined; + res.end("fixture-response"); + }); + const origin = await listenFixture(upstream), proxy = await forwardProxy(kind, origin.port, proxyLeaf); + let socket: Awaited> | undefined; + try { + socket = await dialDesktopUpstream({ proxy: proxy.url, ca: ca.certPem, proxyCa: ca.certPem, target: { host: "chatgpt.com", port: 443 } }); + const response = new Promise((resolve, reject) => { + let text = ""; socket!.on("data", bytes => { text += bytes.toString(); }); socket!.once("end", () => resolve(text)); socket!.once("error", reject); + }); + socket.write("GET /fixture HTTP/1.1\r\nHost: chatgpt.com\r\nCookie: fixture=session\r\nConnection: close\r\n\r\n"); + expect(await response).toContain("fixture-response"); expect(cookie).toBe("fixture=session"); expect(leaked).toBeUndefined(); + expect(proxy.seen).toHaveLength(1); expect(proxy.seen[0]!.authority).toBe("chatgpt.com:443"); + expect(proxy.seen[0]!.auth).toBe(kind === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64")); + } finally { socket?.destroy(); await proxy.close(); await origin.close(); } +}, 15000); + +for (const kind of ["http", "https", "socks5", "http-all", "https-all"] as const) test(`desktop HTTP fetch uses the same authenticated ${kind} egress and validates upstream TLS`, async () => { + const ca = createCertificateAuthority({ commonName: "fetch-egress-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "fetch-egress-fixture", ["chatgpt.com"]), proxyLeaf = issueServerLeaf(ca, "fetch-egress-fixture", ["localhost"]); + let actual: Buffer | undefined; + const upstream = createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { + const chunks: Buffer[] = []; req.on("data", chunk => chunks.push(chunk)); req.on("end", () => { + actual = Buffer.concat(chunks); res.end("fixture-fetch-response"); + }); + }); + const transport = kind === "http-all" ? "http" : kind === "https-all" ? "https" : kind; + const origin = await listenFixture(upstream), proxy = await forwardProxy(transport, origin.port, proxyLeaf); + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-fetch-")), certificate = join(root, "fixture-ca.pem"); + writeFileSync(certificate, ca.certPem); + const child = Bun.spawn([process.execPath, repoPath("tests/helpers/desktop-egress-worker.ts")], { + cwd: repoRoot(), stdout: "pipe", stderr: "pipe", + env: { ...process.env, NODE_EXTRA_CA_CERTS: certificate, HTTPS_PROXY: kind.endsWith("-all") ? "" : proxy.url, https_proxy: "", HTTP_PROXY: "", http_proxy: "", ALL_PROXY: kind.endsWith("-all") ? proxy.url : "", all_proxy: "", NO_PROXY: "", no_proxy: "" }, + }); + const timer = setTimeout(() => child.kill(), 10000); + try { + const [exitCode, output, error] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect({ exitCode, error }).toEqual({ exitCode: 0, error: "" }); + expect(JSON.parse(output)).toEqual({ status: 200, body: "fixture-fetch-response" }); + expect(actual).toEqual(Buffer.from([0, 255, 42])); expect(proxy.seen).toHaveLength(1); + } finally { clearTimeout(timer); if (child.exitCode === null) { child.kill(); await child.exited; } await proxy.close(); await origin.close(); removeTreeWithRetry(root); } +}, 15000); + +test("untrusted proxy TLS is refused without reaching the target", async () => { + const ca = createCertificateAuthority({ commonName: "untrusted-proxy", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "untrusted-proxy", ["localhost"]); + const proxy = await forwardProxy("https", 1, leaf); + try { await expect(dialDesktopUpstream({ proxy: proxy.url, timeoutMs: 1000 })).rejects.toThrow("desktop_upstream_connection_failed"); expect(proxy.seen).toHaveLength(0); } + finally { await proxy.close(); } +}); + +test("trusted certificates with wrong proxy or upstream hostnames are still refused", async () => { + const ca = createCertificateAuthority({ commonName: "wrong-host-fixture", validityDays: 1 }); + const wrongProxy = await forwardProxy("https", 1, issueServerLeaf(ca, "wrong-host-fixture", ["chatgpt.com"])); + try { + await expect(dialDesktopUpstream({ proxy: wrongProxy.url, proxyCa: ca.certPem, timeoutMs: 1000 })).rejects.toThrow("desktop_upstream_connection_failed"); + expect(wrongProxy.seen).toHaveLength(0); + } finally { await wrongProxy.close(); } + const wrongLeaf = issueServerLeaf(ca, "wrong-host-fixture", ["localhost"]); + const upstream = await listenFixture(createHttps({ cert: wrongLeaf.certPem, key: wrongLeaf.keyPem })); + const proxy = await forwardProxy("http", upstream.port, wrongLeaf); + try { await expect(dialDesktopUpstream({ proxy: proxy.url, ca: ca.certPem, timeoutMs: 1000 })).rejects.toThrow("desktop_upstream_connection_failed"); } + finally { await proxy.close(); await upstream.close(); } +}); + +for (const failure of ["eof", "oversized", "timeout", "abort"] as const) test(`proxy ${failure} failure settles and closes the owned connection`, async () => { + let closed!: () => void, accepted!: () => void; + const closedEvent = new Promise(resolve => { closed = resolve; }), acceptedEvent = new Promise(resolve => { accepted = resolve; }); + const server = createTcp(socket => { + socket.once("close", closed); socket.once("data", () => { + accepted(); + if (failure === "eof") socket.end(); + if (failure === "oversized") socket.write(Buffer.alloc(65537, 65)); + }); + }); + const proxy = await listenFixture(server), abort = new AbortController(); + try { + const dial = dialDesktopUpstream({ proxy: `http://127.0.0.1:${proxy.port}`, timeoutMs: failure === "abort" ? 2000 : 500, signal: abort.signal }); + const settled = dial.then(() => null, error => error as Error); + await acceptedEvent; if (failure === "abort") abort.abort(); + const at = Date.now(), result = await settled; + expect(result?.message).toBe("desktop_upstream_connection_failed"); + if (failure === "abort") expect(Date.now() - at).toBeLessThan(500); + await closedEvent; + } finally { await proxy.close(); } +}, 3000); diff --git a/tests/server/management-desktop-compatibility-routes.test.ts b/tests/server/management-desktop-compatibility-routes.test.ts new file mode 100644 index 00000000000..287aa474447 --- /dev/null +++ b/tests/server/management-desktop-compatibility-routes.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { handleDesktopCompatibilityRoutes } from "../../src/server/management/desktop-compatibility-routes"; +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"; + +const url = new URL("http://127.0.0.1:10100/api/codex/desktop-compatibility/certificate"); +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 }; + const service: DesktopCertificateService = { status: async () => { calls.push("status"); return status; }, + 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; + return { calls, ctx }; +} +test("status reads do not invoke setup or OS trust", async () => { + const io = fixture("GET", undefined, "admin-token"); expect((await handleDesktopCompatibilityRoutes(io.ctx))?.status).toBe(200); + expect(io.calls).toEqual(["status"]); +}); +test("only authenticated local dashboard mutations reach the service", async () => { + for (const [principal, loopback] of [["admin-token", true], ["gui-session", false], [undefined, true]] as const) { + const io = fixture("POST", { action: "prepare", confirmed: true }, principal, loopback); + if (principal === undefined) io.ctx.principal = undefined; + expect((await handleDesktopCompatibilityRoutes(io.ctx))?.status).toBe(403); expect(io.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" }, + { action: "prepare", confirmed: true, injected: true }, { action: ["prepare"], confirmed: true }]) { + const io = fixture("POST", body); expect((await handleDesktopCompatibilityRoutes(io.ctx))?.status).toBe(400); expect(io.calls).toEqual([]); + } + const valid = fixture("POST", { action: "trust", confirmed: true, fingerprint: "A".repeat(64) }); + expect((await handleDesktopCompatibilityRoutes(valid.ctx))?.status).toBe(200); expect(valid.calls).toEqual(["trust:" + "A".repeat(64)]); +}); +test("unexpected errors never expose process output or credential strings", async () => { + const io = fixture("POST", { action: "prepare", confirmed: true }); + io.ctx.deps.desktopCertificateService!.prepare = async () => { throw new Error("synthetic-private-key-output"); }; + const response = await handleDesktopCompatibilityRoutes(io.ctx); expect(response?.status).toBe(409); + expect(await response!.json()).toEqual({ ok: false, error: "operation_failed" }); +}); + +test("renewal requires fresh fingerprint and explicit local dashboard confirmation", async () => { + const invalid = fixture("POST", { action: "renew", confirmed: true }); + expect((await handleDesktopCompatibilityRoutes(invalid.ctx))?.status).toBe(400); expect(invalid.calls).toEqual([]); + const valid = fixture("POST", { action: "renew", confirmed: true, fingerprint: "A".repeat(64) }); + expect((await handleDesktopCompatibilityRoutes(valid.ctx))?.status).toBe(200); expect(valid.calls).toEqual(["renew:" + "A".repeat(64)]); +}); +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 }); + 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 new file mode 100644 index 00000000000..2610f63c8b3 --- /dev/null +++ b/tests/server/management-desktop-compatibility-runtime-routes.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { handleDesktopCompatibilityRuntimeRoutes } from "../../src/server/management/desktop-compatibility-runtime-routes"; +import { createDesktopCompatibilityRuntime } from "../../src/codex/desktop-compatibility/runtime"; +import type { ManagementContext } from "../../src/server/management/context"; +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"); +function fixture(method: string, body?: unknown) { + const calls: string[] = []; + const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); + runtime.start = async () => { calls.push("start"); return runtime.status(); }; + 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; + 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 }); + 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 }]) { + 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([]); + } + for (const body of [{ action: "start" }, { action: ["start"], confirmed: true }, { action: "apply", confirmed: true }, + { action: "start", confirmed: true, accountWideConsent: true }, { action: "start", confirmed: true, model: "fixture" }]) { + const io = fixture("POST", body); expect((await handleDesktopCompatibilityRuntimeRoutes(io.ctx))?.status).toBe(400); expect(io.calls).toEqual([]); + } +}); +test("runtime failures report safe codes and never leak subprocess or credential data", async () => { + const io = fixture("POST", { action: "start", confirmed: true }); + io.runtime.start = async () => { throw new Error("synthetic-private-data"); }; + const response = await handleDesktopCompatibilityRuntimeRoutes(io.ctx); + expect(response?.status).toBe(409); expect(await response!.text()).not.toContain("synthetic-private-data"); +}); diff --git a/tests/server/management-desktop-compatibility-settings.test.ts b/tests/server/management-desktop-compatibility-settings.test.ts new file mode 100644 index 00000000000..e0ad711ebe9 --- /dev/null +++ b/tests/server/management-desktop-compatibility-settings.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDesktopStartupSettings } from "../../src/codex/desktop-compatibility/startup-settings"; +import { handleDesktopCompatibilitySettingsRoutes } from "../../src/server/management/desktop-compatibility-settings-routes"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { armClaudeCodeBaseline, loadConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import { mutatePersistedConfig, setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { setIcaclsRunnerForTests, setAsyncIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +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 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")); +const write = (value: unknown) => writeFileSync(join(root, "config.json"), JSON.stringify(value)); +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; root = mkdtempSync(join(tmpdir(), "ocx-desktop-setting-")); process.env.OPENCODEX_HOME = root; + setIcaclsRunnerForTests(success); setAsyncIcaclsRunnerForTests(async () => success()); write(initial); +}); +afterEach(async () => { + setPersistedConfigMutationBeforeCommitForTests(null); await flushConfigDirHardeningAndReaps(root); + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); removeTreeWithRetry(root); +}); +test("field-scoped write preserves unrelated concurrent settings and rejects a stale preference", () => { + const service = createDesktopStartupSettings(), first = service.status(); + expect(first.startOnProxyStart).toBe(false); + setPersistedConfigMutationBeforeCommitForTests(() => write({ ...read(), logLevel: "debug" })); + const enabled = service.set(true, first.revision); + expect(enabled.startOnProxyStart).toBe(true); expect(read().logLevel).toBe("debug"); expect(read().providers).toEqual(initial.providers); + expect(() => service.set(false, first.revision)).toThrow("settings_changed"); expect(read().desktopCompatibility.startOnProxyStart).toBe(true); + expect(service.set(true, enabled.revision)).toEqual(enabled); +}); +test("an unrelated later live save does not revert the persisted next-start preference", () => { + const live = loadConfig(); armClaudeCodeBaseline(live); + const service = createDesktopStartupSettings({ liveConfig: live }); service.set(true, service.status().revision); + live.logLevel = "debug"; saveConfigPreservingClaudeCode(live); + expect(read().desktopCompatibility).toEqual({ startOnProxyStart: true }); expect(read().logLevel).toBe("debug"); + write({ ...read(), desktopCompatibility: { startOnProxyStart: false } }); + live.logLevel = "info"; saveConfigPreservingClaudeCode(live); + expect(read().desktopCompatibility).toEqual({ startOnProxyStart: false }); +}); +test("a publication-side error stays an error but cannot let a later save undo committed intent", () => { + const live = loadConfig(); armClaudeCodeBaseline(live); + const service = createDesktopStartupSettings({ liveConfig: live, mutate: callback => { + mutatePersistedConfig(callback); throw new Error("fixture post-publication failure"); + } }); + expect(() => service.set(true, service.status().revision)).toThrow("fixture post-publication failure"); + expect(service.status().startOnProxyStart).toBe(true); + live.logLevel = "debug"; saveConfigPreservingClaudeCode(live); expect(read().desktopCompatibility.startOnProxyStart).toBe(true); +}); +test("invalid settings are preserved and a competing preference blocks replay", () => { + const service = createDesktopStartupSettings(), first = service.status(); + setPersistedConfigMutationBeforeCommitForTests(() => write({ ...read(), desktopCompatibility: { startOnProxyStart: true } })); + expect(() => service.set(true, first.revision)).toThrow("settings_changed"); + const malformed = { ...initial, desktopCompatibility: { startOnProxyStart: true, apply: true } }; write(malformed); + expect(() => service.status()).toThrow("settings_invalid"); expect(read()).toEqual(malformed); +}); +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; +} +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 }]) { + const ctx = request("POST", { confirmed: true, startOnProxyStart: true, revision: before.revision }); Object.assign(ctx, change); + expect((await handleDesktopCompatibilitySettingsRoutes(ctx))?.status).toBe(403); + } + const invalid = request("POST", { confirmed: true, startOnProxyStart: "true", revision: before.revision }); + 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 }); + expect(response?.status).toBe(200); expect((await response!.json()).settings).toEqual(service.status()); expect(service.status().startOnProxyStart).toBe(true); +}); diff --git a/tests/server/server-desktop-compatibility-startup.test.ts b/tests/server/server-desktop-compatibility-startup.test.ts new file mode 100644 index 00000000000..d479d9dbb6c --- /dev/null +++ b/tests/server/server-desktop-compatibility-startup.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { scheduleDesktopCompatibilityStartup } from "../../src/server/index/desktop-compatibility-startup"; +import { createDesktopCompatibilityService } from "../../src/codex/desktop-compatibility/service"; +import { createDesktopCompatibilityRuntime } from "../../src/codex/desktop-compatibility/runtime"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { nativeCompatibilityOwner } from "../../src/codex/desktop-compatibility/routing-binding"; +import { createReadinessGate } from "../../src/server/readiness"; + +test("startup captures the actual bound hostname and clears that owner on shutdown", async () => { + const config = { ...getDefaultConfig(), hostname: "192.0.2.10" }; + const handle = scheduleDesktopCompatibilityStartup(config, { boundPort: 12001, boundHostname: "127.0.0.1", loopbackPort: 12002, testGuard: true }); + try { expect(nativeCompatibilityOwner()).toMatchObject({ hostname: "127.0.0.1", port: 12001, loopbackPort: 12002 }); } + finally { await handle.shutdown(); } + expect(nativeCompatibilityOwner()).toBeNull(); +}); + +test("startup preference is absent by default, strict for writes and safely disabled on malformed disk input", () => { + expect(getDefaultConfig().desktopCompatibility).toBeUndefined(); + const valid = { ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }; + expect(validateConfigCandidate(valid).ok).toBe(true); + for (const setting of [{ startOnProxyStart: "true" }, { startOnProxyStart: true, apply: true }, { enabled: true }]) { + const value = { ...getDefaultConfig(), desktopCompatibility: setting }; + expect(validateConfigCandidate(value).ok).toBe(false); expect(configSchema.parse(value).desktopCompatibility).toBeUndefined(); + } +}); +test("off, unsupported, guarded, sibling and managed-client starts never load the optional runtime", async () => { + let loads = 0; + for (const patch of [{ setting: false }, { platform: "linux" }, { testGuard: true }, { sibling: true }, { client: true }]) { + const config = { ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: patch.setting ?? true }, ...(patch.client ? { runtimeRole: "client" as const } : {}) }; + const handle = scheduleDesktopCompatibilityStartup(config, { platform: patch.platform ?? "win32", testGuard: patch.testGuard ?? false, sibling: patch.sibling ?? false, + load: async () => { loads++; throw new Error("must not load"); } }); + await Promise.resolve(); await Promise.resolve(); + await handle.shutdown(); + } + expect(loads).toBe(0); +}); +test("failed optional startup reports a generic diagnostic without failing proxy lifecycle", async () => { + const warnings: string[] = []; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, load: async () => { throw new Error("fixture-sensitive-error"); }, warn: text => warnings.push(text) }); + await Promise.resolve(); await Promise.resolve(); await handle.shutdown(); + expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("did not start"); expect(warnings[0]).not.toContain("fixture-sensitive-error"); +}); +test("automatic startup and management share one runtime and teardown waits for pending start", async () => { + let started = 0, stopped = 0, created = 0, ready!: () => void; + const waiting = new Promise(resolve => { ready = resolve; }); + const service = createDesktopCompatibilityService(() => { + created++; const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); + runtime.start = async () => { started++; await waiting; return runtime.status(); }; + runtime.stop = async () => { stopped++; return runtime.status(); }; + return runtime; + }); + const module = { getDesktopCompatibilityRuntime: () => service.get(), shutdownDesktopCompatibility: () => service.shutdown() }; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, load: async () => module }); + await Promise.resolve(); await Promise.resolve(); + expect(started).toBe(1); expect(created).toBe(1); + await expect(service.get().start()).rejects.toThrow("busy"); + let finished = false; const stopping = handle.shutdown().then(() => { finished = true; }); + await Promise.resolve(); expect(finished).toBe(false); expect(stopped).toBe(0); + ready(); await stopping; expect(stopped).toBe(1); expect(service.get()).toBe(service.get()); + await expect(service.get().start()).rejects.toThrow("stopping"); +}); +test("shutdown before module load finishes prevents late service activation", async () => { + let release!: () => void, starts = 0, shutdowns = 0; + const waiting = new Promise(resolve => { release = resolve; }); + const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); + runtime.start = async () => { starts++; return runtime.status(); }; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, load: async () => { + await waiting; return { getDesktopCompatibilityRuntime: () => runtime, shutdownDesktopCompatibility: async () => { shutdowns++; } }; + } }); + await Promise.resolve(); const closing = handle.shutdown(); release(); await closing; + expect(starts).toBe(0); expect(shutdowns).toBe(1); +}); + +test("automatic observation waits for native configuration readiness", async () => { + const readiness = createReadinessGate(); let starts = 0, loads = 0; + const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); + runtime.start = async () => { starts++; return runtime.status(); }; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, readiness, + load: async () => { loads++; return { getDesktopCompatibilityRuntime: () => runtime, shutdownDesktopCompatibility: async () => {} }; } }); + try { + await Bun.sleep(20); expect(loads).toBe(0); expect(starts).toBe(0); + readiness.markReady(); await Bun.sleep(150); + expect(loads).toBe(1); expect(starts).toBe(1); + } finally { await handle.shutdown(); } +}); + +test("shutdown cancels pending readiness and never starts a late observation", async () => { + const readiness = createReadinessGate(); let loads = 0; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, readiness, + load: async () => { loads++; throw new Error("must not load"); }, warn: () => {} }); + await Promise.resolve(); await handle.shutdown(); readiness.markReady(); await Bun.sleep(150); + expect(loads).toBe(0); +}); + +test("failed or timed-out readiness leaves optional observation off", async () => { + for (const failed of [true, false]) { + const readiness = createReadinessGate(); if (failed) readiness.markFailed(); + let loads = 0; const warnings: string[] = []; + const handle = scheduleDesktopCompatibilityStartup({ ...getDefaultConfig(), desktopCompatibility: { startOnProxyStart: true } }, + { platform: "win32", testGuard: false, sibling: false, readiness, readinessTimeoutMs: 1, + load: async () => { loads++; throw new Error("must not load"); }, warn: value => warnings.push(value) }); + await Bun.sleep(150); await handle.shutdown(); + expect(loads).toBe(0); expect(warnings).toHaveLength(1); + } +}); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index c675c9ce639..e063c0fe364 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -20,10 +20,21 @@ import { type Layout, } from "../scripts/test-layout/schema"; -// Independent oracle: the basename -> directory table from devlog 001 §2.D, committed as a -// fixture. The layout guard shares the resolver with the mover, so a resolver defect could move -// a file to the wrong place and bless it; this fixture is the second opinion that catches it. -const EXPECTED = JSON.parse(readFileSync(repoPath("tests", "fixtures", "test-layout-expected.json"), "utf8")) as Record; +// Independent oracle: the basename -> directory table from devlog 001 §2.D, committed as fixture +// shards. The layout guard shares the resolver with the mover, so a resolver defect could move a +// file to the wrong place and bless it; these fixtures are the second opinion that catches it. +const EXPECTED_FIXTURE_PARTS = [ + "test-layout-expected.json", + "test-layout-expected-additional.json", +] as const; +const EXPECTED: Record = {}; +for (const part of EXPECTED_FIXTURE_PARTS) { + const entries = JSON.parse(readFileSync(repoPath("tests", "fixtures", part), "utf8")) as Record; + for (const [file, domain] of Object.entries(entries)) { + if (Object.hasOwn(EXPECTED, file)) throw new Error(`duplicate expected test-layout entry: ${file}`); + EXPECTED[file] = domain; + } +} describe("rewriteSpecifier", () => { const forms = [