From b1164688e7de0ea61cde70e8eb1418751f80c007 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:19:23 +0900 Subject: [PATCH 01/33] feat(codex): prepare protected Windows desktop compatibility lifecycle --- .../content/docs/guides/codex-integration.md | 7 + scripts/test-layout/layout.json | 2 + src/codex/desktop-app/types.ts | 2 + src/codex/desktop-app/windows.ts | 23 ++- .../certificate-store.ts | 137 ++++++++++++++++++ .../windows-activation-source.ts | 44 ++++++ .../windows-key-protection.ts | 55 +++++++ .../windows-package-command.ts | 65 +++++++++ .../windows-package-launch.ts | 28 ++++ structure/INDEX.md | 5 +- structure/clients/codex-desktop.md | 47 ++++++ structure/manifest.json | 7 + structure/runtime.md | 2 +- .../desktop-compatibility-authority.test.ts | 107 ++++++++++++++ .../desktop-compatibility-launch.test.ts | 103 +++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 16 files changed, 625 insertions(+), 11 deletions(-) create mode 100644 src/codex/desktop-compatibility/certificate-store.ts create mode 100644 src/codex/desktop-compatibility/windows-activation-source.ts create mode 100644 src/codex/desktop-compatibility/windows-key-protection.ts create mode 100644 src/codex/desktop-compatibility/windows-package-command.ts create mode 100644 src/codex/desktop-compatibility/windows-package-launch.ts create mode 100644 structure/clients/codex-desktop.md create mode 100644 tests/clients/desktop-compatibility-authority.test.ts create mode 100644 tests/clients/desktop-compatibility-launch.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1b964b14529..adabd0219f9 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -891,6 +891,13 @@ it is not; `ocx doctor` reports restart safety (service/shim coverage). ## Routed models during Codex reserve mode +On Windows, an explicit OpenCodex full-app restart preserves an already active loopback +compatibility PAC argument and launches Codex through Windows package activation. It checks the +package identity and routing argument after launch; 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. + When the ChatGPT 5-hour quota is exhausted, Codex may offer a reserve fallback model (`gpt-reserve` / Luna Reserve). While that state is active, the Codex model picker can make **every other entry unselectable — including opencodex routed models**, even though those diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53bcb676449..fdacc832a8d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -893,6 +893,8 @@ "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-launch.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", 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..d01750e161c 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, captureWindowsCompatibilityContext } from "../desktop-compatibility/windows-package-command"; import { isUnderRoot, type DesktopAppAdapter, @@ -100,7 +101,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 +128,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 +147,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 ? { commandLine: Buffer.from(encoded, "base64").toString("utf8") } : {}) }; } /** @@ -215,13 +220,15 @@ 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) { + 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-store.ts b/src/codex/desktop-compatibility/certificate-store.ts new file mode 100644 index 00000000000..7ae5afcab48 --- /dev/null +++ b/src/codex/desktop-compatibility/certificate-store.ts @@ -0,0 +1,137 @@ +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, 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" | "protection_failed") { + 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; +} + +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): 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) throw new DesktopAuthorityError("expired"); + 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): 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); + } 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); } +} + +/** Reuses one user-protected root across restarts; never installs, rotates or removes trust. */ +export async function ensureDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions): 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); + 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 }); + 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); + 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/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-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..e7057f036d0 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-package-command.ts @@ -0,0 +1,65 @@ +import type { DesktopAppInstall, DesktopExec, DesktopProcess } from "../desktop-app/types"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import { WINDOWS_ACTIVATION_SOURCE } from "./windows-activation-source"; + +/** 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 an already active managed-shape PAC, never arbitrary launch flags. */ +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))) { + 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"); + return urls.size === 1 ? { codexCompatibilityPacUrl: [...urls][0]! } : {}; +} + +export function activateWindowsCodexCompatibility(exec: DesktopExec, install: DesktopAppInstall, pacUrl: string): { pid: number; packageFullName: string } { + const script = compatibilityActivationScript(install, pacUrl); + const text = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 15_000, windowsHide: true }); + const result = JSON.parse(text); + if (result.verified !== true || !Number.isSafeInteger(result.pid) || result.pid <= 0 + || typeof result.packageFullName !== "string" || !result.packageFullName.startsWith(install.id.split("_")[0]! + "_") + || !result.packageFullName.endsWith("_" + install.id.split("_")[1]!)) throw new Error("desktop_compatibility_activation_unverified"); + return { pid: result.pid, packageFullName: result.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/structure/INDEX.md b/structure/INDEX.md index d1a258a6810..b03eb48b0d2 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -77,6 +77,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/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. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -115,10 +116,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.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/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/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)
[`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)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.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..63c0b7f60bb --- /dev/null +++ b/structure/clients/codex-desktop.md @@ -0,0 +1,47 @@ +# 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. 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. + +`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. +`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. 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. Management enable/disable and renewal +remain a separate integration layer. + +`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. diff --git a/structure/manifest.json b/structure/manifest.json index 5201bb9bf71..53f83c3dca4 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -422,6 +422,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/claude-desktop.md", "tier": 5, diff --git a/structure/runtime.md b/structure/runtime.md index 8da1d766045..6905e26b3df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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/tests/clients/desktop-compatibility-authority.test.ts b/tests/clients/desktop-compatibility-authority.test.ts new file mode 100644 index 00000000000..091aa99c4cc --- /dev/null +++ b/tests/clients/desktop-compatibility-authority.test.ts @@ -0,0 +1,107 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureDesktopCompatibilityAuthority } 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("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-launch.test.ts b/tests/clients/desktop-compatibility-launch.test.ts new file mode 100644 index 00000000000..8a506ba3043 --- /dev/null +++ b/tests/clients/desktop-compatibility-launch.test.ts @@ -0,0 +1,103 @@ +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 { captureWindowsCompatibilityContext } from "../../src/codex/desktop-compatibility/windows-package-command"; + +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"; +afterEach(() => setTrustedWindowsElevationExecutablesForTests(null)); +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("the shipped Windows restart adapter captures and reapplies an active compatibility PAC", () => { + 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 }); + windowsDesktopAppAdapter.relaunch(io.exec, install, context); + expect(io.calls.at(-1)).toContain("OpenCodexPackageActivation"); + expect(io.calls.at(-1)).toContain(pac); + }); + + test("helper arguments cannot override the main app and conflicting roots refuse before a stop", () => { + 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 }); + 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({}); + }); + + 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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2924eaf5ca9..5e5a6523e2b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -729,6 +729,8 @@ "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-launch.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", From e014a1b32aa5de33857907096b513e3b3fba4ead Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:03:05 +0900 Subject: [PATCH 02/33] feat(codex): add local certificate setup and trust management --- .../content/docs/reference/management-api.md | 23 +++++ scripts/test-layout/layout.json | 3 + .../certificate-service.ts | 89 +++++++++++++++++++ .../certificate-store.ts | 42 ++++++++- .../windows-certificate-trust.ts | 79 ++++++++++++++++ src/server/management-api.ts | 2 + src/server/management/context.ts | 1 + .../desktop-compatibility-routes.ts | 45 ++++++++++ src/server/management/route-registry.ts | 2 + src/server/management/sibling-guard.ts | 1 + structure/clients/codex-desktop.md | 27 +++++- structure/gui-and-management-api.md | 2 + .../desktop-compatibility-authority.test.ts | 23 ++++- ...-compatibility-certificate-service.test.ts | 60 +++++++++++++ .../desktop-compatibility-trust.test.ts | 69 ++++++++++++++ tests/fixtures/test-layout-expected.json | 3 + ...ement-desktop-compatibility-routes.test.ts | 49 ++++++++++ 17 files changed, 514 insertions(+), 6 deletions(-) create mode 100644 src/codex/desktop-compatibility/certificate-service.ts create mode 100644 src/codex/desktop-compatibility/windows-certificate-trust.ts create mode 100644 src/server/management/desktop-compatibility-routes.ts create mode 100644 tests/clients/desktop-compatibility-certificate-service.test.ts create mode 100644 tests/clients/desktop-compatibility-trust.test.ts create mode 100644 tests/server/management-desktop-compatibility-routes.test.ts diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 0a1b76c94e4..5f700821342 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -84,6 +84,7 @@ 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 /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 | @@ -685,6 +686,28 @@ 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"` 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. + +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. + ## Choosing a client For ordinary administration, the [Web Dashboard](/guides/web-dashboard/) gives the safest guided diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fdacc832a8d..7ce9adcbd99 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -894,7 +894,10 @@ "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-launch.test.ts": "clients", + "management-desktop-compatibility-routes.test.ts": "server", + "desktop-compatibility-trust.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", diff --git a/src/codex/desktop-compatibility/certificate-service.ts b/src/codex/desktop-compatibility/certificate-service.ts new file mode 100644 index 00000000000..3b68c8d8161 --- /dev/null +++ b/src/codex/desktop-compatibility/certificate-service.ts @@ -0,0 +1,89 @@ +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows"; +import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, type DesktopAuthorityInspection, type StoredDesktopAuthority } from "./certificate-store"; +import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust } from "./windows-certificate-trust"; + +export interface DesktopCertificateStatus { + supported: boolean; + state: "missing" | "invalid" | "expired" | "prepared" | "trusted" | "unknown"; + fingerprint?: string; + expiresAt?: number; + renewalDue?: boolean; + trust?: DesktopCertificateTrust; + busy: "prepare" | "trust" | "remove-trust" | null; +} + +export interface DesktopCertificateServiceIo { + platform?: string; + inspect?: () => DesktopAuthorityInspection; + prepare?: () => Promise; + load?: (allowExpiredForRemoval: boolean) => 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" | "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 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" + : 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"); + 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") { + 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 === "remove-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"); + } + busy = null; + return status(); + } finally { busy = null; } + } + return { status, prepare: () => action("prepare"), trust: (fingerprint: string) => action("trust", fingerprint), + removeTrust: (fingerprint: string) => action("remove-trust", fingerprint) }; +} + +export type DesktopCertificateService = ReturnType; diff --git a/src/codex/desktop-compatibility/certificate-store.ts b/src/codex/desktop-compatibility/certificate-store.ts index 7ae5afcab48..1c3f0eb8f19 100644 --- a/src/codex/desktop-compatibility/certificate-store.ts +++ b/src/codex/desktop-compatibility/certificate-store.ts @@ -36,6 +36,29 @@ export interface StoredDesktopAuthority { reused: boolean; } +export type DesktopAuthorityInspection = + | { status: "missing" | "invalid" } + | { status: "present" | "expired"; 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" : "present", 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)) { @@ -51,7 +74,7 @@ function pathPresent(path: string): boolean { } } -function validatedAuthority(certPem: string, keyPem: string, commonName: string, now: number, reused: boolean): StoredDesktopAuthority { +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); @@ -63,7 +86,7 @@ function validatedAuthority(certPem: string, keyPem: string, commonName: string, 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) throw new DesktopAuthorityError("expired"); + if (expiresAt <= now && !allowExpired) throw new DesktopAuthorityError("expired"); return { authority: { certPem, keyPem, privateKey, publicKey }, commonName, fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt, renewalDue: expiresAt - now <= 7 * DAY, reused }; @@ -73,7 +96,7 @@ function validatedAuthority(certPem: string, keyPem: string, commonName: string, } } -async function readAuthority(path: string, protection: AuthorityKeyProtection, now: number): Promise { +async function readAuthority(path: string, protection: AuthorityKeyProtection, now: number, allowExpired = false): Promise { assertPath(path, false); await hardenSecretPathAsync(path, { required: true }); assertPath(path, false); @@ -87,7 +110,7 @@ async function readAuthority(path: string, protection: AuthorityKeyProtection, n 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); + 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. @@ -95,6 +118,17 @@ async function readAuthority(path: string, protection: AuthorityKeyProtection, n } finally { cleartext?.fill(0); } } +/** Loads only existing state. Expired keys may be loaded solely to remove their OS trust. */ +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 { if (!isAbsolute(options.directory)) throw new DesktopAuthorityError("unsafe_path"); 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..69283807926 --- /dev/null +++ b/src/codex/desktop-compatibility/windows-certificate-trust.ts @@ -0,0 +1,79 @@ +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"; + +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")); + 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/server/management-api.ts b/src/server/management-api.ts index dc8390c84ca..7a3b1da6c0e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -77,6 +77,7 @@ 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 { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -341,6 +342,7 @@ export async function handleManagementAPI( ?? (await handleNativeIntegrationRoutes(ctx)) ?? (await handleCursorIntegrationRoutes(ctx)) ?? (await handleClaudeDesktopPickerRoutes(ctx)) + ?? (await handleDesktopCompatibilityRoutes(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 0a515b78104..a0f0daba3a9 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -44,6 +44,7 @@ export interface ManagementRequestIngress { } export interface ManagementApiDeps { + desktopCertificateService?: import("../../codex/desktop-compatibility/certificate-service").DesktopCertificateService; /** Bound Claude intercept state, injectable for isolated management-route tests. */ getClaudeInterceptState?: typeof import("../../claude/intercept/runtime").getClaudeInterceptState; /** Reconciliation seam for field-scoped rollback 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..8ad22dacf04 --- /dev/null +++ b/src/server/management/desktop-compatibility-routes.ts @@ -0,0 +1,45 @@ +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" | 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"].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 === "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", "app_state_unknown", "trust_unknown", "trust_not_applied", + "unsafe_path", "unreadable", "expired", "protection_failed"]); + return jsonResponse({ ok: false, error: allowed.has(code) ? code : "operation_failed" }, 409); + } +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index b9b9c4220a8..4ea99303b17 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -158,6 +158,8 @@ 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: "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 7c2f46473c8..a923d4284cc 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/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 63c0b7f60bb..15603a216d9 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -38,10 +38,35 @@ Reopening reuses the same key and fingerprint. Corrupt, foreign-user and expired 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. Management enable/disable and renewal -remain a separate integration layer. +remain a separate integration layer from certificate preparation. `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. + +`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. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index be59cb022dd..31fe648c29b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -56,6 +56,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. Native status and cancellation require `flowId`; diff --git a/tests/clients/desktop-compatibility-authority.test.ts b/tests/clients/desktop-compatibility-authority.test.ts index 091aa99c4cc..d9b02ac4c2f 100644 --- a/tests/clients/desktop-compatibility-authority.test.ts +++ b/tests/clients/desktop-compatibility-authority.test.ts @@ -3,7 +3,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ensureDesktopCompatibilityAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; +import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority } 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"; @@ -34,6 +34,27 @@ function protector(): AuthorityKeyProtection { } describe("Codex Desktop compatibility authority lifecycle", () => { + 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 }); 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..4ad99cbadf2 --- /dev/null +++ b/tests/clients/desktop-compatibility-certificate-service.test.ts @@ -0,0 +1,60 @@ +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; + 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; }, + readTrust: async () => { calls.push("read-trust"); return trusted ? "trusted" : "not-trusted"; }, + changeTrust: async (_value, action) => { calls.push(action); trusted = action === "trust"; return trusted ? "trusted" : "not-trusted"; }, + appRunning: async () => appRunning, + }); + return { calls, service, present, setAppRunning: (value: boolean | null) => { appRunning = value; }, setStored: (value: DesktopAuthorityInspection) => { stored = value; } }; +} + +describe("compatibility certificate setup service", () => { + 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-trust.test.ts b/tests/clients/desktop-compatibility-trust.test.ts new file mode 100644 index 00000000000..62b7eaa8ff5 --- /dev/null +++ b/tests/clients/desktop-compatibility-trust.test.ts @@ -0,0 +1,69 @@ +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(): StoredDesktopAuthority { + const commonName = `OpenCodex Codex Desktop ${randomUUID()}`; + const authority = createCertificateAuthority({ commonName, validityDays: 1, permittedDnsNames: ["chatgpt.com"], excludeAllIpAddresses: true }); + 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("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.json b/tests/fixtures/test-layout-expected.json index 5e5a6523e2b..a8f640045bb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -730,7 +730,10 @@ "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-launch.test.ts": "clients", + "management-desktop-compatibility-routes.test.ts": "server", + "desktop-compatibility-trust.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", 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..e15e8722419 --- /dev/null +++ b/tests/server/management-desktop-compatibility-routes.test.ts @@ -0,0 +1,49 @@ +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; } }; + 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("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"]); +}); From da36d501eb6dea7963c31f6cf71319467eac0daf Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:03:10 +0900 Subject: [PATCH 03/33] test(cli): isolate remote connect lifecycle lock --- tests/cli/cli-headless-parity.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index fd96824c854..08074d0cdf8 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -945,13 +945,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); @@ -959,6 +963,7 @@ describe("headless GUI parity CLI", () => { } finally { logSpy.mockRestore(); errorSpy.mockRestore(); + removeTreeWithRetry(lockRoot); } }); From 3569da4406d9be4312c2579e23e34211e6e19058 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:10:22 +0900 Subject: [PATCH 04/33] feat(codex): add recoverable compatibility certificate renewal --- .../content/docs/reference/management-api.md | 8 +++++- .../certificate-service.ts | 15 +++++++---- .../certificate-store.ts | 27 ++++++++++++++++--- .../desktop-compatibility-routes.ts | 5 ++-- structure/clients/codex-desktop.md | 9 +++++-- .../desktop-compatibility-authority.test.ts | 26 +++++++++++++++++- ...-compatibility-certificate-service.test.ts | 26 +++++++++++++++--- ...ement-desktop-compatibility-routes.test.ts | 9 ++++++- 8 files changed, 107 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5f700821342..4d055c38dc3 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -693,7 +693,7 @@ support, state, fingerprint, expiry, renewal notice, trust observation and any a 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"` and `confirmed: true`. +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. @@ -705,6 +705,12 @@ Cancellation or uncertain OS command completion is checked against the actual ce 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. diff --git a/src/codex/desktop-compatibility/certificate-service.ts b/src/codex/desktop-compatibility/certificate-service.ts index 3b68c8d8161..eef9641d524 100644 --- a/src/codex/desktop-compatibility/certificate-service.ts +++ b/src/codex/desktop-compatibility/certificate-service.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { getConfigDir } from "../../config/paths"; import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows"; -import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, type DesktopAuthorityInspection, type StoredDesktopAuthority } from "./certificate-store"; +import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, renewDesktopCompatibilityAuthority, type DesktopAuthorityInspection, type StoredDesktopAuthority } from "./certificate-store"; import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust } from "./windows-certificate-trust"; export interface DesktopCertificateStatus { @@ -11,7 +11,7 @@ export interface DesktopCertificateStatus { expiresAt?: number; renewalDue?: boolean; trust?: DesktopCertificateTrust; - busy: "prepare" | "trust" | "remove-trust" | null; + busy: "prepare" | "trust" | "remove-trust" | "renew" | null; } export interface DesktopCertificateServiceIo { @@ -19,6 +19,7 @@ export interface DesktopCertificateServiceIo { 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; @@ -36,6 +37,7 @@ export function createDesktopCertificateService(directory = join(getConfigDir(), 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 () => { @@ -63,13 +65,13 @@ export function createDesktopCertificateService(directory = join(getConfigDir(), 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") { + 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 === "remove-trust"); + 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(); @@ -77,13 +79,16 @@ export function createDesktopCertificateService(directory = join(getConfigDir(), 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; } } return { status, prepare: () => action("prepare"), trust: (fingerprint: string) => action("trust", fingerprint), - removeTrust: (fingerprint: string) => action("remove-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 index 1c3f0eb8f19..e6f76dcba35 100644 --- a/src/codex/desktop-compatibility/certificate-store.ts +++ b/src/codex/desktop-compatibility/certificate-store.ts @@ -14,7 +14,7 @@ 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" | "protection_failed") { + constructor(readonly code: "unsafe_path" | "unreadable" | "expired" | "protection_failed" | "fingerprint_changed") { super(`desktop_compatibility_authority_${code}`); this.name = "DesktopAuthorityError"; } } @@ -131,6 +131,16 @@ export async function loadDesktopCompatibilityAuthority(options: DesktopAuthorit /** 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(); @@ -142,7 +152,13 @@ export async function ensureDesktopCompatibilityAuthority(options: DesktopAuthor return withClientLifecycle(async () => { assertPath(options.directory, true); const path = join(options.directory, FILE); - if (pathPresent(path)) return readAuthority(path, protection, now); + 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 }); @@ -161,7 +177,12 @@ export async function ensureDesktopCompatibilityAuthority(options: DesktopAuthor try { writeFileSync(fd, contents); fsyncSync(fd); } finally { closeSync(fd); } await hardenSecretPathAsync(temporary, { required: true }); assertPath(temporary, false); assertPath(options.directory, true); - if (pathPresent(path)) throw new DesktopAuthorityError("unsafe_path"); + // 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); diff --git a/src/server/management/desktop-compatibility-routes.ts b/src/server/management/desktop-compatibility-routes.ts index 8ad22dacf04..bbf53051483 100644 --- a/src/server/management/desktop-compatibility-routes.ts +++ b/src/server/management/desktop-compatibility-routes.ts @@ -14,7 +14,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): 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" | undefined, fingerprint: string | undefined; + 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); } @@ -22,7 +22,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): 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"].includes(value.action)) return jsonResponse({ error: "invalid_request" }, 400); + || 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); @@ -34,6 +34,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): 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) { diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 15603a216d9..76d542625cb 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -37,8 +37,9 @@ 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. Management enable/disable and renewal -remain a separate integration layer from certificate preparation. +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. `src/codex/desktop-compatibility/windows-key-protection.ts` uses trusted PowerShell and bounded stdin/stdout, never command-line secrets or plaintext fallback. CurrentUser @@ -60,6 +61,10 @@ busy operations. Only prepare may generate a key. Trust/removal load existing va 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 diff --git a/tests/clients/desktop-compatibility-authority.test.ts b/tests/clients/desktop-compatibility-authority.test.ts index d9b02ac4c2f..af9a68fdbaa 100644 --- a/tests/clients/desktop-compatibility-authority.test.ts +++ b/tests/clients/desktop-compatibility-authority.test.ts @@ -3,7 +3,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; +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"; @@ -34,6 +34,30 @@ function protector(): AuthorityKeyProtection { } describe("Codex Desktop compatibility authority lifecycle", () => { + 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); diff --git a/tests/clients/desktop-compatibility-certificate-service.test.ts b/tests/clients/desktop-compatibility-certificate-service.test.ts index 4ad99cbadf2..b07d0be18de 100644 --- a/tests/clients/desktop-compatibility-certificate-service.test.ts +++ b/tests/clients/desktop-compatibility-certificate-service.test.ts @@ -7,20 +7,40 @@ const authority = { fingerprint } as StoredDesktopAuthority; function fixture() { const calls: string[] = []; let stored: DesktopAuthorityInspection = { status: "missing" }, trusted = false; - let appRunning: boolean | null = 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" : "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; }, setStored: (value: DesktopAuthorityInspection) => { stored = value; } }; + 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("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"); } }); diff --git a/tests/server/management-desktop-compatibility-routes.test.ts b/tests/server/management-desktop-compatibility-routes.test.ts index e15e8722419..287aa474447 100644 --- a/tests/server/management-desktop-compatibility-routes.test.ts +++ b/tests/server/management-desktop-compatibility-routes.test.ts @@ -11,7 +11,7 @@ function fixture(method: string, body?: object, principal: ManagementContext["pr 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; } }; + 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 }; @@ -41,6 +41,13 @@ test("unexpected errors never expose process output or credential strings", asyn 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, From 81d6f6ab2d3ee26968dfaa3e04b8bc634c24cc6e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:35:41 +0900 Subject: [PATCH 05/33] feat(codex): wire bounded desktop compatibility runtime --- .../content/docs/reference/management-api.md | 26 ++++ scripts/test-layout/layout.json | 1 + .../certificate-service.ts | 8 +- src/codex/desktop-compatibility/json-body.ts | 29 ++++ .../desktop-compatibility/native-identity.ts | 52 +++++++ .../desktop-compatibility/relay-listener.ts | 99 +++++++++++++ .../runtime-ownership.ts | 10 ++ src/codex/desktop-compatibility/runtime.ts | 139 ++++++++++++++++++ .../desktop-compatibility/usage-activation.ts | 77 ++++++++++ .../usage-controlled-fetch.ts | 59 ++++++++ .../desktop-compatibility/usage-controller.ts | 81 ++++++++++ .../desktop-compatibility/usage-policy.ts | 49 ++++++ .../desktop-compatibility/usage-refresh.ts | 32 ++++ .../usage-sse-controller.ts | 66 +++++++++ src/server/management-api.ts | 2 + src/server/management/context.ts | 1 + .../desktop-compatibility-routes.ts | 2 +- .../desktop-compatibility-runtime-routes.ts | 45 ++++++ src/server/management/route-registry.ts | 2 + structure/clients/codex-desktop.md | 37 +++++ structure/gui-and-management-api.md | 4 + .../desktop-compatibility-relay.test.ts | 49 ++++++ .../desktop-compatibility-runtime.test.ts | 132 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 + ...sktop-compatibility-runtime-routes.test.ts | 41 ++++++ 25 files changed, 1043 insertions(+), 3 deletions(-) create mode 100644 src/codex/desktop-compatibility/json-body.ts create mode 100644 src/codex/desktop-compatibility/native-identity.ts create mode 100644 src/codex/desktop-compatibility/relay-listener.ts create mode 100644 src/codex/desktop-compatibility/runtime-ownership.ts create mode 100644 src/codex/desktop-compatibility/runtime.ts create mode 100644 src/codex/desktop-compatibility/usage-activation.ts create mode 100644 src/codex/desktop-compatibility/usage-controlled-fetch.ts create mode 100644 src/codex/desktop-compatibility/usage-controller.ts create mode 100644 src/codex/desktop-compatibility/usage-policy.ts create mode 100644 src/codex/desktop-compatibility/usage-refresh.ts create mode 100644 src/codex/desktop-compatibility/usage-sse-controller.ts create mode 100644 src/server/management/desktop-compatibility-runtime-routes.ts create mode 100644 tests/clients/desktop-compatibility-relay.test.ts create mode 100644 tests/clients/desktop-compatibility-runtime.test.ts create mode 100644 tests/server/management-desktop-compatibility-runtime-routes.test.ts diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 4d055c38dc3..97f67ef730e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -85,6 +85,7 @@ route-specific results rather than repeating this table. | `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 /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 | @@ -714,6 +715,31 @@ 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; a dashboard panel and saved startup preference are not yet provided. + +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. +An outbound proxy configuration is currently unsupported and refuses startup rather than +routing some app connections outside that proxy. Start never registers a certificate or changes +login. Launch preserves package identity and refuses an app that is already running. + +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. + +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. + ## Choosing a client For ordinary administration, the [Web Dashboard](/guides/web-dashboard/) gives the safest guided diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7ce9adcbd99..b9e718a18fc 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -894,6 +894,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", + "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", diff --git a/src/codex/desktop-compatibility/certificate-service.ts b/src/codex/desktop-compatibility/certificate-service.ts index eef9641d524..fcf201f8474 100644 --- a/src/codex/desktop-compatibility/certificate-service.ts +++ b/src/codex/desktop-compatibility/certificate-service.ts @@ -3,6 +3,7 @@ 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; @@ -26,7 +27,7 @@ export interface DesktopCertificateServiceIo { } export class DesktopCertificateServiceError extends Error { - constructor(readonly code: "unsupported" | "busy" | "not_prepared" | "fingerprint_changed" | "app_running" | "app_state_unknown" | "trust_unknown" | "trust_not_applied") { + 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"; } } @@ -59,6 +60,9 @@ export function createDesktopCertificateService(directory = join(getConfigDir(), 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") { @@ -85,7 +89,7 @@ export function createDesktopCertificateService(directory = join(getConfigDir(), } busy = null; return status(); - } finally { busy = null; } + } 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) }; 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..edaba6747cb --- /dev/null +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -0,0 +1,52 @@ +import { readFileSync, statSync } from 'node:fs'; +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; + +/** Local native credentials remain in memory and are never returned by the public reader. */ +export function createNativeIdentityReader(authPath: string, upstreamFetch: typeof fetch = fetch) { + const readCredential = (): Credential => { + if (statSync(authPath).size > 1048576) throw new Error('Unexpected native auth size'); + const auth = JSON.parse(readFileSync(authPath, 'utf8')); + 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' }, 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..c2d98d9c6b0 --- /dev/null +++ b/src/codex/desktop-compatibility/relay-listener.ts @@ -0,0 +1,99 @@ +import { createServer } from "node:https"; +import { connect as connectTls } from "node:tls"; +import { Readable, type Duplex } from "node:stream"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { effectiveProxyFor } from "../../lib/proxy-env"; + +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 }; +} + +/** Transparent HTTP and raw upgraded-socket relay. Only fetchImpl owns usage policy. */ +export async function startDesktopRelay(options: DesktopRelayOptions) { + // Preserve all app features together. Until the shared proxy-aware WS transport lands, + // refuse this optional integration rather than silently bypass configured egress. + if (!options.websocketPeer && effectiveProxyFor(new URL(ORIGIN), process.env)) throw new Error("desktop_compatibility_egress_proxy_unsupported"); + 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 === "chatgpt.com" || host === "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 upstream = connectTls({ host: peer?.host ?? "chatgpt.com", port: peer?.port ?? 443, + servername: "chatgpt.com", ca: peer?.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"] }); + sockets.add(upstream); + let established = false; + const fail = () => { + if (!established && !client.destroyed) client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + else client.destroy(); + upstream.destroy(); + }; + upstream.setTimeout(10_000, fail); + upstream.once("secureConnect", () => { + if (client.destroyed) { upstream.destroy(); return; } + established = true; upstream.setTimeout(0); + 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); + }); + upstream.once("error", fail); client.once("error", () => upstream.destroy()); + upstream.once("close", () => { sockets.delete(upstream); client.destroy(); }); + client.once("close", () => upstream.destroy()); + }); + 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/runtime-ownership.ts b/src/codex/desktop-compatibility/runtime-ownership.ts new file mode 100644 index 00000000000..40372b9b1ca --- /dev/null +++ b/src/codex/desktop-compatibility/runtime-ownership.ts @@ -0,0 +1,10 @@ +/** Process-local ownership; the management sibling guard owns the cross-instance boundary. */ +let owner: { token: symbol; kind: "runtime" | "certificate" } | 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"); diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts new file mode 100644 index 00000000000..25eaea7f322 --- /dev/null +++ b/src/codex/desktop-compatibility/runtime.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { basename, 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 { effectiveProxyFor } from "../../lib/proxy-env"; +import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows"; +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 } from "./runtime-ownership"; + +// Reviewed usage.snapshot v1/cache/composer contract. A new build needs a new assessment. +export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; +export function isAssessedDesktopInstalled(): boolean { + const install = windowsDesktopAppAdapter.discover(windowsDefaultExec); + return !!install && basename(install.root).startsWith(`OpenAI.Codex_${DESKTOP_COMPATIBILITY_ASSESSED_VERSION}_`); +} +export interface DesktopRuntimeIo { + platform?: string; + loadAuthority?: () => Promise; + trust?: (authority: StoredDesktopAuthority) => Promise<"trusted" | "not-trusted" | "unknown">; + identity?: ReturnType; + buildSupported?: () => boolean; + upstreamFetch?: typeof fetch; + /** 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; + const buildSupported = io.buildSupported ?? isAssessedDesktopInstalled; + 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, + ...(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)) throw new Error("desktop_compatibility_test_environment"); + if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); + if (!buildSupported()) throw new Error("desktop_compatibility_build_unverified"); + if (effectiveProxyFor(new URL("https://chatgpt.com"), process.env)) throw new Error("desktop_compatibility_egress_proxy_unsupported"); + releaseOwner = acquireDesktopCompatibilityRuntime(); phase = "starting"; + let relay: Awaited> | undefined, proxy: ConnectProxyHandle | undefined; + let pac: ReturnType | undefined, timer: ReturnType | undefined; + let detach: (() => void) | undefined, controller: UsageRelayController | undefined; + const cleanup = async () => { + 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()]); + if (results.some(result => result.status === "rejected")) throw new Error("desktop_compatibility_cleanup_incomplete"); + }; + pendingCleanup = cleanup; + try { + 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 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")); + 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); + relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]), + fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? fetch) }); + proxy = await startConnectProxy(0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); + const runId = randomUUID(), proxyPort = proxy.port; + pac = Bun.serve({ hostname: "127.0.0.1", port: 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" } }); + } }); + if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); + const active = controller; let ticking = false, deadlineHandled = false; + timer = setInterval(() => { + if (ticking) return; ticking = true; + const expired = Date.now() >= deadline; + const work = 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"; + 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"); + if (!buildSupported()) { await current.controller.observeOnly(); throw new Error("desktop_compatibility_build_unverified"); } + 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(); }, + launch() { + if (!owned || phase !== "running") throw new Error("desktop_compatibility_not_running"); + if (!buildSupported()) throw new Error("desktop_compatibility_build_unverified"); + return launchWindowsCodexCompatibility(owned.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/usage-activation.ts b/src/codex/desktop-compatibility/usage-activation.ts new file mode 100644 index 00000000000..862cc499f07 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-activation.ts @@ -0,0 +1,77 @@ +/** 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()};return true; + } + private async reset(phase:string) { + this.mode='observe';this.phase=phase;this.startedAt=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()}; + 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()}; + if(this.mode==='apply')return{accepted:false,reason:'activation-already-pending',...this.snapshot()}; + 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.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.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..5e8f960d6c4 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-controller.ts @@ -0,0 +1,81 @@ +import { UsageActivation, type ApplyScope } from './usage-activation'; +import { UsageRefreshRegistry } from './usage-refresh'; +import { evaluateUsageRewrite, type UsageRewriteContext } from './usage-policy'; + +export type UsageIdentity = NonNullable; +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; +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; + constructor(account: UsageIdentity, private readonly readCurrentIdentity: () => Promise, + verifyFreshIdentity: () => Promise, private readonly clock: () => number, + private readonly expiresAt: number, timeoutMs = 180000) { + 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]); + 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 }; } + async activate(options: { scope: ApplyScope; accountWideConsent: boolean }) { + 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; + 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 || !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..deb5ac9d777 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-refresh.ts @@ -0,0 +1,32 @@ +/** 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; } +} 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..22563c8acc9 --- /dev/null +++ b/src/codex/desktop-compatibility/usage-sse-controller.ts @@ -0,0 +1,66 @@ +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, lineBytes = 0, pendingCR = false, first = true; + return new TransformStream({ + async transform(chunk, controller) { + const endLine = async () => { + if (lineBytes === 0) { + controller.enqueue(await rewriteRecord(buffer.subarray(0, used), first, rewrite)); + first = false; used = 0; + } + lineBytes = 0; + }; + for (const byte of chunk) { + if (pendingCR) { + pendingCR = false; + if (byte !== 10) await endLine(); + else { + if (used === cap) throw new Error('Usage SSE record exceeds limit'); + buffer[used++] = byte; await endLine(); continue; + } + } + if (used === cap) throw new Error('Usage SSE record exceeds limit'); + buffer[used++] = byte; + if (byte === 13) pendingCR = true; + else if (byte === 10) await endLine(); + else lineBytes++; + } + }, + async flush(controller) { + if (pendingCR && lineBytes === 0) { 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/server/management-api.ts b/src/server/management-api.ts index 7a3b1da6c0e..ddaf8906f2f 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -78,6 +78,7 @@ 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 { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -343,6 +344,7 @@ export async function handleManagementAPI( ?? (await handleCursorIntegrationRoutes(ctx)) ?? (await handleClaudeDesktopPickerRoutes(ctx)) ?? (await handleDesktopCompatibilityRoutes(ctx)) + ?? (await handleDesktopCompatibilityRuntimeRoutes(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 a0f0daba3a9..e2a00b9c6b9 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -45,6 +45,7 @@ export interface ManagementRequestIngress { export interface ManagementApiDeps { desktopCertificateService?: import("../../codex/desktop-compatibility/certificate-service").DesktopCertificateService; + desktopCompatibilityRuntime?: import("../../codex/desktop-compatibility/runtime").DesktopCompatibilityRuntime; /** Bound Claude intercept state, injectable for isolated management-route tests. */ getClaudeInterceptState?: typeof import("../../claude/intercept/runtime").getClaudeInterceptState; /** Reconciliation seam for field-scoped rollback tests. */ diff --git a/src/server/management/desktop-compatibility-routes.ts b/src/server/management/desktop-compatibility-routes.ts index bbf53051483..0c3459dbc37 100644 --- a/src/server/management/desktop-compatibility-routes.ts +++ b/src/server/management/desktop-compatibility-routes.ts @@ -39,7 +39,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): 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", "app_state_unknown", "trust_unknown", "trust_not_applied", + 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", "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..472a6b1744c --- /dev/null +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -0,0 +1,45 @@ +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"; +let runtime: DesktopCompatibilityRuntime | undefined; +const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stopping", "build_unverified", "egress_proxy_unsupported", + "trust_required", "certificate_expiring", "certificate_not_prepared", "certificate_invalid", "certificate_expired", + "native_identity_unverified", "cleanup_incomplete", "not_running"]); + +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 service = ctx.deps.desktopCompatibilityRuntime ?? (runtime ??= (await import("../../codex/desktop-compatibility/runtime")).createDesktopCompatibilityRuntime()); + 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 = 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/route-registry.ts b/src/server/management/route-registry.ts index 4ea99303b17..202af97788e 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -160,6 +160,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { 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: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 76d542625cb..05a3e1559af 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -75,3 +75,40 @@ remains unknown. Public responses contain no PEM, private-key objects or subproc 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 and configured outbound +proxies refuse activation; no automatic direct-egress fallback bypasses a selected proxy. +Proxy-aware WebSocket egress remains an integration item with the shared transport work. + +`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. +The package launcher uses only the runtime-owned PAC and never kills an existing app. + +`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. +Fresh identity checks, generation changes, unknown schemas and elapsed deadlines refuse +correction. `usage-sse-controller.ts` preserves event metadata and original sequence IDs; +`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. + +`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 panel is changed by this API. GUI and persisted startup integration are pending. +The status CLI verb remains deferred to this integration owner. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 31fe648c29b..a75799f99be 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,9 @@ # 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). + Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). The companion settings contract in `src/companion/` persists menu-bar and widget display diff --git a/tests/clients/desktop-compatibility-relay.test.ts b/tests/clients/desktop-compatibility-relay.test.ts new file mode 100644 index 00000000000..251e9bd3338 --- /dev/null +++ b/tests/clients/desktop-compatibility-relay.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { createServer } 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"; + +test("upgraded native app traffic preserves handshake and raw frames in both directions", 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 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 } }); + 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"); + } finally { + client.destroy(); await relay.close(); + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => upstream.close(() => resolve())); + } +}, 10000); diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts new file mode 100644 index 00000000000..dce8b6fdeb7 --- /dev/null +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -0,0 +1,132 @@ +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 } from "../../src/codex/desktop-compatibility/runtime-ownership"; +import { createDesktopCertificateService } from "../../src/codex/desktop-compatibility/certificate-service"; +import { resetOptionalShutdownHooksForTests, runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; + +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("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) { + 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; + const runtime = createDesktopCompatibilityRuntime({ platform: "win32", testOnly: true, identity, buildSupported: () => buildSupported, + 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; } }; +} + +describe("optional native compatibility runtime", () => { + 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"); + 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"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a8f640045bb..665762e76ad 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -730,6 +730,9 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", + "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", 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"); +}); From bf127054d2f835ae125ae37fcf4f5e6d4520f81a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:59:19 +0900 Subject: [PATCH 06/33] feat(gui): add native desktop compatibility controls --- .../content/docs/guides/codex-integration.md | 29 ++++- .../content/docs/reference/management-api.md | 4 +- gui/src/App.tsx | 2 +- gui/src/app-routing.ts | 2 +- gui/src/desktop-compatibility-api.ts | 72 ++++++++++++ gui/src/i18n/de.ts | 2 + gui/src/i18n/desktop-compatibility-copy.ts | 38 ++++++ gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/CodexSet.tsx | 15 ++- gui/src/pages/codex-desktop-compatibility.tsx | 111 ++++++++++++++++++ gui/src/pages/codex-set-tab.ts | 26 ++-- gui/tests/codex-set-shell.test.tsx | 21 +++- gui/tests/desktop-compatibility-api.test.ts | 37 ++++++ .../desktop-compatibility-panel.test.tsx | 88 ++++++++++++++ structure/clients/codex-desktop.md | 17 ++- structure/gui-and-management-api.md | 2 + 24 files changed, 460 insertions(+), 24 deletions(-) create mode 100644 gui/src/desktop-compatibility-api.ts create mode 100644 gui/src/i18n/desktop-compatibility-copy.ts create mode 100644 gui/src/pages/codex-desktop-compatibility.tsx create mode 100644 gui/tests/desktop-compatibility-api.test.ts create mode 100644 gui/tests/desktop-compatibility-panel.test.tsx diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index adabd0219f9..a1c9249927f 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -889,6 +889,33 @@ ocx service install # persistent: auto-starts on login and respawns on crash `ocx status` shows whether the proxy is running and prints the same restart hint when it is not; `ocx doctor` reports restart safety (service/shim coverage). +## 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. + +**Return to observation** disarms correction. **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. + +Current support is limited to the assessed Windows build `26.924.2738.0`, native file-based +login and direct outbound connectivity. Configured outbound proxies and OpenCodex managed client +mode are not yet supported by these controls. There is no saved automatic-start preference yet. +Unknown app builds refuse correction; the integration does not patch application files or +switch to login-free mode. + ## Routed models during Codex reserve mode On Windows, an explicit OpenCodex full-app restart preserves an already active loopback @@ -903,7 +930,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 97f67ef730e..fbf571c43b1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -720,7 +720,9 @@ CurrentUser protection does not isolate secrets from other processes running as `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; a dashboard panel and saved startup preference are not yet provided. +The endpoint is experimental. The dashboard exposes it under **Codex Set → Desktop compatibility**; +a saved startup preference is not yet provided. 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. diff --git a/gui/src/App.tsx b/gui/src/App.tsx index ef9f1a2b245..bc3df5fac8a 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -575,7 +575,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 === "integrations" && } )} diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 3f7f107b8c1..259c40ea05d 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -122,7 +122,7 @@ export function hashBelongsToPage(rawHash: string, page: Page): boolean { return rawHash === page || (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..b89c30f56c0 --- /dev/null +++ b/gui/src/desktop-compatibility-api.ts @@ -0,0 +1,72 @@ +export type CertificateState = "missing" | "invalid" | "expired" | "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; + usage?: { mode: "observe" | "apply"; phase: string; outputs: number; appCacheConfirmed: false }; +} +export interface CompatibilitySnapshot { certificate: CompatibilityCertificate; runtime: CompatibilityRuntime } +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; +} +export function parseCompatibilityCertificate(value: unknown): CompatibilityCertificate { + if (!object(value) || typeof value.supported !== "boolean" || typeof value.state !== "string" || !["missing", "invalid", "expired", "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.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 }; + } + 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 2b83d98306a..4f54d9337d8 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"), "kiroLogin.title": "Bei Kiro anmelden", "kiroLogin.chooseMethod": "Anmeldemethode wählen", "kiroLogin.cli": "Mit Kiro CLI anmelden", diff --git a/gui/src/i18n/desktop-compatibility-copy.ts b/gui/src/i18n/desktop-compatibility-copy.ts new file mode 100644 index 00000000000..7c84f3960d0 --- /dev/null +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -0,0 +1,38 @@ +type Locale = "en" | "ko" | "de" | "fr" | "zh" | "zh-TW" | "ru" | "ja" | "tr" | "vi"; +const locales: readonly Locale[] = ["en", "ko", "de", "fr", "zh", "zh-TW", "ru", "ja", "tr", "vi"]; +type Row = readonly [string, string, string, string, string, string, string, string, string, string]; +const rows = { + 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."], + blockedByProxy: ["This integration does not yet support the configured outbound proxy. It has not bypassed that proxy.", "설정된 외부 프록시를 아직 지원하지 않습니다. 해당 프록시를 우회하지 않았습니다.", "Der konfigurierte ausgehende Proxy wird noch nicht unterstützt. Er wurde nicht umgangen.", "Le proxy sortant configuré n’est pas encore pris en charge. Il n’a pas été contourné.", "尚不支持配置的出站代理。未绕过该代理。", "尚不支援設定的輸出代理。並未繞過該代理。", "Настроенный исходящий прокси пока не поддерживается. Он не был обойдён.", "設定された送信プロキシには未対応です。プロキシの迂回は行っていません。", "Ayarlanan çıkış proxy’si henüz desteklenmiyor. Proxy atlanmadı.", "Chưa hỗ trợ proxy đầu ra đã cấu hình. Không bỏ qua proxy đó."], + 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."], + 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."], + title: ["Desktop compatibility", "데스크톱 호환", "Desktop-Kompatibilität", "Compatibilité du bureau", "桌面兼容", "桌面相容", "Совместимость приложения", "デスクトップ互換性", "Masaüstü uyumluluğu", "Tương thích ứng dụng"], + 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."], + refresh: ["Refresh status", "상태 새로고침", "Status aktualisieren", "Actualiser l’état", "刷新状态", "重新整理狀態", "Обновить состояние", "状態を更新", "Durumu yenile", "Làm mới trạng thái"], + prepare: ["Prepare certificate", "인증서 준비", "Zertifikat vorbereiten", "Préparer le certificat", "准备证书", "準備憑證", "Подготовить сертификат", "証明書を準備", "Sertifikayı hazırla", "Chuẩn bị chứng chỉ"], + trust: ["Register trust", "신뢰 등록", "Vertrauen registrieren", "Enregistrer la confiance", "注册信任", "註冊信任", "Добавить доверие", "信頼を登録", "Güveni kaydet", "Đăng ký tin cậy"], + remove: ["Remove trust", "신뢰 해제", "Vertrauen entfernen", "Retirer la confiance", "移除信任", "移除信任", "Удалить доверие", "信頼を解除", "Güveni kaldır", "Gỡ tin cậy"], + renew: ["Renew certificate", "인증서 갱신", "Zertifikat erneuern", "Renouveler le certificat", "更新证书", "更新憑證", "Обновить сертификат", "証明書を更新", "Sertifikayı yenile", "Gia hạn chứng chỉ"], + start: ["Start observation", "관찰 시작", "Beobachtung starten", "Démarrer l’observation", "开始观察", "開始觀察", "Начать наблюдение", "観察を開始", "Gözlemi başlat", "Bắt đầu quan sát"], + stop: ["Stop service", "서비스 중지", "Dienst stoppen", "Arrêter le service", "停止服务", "停止服務", "Остановить службу", "サービスを停止", "Hizmeti durdur", "Dừng dịch vụ"], + launch: ["Open Codex", "Codex 열기", "Codex öffnen", "Ouvrir Codex", "打开 Codex", "開啟 Codex", "Открыть Codex", "Codex を開く", "Codex’i aç", "Mở Codex"], + observe: ["Return to observation", "관찰 모드로 복귀", "Zur Beobachtung zurück", "Revenir à l’observation", "返回观察", "返回觀察", "Вернуться к наблюдению", "観察に戻る", "Gözleme dön", "Trở lại quan sát"], + 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"], + confirm: ["Confirm action", "동작 확인", "Aktion bestätigen", "Confirmer l’action", "确认操作", "確認操作", "Подтвердить действие", "操作を確認", "İşlemi onayla", "Xác nhận thao tác"], + 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."], + 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."], + 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."], + 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."], + 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."], + certificate: ["Certificate", "인증서", "Zertifikat", "Certificat", "证书", "憑證", "Сертификат", "証明書", "Sertifika", "Chứng chỉ"], + runtime: ["Service", "서비스", "Dienst", "Service", "服务", "服務", "Служба", "サービス", "Hizmet", "Dịch vụ"], + expiry: ["Expires", "만료", "Ablauf", "Expiration", "到期", "到期", "Истекает", "有効期限", "Bitiş", "Hết hạn"], + 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."], + 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ợ."], + 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 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 dad65cfc6dd..179e615e04a 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"), "kiroLogin.title": "Sign in to Kiro", "kiroLogin.chooseMethod": "Choose a sign-in method", "kiroLogin.cli": "Kiro CLI", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ce302bb3e37..efd8111f23d 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"), "kiroLogin.title": "Se connecter à Kiro", "kiroLogin.chooseMethod": "Choisir une méthode de connexion", "kiroLogin.cli": "Importer avec Kiro CLI", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 938ee65ec43..7691892b98c 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"), "kiroLogin.title": "Kiro にログイン", "kiroLogin.chooseMethod": "ログイン方法を選択", "kiroLogin.cli": "Kiro CLI から取り込む", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 80b4af705c1..ba94790f067 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"), "kiroLogin.title": "Kiro에 로그인", "kiroLogin.chooseMethod": "로그인 방법 선택", "kiroLogin.cli": "Kiro CLI에서 가져오기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2fcf9a11a33..8d600ed7923 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"), "kiroLogin.title": "Войти в Kiro", "kiroLogin.chooseMethod": "Выберите способ входа", "kiroLogin.cli": "Импортировать через Kiro CLI", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4d7f56ccedd..d9af939e366 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"), "kiroLogin.title": "Kiro oturumu aç", "kiroLogin.chooseMethod": "Oturum açma yöntemi seç", "kiroLogin.cli": "Kiro CLI ile içe aktar", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 5dba045198c..7c337b02fde 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"), "kiroLogin.title": "Đăng nhập Kiro", "kiroLogin.chooseMethod": "Chọn cách đăng nhập", "kiroLogin.cli": "Nhập từ Kiro CLI", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f8aee1a7a86..413ce6bce8d 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"), "kiroLogin.title": "登入 Kiro", "kiroLogin.chooseMethod": "選擇登入方式", "kiroLogin.cli": "從 Kiro CLI 匯入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0e740414da5..26a8bbf4f4f 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"), "kiroLogin.title": "登录 Kiro", "kiroLogin.chooseMethod": "选择登录方式", "kiroLogin.cli": "从 Kiro CLI 导入", 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..b0f10e859b3 --- /dev/null +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -0,0 +1,111 @@ +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"; + +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_unsupported: "desktopCompat.blockedByProxy", + 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 }); + actions.push({ target: "certificate", action: "remove-trust", fingerprint: cert.fingerprint }, { 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")}

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

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

+ {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/tests/codex-set-shell.test.tsx b/gui/tests/codex-set-shell.test.tsx index b105b97640a..7d4d3b8aa3e 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,21 @@ 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("/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(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..b5b6487f998 --- /dev/null +++ b/gui/tests/desktop-compatibility-api.test.ts @@ -0,0 +1,37 @@ +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; +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(); +}); +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..6dffb068f86 --- /dev/null +++ b/gui/tests/desktop-compatibility-panel.test.tsx @@ -0,0 +1,88 @@ +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) { + requests.length = 0; let trusted = 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 (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" : "prepared", busy: null, + fingerprint: (url.startsWith("/second") ? "B" : "A").repeat(64) } } : { ok: true, runtime: { supported: true, phase: "off", running: false } }); + }) 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("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(); +}); diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 05a3e1559af..574f9f77d31 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -110,5 +110,20 @@ a failed cleanup retains ownership and reports `cleanup-required`, never a false `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 panel is changed by this API. GUI and persisted startup integration are pending. +dashboard preference is changed by this API. Persisted startup integration is pending. 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. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a75799f99be..18a0dd3593c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -3,6 +3,8 @@ 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). Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). From fea82e190b3a47bfedb7e2b4df64f414a09d9951 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:13:57 +0900 Subject: [PATCH 07/33] feat(codex): preserve desktop compatibility endpoints across restarts --- .../content/docs/guides/codex-integration.md | 8 ++ .../content/docs/reference/management-api.md | 4 + gui/src/i18n/desktop-compatibility-copy.ts | 1 + gui/src/pages/codex-desktop-compatibility.tsx | 1 + scripts/test-layout/layout.json | 2 +- .../desktop-compatibility/connection-store.ts | 85 +++++++++++++++++++ src/codex/desktop-compatibility/runtime.ts | 17 ++-- .../desktop-compatibility-runtime-routes.ts | 2 +- structure/clients/codex-desktop.md | 9 ++ ...top-compatibility-connection-store.test.ts | 68 +++++++++++++++ .../desktop-compatibility-runtime.test.ts | 36 +++++++- tests/fixtures/test-layout-expected.json | 1 + 12 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 src/codex/desktop-compatibility/connection-store.ts create mode 100644 tests/clients/desktop-compatibility-connection-store.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a1c9249927f..f0d772dff23 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -910,6 +910,14 @@ the certificate for reuse; active connections may be interrupted. For renewal or 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. +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 and direct outbound connectivity. Configured outbound proxies and OpenCodex managed client mode are not yet supported by these controls. There is no saved automatic-start preference yet. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index fbf571c43b1..bcd79325190 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -729,6 +729,10 @@ ChatGPT login and the assessed Codex Windows build `26.924.2738.0`. It begins in An outbound proxy configuration is currently unsupported and refuses startup rather than routing some app connections outside that proxy. 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. 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, diff --git a/gui/src/i18n/desktop-compatibility-copy.ts b/gui/src/i18n/desktop-compatibility-copy.ts index 7c84f3960d0..243056b8c33 100644 --- a/gui/src/i18n/desktop-compatibility-copy.ts +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -2,6 +2,7 @@ type Locale = "en" | "ko" | "de" | "fr" | "zh" | "zh-TW" | "ru" | "ja" | "tr" | const locales: readonly Locale[] = ["en", "ko", "de", "fr", "zh", "zh-TW", "ru", "ja", "tr", "vi"]; type Row = readonly [string, string, string, string, string, string, string, string, string, string]; const rows = { + 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."], 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."], blockedByProxy: ["This integration does not yet support the configured outbound proxy. It has not bypassed that proxy.", "설정된 외부 프록시를 아직 지원하지 않습니다. 해당 프록시를 우회하지 않았습니다.", "Der konfigurierte ausgehende Proxy wird noch nicht unterstützt. Er wurde nicht umgangen.", "Le proxy sortant configuré n’est pas encore pris en charge. Il n’a pas été contourné.", "尚不支持配置的出站代理。未绕过该代理。", "尚不支援設定的輸出代理。並未繞過該代理。", "Настроенный исходящий прокси пока не поддерживается. Он не был обойдён.", "設定された送信プロキシには未対応です。プロキシの迂回は行っていません。", "Ayarlanan çıkış proxy’si henüz desteklenmiyor. Proxy atlanmadı.", "Chưa hỗ trợ proxy đầu ra đã cấu hình. Không bỏ qua proxy đó."], 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."], diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx index b0f10e859b3..77b9767d6b7 100644 --- a/gui/src/pages/codex-desktop-compatibility.tsx +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -8,6 +8,7 @@ import { setClientResourceData, useClientResource } from "../client-resource"; 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_unsupported: "desktopCompat.blockedByProxy", + 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 }) { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b9e718a18fc..450dc3e0283 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -894,7 +894,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", - "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", + "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-connection-store.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", diff --git a/src/codex/desktop-compatibility/connection-store.ts b/src/codex/desktop-compatibility/connection-store.ts new file mode 100644 index 00000000000..268645baba8 --- /dev/null +++ b/src/codex/desktop-compatibility/connection-store.ts @@ -0,0 +1,85 @@ +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") => new Error(`desktop_compatibility_connection_${reason}`); +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; + } finally { + if (created && present(temporary)) { try { unlinkSync(temporary); } catch { throw fail("cleanup_required"); } } + } + }, { lockPath: join(directory, "connection-publication.sqlite") }); + } }; +} diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts index 25eaea7f322..92bba76ed23 100644 --- a/src/codex/desktop-compatibility/runtime.ts +++ b/src/codex/desktop-compatibility/runtime.ts @@ -15,6 +15,7 @@ import { createUsageControlledFetch } from "./usage-controlled-fetch"; import { startDesktopRelay } from "./relay-listener"; import { launchWindowsCodexCompatibility } from "./windows-package-launch"; import { acquireDesktopCompatibilityRuntime } from "./runtime-ownership"; +import { createDesktopConnectionStore, type DesktopConnectionStore } from "./connection-store"; // Reviewed usage.snapshot v1/cache/composer contract. A new build needs a new assessment. export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; @@ -29,6 +30,7 @@ export interface DesktopRuntimeIo { identity?: ReturnType; buildSupported?: () => boolean; upstreamFetch?: typeof fetch; + connectionStore?: DesktopConnectionStore; /** Synthetic-test execution is admitted only with explicit fixture identity and CA seams. */ testOnly?: boolean; } @@ -56,7 +58,7 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { 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)) throw new Error("desktop_compatibility_test_environment"); + if (isTestHomeGuardArmed() && !(io.testOnly && io.identity && io.loadAuthority && io.trust && io.buildSupported && io.connectionStore)) throw new Error("desktop_compatibility_test_environment"); if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); if (!buildSupported()) throw new Error("desktop_compatibility_build_unverified"); if (effectiveProxyFor(new URL("https://chatgpt.com"), process.env)) throw new Error("desktop_compatibility_egress_proxy_unsupported"); @@ -82,6 +84,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { 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")); @@ -91,14 +95,17 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { controller = new UsageRelayController(account, identity.readCurrentIdentity, identity.verifyFreshIdentity, Date.now, deadline); relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]), fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? fetch) }); - proxy = await startConnectProxy(0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); - const runId = randomUUID(), proxyPort = proxy.port; - pac = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + 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; timer = setInterval(() => { diff --git a/src/server/management/desktop-compatibility-runtime-routes.ts b/src/server/management/desktop-compatibility-runtime-routes.ts index 472a6b1744c..789af32d3f9 100644 --- a/src/server/management/desktop-compatibility-runtime-routes.ts +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -7,7 +7,7 @@ const PATH = "/api/codex/desktop-compatibility/runtime"; let runtime: DesktopCompatibilityRuntime | undefined; const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stopping", "build_unverified", "egress_proxy_unsupported", "trust_required", "certificate_expiring", "certificate_not_prepared", "certificate_invalid", "certificate_expired", - "native_identity_unverified", "cleanup_incomplete", "not_running"]); + "native_identity_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; diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 574f9f77d31..1e51a32ecc4 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -92,6 +92,15 @@ upstream is fixed to chatgpt.com; request Host cannot select another destination allows only chatgpt.com:443. PAC has a certificate-relative deadline and `DIRECT` fallback. 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. + `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 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..e9a25212027 --- /dev/null +++ b/tests/clients/desktop-compatibility-connection-store.test.ts @@ -0,0 +1,68 @@ +import { afterAll, expect, test } from "bun:test"; +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("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, + 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-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts index dce8b6fdeb7..7f90b6ce2a7 100644 --- a/tests/clients/desktop-compatibility-runtime.test.ts +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -6,6 +6,8 @@ import { UsageRelayController, type UsageIdentity } from "../../src/codex/deskto import { desktopCompatibilityRuntimeActive } from "../../src/codex/desktop-compatibility/runtime-ownership"; 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, @@ -50,13 +52,15 @@ describe("bounded compatibility usage controller", () => { }); }); -function fixture(trusted = true) { +function fixture(trusted = true, connectionStore?: DesktopConnectionStore) { 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; + let connection: DesktopConnectionIdentity | null = null; const runtime = createDesktopCompatibilityRuntime({ platform: "win32", testOnly: true, identity, buildSupported: () => buildSupported, + 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) => { @@ -129,4 +133,34 @@ describe("optional native compatibility runtime", () => { 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("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(); + } + }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 665762e76ad..71dc079642d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -730,6 +730,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", + "desktop-compatibility-connection-store.test.ts": "clients", "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", From cf3d0dd0469813974a6ed03750a3623817b72861 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:21:51 +0900 Subject: [PATCH 08/33] feat(codex): resume optional desktop observation on proxy startup --- .../content/docs/guides/codex-integration.md | 9 ++- .../content/docs/reference/management-api.md | 3 +- scripts/test-layout/layout.json | 2 +- src/codex/desktop-compatibility/service.ts | 30 +++++++++ src/config/diagnostics.ts | 3 +- src/config/load-degrade.ts | 2 + src/config/schema/config-schema.ts | 2 + src/config/schema/desktop-compatibility.ts | 9 +++ src/server/index.ts | 16 ++--- .../index/desktop-compatibility-startup.ts | 30 +++++++++ src/server/index/startup-warnings.ts | 9 +++ src/server/management/context.ts | 1 + .../desktop-compatibility-runtime-routes.ts | 5 +- src/types/config.ts | 2 + structure/clients/codex-desktop.md | 16 ++++- structure/config.md | 2 +- structure/gui-and-management-api.md | 1 + structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...rver-desktop-compatibility-startup.test.ts | 66 +++++++++++++++++++ 20 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 src/codex/desktop-compatibility/service.ts create mode 100644 src/config/schema/desktop-compatibility.ts create mode 100644 src/server/index/desktop-compatibility-startup.ts create mode 100644 tests/server/server-desktop-compatibility-startup.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index f0d772dff23..d36889323cd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -920,10 +920,17 @@ 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 and direct outbound connectivity. Configured outbound proxies and OpenCodex managed client -mode are not yet supported by these controls. There is no saved automatic-start preference yet. +mode are not yet supported by these controls. Unknown app builds refuse correction; the integration does not patch application files or switch to login-free mode. +After certificate preparation and a successful manual start, 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. +If prerequisites are missing, observation stays off and the model proxy continues running. +The preference currently has no dashboard toggle; the rest of the controls remain available above. + ## Routed models during Codex reserve mode On Windows, an explicit OpenCodex full-app restart preserves an already active loopback diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index bcd79325190..ad2e776c8fb 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -721,7 +721,8 @@ CurrentUser protection does not isolate secrets from other processes running as 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**; -a saved startup preference is not yet provided. OpenCodex managed client mode does not offer these +the optional `desktopCompatibility.startOnProxyStart` config preference resumes observation only +and currently has no dashboard toggle. 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 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 450dc3e0283..a8af4b64e8a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -894,7 +894,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", - "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", + "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "desktop-compatibility-certificate-service.test.ts": "clients", "desktop-compatibility-launch.test.ts": "clients", "management-desktop-compatibility-routes.test.ts": "server", 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/config/diagnostics.ts b/src/config/diagnostics.ts index ca7b899e3d6..0f9fd3e0fb7 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -3,6 +3,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 type { OcxConfig } from "../types"; import { configReasoningPinsConfigError } from "./provider-validation"; import { loopbackCompanionAllowed } from "../codex/loopback-target"; @@ -599,7 +600,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }; } - const boundaryError = compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value) + const boundaryError = compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) ?? blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index aa8cc5e94c4..af8af97e5ee 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; +import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError, @@ -120,6 +121,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."); warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); } diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 48460d3667e..95e17d056b1 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -1,5 +1,6 @@ import * as z from "zod/v4"; import { compactionRecoverySchema } from "./compaction-recovery"; +import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { agentTaskRecoverySchema, catalogAutoRefreshSchema, @@ -158,6 +159,7 @@ export const configSchema = z.object({ modelPinnedEfforts: modelPinnedEffortsSchema.optional(), compactionRouting: compactionRoutingSchema.optional().catch(undefined), compactionRecovery: compactionRecoverySchema.optional().catch(undefined), + desktopCompatibility: desktopCompatibilitySchema.optional().catch(undefined), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), // Malformed hand edits disable this opt-in projection without rejecting providers. 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/server/index.ts b/src/server/index.ts index eef1f39a802..6135e434071 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,10 +639,13 @@ 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, remoteWorkspaceStopping: () => remoteWorkspaceStopping, onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; }, linkSupervisor: () => optionalListeners.linkSupervisor(), linkListener: () => optionalListeners, + onDesktopCompatibilityShutdown: shutdown => { desktopCompatibilityShutdown = shutdown; }, }; let workspaceRuntimeFlight: Promise | undefined; const loadRemoteWorkspaceRuntime = () => { @@ -775,6 +779,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe : []), () => optionalListeners.stop(), async () => { await remoteWorkspaceShutdown?.(); }, + async () => { await desktopCompatibilityStartup?.shutdown(); await desktopCompatibilityShutdown?.(); }, async () => { try { userCostOverlayReconciler?.stop(); @@ -804,13 +809,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 @@ -882,6 +881,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } startPackageRefresh(); + desktopCompatibilityStartup = scheduleDesktopCompatibilityStartup(config); 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..a7e5199e804 --- /dev/null +++ b/src/server/index/desktop-compatibility-startup.ts @@ -0,0 +1,30 @@ +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"; + +type RuntimeModule = { getDesktopCompatibilityRuntime(): DesktopCompatibilityRuntime; shutdownDesktopCompatibility(): Promise }; +interface StartupIo { + platform?: string; + testGuard?: boolean; + sibling?: boolean; + load?: () => Promise; + warn?: (message: string) => void; +} +/** 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; + 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; + 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; 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/context.ts b/src/server/management/context.ts index e2a00b9c6b9..47234105d91 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -46,6 +46,7 @@ export interface ManagementRequestIngress { export interface ManagementApiDeps { desktopCertificateService?: import("../../codex/desktop-compatibility/certificate-service").DesktopCertificateService; desktopCompatibilityRuntime?: import("../../codex/desktop-compatibility/runtime").DesktopCompatibilityRuntime; + onDesktopCompatibilityShutdown?: (shutdown: () => Promise) => void; /** Bound Claude intercept state, injectable for isolated management-route tests. */ getClaudeInterceptState?: typeof import("../../claude/intercept/runtime").getClaudeInterceptState; /** Reconciliation seam for field-scoped rollback tests. */ diff --git a/src/server/management/desktop-compatibility-runtime-routes.ts b/src/server/management/desktop-compatibility-runtime-routes.ts index 789af32d3f9..b5b016949e9 100644 --- a/src/server/management/desktop-compatibility-runtime-routes.ts +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -4,7 +4,6 @@ import type { ManagementContext } from "./context"; import type { DesktopCompatibilityRuntime } from "../../codex/desktop-compatibility/runtime"; const PATH = "/api/codex/desktop-compatibility/runtime"; -let runtime: DesktopCompatibilityRuntime | undefined; const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stopping", "build_unverified", "egress_proxy_unsupported", "trust_required", "certificate_expiring", "certificate_not_prepared", "certificate_invalid", "certificate_expired", "native_identity_unverified", "cleanup_incomplete", "not_running", "connection_invalid", "connection_changed", "connection_cleanup_required", "connection_unavailable"]); @@ -25,7 +24,9 @@ export async function handleDesktopCompatibilityRuntimeRoutes(ctx: ManagementCon if (value.action === "apply" ? value.accountWideConsent !== true : value.accountWideConsent !== undefined) return jsonResponse({ error: "invalid_consent_scope" }, 400); action = value.action as typeof action; } - const service = ctx.deps.desktopCompatibilityRuntime ?? (runtime ??= (await import("../../codex/desktop-compatibility/runtime")).createDesktopCompatibilityRuntime()); + 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); diff --git a/src/types/config.ts b/src/types/config.ts index 6e7fa9a48ac..eced1d442ad 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -724,6 +724,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 }; /** * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1e51a32ecc4..e7c91515906 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -119,7 +119,7 @@ a failed cleanup retains ownership and reports `cleanup-required`, never a false `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. Persisted startup integration is pending. +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 @@ -136,3 +136,17 @@ response; a fresh status read is required. Changing the API target remounts the 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. + +## 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. + +`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. +Its dashboard toggle is a remaining integration item; the typed config preference is available. diff --git a/structure/config.md b/structure/config.md index 3ce49bf31f3..cd5f6346f8d 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 -`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. +`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. 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. See [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). Google providers may persist `googleToolSchemaPolicy` as `compatible` or `reject-lossy`. `ocx provider add --google-tool-schema-policy` is one authoring path and is accepted only when the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 18a0dd3593c..6da7262b658 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -5,6 +5,7 @@ raw admin-token and remote ingress mutations are refused. Its start/stop/launch 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. Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). diff --git a/structure/runtime.md b/structure/runtime.md index 6905e26b3df..8336e8b81e6 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71dc079642d..74500ce722e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -731,6 +731,7 @@ "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", + "server-desktop-compatibility-startup.test.ts": "server", "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", 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..5f7d505a70e --- /dev/null +++ b/tests/server/server-desktop-compatibility-startup.test.ts @@ -0,0 +1,66 @@ +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"; + +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); +}); From 292077940ed2aa4110671d96512851a2d1564b80 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:32:40 +0900 Subject: [PATCH 09/33] feat(gui): persist desktop observation startup preference --- .../content/docs/guides/codex-integration.md | 6 +- .../content/docs/reference/management-api.md | 12 ++- gui/src/desktop-compatibility-api.ts | 12 +++ gui/src/i18n/desktop-compatibility-copy.ts | 2 + gui/src/pages/codex-desktop-compatibility.tsx | 2 + .../desktop-compatibility-startup-setting.tsx | 36 +++++++++ gui/tests/codex-set-shell.test.tsx | 2 +- .../desktop-compatibility-panel.test.tsx | 25 +++++- scripts/test-layout/layout.json | 2 +- .../desktop-compatibility/startup-settings.ts | 50 ++++++++++++ src/config/live-reconcile.ts | 13 +++ src/server/management-api.ts | 2 + src/server/management/context.ts | 1 + .../desktop-compatibility-settings-routes.ts | 27 +++++++ src/server/management/route-registry.ts | 2 + structure/clients/codex-desktop.md | 9 ++- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 + tests/fixtures/test-layout-expected.json | 1 + ...ent-desktop-compatibility-settings.test.ts | 80 +++++++++++++++++++ 20 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 gui/src/pages/desktop-compatibility-startup-setting.tsx create mode 100644 src/codex/desktop-compatibility/startup-settings.ts create mode 100644 src/server/management/desktop-compatibility-settings-routes.ts create mode 100644 tests/server/management-desktop-compatibility-settings.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index d36889323cd..5a9ac75c651 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -924,12 +924,14 @@ mode are not yet supported by these controls. Unknown app builds refuse correction; the integration does not patch application files or switch to login-free mode. -After certificate preparation and a successful manual start, the optional OpenCodex configuration +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. If prerequisites are missing, observation stays off and the model proxy continues running. -The preference currently has no dashboard toggle; the rest of the controls remain available above. +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. ## Routed models during Codex reserve mode diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ad2e776c8fb..5498a23b072 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -86,6 +86,7 @@ route-specific results rather than repeating this table. | `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 | @@ -721,8 +722,8 @@ CurrentUser protection does not isolate secrets from other processes running as 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 optional `desktopCompatibility.startOnProxyStart` config preference resumes observation only -and currently has no dashboard toggle. OpenCodex managed client mode does not offer these +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 @@ -747,6 +748,13 @@ reuse. Stop the runtime and close Codex before removing trust or renewing the ce 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/desktop-compatibility-api.ts b/gui/src/desktop-compatibility-api.ts index b89c30f56c0..45017565277 100644 --- a/gui/src/desktop-compatibility-api.ts +++ b/gui/src/desktop-compatibility-api.ts @@ -7,6 +7,7 @@ export interface CompatibilityRuntime { usage?: { mode: "observe" | "apply"; phase: string; outputs: number; appCacheConfirmed: 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" }; @@ -26,6 +27,17 @@ async function reply(response: Response): Promise> { } 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", "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"); diff --git a/gui/src/i18n/desktop-compatibility-copy.ts b/gui/src/i18n/desktop-compatibility-copy.ts index 243056b8c33..ba1cf75366f 100644 --- a/gui/src/i18n/desktop-compatibility-copy.ts +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -2,6 +2,8 @@ type Locale = "en" | "ko" | "de" | "fr" | "zh" | "zh-TW" | "ru" | "ja" | "tr" | const locales: readonly Locale[] = ["en", "ko", "de", "fr", "zh", "zh-TW", "ru", "ja", "tr", "vi"]; type Row = readonly [string, string, string, string, string, string, string, string, string, string]; const rows = { + 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"], + 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."], 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."], 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."], blockedByProxy: ["This integration does not yet support the configured outbound proxy. It has not bypassed that proxy.", "설정된 외부 프록시를 아직 지원하지 않습니다. 해당 프록시를 우회하지 않았습니다.", "Der konfigurierte ausgehende Proxy wird noch nicht unterstützt. Er wurde nicht umgangen.", "Le proxy sortant configuré n’est pas encore pris en charge. Il n’a pas été contourné.", "尚不支持配置的出站代理。未绕过该代理。", "尚不支援設定的輸出代理。並未繞過該代理。", "Настроенный исходящий прокси пока не поддерживается. Он не был обойдён.", "設定された送信プロキシには未対応です。プロキシの迂回は行っていません。", "Ayarlanan çıkış proxy’si henüz desteklenmiyor. Proxy atlanmadı.", "Chưa hỗ trợ proxy đầu ra đã cấu hình. Không bỏ qua proxy đó."], diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx index 77b9767d6b7..c1d4a375faf 100644 --- a/gui/src/pages/codex-desktop-compatibility.tsx +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -4,6 +4,7 @@ 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; @@ -70,6 +71,7 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool

