diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index acf3da827d7..ae8aab96629 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1088,6 +1088,13 @@ an app updater can omit the managed connection argument. Save drafts, close Code After a Codex update, an unassessed app version refuses correction until its compatibility is reviewed; reinstalling the certificate does not make an unassessed build supported. +The panel can also report the last completed build check as diagnostic metadata: the package +version this build was assessed against, the package version observed at that check, whether +they matched, and when the check finished. It is a past observation, not a live reading of the +installed package. After the service stops, the app updates, or a check is still running or was +interrupted, it can lag the actual installation; only a completed probe refreshes it, and a +`verified` result describes the package observed at that check, not whatever is installed now. + On Windows, an explicit OpenCodex full-app restart preserves an already active loopback compatibility PAC argument only when the current OpenCodex process owns the serving compatibility runtime. A stale or foreign endpoint is refused before stopping Codex; replacement of the runtime diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 19c55968fa8..dccb0d0b138 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -854,6 +854,17 @@ ports or overwrite the existing connection record. Restart returns to Observe, n 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. +A status response can also carry `buildAssessment`, the public metadata of the last completed +build check: `assessedVersion` (the exact package this runtime was assessed against), +`observedVersion`, `verified` and `completedAt`. It records a past observation, not a live +install reading: a stopped runtime, an app update or a pending or interrupted check can leave it +older than the installed package, and only a completed check replaces it. `observedVersion` is +null unless discovery matched the assessed package family and entry point; an identified newer +build keeps its own version with `verified` false, and `verified` always describes the package +observed at `completedAt`, never whatever is installed later. The field is absent until the +service creates its probe during start and `null` until that probe finishes a check; reading +status never starts one. + 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: diff --git a/gui/src/desktop-compatibility-api.ts b/gui/src/desktop-compatibility-api.ts index 1d51c90b2aa..f86f8bacf96 100644 --- a/gui/src/desktop-compatibility-api.ts +++ b/gui/src/desktop-compatibility-api.ts @@ -5,10 +5,12 @@ export interface CompatibilityCertificate { export interface CompatibilityRuntime { supported: boolean; phase: "off" | "starting" | "running" | "stopping" | "cleanup-required"; running: boolean; contextFailure?: "build_unverified" | "native_routing_unverified" | null; + buildAssessment?: CompatibilityBuildAssessment | null; usage?: { mode: "observe" | "apply"; phase: string; outputs: number; appCacheConfirmed: false; observation?: { jsonSnapshots: number; streamSnapshots: number; validatedActiveStreams: number; lastSnapshotAt: number | null; sourceProcessVerified: false; composerRecoveryVerified: false } }; } +export interface CompatibilityBuildAssessment { assessedVersion: string; observedVersion: string | null; verified: boolean; completedAt: number } export interface CompatibilitySnapshot { certificate: CompatibilityCertificate; runtime: CompatibilityRuntime } export interface CompatibilityStartupSettings { startOnProxyStart: boolean; revision: string } export type CompatibilityAction = @@ -59,6 +61,21 @@ export function parseCompatibilityRuntime(value: unknown): CompatibilityRuntime 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.buildAssessment !== undefined) { + if (value.buildAssessment === null) runtime.buildAssessment = null; + else { + const assessment = value.buildAssessment; + const version = (candidate: unknown) => typeof candidate === "string" && /^\d{1,5}(\.\d{1,5}){3}$/.test(candidate); + if (!object(assessment) || !version(assessment.assessedVersion) + || !(assessment.observedVersion === null || version(assessment.observedVersion)) + || typeof assessment.verified !== "boolean" + || !Number.isSafeInteger(assessment.completedAt) || Number(assessment.completedAt) < 0 || Number(assessment.completedAt) > 8.64e15 + || assessment.verified === true && assessment.observedVersion !== assessment.assessedVersion) throw new CompatibilityApiError("invalid_runtime_status"); + runtime.buildAssessment = { assessedVersion: assessment.assessedVersion as string, + observedVersion: assessment.observedVersion === null ? null : assessment.observedVersion as string, + verified: assessment.verified as boolean, completedAt: assessment.completedAt as number }; + } + } 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 94c40abbe3e..43a1140af6d 100644 --- a/gui/src/i18n/desktop-compatibility-copy.ts +++ b/gui/src/i18n/desktop-compatibility-copy.ts @@ -37,6 +37,10 @@ const rows = { unsupported: ["Available on supported Windows installations only.", "지원되는 Windows 설치에서만 사용할 수 있습니다.", "Nur für unterstützte Windows-Installationen.", "Disponible uniquement sur les installations Windows prises en charge.", "仅适用于受支持的 Windows 安装。", "僅適用於受支援的 Windows 安裝。", "Доступно только для поддерживаемых установок Windows.", "対応する Windows 環境でのみ利用できます。", "Yalnızca desteklenen Windows kurulumlarında kullanılabilir.", "Chỉ dùng được trên bản cài Windows được hỗ trợ.", "Disponível apenas em instalações Windows compatíveis."], localOnly: ["Changes require this computer’s local dashboard. Save drafts and close Codex before launching it here.", "변경은 이 컴퓨터의 로컬 대시보드에서 가능합니다. 여기서 앱을 실행하기 전에 초안을 저장하고 Codex를 닫으세요.", "Änderungen erfordern das lokale Dashboard dieses Computers. Vor dem Start Entwürfe speichern und Codex schließen.", "Les modifications nécessitent le tableau de bord local de cet ordinateur. Enregistrez les brouillons et fermez Codex avant de le lancer ici.", "更改需要此计算机的本地仪表板。在此启动前,请保存草稿并关闭 Codex。", "變更需要此電腦的本機儀表板。在此啟動前,請儲存草稿並關閉 Codex。", "Изменения требуют локальной панели этого компьютера. Сохраните черновики и закройте Codex перед запуском здесь.", "変更にはこのPCのローカルダッシュボードが必要です。ここから起動する前に下書きを保存し Codex を閉じてください。", "Değişiklikler bu bilgisayarın yerel panosunu gerektirir. Buradan açmadan önce taslakları kaydedip Codex’i kapatın.", "Thay đổi cần bảng điều khiển cục bộ của máy này. Lưu bản nháp và đóng Codex trước khi mở tại đây.", "As alterações exigem o painel local deste computador. Salve os rascunhos e feche o Codex antes de abri-lo aqui."], reconnectHint: ["A running service does not confirm that Codex is connected. After a normal app launch or update, close Codex and use Open Codex here to restore the managed connection. An unreviewed app version cannot run a correction trial.", "서비스 실행만으로 Codex 연결이 확인되지는 않습니다. 앱을 일반 실행하거나 업데이트한 뒤에는 Codex를 닫고 여기의 Codex 열기로 관리 연결을 복구하세요. 검토되지 않은 앱 버전에서는 교정 시험이 차단됩니다.", "Ein laufender Dienst bestätigt keine Codex-Verbindung. Nach normalem Start oder Update Codex schließen und hier über Codex öffnen die verwaltete Verbindung wiederherstellen. Eine ungeprüfte App-Version kann keinen Korrekturtest ausführen.", "Un service actif ne confirme pas la connexion de Codex. Après un lancement normal ou une mise à jour, fermez Codex puis ouvrez-le ici pour rétablir la connexion gérée. Une version non vérifiée ne peut pas lancer d’essai de correction.", "服务运行不代表 Codex 已连接。正常启动或更新应用后,请关闭 Codex 并使用此处的打开 Codex 恢复受管连接。未经验证的应用版本无法运行修正试验。", "服務執行不代表 Codex 已連線。一般啟動或更新應用程式後,請關閉 Codex 並使用此處的開啟 Codex 恢復受管理連線。未經驗證的應用程式版本無法執行修正試驗。", "Работа службы не подтверждает подключение Codex. После обычного запуска или обновления закройте Codex и откройте его здесь для восстановления управляемого подключения. Непроверенная версия не допускается к пробной корректировке.", "サービスの稼働だけでは Codex の接続を確認できません。通常起動や更新後は Codex を閉じ、ここから開いて管理接続を復元してください。未検証のアプリ版では補正試験を実行できません。", "Hizmetin çalışması Codex bağlantısını doğrulamaz. Normal açılış veya güncellemeden sonra Codex’i kapatıp yönetilen bağlantıyı geri getirmek için buradan açın. İncelenmemiş uygulama sürümü düzeltme denemesini çalıştıramaz.", "Dịch vụ chạy không xác nhận Codex đã kết nối. Sau khi mở ứng dụng bình thường hoặc cập nhật, hãy đóng Codex rồi mở từ đây để khôi phục kết nối được quản lý. Phiên bản chưa được kiểm tra không thể chạy thử điều chỉnh.", "Um serviço em execução não confirma que o Codex está conectado. Após abrir o app normalmente ou atualizá-lo, feche o Codex e use Abrir Codex aqui para restaurar a conexão gerenciada. Uma versão do app ainda não avaliada não pode executar um teste de correção."], + assessedPackage: ["Verified package version", "검증된 패키지 버전", "Geprüfte Paketversion", "Version du paquet vérifiée", "已验证的包版本", "已驗證的套件版本", "Проверенная версия пакета", "検証済みパッケージバージョン", "Doğrulanan paket sürümü", "Phiên bản gói đã xác minh", "Versão do pacote verificada"], + observedPackage: ["Last observed package version", "마지막으로 관측한 패키지 버전", "Zuletzt erkannte Paketversion", "Dernière version du paquet observée", "上次检测到的包版本", "上次偵測到的套件版本", "Последняя обнаруженная версия пакета", "最後に検出されたパッケージバージョン", "Son algılanan paket sürümü", "Phiên bản gói quan sát lần cuối", "Última versão do pacote observada"], + lastObservedAt: ["Observed at", "관측 시각", "Erkannt am", "Observée le", "检测时间", "偵測時間", "Время проверки", "検出日時", "Algılanma zamanı", "Thời điểm quan sát", "Observada em"], + noObservedPackage: ["Not detected", "감지되지 않음", "Nicht erkannt", "Non détectée", "未检测到", "未偵測到", "Не обнаружена", "未検出", "Algılanmadı", "Không phát hiện", "Não detectada"], } as const satisfies Record; export function desktopCompatibilityCopy(locale: Locale): Record<`desktopCompat.${keyof typeof rows}`, string> { diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx index 83df10b9068..dec7189e95e 100644 --- a/gui/src/pages/codex-desktop-compatibility.tsx +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -86,6 +86,11 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool {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.runtime.buildAssessment && <> +
{t("desktopCompat.assessedPackage")}
{snapshot.runtime.buildAssessment.assessedVersion}
+
{t("desktopCompat.observedPackage")}
{snapshot.runtime.buildAssessment.observedVersion ?? t("desktopCompat.noObservedPackage")}
+
{t("desktopCompat.lastObservedAt")}
{new Date(snapshot.runtime.buildAssessment.completedAt).toLocaleString(locale)}
+ } {snapshot.certificate.renewalDue &&

{t("desktopCompat.renewalDue")}

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

{t("desktopCompat.trialRisk")}

} diff --git a/gui/tests/desktop-compatibility-api.test.ts b/gui/tests/desktop-compatibility-api.test.ts index e6735a77a2e..b1ee4f6d2b3 100644 --- a/gui/tests/desktop-compatibility-api.test.ts +++ b/gui/tests/desktop-compatibility-api.test.ts @@ -39,6 +39,18 @@ test("only the deliberate apply action carries account-wide consent", async () = await runCompatibilityAction("", { target: "runtime", action: "apply" }, new AbortController().signal); expect(bodies).toEqual([{ action: "start", confirmed: true }, { action: "apply", confirmed: true, accountWideConsent: true }]); }); +test("build assessment projection is strict, optional and never marks another version verified", () => { + const base = { supported: true, phase: "off", running: false }; + expect(parseCompatibilityRuntime(base).buildAssessment).toBeUndefined(); + expect(parseCompatibilityRuntime({ ...base, buildAssessment: null }).buildAssessment).toBeNull(); + const assessed = { assessedVersion: "26.924.2738.0", observedVersion: "26.1007.9446.0", verified: false, completedAt: 1770000000000 }; + expect(parseCompatibilityRuntime({ ...base, buildAssessment: assessed }).buildAssessment).toEqual(assessed); + expect(parseCompatibilityRuntime({ ...base, buildAssessment: { ...assessed, observedVersion: "26.924.2738.0", verified: true } }).buildAssessment?.verified).toBe(true); + for (const invalid of [{ ...assessed, verified: true }, { ...assessed, assessedVersion: "26.924" }, { ...assessed, observedVersion: "C:\\path" }, + { ...assessed, verified: "true" }, { ...assessed, completedAt: 1.5 }, { ...assessed, completedAt: Infinity }, + { ...assessed, observedVersion: undefined }, "C:\\Program Files\\WindowsApps", 42]) + expect(() => parseCompatibilityRuntime({ ...base, buildAssessment: invalid })).toThrow("invalid_runtime_status"); +}); 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); diff --git a/gui/tests/desktop-compatibility-panel.test.tsx b/gui/tests/desktop-compatibility-panel.test.tsx index 3f0d3485a4a..072231e534e 100644 --- a/gui/tests/desktop-compatibility-panel.test.tsx +++ b/gui/tests/desktop-compatibility-panel.test.tsx @@ -22,7 +22,7 @@ afterEach(async () => { for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); }); const requests: { url: string; method: string; body?: unknown }[] = []; -function server(uncertain = false, certificateState?: string, usagePhase?: string, observation?: Record) { +function server(uncertain = false, certificateState?: string, usagePhase?: string, observation?: Record, buildAssessment?: unknown) { requests.length = 0; let trusted = false, startup = false; globalThis.fetch = (async (input, init) => { const url = String(input), method = init?.method ?? "GET"; @@ -34,6 +34,7 @@ function server(uncertain = false, certificateState?: string, usagePhase?: strin if (method === "POST") { trusted = true; if (uncertain) throw new TypeError("uncertain response"); return Response.json({ ok: true }); } return Response.json(url.endsWith("/certificate") ? { ok: true, certificate: { supported: true, state: trusted ? "trusted" : certificateState ?? "prepared", busy: null, fingerprint: (url.startsWith("/second") ? "B" : "A").repeat(64) } } : { ok: true, runtime: { supported: true, phase: usagePhase ? "running" : "off", running: !!usagePhase, + ...(buildAssessment !== undefined ? { buildAssessment } : {}), ...(usagePhase ? { usage: { mode: "observe", phase: usagePhase, outputs: 0, appCacheConfirmed: false, ...(observation ? { observation } : {}) } } : {}) } }); }) as typeof fetch; } @@ -126,6 +127,42 @@ 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("a completed build assessment shows verified and last observed versions without claiming support", async () => { + server(false, "trusted", undefined, undefined, + { assessedVersion: "26.924.2738.0", observedVersion: "26.1007.9446.0", verified: false, completedAt: 1770000000000, privatePath: "fixture-secret" }); + const container = await mount(); + expect(container.textContent).toContain("Verified package version"); + expect(container.textContent).toContain("Last observed package version"); + expect(container.textContent).not.toContain("Installed package version"); + expect(container.textContent).toContain("26.924.2738.0"); + expect(container.textContent).toContain("26.1007.9446.0"); + expect(container.textContent).toContain(new Date(1770000000000).toLocaleString("en")); + expect(container.textContent).not.toContain("fixture-secret"); + expect(container.textContent).not.toContain("Not detected"); +}); +test("a build assessment without an observed version reports detection, not verification", async () => { + server(false, "trusted", undefined, undefined, + { assessedVersion: "26.924.2738.0", observedVersion: null, verified: false, completedAt: 1770000000000 }); + const container = await mount(); + expect(container.textContent).toContain("Last observed package version"); + expect(container.textContent).toContain("Not detected"); + expect(container.textContent).toContain(new Date(1770000000000).toLocaleString("en")); +}); +test("no probe result hides the build version rows", async () => { + server(false, "trusted"); + const container = await mount(); + expect(container.textContent).not.toContain("Last observed package version"); + expect(container.textContent).not.toContain("Observed at"); +}); +test("a stopped runtime still presents a stale verified check as a past observation, not current proof", async () => { + server(false, "trusted", undefined, undefined, + { assessedVersion: "26.924.2738.0", observedVersion: "26.924.2738.0", verified: true, completedAt: 1770000000000 }); + const container = await mount(); + expect(container.textContent).toContain("Last observed package version"); + expect(container.textContent).toContain(new Date(1770000000000).toLocaleString("en")); + expect(container.textContent).not.toContain("Installed package version"); + expect(container.textContent).not.toContain("currently installed"); +}); 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"); diff --git a/src/codex/desktop-compatibility/installed-build.ts b/src/codex/desktop-compatibility/installed-build.ts index 36dbf3bc5b5..ba45623d608 100644 --- a/src/codex/desktop-compatibility/installed-build.ts +++ b/src/codex/desktop-compatibility/installed-build.ts @@ -8,11 +8,21 @@ import type { DesktopAppInstall } from "../desktop-app/types"; export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0"; -function matchesAssessedPackage(install: DesktopAppInstall): boolean { +/** Last completed probe outcome. Only public package facts: never paths, keys or account data. */ +export interface DesktopBuildAssessment { + assessedVersion: string; + observedVersion: string | null; + verified: boolean; + completedAt: number; +} + +function assessInstalledPackage(install: DesktopAppInstall): { observedVersion: string | null; verified: boolean } { const name = win32.basename(install.root); const parts = /^OpenAI\.Codex_(\d+\.\d+\.\d+\.\d+)_(x64|arm64|x86|neutral)_([A-Za-z0-9.-]*)_([A-Za-z0-9]+)$/.exec(name); - return install.id === DESKTOP_COMPATIBILITY_ASSESSED_FAMILY && install.relaunch === `${install.id}!App` - && !!parts && parts[1] === DESKTOP_COMPATIBILITY_ASSESSED_VERSION && `OpenAI.Codex_${parts[4]}` === install.id; + const identified = install.id === DESKTOP_COMPATIBILITY_ASSESSED_FAMILY && install.relaunch === `${install.id}!App` + && !!parts && `OpenAI.Codex_${parts[4]}` === install.id; + const observedVersion = identified ? parts![1] : null; + return { observedVersion, verified: identified && observedVersion === DESKTOP_COMPATIBILITY_ASSESSED_VERSION }; } /** Resolve only after the exact asynchronous child has closed, including abort/timeout. */ @@ -34,17 +44,25 @@ function queryInstalledPackage(signal: AbortSignal): Promise Promise = queryInstalledPackage) { let pending: Promise | null = null, controller: AbortController | null = null, closed = false; + let lastResult: DesktopBuildAssessment | null = null; + const record = (install: DesktopAppInstall | null) => { + const outcome = install ? assessInstalledPackage(install) : { observedVersion: null, verified: false }; + lastResult = { assessedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, ...outcome, completedAt: Date.now() }; + return outcome.verified; + }; return { check(): Promise { if (closed) return Promise.resolve(false); if (pending) return pending; const current = new AbortController(); controller = current; pending = Promise.resolve().then(() => current.signal.aborted ? null : query(current.signal)) - .then(install => !closed && !current.signal.aborted && !!install - && matchesAssessedPackage(install)) - .catch(() => false).finally(() => { pending = null; controller = null; }); + .then(install => closed || current.signal.aborted ? false : record(install)) + .catch(() => closed || current.signal.aborted ? false : record(null)) + .finally(() => { pending = null; controller = null; }); return pending; }, + /** Last completed check only: an aborted or in-flight query never rewrites it. */ + lastResult(): DesktopBuildAssessment | null { return lastResult ? { ...lastResult } : null; }, async close(): Promise { closed = true; controller?.abort(); await pending; }, }; } diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts index 439db5d4276..074f17609c3 100644 --- a/src/codex/desktop-compatibility/runtime.ts +++ b/src/codex/desktop-compatibility/runtime.ts @@ -16,7 +16,7 @@ import { launchWindowsCodexCompatibility } from "./windows-package-launch"; import { acquireDesktopCompatibilityRuntime, bindDesktopCompatibilityLaunch } from "./runtime-ownership"; import { createDesktopConnectionStore, type DesktopConnectionStore } from "./connection-store"; import { createNativeRoutingVerifier } from "./routing-preflight"; -import { createInstalledBuildProbe } from "./installed-build"; +import { createInstalledBuildProbe, type DesktopBuildAssessment } from "./installed-build"; export { DESKTOP_COMPATIBILITY_ASSESSED_VERSION, isAssessedDesktopInstalled } from "./installed-build"; export interface DesktopRuntimeIo { @@ -25,6 +25,8 @@ export interface DesktopRuntimeIo { trust?: (authority: StoredDesktopAuthority) => Promise<"trusted" | "not-trusted" | "unknown">; identity?: ReturnType; buildSupported?: () => boolean | Promise; + /** Internal fixture probe factory; requires testOnly and all sandbox identity/CA seams. */ + testOnlyBuildProbe?: () => ReturnType; upstreamFetch?: typeof fetch; connectionStore?: DesktopConnectionStore; routingSupported?: () => boolean; @@ -36,6 +38,7 @@ export interface DesktopRuntimeIo { export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { const platform = io.platform ?? process.platform; let buildProbe: ReturnType | undefined; + let previousBuildAssessment: DesktopBuildAssessment | null = null; const buildSupported = io.buildSupported ?? (() => buildProbe?.check() ?? Promise.resolve(false)); let routingSupported = io.routingSupported ?? (() => false); let contextFailure: "build_unverified" | "native_routing_unverified" | null = null; @@ -54,6 +57,7 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { let pendingCleanup: (() => Promise) | null = null; let releaseOwner: (() => void) | null = null; const status = () => ({ phase, supported: platform === "win32", running: owned !== null, contextFailure, + ...(buildProbe ? { buildAssessment: buildProbe.lastResult() ?? (previousBuildAssessment ? { ...previousBuildAssessment } : 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"); @@ -67,11 +71,11 @@ 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 && io.connectionStore && io.routingSupported)) throw new Error("desktop_compatibility_test_environment"); + if (io.testOnlyBuildProbe && (!io.testOnly || io.buildSupported)) throw new Error("desktop_compatibility_test_environment"); + if ((isTestHomeGuardArmed() || io.testOnlyBuildProbe) && !(io.testOnly && io.identity && io.loadAuthority && io.trust && (io.buildSupported || io.testOnlyBuildProbe) && io.connectionStore && io.routingSupported)) throw new Error("desktop_compatibility_test_environment"); if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); try { desktopProxyFor(new URL("https://chatgpt.com")); } catch { throw new Error("desktop_compatibility_egress_proxy_invalid"); } releaseOwner = acquireDesktopCompatibilityRuntime(); phase = "starting"; - if (!io.buildSupported) buildProbe = createInstalledBuildProbe(); let relay: Awaited> | undefined, proxy: ConnectProxyHandle | undefined; let pac: ReturnType | undefined, timer: ReturnType | undefined; let detach: (() => void) | undefined, unbindLaunch: (() => void) | undefined, controller: UsageRelayController | undefined; @@ -84,6 +88,11 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { }; pendingCleanup = cleanup; try { + if (!io.buildSupported) { + // A fresh probe owns this start; diagnostics retain the last completed observation. + previousBuildAssessment = buildProbe?.lastResult() ?? previousBuildAssessment; + buildProbe = io.testOnlyBuildProbe ? io.testOnlyBuildProbe() : createInstalledBuildProbe(); + } if (!io.routingSupported) routingSupported = createNativeRoutingVerifier((await import("../paths")).getCodexHome()); if (!await contextValid()) throw new Error(`desktop_compatibility_${contextFailure}`); const directory = join(getConfigDir(), "codex-desktop-compatibility"); diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index f2061ae2bd9..0312fb9cf71 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -165,6 +165,18 @@ unchanged responses do not query Windows. Background refresh runs once a minute every ten seconds during Apply, and stops probing after the safety deadline. Failed checks disarm Apply. Native update/routing failures are public diagnostic codes; originals continue to relay and no app/config repair is automatic. +Each completed check also publishes a `buildAssessment` record on runtime status holding only +`assessedVersion`, `observedVersion`, `verified` and `completedAt`. A status read never starts +a probe, a runtime without a real probe omits the field, and an aborted or in-flight check never +replaces the last completed assessment, including across stop/start probe replacement and early +routing failures. A newly completed failed probe supersedes that prior observation; retained +metadata is diagnostic only and never authorizes a new start or Apply. `observedVersion` is reported only when the discovered +package matches the assessed family, App entry and canonical basename, so a foreign or unidentifiable +install reports `null` rather than raw discovery data, an identified newer build reports its own +version with `verified` false, and a failed or missing read is unverified rather +than a stale success. The allowlist stays pinned to one exact package version because the +correction contract is verified per build, which is why a newer installed client is reported +unverified instead of treated as covered. `runtime-ownership.ts` serializes certificate mutations against active/starting runtimes. The existing sibling guard blocks all runtime mutations in sibling instances. Core shutdown diff --git a/tests/clients/desktop-compatibility-build-probe.test.ts b/tests/clients/desktop-compatibility-build-probe.test.ts index a412e914592..e568e28b6d3 100644 --- a/tests/clients/desktop-compatibility-build-probe.test.ts +++ b/tests/clients/desktop-compatibility-build-probe.test.ts @@ -47,3 +47,52 @@ test("a failed probe is closed and the next fresh query can recover", async () = expect(await probe.check()).toBe(false); expect(await probe.check()).toBe(true); await probe.close(); }); + +test("build assessment exposes only the last completed public package metadata", async () => { + let install: DesktopAppInstall | null = null, calls = 0; + const probe = createInstalledBuildProbe(async () => (++calls, install)); + expect(probe.lastResult()).toBeNull(); + install = installed("26.1007.9446.0"); + expect(await probe.check()).toBe(false); + const unverified = probe.lastResult(); + expect(unverified).toMatchObject({ assessedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, observedVersion: "26.1007.9446.0", verified: false }); + expect(unverified!.completedAt).toBeGreaterThan(0); + expect(JSON.stringify(unverified)).not.toContain("WindowsApps"); + install = installed(); + expect(await probe.check()).toBe(true); + expect(probe.lastResult()).toMatchObject({ observedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, verified: true }); + install = { ...installed(), root: "E:\elsewhere\not-a-package-name" }; + expect(await probe.check()).toBe(false); + expect(probe.lastResult()!.observedVersion).toBeNull(); + install = null; + expect(await probe.check()).toBe(false); + expect(probe.lastResult()!.observedVersion).toBeNull(); + const settled = probe.lastResult()!; + expect(calls).toBe(4); await probe.close(); + expect(probe.lastResult()).toEqual(settled); +}); + +test("an interrupted in-flight check preserves the last completed assessment", async () => { + let calls = 0, queryAborted = false, secondStarted!: () => void; + const started = new Promise(resolve => { secondStarted = resolve; }); + const probe = createInstalledBuildProbe(signal => { calls++; return calls === 1 ? Promise.resolve(installed()) + : new Promise(resolve => { secondStarted(); signal.addEventListener("abort", () => { queryAborted = true; resolve(installed("9.9.9.9")); }, { once: true }); }); }); + expect(await probe.check()).toBe(true); + const pending = probe.check(); + await started; expect(calls).toBe(2); + let settled = false; void pending.then(() => { settled = true; }); + await Bun.sleep(0); expect(settled).toBe(false); + const closing = probe.close(); expect(queryAborted).toBe(true); + expect(await pending).toBe(false); await closing; + expect(probe.lastResult()!.verified).toBe(true); + expect(probe.lastResult()!.observedVersion).toBe(DESKTOP_COMPATIBILITY_ASSESSED_VERSION); +}); + +test("a completed probe failure reports unverified instead of retaining a stale verdict", async () => { + let calls = 0; + const probe = createInstalledBuildProbe(async () => (++calls === 1 ? installed() : Promise.reject(new Error("fixture failure")))); + expect(await probe.check()).toBe(true); + expect(await probe.check()).toBe(false); + expect(probe.lastResult()).toMatchObject({ observedVersion: null, verified: false }); + await probe.close(); +}); diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts index 57e44aec97a..cab0a74d7e8 100644 --- a/tests/clients/desktop-compatibility-runtime.test.ts +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -3,7 +3,9 @@ import { evaluateUsageRewrite } from "../../src/codex/desktop-compatibility/usag 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 { createDesktopCompatibilityRuntime, type DesktopRuntimeIo } from "../../src/codex/desktop-compatibility/runtime"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_COMPATIBILITY_ASSESSED_FAMILY } from "../../src/codex/desktop-compatibility/installed-build"; +import type { DesktopAppInstall } from "../../src/codex/desktop-app/types"; import { UsageRelayController, type UsageIdentity } from "../../src/codex/desktop-compatibility/usage-controller"; import { desktopCompatibilityRuntimeActive, readDesktopCompatibilityLaunch } from "../../src/codex/desktop-compatibility/runtime-ownership"; import { captureWindowsCompatibilityContext, assertWindowsCompatibilityContext } from "../../src/codex/desktop-compatibility/windows-package-command"; @@ -171,14 +173,16 @@ describe("bounded compatibility usage controller", () => { }); }); -function fixture(trusted = true, connectionStore?: DesktopConnectionStore, buildProbe?: () => boolean | Promise) { +function fixture(trusted = true, connectionStore?: DesktopConnectionStore, buildProbe?: () => boolean | Promise, + probeFactory?: DesktopRuntimeIo["testOnlyBuildProbe"]) { const authority = createCertificateAuthority({ commonName: "runtime-fixture", validityDays: 1 }); const cert = new X509Certificate(authority.certPem); const identity = { readCurrentIdentity: async () => account, verifyFreshIdentity: async () => account }; const calls: { path: string; method: string; bytes: number; cookie: string | null }[] = []; let streamSequence = 0, cancelledStreams = 0, buildSupported = true, routingSupported = true; let connection: DesktopConnectionIdentity | null = null; - const runtime = createDesktopCompatibilityRuntime({ platform: "win32", testOnly: true, identity, buildSupported: () => buildProbe ? buildProbe() : buildSupported, + const runtimeIo: DesktopRuntimeIo = { platform: "win32", testOnly: true, identity, + ...(probeFactory ? { testOnlyBuildProbe: probeFactory } : { buildSupported: () => buildProbe ? buildProbe() : buildSupported }), routingSupported: () => routingSupported, connectionStore: connectionStore ?? { read: () => connection, publish: async value => (connection ??= value) }, loadAuthority: async () => ({ authority, commonName: "runtime-fixture", fingerprint: cert.fingerprint256.replaceAll(":", ""), @@ -195,12 +199,98 @@ function fixture(trusted = true, connectionStore?: DesktopConnectionStore, build }), { headers: { "content-type": "text/event-stream" } }); return new Response(data, { headers: { "content-type": "application/octet-stream", "set-cookie": "fixture=kept; Secure" } }); }) as typeof fetch, - }); + }; + const runtime = createDesktopCompatibilityRuntime(runtimeIo); stopped.push(() => runtime.stop()); - return { runtime, authority, calls, cancelledStreams: () => cancelledStreams, setBuildSupported: (value: boolean) => { buildSupported = value; }, setRoutingSupported: (value: boolean) => { routingSupported = value; } }; + return { runtime, runtimeIo, authority, calls, cancelledStreams: () => cancelledStreams, setBuildSupported: (value: boolean) => { buildSupported = value; }, setRoutingSupported: (value: boolean) => { routingSupported = value; } }; } describe("optional native compatibility runtime", () => { + const assessedInstall = (): DesktopAppInstall => ({ id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, + root: `C:\\Program Files\\WindowsApps\\OpenAI.Codex_${DESKTOP_COMPATIBILITY_ASSESSED_VERSION}_x64__2p2nqsd0c76g0`, + relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App` }); + + test("stop/start retains a completed assessment through routing failure and replacement probe pending", async () => { + let created = 0, queries = 0, hold = false, entered!: () => void, release!: (install: DesktopAppInstall | null) => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const io = fixture(true, undefined, undefined, () => { + created++; + return createInstalledBuildProbe(async () => { + queries++; + if (!hold) return assessedInstall(); + entered(); return new Promise(resolve => { release = resolve; }); + }); + }); + expect(io.runtime.status()).not.toHaveProperty("buildAssessment"); expect(created).toBe(0); + await io.runtime.start(); + const completed = io.runtime.status().buildAssessment!; + expect(completed.verified).toBe(true); + await io.runtime.stop(); expect(io.runtime.status().buildAssessment).toEqual(completed); + io.setRoutingSupported(false); + await expect(io.runtime.start()).rejects.toThrow("native_routing_unverified"); + expect(created).toBe(2); expect(queries).toBe(1); + expect(io.runtime.status().buildAssessment).toEqual(completed); + io.setRoutingSupported(true); hold = true; + const starting = io.runtime.start(); await waiting; + expect(created).toBe(3); expect(io.runtime.status().phase).toBe("starting"); + expect(io.runtime.status().buildAssessment).toEqual(completed); + io.runtime.status().buildAssessment!.verified = false; + expect(io.runtime.status().buildAssessment).toEqual(completed); + release({ ...assessedInstall(), root: assessedInstall().root.replace(DESKTOP_COMPATIBILITY_ASSESSED_VERSION, "99.1.1.0") }); + await expect(starting).rejects.toThrow("build_unverified"); + expect(io.runtime.status().buildAssessment).toMatchObject({ observedVersion: "99.1.1.0", verified: false }); + expect(io.runtime.status().phase).toBe("off"); + }); + + test("completed failures replace prior observations and runtime stop abort preserves the latest completion", async () => { + let mode: "success" | "missing" | "throw" | "pending" = "success", aborted = false, created = 0; + let entered!: () => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const io = fixture(true, undefined, undefined, () => { + created++; + return createInstalledBuildProbe(async signal => { + if (mode === "throw") throw new Error("fixture query failed"); + if (mode === "missing") return null; + if (mode === "success") return assessedInstall(); + entered(); return new Promise(resolve => { + signal.addEventListener("abort", () => { aborted = true; resolve(null); }, { once: true }); + }); + }); + }); + await io.runtime.start(); await io.runtime.stop(); + mode = "missing"; + await expect(io.runtime.start()).rejects.toThrow("build_unverified"); + expect(io.runtime.status().buildAssessment).toMatchObject({ observedVersion: null, verified: false }); + mode = "success"; await io.runtime.start(); await io.runtime.stop(); + mode = "throw"; + await expect(io.runtime.start()).rejects.toThrow("build_unverified"); + expect(io.runtime.status().buildAssessment).toMatchObject({ observedVersion: null, verified: false }); + mode = "success"; await io.runtime.start(); + const completed = io.runtime.status().buildAssessment!; + mode = "pending"; + const applying = io.runtime.apply(true).catch(error => error as Error); + await waiting; expect(io.runtime.status().buildAssessment).toEqual(completed); + await io.runtime.stop(); + expect(await applying).toBeInstanceOf(Error); + expect(((await applying) as Error).message).toContain("not_running"); + expect(aborted).toBe(true); expect(created).toBe(5); + expect(io.runtime.status().buildAssessment).toEqual(completed); + expect(desktopCompatibilityRuntimeActive()).toBe(false); + }); + + test("fixture probe factory requires testOnly and every sandbox seam and cannot mix build overrides", async () => { + let created = 0; + const io = fixture(true, undefined, undefined, () => { created++; return createInstalledBuildProbe(async () => assessedInstall()); }); + for (const key of ["testOnly", "identity", "loadAuthority", "trust", "connectionStore", "routingSupported"] as const) { + const runtime = createDesktopCompatibilityRuntime({ ...io.runtimeIo, [key]: undefined }); + await expect(runtime.start()).rejects.toThrow("test_environment"); + expect(runtime.status().phase).toBe("off"); + } + const mixed = createDesktopCompatibilityRuntime({ ...io.runtimeIo, buildSupported: () => true }); + await expect(mixed.start()).rejects.toThrow("test_environment"); + expect(created).toBe(0); expect(desktopCompatibilityRuntimeActive()).toBe(false); + }); + test("only a serving runtime attests PAC preservation and stop/start invalidates the captured generation", async () => { const io = fixture(); expect(readDesktopCompatibilityLaunch()).toBeNull();