From d0360b95709ba52326df0f1ae922716bb355db73 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:26:58 +0900 Subject: [PATCH 1/2] fix(desktop): bind short trial consent to fresh package proof --- .../desktop-compatibility/installed-build.ts | 77 ++++++++++++++++--- src/codex/desktop-compatibility/runtime.ts | 34 ++++++-- .../desktop-compatibility/usage-controller.ts | 2 + structure/clients/codex-desktop.md | 14 +++- .../desktop-compatibility-build-probe.test.ts | 58 +++++++++++++- .../desktop-compatibility-runtime.test.ts | 51 ++++++++++-- tests/helpers/desktop-package-observation.ts | 14 ++++ 7 files changed, 222 insertions(+), 28 deletions(-) create mode 100644 tests/helpers/desktop-package-observation.ts diff --git a/src/codex/desktop-compatibility/installed-build.ts b/src/codex/desktop-compatibility/installed-build.ts index ba45623d608..1632591142f 100644 --- a/src/codex/desktop-compatibility/installed-build.ts +++ b/src/codex/desktop-compatibility/installed-build.ts @@ -1,13 +1,39 @@ import { execFile } from "node:child_process"; import { win32 } from "node:path"; +import { createHash } from "node:crypto"; import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; -import { parseWindowsDesktopPackage, WINDOWS_PACKAGE_DISCOVERY_SCRIPT } from "../desktop-app/windows"; import type { DesktopAppInstall } from "../desktop-app/types"; export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0"; +/** Internal observation only; never returned by management status. */ +export interface DesktopPackageObservation extends DesktopAppInstall { + proof?: { packageFullName: string; publisherId: string; publisher: string; manifestName: string; + manifestPublisher: string; manifestVersion: string; appId: string; executable: string; + canonicalExecutable: string; manifestDigest: string; executableIdentity: string }; +} + +// Compatibility-only read: do not alter general discovery or execute/activate the application. +export const DESKTOP_PACKAGE_PROOF_SCRIPT = [ + "$ErrorActionPreference='Stop'; Import-Module Appx -ErrorAction Stop", + "$packages=@(Get-AppxPackage -Name OpenAI.Codex)", + "if($packages.Count -ne 1){throw 'Package not unique'}; $p=$packages[0]", + "$root=Get-Item -LiteralPath $p.InstallLocation; $app=Get-Item -LiteralPath (Join-Path $root.FullName 'app')", + "$exe=Get-Item -LiteralPath (Join-Path $app.FullName 'ChatGPT.exe')", + "foreach($item in @($root,$app,$exe)){if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Reparse identity'}}", + "if(-not $root.PSIsContainer -or -not $app.PSIsContainer -or $exe.PSIsContainer){throw 'Wrong file kind'}", + "$manifest=Get-AppxPackageManifest -Package $p.PackageFullName", + "$entry=@($manifest.Package.Applications.Application|Where-Object {$_.Id -eq 'App'})", + "if($entry.Count -ne 1){throw 'App not unique'}; $entry=$entry[0]", + "$xml=[Text.Encoding]::UTF8.GetBytes($manifest.OuterXml); if($xml.Length -gt 1048576){throw 'Manifest too large'}", + "$sha=[Security.Cryptography.SHA256]::Create(); try{$digest=([BitConverter]::ToString($sha.ComputeHash($xml))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}", + "$again=@(Get-AppxPackage -Name OpenAI.Codex); $exeAgain=Get-Item -LiteralPath $exe.FullName", + "if($again.Count -ne 1 -or $again[0].PackageFullName -cne $p.PackageFullName -or $again[0].Publisher -cne $p.Publisher -or $again[0].InstallLocation -ine $p.InstallLocation -or ($exeAgain.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $exeAgain.Length -ne $exe.Length -or $exeAgain.CreationTimeUtc.Ticks -ne $exe.CreationTimeUtc.Ticks -or $exeAgain.LastWriteTimeUtc.Ticks -ne $exe.LastWriteTimeUtc.Ticks){throw 'Package changed during proof'}", + "[pscustomobject]@{id=$p.PackageFamilyName;root=$root.FullName;relaunch=$p.PackageFamilyName+'!App';proof=@{packageFullName=$p.PackageFullName;publisherId=$p.PublisherId;publisher=$p.Publisher;manifestName=[string]$manifest.Package.Identity.Name;manifestPublisher=[string]$manifest.Package.Identity.Publisher;manifestVersion=[string]$manifest.Package.Identity.Version;appId=[string]$entry.Id;executable=[string]$entry.Executable;canonicalExecutable=$exe.FullName;manifestDigest=$digest;executableIdentity=([string]$exe.Length+':'+$exe.CreationTimeUtc.Ticks+':'+$exe.LastWriteTimeUtc.Ticks)}}|ConvertTo-Json -Depth 3 -Compress", +].join("; "); + /** Last completed probe outcome. Only public package facts: never paths, keys or account data. */ export interface DesktopBuildAssessment { assessedVersion: string; @@ -16,24 +42,46 @@ export interface DesktopBuildAssessment { completedAt: number; } -function assessInstalledPackage(install: DesktopAppInstall): { observedVersion: string | null; verified: boolean } { +function assessInstalledPackage(install: DesktopPackageObservation): { observedVersion: string | null; verified: boolean; identity: string | null } { 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); 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 }; + const proof = install.proof; + const verified = identified && observedVersion === DESKTOP_COMPATIBILITY_ASSESSED_VERSION && !!proof + && /^[A-Za-z]:\\/.test(install.root) && win32.normalize(install.root) === install.root + && proof.packageFullName === name && proof.publisherId === "2p2nqsd0c76g0" + && typeof proof.publisher === "string" && proof.publisher.length > 0 && proof.publisher.length <= 1024 + && proof.manifestName === "OpenAI.Codex" && proof.manifestPublisher === proof.publisher + && proof.manifestVersion === observedVersion && proof.appId === "App" + && typeof proof.executable === "string" && proof.executable.replaceAll("/", "\\").toLowerCase() === "app\\chatgpt.exe" + && proof.canonicalExecutable === win32.join(install.root, "app", "ChatGPT.exe") + && /^[a-f0-9]{64}$/.test(proof.manifestDigest) && /^\d{1,16}:\d{1,20}:\d{1,20}$/.test(proof.executableIdentity); + const identity = verified && proof ? createHash("sha256").update(JSON.stringify([install.id, install.root, install.relaunch, + proof.packageFullName, proof.publisherId, proof.publisher, proof.manifestName, proof.manifestPublisher, + proof.manifestVersion, proof.appId, proof.executable, proof.canonicalExecutable, proof.manifestDigest, + proof.executableIdentity])).digest("hex") : null; + return { observedVersion, verified, identity }; } /** Resolve only after the exact asynchronous child has closed, including abort/timeout. */ -function queryInstalledPackage(signal: AbortSignal): Promise { +function queryInstalledPackage(signal: AbortSignal): Promise { if (process.platform !== "win32" || isTestHomeGuardArmed() || signal.aborted) return Promise.resolve(null); return new Promise(resolve => { - let completed = false, closed = false, result: DesktopAppInstall | null = null; + let completed = false, closed = false, result: DesktopPackageObservation | null = null; const finish = () => { if (completed && closed) { signal.removeEventListener("abort", abort); resolve(result); } }; - const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PACKAGE_DISCOVERY_SCRIPT], { + const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", DESKTOP_PACKAGE_PROOF_SCRIPT], { timeout: 10000, maxBuffer: 65536, encoding: "utf8", windowsHide: true, - }, (error, stdout) => { result = error ? null : parseWindowsDesktopPackage(stdout); completed = true; finish(); }); + }, (error, stdout) => { + try { + const value = error ? null : JSON.parse(stdout); + if (value && typeof value === "object" && !Array.isArray(value) && typeof value.id === "string" + && typeof value.root === "string" && typeof value.relaunch === "string" + && value.proof && typeof value.proof === "object" && !Array.isArray(value.proof)) result = value; + } catch { /* An unverifiable package never inherits a completed success. */ } + completed = true; finish(); + }); const abort = () => { try { child.kill(); } catch { /* Keep ownership until execFile reports close. */ } }; child.once("close", () => { closed = true; finish(); }); signal.addEventListener("abort", abort, { once: true }); @@ -42,12 +90,15 @@ function queryInstalledPackage(signal: AbortSignal): Promise Promise = queryInstalledPackage) { +export function createInstalledBuildProbe(query: (signal: AbortSignal) => 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() }; + let identity: string | null = null, revision = 0; + const record = (install: DesktopPackageObservation | null) => { + const outcome = install ? assessInstalledPackage(install) : { observedVersion: null, verified: false, identity: null }; + if (outcome.identity !== identity) { identity = outcome.identity; revision++; } + lastResult = { assessedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, observedVersion: outcome.observedVersion, + verified: outcome.verified, completedAt: Date.now() }; return outcome.verified; }; return { @@ -63,7 +114,9 @@ export function createInstalledBuildProbe(query: (signal: AbortSignal) => Promis }, /** 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; }, + /** Private consent fence: a completed A->B->A proof is distinct from unchanged A. */ + binding() { return { identity, revision }; }, + async close(): Promise { closed = true; identity = null; revision++; controller?.abort(); await pending; }, }; } diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts index 074f17609c3..16bd52dd87a 100644 --- a/src/codex/desktop-compatibility/runtime.ts +++ b/src/codex/desktop-compatibility/runtime.ts @@ -39,16 +39,34 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { const platform = io.platform ?? process.platform; let buildProbe: ReturnType | undefined; let previousBuildAssessment: DesktopBuildAssessment | null = null; + let boundBuild: { probe: ReturnType; revision: number } | null = null; + let invalidateTrial: (() => void) | undefined; + let contextEpoch = 0; const buildSupported = io.buildSupported ?? (() => buildProbe?.check() ?? Promise.resolve(false)); let routingSupported = io.routingSupported ?? (() => false); let contextFailure: "build_unverified" | "native_routing_unverified" | null = null; const contextValid = async () => { + const epoch = contextEpoch, probe = buildProbe; try { - if (!routingSupported()) { contextFailure = "native_routing_unverified"; return false; } + if (!routingSupported()) { contextFailure = "native_routing_unverified"; invalidateTrial?.(); return false; } const supported = await buildSupported(); + // An old probe completion must not invalidate a replacement runtime's consent. + if (epoch !== contextEpoch || probe !== buildProbe) return false; + if (!io.buildSupported && probe) { + const binding = probe.binding(); + const changed = boundBuild?.probe === probe && boundBuild.revision !== binding.revision; + boundBuild = { probe, revision: binding.revision }; + if (!supported || !binding.identity || changed) { + contextFailure = "build_unverified"; invalidateTrial?.(); return false; + } + } contextFailure = !routingSupported() ? "native_routing_unverified" : !supported ? "build_unverified" : null; } - catch { contextFailure = "build_unverified"; } + catch { + if (epoch !== contextEpoch || probe !== buildProbe) return false; + contextFailure = "build_unverified"; + } + if (contextFailure) invalidateTrial?.(); return contextFailure === null; }; let phase: "off" | "starting" | "running" | "stopping" | "cleanup-required" = "off"; @@ -63,14 +81,17 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (phase === "starting") throw new Error("desktop_compatibility_busy"); if (closing) { await closing; return status(); } if (!pendingCleanup) return status(); + contextEpoch++; invalidateTrial?.(); phase = "stopping"; - closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; }) + closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; invalidateTrial = undefined; boundBuild = 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"); + contextEpoch++; boundBuild = null; invalidateTrial = undefined; 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"); @@ -114,6 +135,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (!account) throw new Error("desktop_compatibility_native_identity_unverified"); if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); controller = new UsageRelayController(account, identity.readCurrentIdentity, identity.verifyFreshIdentity, Date.now, deadline, 180000, contextValid); + const trialController = controller; + invalidateTrial = () => trialController.invalidateContext(); relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]), fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? desktopOutboundFetch as typeof fetch) }); try { proxy = await startConnectProxy(previousConnection?.connectPort ?? 0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); } @@ -150,7 +173,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { detach = registerOptionalShutdownHook("codex-desktop-compatibility", () => { void stop().catch(() => {}); }); return status(); } catch (error) { - try { await cleanup(); pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; } + try { await cleanup(); pendingCleanup = null; invalidateTrial = undefined; boundBuild = null; + phase = "off"; releaseOwner?.(); releaseOwner = null; } catch { phase = "cleanup-required"; throw new Error("desktop_compatibility_cleanup_incomplete"); } throw error; } @@ -160,8 +184,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (!current || phase !== "running") throw new Error("desktop_compatibility_not_running"); const generation = current.controller.snapshot().generation, valid = await contextValid(); if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running"); - if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() }; if (!valid) { await current.controller.observeOnly(); throw new Error(`desktop_compatibility_${contextFailure}`); } + if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() }; 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; diff --git a/src/codex/desktop-compatibility/usage-controller.ts b/src/codex/desktop-compatibility/usage-controller.ts index ee01c04caf4..bc6d43e7551 100644 --- a/src/codex/desktop-compatibility/usage-controller.ts +++ b/src/codex/desktop-compatibility/usage-controller.ts @@ -46,6 +46,8 @@ export class UsageRelayController { return this.activation.activate(options); } observeOnly() { return this.activation.observeOnly(); } + /** Internal package-proof revocation; synchronous so pending output loses its generation first. */ + invalidateContext() { this.activation.invalidateIdentity(); } expireIfNeeded() { return this.activation.expireIfNeeded(); } registerStream(exchange: Exchange, close: () => Promise): Registration | null { if (exchange.status !== 200) return null; diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1471016161a..35c2fe28b45 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -183,13 +183,23 @@ table do not invalidate the routing snapshot. Desktop refreshes that tool-transp after launch; all other native fields, including unknown ones and selected profiles, remain bound. It does not claim knowledge of project-local overrides or a conversation's selected model. Each record that would be corrected checks routing and the assessed installed build asynchronously, -then rechecks account identity and trial generation before emitting it. `installed-build.ts` shares -the Windows adapter's discovery parser, coalesces concurrent probes without caching a positive +then rechecks account identity and trial generation before emitting it. `installed-build.ts` uses a +compatibility-only current-user package query that reads the registered manifest's actual App and +canonical `app/ChatGPT.exe` entry, binds its publisher/name/version to the exact official family, +and refuses missing/reparse executable facts. It coalesces concurrent probes without caching a positive result across requests, and aborts/reaps its bounded child before shutdown completes. Observe and unchanged responses do not query Windows. Background refresh runs once a minute in Observe, every ten seconds during Apply, and stops probing after the safety deadline. Failed checks disarm Apply. Native update/routing failures are public diagnostic codes; originals continue to relay and no app/config repair is automatic. +The package proof's manifest digest and canonical executable metadata stay private. An observed +identity change or failed proof invalidates trial generation, prior exhaustion and outputs before +another correction; a supported-version A-to-B-to-A transition cannot revive old consent. Later +success requires a new original exhaustion and explicit account-wide consent with the unchanged +maximum three-minute trial. Stop/start replaces the observation context, and old probe completions +cannot affect its replacement. Identical observed registration/file metadata cannot detect every +same-version reinstall; this is not continuous file/process or signature attestation. The launcher, +exact version allowlist, GET/SSE surface and default-off behavior remain unchanged. 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 diff --git a/tests/clients/desktop-compatibility-build-probe.test.ts b/tests/clients/desktop-compatibility-build-probe.test.ts index e568e28b6d3..4cb122037dc 100644 --- a/tests/clients/desktop-compatibility-build-probe.test.ts +++ b/tests/clients/desktop-compatibility-build-probe.test.ts @@ -1,9 +1,59 @@ import { expect, test } from "bun:test"; -import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_COMPATIBILITY_ASSESSED_FAMILY } from "../../src/codex/desktop-compatibility/installed-build"; -import type { DesktopAppInstall } from "../../src/codex/desktop-app/types"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_PACKAGE_PROOF_SCRIPT } from "../../src/codex/desktop-compatibility/installed-build"; +import type { DesktopPackageObservation as DesktopAppInstall } from "../../src/codex/desktop-compatibility/installed-build"; +import { desktopPackageObservation } from "../helpers/desktop-package-observation"; -const installed = (version = DESKTOP_COMPATIBILITY_ASSESSED_VERSION): DesktopAppInstall => ({ - id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, root: `C:\\Program Files\\WindowsApps\\OpenAI.Codex_${version}_x64__2p2nqsd0c76g0`, relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App`, +const installed = desktopPackageObservation; + +test("manifest and canonical executable facts are required without admitting another version", async () => { + const good = installed(); + for (const proof of [undefined, + { ...good.proof!, manifestName: "Foreign.Codex" }, { ...good.proof!, manifestPublisher: "CN=Foreign" }, + { ...good.proof!, appId: "Other" }, { ...good.proof!, executable: "other\\ChatGPT.exe" }, + { ...good.proof!, canonicalExecutable: good.root + "-foreign\\app\\ChatGPT.exe" }, + { ...good.proof!, packageFullName: good.proof!.packageFullName + "_extra" }, + { ...good.proof!, manifestDigest: "unverified" }, { ...good.proof!, executableIdentity: "" }]) { + const probe = createInstalledBuildProbe(async () => ({ ...good, proof })); + expect(await probe.check()).toBe(false); expect(probe.binding().identity).toBeNull(); await probe.close(); + } + const newer = createInstalledBuildProbe(async () => installed("26.1007.9446.0")); + expect(await newer.check()).toBe(false); await newer.close(); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).toContain("Get-AppxPackageManifest"); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).toContain("ChatGPT.exe"); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).not.toContain("Activate"); +}); + +test("same-version package proof changes and failure advance a private ABA fence", async () => { + let value: DesktopAppInstall | null = installed(); + const probe = createInstalledBuildProbe(async () => value); + expect(await probe.check()).toBe(true); const first = probe.binding(); + expect(await probe.check()).toBe(true); expect(probe.binding()).toEqual(first); + value = { ...installed(), proof: { ...installed().proof!, manifestDigest: "b".repeat(64) } }; + expect(await probe.check()).toBe(true); expect(probe.binding().revision).toBeGreaterThan(first.revision); + value = installed(); expect(await probe.check()).toBe(true); + expect(probe.binding().identity).toBe(first.identity); expect(probe.binding().revision).toBeGreaterThan(first.revision); + value = null; expect(await probe.check()).toBe(false); expect(probe.binding().identity).toBeNull(); + const failedRevision = probe.binding().revision; + value = installed(); expect(await probe.check()).toBe(true); expect(probe.binding().revision).toBeGreaterThan(failedRevision); + expect(JSON.stringify(probe.lastResult())).not.toContain(first.identity!); + expect(JSON.stringify(probe.lastResult())).not.toContain("ChatGPT.exe"); + await probe.close(); expect(probe.binding().identity).toBeNull(); +}); + +test("identical observed metadata cannot claim to detect a same-version reinstall", async () => { + const probe = createInstalledBuildProbe(async () => structuredClone(installed())); + expect(await probe.check()).toBe(true); const original = probe.binding(); + expect(await probe.check()).toBe(true); expect(probe.binding()).toEqual(original); + await probe.close(); +}); + +test("a successful query completed after close cannot restore private proof", async () => { + let release!: (value: DesktopAppInstall) => void; + const probe = createInstalledBuildProbe(() => new Promise(resolve => { release = resolve; })); + const checking = probe.check(); await Promise.resolve(); + const closing = probe.close(); const closedBinding = probe.binding(); + release(installed()); expect(await checking).toBe(false); await closing; + expect(probe.binding()).toEqual(closedBinding); expect(probe.lastResult()).toBeNull(); }); test("matching version text cannot qualify a foreign package family, publisher or app entry", async () => { diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts index cab0a74d7e8..ba067adb81c 100644 --- a/tests/clients/desktop-compatibility-runtime.test.ts +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -4,8 +4,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import { X509Certificate } from "node:crypto"; 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 { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION } from "../../src/codex/desktop-compatibility/installed-build"; +import type { DesktopPackageObservation as DesktopAppInstall } from "../../src/codex/desktop-compatibility/installed-build"; +import { desktopPackageObservation } from "../helpers/desktop-package-observation"; 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"; @@ -206,9 +207,49 @@ function fixture(trusted = true, connectionStore?: DesktopConnectionStore, build } 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` }); + const assessedInstall = desktopPackageObservation; + + for (const failure of ["same-version-replacement", "unverifiable", "ABA"] as const) { + test(`${failure} revokes package-bound trial consent before another corrected response`, async () => { + let current: DesktopAppInstall | null = assessedInstall(); + let probe!: ReturnType; + const io = fixture(true, undefined, undefined, () => (probe = createInstalledBuildProbe(async () => current))); + await io.runtime.start(); + const pac = await fetch(io.runtime.getPacUrl()!).then(res => res.text()); + const port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + const read = () => fetch("https://chatgpt.com/backend-api/wham/usage", { + proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem }, + }).then(res => res.json()); + await read(); expect((await io.runtime.apply(true)).accepted).toBe(true); + expect((await read()).rate_limit.allowed).toBe(true); + const generation = io.runtime.status().usage!.generation; + current = failure === "unverifiable" ? null + : { ...assessedInstall(), proof: { ...assessedInstall().proof!, executableIdentity: "1024:100:201" } }; + if (failure === "ABA") { + expect(await probe.check()).toBe(true); + current = assessedInstall(); expect(await probe.check()).toBe(true); + } + expect(await read()).toEqual(usage); + expect(io.runtime.status().usage).toMatchObject({ mode: "observe", outputs: 0 }); + expect(io.runtime.status().usage!.generation).toBeGreaterThan(generation); + if (failure === "unverifiable") { + current = assessedInstall(); + // Recovery is another identity transition, not permission to revive consent. + await expect(io.runtime.apply(true)).rejects.toThrow("build_unverified"); + } + expect((await io.runtime.apply(true)).accepted).toBe(false); + expect(await read()).toEqual(usage); + expect((await io.runtime.apply(false)).accepted).toBe(false); + expect((await io.runtime.apply(true)).accepted).toBe(true); + expect((await read()).rate_limit.allowed).toBe(true); + expect(JSON.stringify(io.runtime.status())).not.toContain("ChatGPT.exe"); + expect(JSON.stringify(io.runtime.status())).not.toContain("manifestDigest"); + await io.runtime.stop(); await io.runtime.start(); + expect(io.runtime.status().usage?.mode).toBe("observe"); + expect((await io.runtime.apply(true)).accepted).toBe(false); + await io.runtime.stop(); + }, 15_000); + } 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; diff --git a/tests/helpers/desktop-package-observation.ts b/tests/helpers/desktop-package-observation.ts new file mode 100644 index 00000000000..e23209ef249 --- /dev/null +++ b/tests/helpers/desktop-package-observation.ts @@ -0,0 +1,14 @@ +import { win32 } from "node:path"; +import { DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, + type DesktopPackageObservation } from "../../src/codex/desktop-compatibility/installed-build"; + +/** Synthetic registered manifest/file facts; never performs a host package query. */ +export function desktopPackageObservation(version = DESKTOP_COMPATIBILITY_ASSESSED_VERSION): DesktopPackageObservation { + const packageFullName = `OpenAI.Codex_${version}_x64__2p2nqsd0c76g0`; + const root = win32.join("C:\\Program Files\\WindowsApps", packageFullName); + return { id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, root, relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App`, + proof: { packageFullName, publisherId: "2p2nqsd0c76g0", publisher: "CN=Fixture Registered Publisher", + manifestName: "OpenAI.Codex", manifestPublisher: "CN=Fixture Registered Publisher", manifestVersion: version, + appId: "App", executable: "app\\ChatGPT.exe", canonicalExecutable: win32.join(root, "app", "ChatGPT.exe"), + manifestDigest: "a".repeat(64), executableIdentity: "1024:100:200" } }; +} From 2e2ca4f54cfd74c0129bdb7a08f823685045e930 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:37:14 +0900 Subject: [PATCH 2/2] fix(desktop): reject reparse ancestry in trial identity proof --- .../desktop-compatibility/installed-build.ts | 31 ++++++++-- structure/clients/codex-desktop.md | 8 ++- .../desktop-compatibility-build-probe.test.ts | 56 ++++++++++++++++++- .../desktop-compatibility-runtime.test.ts | 36 ++++++++++++ tests/helpers/desktop-package-observation.ts | 2 +- 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/codex/desktop-compatibility/installed-build.ts b/src/codex/desktop-compatibility/installed-build.ts index 1632591142f..f7cbb4052bd 100644 --- a/src/codex/desktop-compatibility/installed-build.ts +++ b/src/codex/desktop-compatibility/installed-build.ts @@ -12,14 +12,32 @@ export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0 export interface DesktopPackageObservation extends DesktopAppInstall { proof?: { packageFullName: string; publisherId: string; publisher: string; manifestName: string; manifestPublisher: string; manifestVersion: string; appId: string; executable: string; - canonicalExecutable: string; manifestDigest: string; executableIdentity: string }; + canonicalExecutable: string; manifestDigest: string; executableIdentity: string; ancestryIdentity: string }; } +/** Shared only with owned-path fixtures. Metadata walk, not handle-bound final-path attestation. */ +export const DESKTOP_PACKAGE_PATH_GUARD_SCRIPT = [ + "function Assert-DesktopPackagePath([string]$Path){", + String.raw`if($Path.Length -gt 8192 -or $Path -notmatch '^[A-Za-z]:\\' -or [IO.Path]::GetFullPath($Path) -ine $Path){throw 'Noncanonical package path'}`, + "$volume=[IO.Path]::GetPathRoot($Path); $current=$volume; $parts=@($Path.Substring($volume.Length).Split('\\')|Where-Object {$_ -ne ''})", + "if($parts.Count -gt 128){throw 'Package ancestry too deep'}; $seen=[Collections.Generic.List[string]]::new()", + "for($i=-1;$i -lt $parts.Count;$i++){", + "if($i -ge 0){$current=Join-Path $current $parts[$i]}; $item=Get-Item -LiteralPath $current -Force", + "if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($i -lt $parts.Count-1 -and -not $item.PSIsContainer)){throw 'Reparse package ancestry'}", + "$seen.Add($item.FullName.ToLowerInvariant()+':'+$item.CreationTimeUtc.Ticks)", + "}; return ($seen -join '|')", + "}", +].join("; "); + // Compatibility-only read: do not alter general discovery or execute/activate the application. export const DESKTOP_PACKAGE_PROOF_SCRIPT = [ "$ErrorActionPreference='Stop'; Import-Module Appx -ErrorAction Stop", + DESKTOP_PACKAGE_PATH_GUARD_SCRIPT, "$packages=@(Get-AppxPackage -Name OpenAI.Codex)", "if($packages.Count -ne 1){throw 'Package not unique'}; $p=$packages[0]", + // Walk from volume root down, rejecting junctions before reading the manifest/executable. + "$exePath=Join-Path $p.InstallLocation 'app\\ChatGPT.exe'; $manifestPath=Join-Path $p.InstallLocation 'AppxManifest.xml'", + "$exeChain=Assert-DesktopPackagePath $exePath; $manifestChain=Assert-DesktopPackagePath $manifestPath", "$root=Get-Item -LiteralPath $p.InstallLocation; $app=Get-Item -LiteralPath (Join-Path $root.FullName 'app')", "$exe=Get-Item -LiteralPath (Join-Path $app.FullName 'ChatGPT.exe')", "foreach($item in @($root,$app,$exe)){if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Reparse identity'}}", @@ -29,9 +47,13 @@ export const DESKTOP_PACKAGE_PROOF_SCRIPT = [ "if($entry.Count -ne 1){throw 'App not unique'}; $entry=$entry[0]", "$xml=[Text.Encoding]::UTF8.GetBytes($manifest.OuterXml); if($xml.Length -gt 1048576){throw 'Manifest too large'}", "$sha=[Security.Cryptography.SHA256]::Create(); try{$digest=([BitConverter]::ToString($sha.ComputeHash($xml))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}", + // Revalidate the whole chain before further file reads and immediately before publication. + "if((Assert-DesktopPackagePath $exePath) -cne $exeChain -or (Assert-DesktopPackagePath $manifestPath) -cne $manifestChain){throw 'Package ancestry changed'}", "$again=@(Get-AppxPackage -Name OpenAI.Codex); $exeAgain=Get-Item -LiteralPath $exe.FullName", "if($again.Count -ne 1 -or $again[0].PackageFullName -cne $p.PackageFullName -or $again[0].Publisher -cne $p.Publisher -or $again[0].InstallLocation -ine $p.InstallLocation -or ($exeAgain.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $exeAgain.Length -ne $exe.Length -or $exeAgain.CreationTimeUtc.Ticks -ne $exe.CreationTimeUtc.Ticks -or $exeAgain.LastWriteTimeUtc.Ticks -ne $exe.LastWriteTimeUtc.Ticks){throw 'Package changed during proof'}", - "[pscustomobject]@{id=$p.PackageFamilyName;root=$root.FullName;relaunch=$p.PackageFamilyName+'!App';proof=@{packageFullName=$p.PackageFullName;publisherId=$p.PublisherId;publisher=$p.Publisher;manifestName=[string]$manifest.Package.Identity.Name;manifestPublisher=[string]$manifest.Package.Identity.Publisher;manifestVersion=[string]$manifest.Package.Identity.Version;appId=[string]$entry.Id;executable=[string]$entry.Executable;canonicalExecutable=$exe.FullName;manifestDigest=$digest;executableIdentity=([string]$exe.Length+':'+$exe.CreationTimeUtc.Ticks+':'+$exe.LastWriteTimeUtc.Ticks)}}|ConvertTo-Json -Depth 3 -Compress", + "if((Assert-DesktopPackagePath $exePath) -cne $exeChain -or (Assert-DesktopPackagePath $manifestPath) -cne $manifestChain){throw 'Package ancestry changed'}", + "$sha=[Security.Cryptography.SHA256]::Create(); try{$ancestry=([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($exeChain+'|'+$manifestChain)))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}", + "[pscustomobject]@{id=$p.PackageFamilyName;root=$root.FullName;relaunch=$p.PackageFamilyName+'!App';proof=@{packageFullName=$p.PackageFullName;publisherId=$p.PublisherId;publisher=$p.Publisher;manifestName=[string]$manifest.Package.Identity.Name;manifestPublisher=[string]$manifest.Package.Identity.Publisher;manifestVersion=[string]$manifest.Package.Identity.Version;appId=[string]$entry.Id;executable=[string]$entry.Executable;canonicalExecutable=$exe.FullName;manifestDigest=$digest;executableIdentity=([string]$exe.Length+':'+$exe.CreationTimeUtc.Ticks+':'+$exe.LastWriteTimeUtc.Ticks);ancestryIdentity=$ancestry}}|ConvertTo-Json -Depth 3 -Compress", ].join("; "); /** Last completed probe outcome. Only public package facts: never paths, keys or account data. */ @@ -57,11 +79,12 @@ function assessInstalledPackage(install: DesktopPackageObservation): { observedV && proof.manifestVersion === observedVersion && proof.appId === "App" && typeof proof.executable === "string" && proof.executable.replaceAll("/", "\\").toLowerCase() === "app\\chatgpt.exe" && proof.canonicalExecutable === win32.join(install.root, "app", "ChatGPT.exe") - && /^[a-f0-9]{64}$/.test(proof.manifestDigest) && /^\d{1,16}:\d{1,20}:\d{1,20}$/.test(proof.executableIdentity); + && /^[a-f0-9]{64}$/.test(proof.manifestDigest) && /^[a-f0-9]{64}$/.test(proof.ancestryIdentity) + && /^\d{1,16}:\d{1,20}:\d{1,20}$/.test(proof.executableIdentity); const identity = verified && proof ? createHash("sha256").update(JSON.stringify([install.id, install.root, install.relaunch, proof.packageFullName, proof.publisherId, proof.publisher, proof.manifestName, proof.manifestPublisher, proof.manifestVersion, proof.appId, proof.executable, proof.canonicalExecutable, proof.manifestDigest, - proof.executableIdentity])).digest("hex") : null; + proof.executableIdentity, proof.ancestryIdentity])).digest("hex") : null; return { observedVersion, verified, identity }; } diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 35c2fe28b45..cb11a2005d9 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -185,7 +185,7 @@ It does not claim knowledge of project-local overrides or a conversation's selec Each record that would be corrected checks routing and the assessed installed build asynchronously, then rechecks account identity and trial generation before emitting it. `installed-build.ts` uses a compatibility-only current-user package query that reads the registered manifest's actual App and -canonical `app/ChatGPT.exe` entry, binds its publisher/name/version to the exact official family, +fixed ChatGPT.exe entry in the installed package's app directory, binds its publisher/name/version to the exact official family, and refuses missing/reparse executable facts. It coalesces concurrent probes without caching a positive result across requests, and aborts/reaps its bounded child before shutdown completes. Observe and unchanged responses do not query Windows. Background refresh runs once a minute in Observe, @@ -198,7 +198,11 @@ another correction; a supported-version A-to-B-to-A transition cannot revive old success requires a new original exhaustion and explicit account-wide consent with the unchanged maximum three-minute trial. Stop/start replaces the observation context, and old probe completions cannot affect its replacement. Identical observed registration/file metadata cannot detect every -same-version reinstall; this is not continuous file/process or signature attestation. The launcher, +same-version reinstall; this is not continuous file/process or signature attestation. +Every volume-root-to-file ancestor is checked for reparse points before manifest/file consumption +and rechecked before proof publication. FullName is lexical and is not a held-handle canonical-path +proof; the bounded before/after metadata walk does not exclude a transient replace-and-restore +between observations. Such unobserved replacement remains outside this observation contract. The launcher, exact version allowlist, GET/SSE surface and default-off behavior remain unchanged. Each completed check also publishes a `buildAssessment` record on runtime status holding only `assessedVersion`, `observedVersion`, `verified` and `completedAt`. A status read never starts diff --git a/tests/clients/desktop-compatibility-build-probe.test.ts b/tests/clients/desktop-compatibility-build-probe.test.ts index 4cb122037dc..112c66ec3ab 100644 --- a/tests/clients/desktop-compatibility-build-probe.test.ts +++ b/tests/clients/desktop-compatibility-build-probe.test.ts @@ -1,10 +1,61 @@ import { expect, test } from "bun:test"; -import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_PACKAGE_PROOF_SCRIPT } from "../../src/codex/desktop-compatibility/installed-build"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_PACKAGE_PROOF_SCRIPT, DESKTOP_PACKAGE_PATH_GUARD_SCRIPT } from "../../src/codex/desktop-compatibility/installed-build"; +import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { join, parse } from "node:path"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; import type { DesktopPackageObservation as DesktopAppInstall } from "../../src/codex/desktop-compatibility/installed-build"; import { desktopPackageObservation } from "../helpers/desktop-package-observation"; const installed = desktopPackageObservation; +test.skipIf(process.platform !== "win32")("owned ancestor junctions and persistent retargets fail before proof publication", async () => { + // Ordinary volume-root fixture: never use a registered package or the installed application. + const root = mkdtempSync(join(parse(process.cwd()).root, "ocx-package-ancestry-")); + const first = join(root, "first"), second = join(root, "second"), alias = join(root, "alias"); + let terminal = true; + const quote = (value: string) => "'" + value.replaceAll("'", "''") + "'"; + try { + for (const directory of [first, second]) { + mkdirSync(join(directory, "package", "app"), { recursive: true }); + writeFileSync(join(directory, "package", "app", "ChatGPT.exe"), "fixture, not executable"); + writeFileSync(join(directory, "package", "AppxManifest.xml"), "fixture, not Appx"); + } + symlinkSync(first, alias, "junction"); + const script = ["$ErrorActionPreference='Stop'", DESKTOP_PACKAGE_PATH_GUARD_SCRIPT, + `$alias=${quote(alias)}; $first=${quote(first)}; $second=${quote(second)}`, + "$leaf=Join-Path $alias 'package\\app\\ChatGPT.exe'", + // Leaf attributes alone cannot reveal the ancestor junction. + "if((Get-Item -LiteralPath $leaf).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Fixture leaf is not ordinary'}", + "function Refuses-BeforeRead([string]$File){$refused=$false; try{$null=Assert-DesktopPackagePath $File}catch{$refused=$true}; if(-not $refused){throw 'Guard admitted redirected ancestry'}; return $refused}", + "$initial=Refuses-BeforeRead $leaf", + "[IO.Directory]::Delete($alias); $null=New-Item -ItemType Junction -Path $alias -Target $second", + "$retarget=Refuses-BeforeRead $leaf", + "$ordinary=Join-Path $first 'package\\app\\ChatGPT.exe'; $before=Assert-DesktopPackagePath $ordinary", + "$null=Move-Item -LiteralPath $first -Destination ($first+'-saved'); $null=New-Item -ItemType Junction -Path $first -Target $second", + // The same revalidation used before publishing production proof must reject this change. + "$publication=Refuses-BeforeRead $ordinary", + "[pscustomobject]@{initial=$initial;retarget=$retarget;publication=$publication;ordinary=($before.Length -gt 0)}|ConvertTo-Json -Compress", + ].join("; "); + terminal = false; + const result = await spawnTestChild([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-Command", script], + { cwd: root, deadlineMs: 10_000 }); + terminal = result.reaped && result.pipesClosed; + expect(result).toMatchObject({ exitCode: 0, timedOut: false, reaped: true, pipesClosed: true }); + expect(JSON.parse(result.stdout)).toEqual({ initial: true, retarget: true, publication: true, ordinary: true }); + } finally { + // An unreaped child retains its fixture; never recursively follow the owned junctions. + if (terminal) { + for (const path of [alias, first]) { + try { if (lstatSync(path).isSymbolicLink()) unlinkSync(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + rmSync(root, { recursive: true }); + } + } +}, 20_000); + test("manifest and canonical executable facts are required without admitting another version", async () => { const good = installed(); for (const proof of [undefined, @@ -12,7 +63,8 @@ test("manifest and canonical executable facts are required without admitting ano { ...good.proof!, appId: "Other" }, { ...good.proof!, executable: "other\\ChatGPT.exe" }, { ...good.proof!, canonicalExecutable: good.root + "-foreign\\app\\ChatGPT.exe" }, { ...good.proof!, packageFullName: good.proof!.packageFullName + "_extra" }, - { ...good.proof!, manifestDigest: "unverified" }, { ...good.proof!, executableIdentity: "" }]) { + { ...good.proof!, manifestDigest: "unverified" }, { ...good.proof!, ancestryIdentity: "" }, + { ...good.proof!, executableIdentity: "" }]) { const probe = createInstalledBuildProbe(async () => ({ ...good, proof })); expect(await probe.check()).toBe(false); expect(probe.binding().identity).toBeNull(); await probe.close(); } diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts index ba067adb81c..09180163551 100644 --- a/tests/clients/desktop-compatibility-runtime.test.ts +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -209,6 +209,42 @@ function fixture(trusted = true, connectionStore?: DesktopConnectionStore, build describe("optional native compatibility runtime", () => { const assessedInstall = desktopPackageObservation; + for (const completion of ["success", "rejection"] as const) { + test(`late ${completion} from a replaced runtime cannot invalidate its successor`, async () => { + let hold = false, entered!: () => void, resolveOld!: (value: boolean) => void, rejectOld!: (error: Error) => void; + const enteredPromise = new Promise(resolve => { entered = resolve; }); + // The override isolates the runtime epoch fence: production probe.close() separately drains its child. + const io = fixture(true, undefined, () => { + if (!hold) return true; + entered(); return new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject; }); + }); + const read = async () => { + const pac = await fetch(io.runtime.getPacUrl()!).then(response => response.text()); + const port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + return fetch("https://chatgpt.com/backend-api/wham/usage", { + proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem }, + }).then(response => response.json()); + }; + await io.runtime.start(); await read(); hold = true; + const oldApply = io.runtime.apply(true).catch(error => error); + await enteredPromise; + try { + hold = false; await io.runtime.stop(); await io.runtime.start(); + expect((await io.runtime.apply(true)).accepted).toBe(false); + expect(await read()).toEqual(usage); + expect((await io.runtime.apply(true)).accepted).toBe(true); + const generation = io.runtime.status().usage!.generation; + if (completion === "success") resolveOld(true); else rejectOld(new Error("old fixture proof rejected")); + expect(await oldApply).toBeInstanceOf(Error); + expect(io.runtime.status().contextFailure).toBeNull(); + expect(io.runtime.status().usage).toMatchObject({ mode: "apply", generation }); + expect((await read()).rate_limit.allowed).toBe(true); + } finally { + resolveOld(true); await oldApply; await io.runtime.stop(); + } + }, 15_000); + } + for (const failure of ["same-version-replacement", "unverifiable", "ABA"] as const) { test(`${failure} revokes package-bound trial consent before another corrected response`, async () => { let current: DesktopAppInstall | null = assessedInstall(); diff --git a/tests/helpers/desktop-package-observation.ts b/tests/helpers/desktop-package-observation.ts index e23209ef249..89af754625c 100644 --- a/tests/helpers/desktop-package-observation.ts +++ b/tests/helpers/desktop-package-observation.ts @@ -10,5 +10,5 @@ export function desktopPackageObservation(version = DESKTOP_COMPATIBILITY_ASSESS proof: { packageFullName, publisherId: "2p2nqsd0c76g0", publisher: "CN=Fixture Registered Publisher", manifestName: "OpenAI.Codex", manifestPublisher: "CN=Fixture Registered Publisher", manifestVersion: version, appId: "App", executable: "app\\ChatGPT.exe", canonicalExecutable: win32.join(root, "app", "ChatGPT.exe"), - manifestDigest: "a".repeat(64), executableIdentity: "1024:100:200" } }; + manifestDigest: "a".repeat(64), executableIdentity: "1024:100:200", ancestryIdentity: "c".repeat(64) } }; }