{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")}

} 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 7d4d3b8aa3e..aa8230527fc 100644 --- a/gui/tests/codex-set-shell.test.tsx +++ b/gui/tests/codex-set-shell.test.tsx @@ -141,7 +141,7 @@ test("1. #codex-set renders Multi-auth, and Prompt is not mounted", async () => 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("/certificate") + 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"); diff --git a/gui/tests/desktop-compatibility-panel.test.tsx b/gui/tests/desktop-compatibility-panel.test.tsx index 6dffb068f86..4792b023369 100644 --- a/gui/tests/desktop-compatibility-panel.test.tsx +++ b/gui/tests/desktop-compatibility-panel.test.tsx @@ -23,10 +23,14 @@ afterEach(async () => { }); const requests: { url: string; method: string; body?: unknown }[] = []; function server(uncertain = false) { - requests.length = 0; let trusted = false; + 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" : "prepared", busy: null, fingerprint: (url.startsWith("/second") ? "B" : "A").repeat(64) } } : { ok: true, runtime: { supported: true, phase: "off", running: false } }); @@ -86,3 +90,22 @@ test("managed client mode never falls back to mutating the shared hub", async () 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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8af4b64e8a..765bae402b5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -894,7 +894,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-compatibility-authority.test.ts": "clients", - "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", + "desktop-compatibility-runtime.test.ts": "clients", "desktop-compatibility-relay.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "desktop-compatibility-certificate-service.test.ts": "clients", "desktop-compatibility-launch.test.ts": "clients", "management-desktop-compatibility-routes.test.ts": "server", 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/config/live-reconcile.ts b/src/config/live-reconcile.ts index 085b2529a8f..d07c0f4c698 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -100,6 +100,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/server/management-api.ts b/src/server/management-api.ts index ddaf8906f2f..c6586447491 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -79,6 +79,7 @@ import { handleNativeIntegrationRoutes } from "./management/native-integration-r 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"; @@ -345,6 +346,7 @@ export async function handleManagementAPI( ?? (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 47234105d91..92a9f4b8cbb 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -44,6 +44,7 @@ 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; 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 202af97788e..e1bb375115b 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -162,6 +162,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { 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/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index e7c91515906..ec0f3f2fa00 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -149,4 +149,11 @@ warn without stopping the model proxy or installing trust. `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. -Its dashboard toggle is a remaining integration item; the typed config preference is available. +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 cd5f6346f8d..426fb8b2180 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 -`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. 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. See [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). +`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. 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). Google providers may persist `googleToolSchemaPolicy` as `compatible` or `reject-lossy`. `ocx provider add --google-tool-schema-policy` is one authoring path and is accepted only when the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 6da7262b658..c1a781876d5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -6,6 +6,8 @@ three-minute account-UI trial follow the [native compatibility contract](clients 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; no runtime action is triggered by saving the next-start preference. Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 74500ce722e..153d0001947 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -732,6 +732,7 @@ "desktop-compatibility-authority.test.ts": "clients", "desktop-compatibility-connection-store.test.ts": "clients", "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", 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); +}); From 973ca487a85296263c2f0bd227c5e1bb37286ea5 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:42:18 +0900 Subject: [PATCH 10/33] fix(codex): disarm desktop compatibility when native context changes --- .../content/docs/guides/codex-integration.md | 6 +++ .../content/docs/reference/management-api.md | 3 ++ gui/src/desktop-compatibility-api.ts | 5 +++ gui/src/i18n/desktop-compatibility-copy.ts | 1 + gui/src/pages/codex-desktop-compatibility.tsx | 2 + gui/tests/desktop-compatibility-api.test.ts | 2 + scripts/test-layout/layout.json | 2 +- .../desktop-compatibility/routing-binding.ts | 10 +++++ .../routing-preflight.ts | 38 ++++++++++++++++++ src/codex/desktop-compatibility/runtime.ts | 27 ++++++++++--- .../desktop-compatibility/usage-controller.ts | 4 +- src/server/index.ts | 2 +- .../index/desktop-compatibility-startup.ts | 6 ++- .../desktop-compatibility-runtime-routes.ts | 2 +- structure/clients/codex-desktop.md | 10 +++++ ...top-compatibility-connection-store.test.ts | 2 +- .../desktop-compatibility-routing.test.ts | 39 +++++++++++++++++++ .../desktop-compatibility-runtime.test.ts | 22 ++++++++++- tests/fixtures/test-layout-expected.json | 1 + 19 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 src/codex/desktop-compatibility/routing-binding.ts create mode 100644 src/codex/desktop-compatibility/routing-preflight.ts create mode 100644 tests/clients/desktop-compatibility-routing.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5a9ac75c651..b54b48822ae 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -923,6 +923,12 @@ login and direct outbound connectivity. Configured outbound proxies and OpenCode mode are not yet supported by these controls. 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. After certificate preparation and a successful manual start, enable **Resume observation when OpenCodex starts** in the same panel. The optional OpenCodex configuration diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5498a23b072..49002433b5d 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -735,6 +735,9 @@ PAC and CONNECT ports are persisted after successful first binding and reused on `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, diff --git a/gui/src/desktop-compatibility-api.ts b/gui/src/desktop-compatibility-api.ts index 45017565277..851c98b6b06 100644 --- a/gui/src/desktop-compatibility-api.ts +++ b/gui/src/desktop-compatibility-api.ts @@ -4,6 +4,7 @@ export interface CompatibilityCertificate { } 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 }; } export interface CompatibilitySnapshot { certificate: CompatibilityCertificate; runtime: CompatibilityRuntime } @@ -52,6 +53,10 @@ export function parseCompatibilityRuntime(value: unknown): CompatibilityRuntime || 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 diff --git a/gui/src/i18n/desktop-compatibility-copy.ts b/gui/src/i18n/desktop-compatibility-copy.ts index ba1cf75366f..78ea1fd61d2 100644 --- a/gui/src/i18n/desktop-compatibility-copy.ts +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -2,6 +2,7 @@ type Locale = "en" | "ko" | "de" | "fr" | "zh" | "zh-TW" | "ru" | "ja" | "tr" | const locales: readonly Locale[] = ["en", "ko", "de", "fr", "zh", "zh-TW", "ru", "ja", "tr", "vi"]; type Row = readonly [string, string, string, string, string, string, string, string, string, string]; const rows = { + 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."], 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"], 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."], 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."], diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx index c1d4a375faf..f14a8df0969 100644 --- a/gui/src/pages/codex-desktop-compatibility.tsx +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -9,6 +9,7 @@ import DesktopCompatibilityStartupSetting from "./desktop-compatibility-startup- 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_unsupported: "desktopCompat.blockedByProxy", + 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; @@ -85,6 +86,7 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool {snapshot.certificate.renewalDue &&

{t("desktopCompat.renewalDue")}

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

{t("desktopCompat.trialRisk")}

} + {snapshot.runtime.contextFailure &&

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

} }
{actions.map(action =>