Sentinel Fallback Validation — Exercising the PoW Path on macOS
Runtime validation of the sentinel/PoW fallback by flipping the DeviceCheck gate — real fingerprint values, real headers, placeholder confirmation
Sentinel Fallback Validation — Exercising the PoW Path on macOS
Part of openai-research. Method: bezetacil v4 runtime flip of the isDeviceCheckSupported gate, then observing the resulting network flow.
The gate and its flip
The app's vPr() gate in the preload:
isDeviceCheckSupported: () => process.platform === `darwin` && process.arch === `arm64`,
vPr() = getSentryInitOptions().appVersion !== '0.0.0' && isDeviceCheckSupported().
To force the fallback on macOS, a bezetacil preload added via session.setPreloads flips the check:
// flip preload — registered into the session before the app preload runs
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: true });
Object.defineProperty(process, 'arch', { value: 'x64', configurable: true, writable: true });
} catch (e) {}
Registered via the bezetacil REST eval:
session.setPreloads([...session.getPreloads(), '/Users/alefita/.bezetacil-chatgpt/scripts/flip-devicecheck-preload.js'])
webContents.reloadIgnoringCache()
Result verified: window.electronBridge.isDeviceCheckSupported() → false after the reload.
The observed fallback flow (post-flip)
The app switched from the attestation flow to the sentinel flow. Observed requests (real captured traces, 2026-08-26 18:35):
POST https://chatgpt.com/backend-api/sentinel/chat-requirements/prepare (×2, no attestation_challenge)
POST /backend-api/f/conversation/prepare (the normal prepare — no attestation present)
Sentinel prepare headers (the fallback surface)
OAI-Language: en-US
oai-did: a05f9cdd-ca51-4a91-9da1-9c374dfe0536
originator: Codex Browser ← (NOT "Codex Desktop" — the sentinel path uses browser originator!)
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36
sec-ch-ua: "Chromium";v="151", "Google Chrome";v="151", "Not=A?Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Content-Type: application/json
Authorization: Bearer <same access token>
ChatGPT-Account-Id: <account id>
The real requirementsKey fingerprint (decoded base64)
gAAAAAC + base64(JSON(fingerprint)):
0: "Wed Aug 26 2026 15:35:12 GMT-0300 (Brasilia Standard Time)" ← current date
1: 0.4…
2: 4395630592 ← real heap size
3: 1 ← counter
4: "Mozilla/5.0 (Macintosh…) Chrome/151.0.0.0 Safari/537.36"
5: "app://-/assets/index-rLYmMQgS.js" ← real webview chunk!
6: "<!-- PROD_BUILD_TAG_HERE -->" ← THE LITERAL PLACEHOLDER — CONFIRMED OUT OF THE CLIENT
7: "en-US"
8: "en-US,en"
9: 0.6…
...
Confirmed: the literal placeholder <!-- PROD_BUILD_TAG_HERE --> IS the value that leaves the client in slot 6. The static asar analysis was correct; the runtime trace now proves it. The earlier claim (that a client would never send a placeholder) was disproven — the deployed app runs with the placeholder tag intact.
Server behavior: the /prepare did NOT reject the fingerprint containing the placeholder — the server accepted it (the sentinel prepare succeeded post-flip). This validates that slot 5/6 validation is either lenient for desktop or not enforced for this deployment.
The PoW solver chain (stack captured)
net.fetch ← net.fetch ← net.fetch ← y ← hI.performDesktopFetch ← hI.handleRequest ← kge.handleMessage
The nested net.fetch stack (from the three bezetacil hooks) shows the sentinel call path goes through the same performDesktopFetch, and the fingerprint is built at the call site.
What this validates
- The hierarchical flow confirmed: vPr() true → attestation flow (DeviceCheck); vPr() false → sentinel/PoW flow. Both exist and are exercisable; the flip on macOS selects the sentinel path.
- Originator switch: the sentinel path uses
originator: Codex Browser(notCodex Desktop), with a browser-like UA + full sec-ch-ua set. The attestation path usesCodex Desktop+Codex Desktop/26.818.61809 (Mac OS; arm64)UA. - The fingerprint real values: heap size 4395630592, chunk
app://-/assets/index-rLYmMQgS.js, placeholder literal, current date string, counter — all produced by the real app, matching the sPr() structure. - Data-build placeholder confirmed: slot 6 in the real fingerprint IS
<!-- PROD_BUILD_TAG_HERE -->— the client really sends it, and the server accepts it.
The sentinel heartbeat (analyzed — NOT part of the protocol)
POST /backend-api/sentinel/heartbeat — no body, originator: Codex Desktop + Bearer, fired every ~60s by the renderer (y_u(){ wb.safePost('/sentinel/heartbeat').catch(b_u) }, interval x_u=6e4), gated on authMethod === 'chatgpt' + config flag. It is fire-and-forget — the response is discarded (empty catch), nothing is stored, no key exchange, no PoW — a presence/telemetry ping only. Not needed by the conversation protocol (prepare/conversation never consume it). The probe/standalone does not need to send heartbeats.
Windows sandbox — what "Finish Windows setup" actually is
The flip made the app run the Windows sandbox onboarding (not a UAC prompt of the app per se — the UAC is the elevation the Windows sandbox setup needs). Evidence chain:
- App-server (Rust): methods
windowsSandbox/setupStart+windowsSandbox/readiness(JSON-RPC,ClientRequest::WindowsSandboxSetupStart/WindowsSandboxReadiness),core/src/windows_sandbox.rs,app-server/src/request_processors/windows_sandbox_processor.rs; featuresexperimental_windows_sandbox,elevated_windows_sandbox; requirementswindows.sandbox_private_desktop; metricscodex.windows_sandbox.fallback_prompt_shown,codex.windows_sandbox.elevated_setup_duration_ms - Renderer:
createWindowsSandboxSetupSession→startWindowsSandboxSetup(runtime lifecycle →windowsSandbox/setupStart); statsigcodex_windows_sandbox_setup_attempted {sandbox_mode} - Analytics event:
CodexWindowsSandboxStateObservedwithreadiness: CODEX_WINDOWS_SANDBOX_READINESS_NOT_CONFIGURED(protobuf enum: UNSPECIFIED / READY / NOT_CONFIGURED) - Banner strings (i18n
codex.windowsSandboxBanner.*): title "Finish Windows setup to continue" with description "Primary status shown when Agent mode requires the Windows sandbox to be enabled"; states:backgroundSetup("{appName} is creating the sandbox required… may take a few minutes"),backupCta("Continue without administrator access" — theallowUnelevatedFallbackpath),readinessError,dismiss - What responding to the UAC does: the setup session runs
windowsSandbox/setupStart(the elevation creates/hatches the sandbox); the "Continue without administrator access" CTA is the unelevated fallback (allowUnelevatedFallback). The banner only clears whenreadiness= READY. On macOS (flip) it stays NOT_CONFIGURED — the setup cannot succeed because there is no Windows sandbox — so Agent mode stays blocked. This is a Windows-integration feature of the app-server, not a macOS permission issue (matches the hierarchy: macOS uses its own sandbox/executor; Linux uses bwrapuse_linux_sandbox_bwrap).
TypeScript types (the sentinel fallback surface)
// ── the gate and the flip ────────────────────────────────────────────────
export interface PreloadGate {
isDeviceCheckSupported: () => boolean // `platform === 'darwin' && arch === 'arm64'`
vPr: (appVersion: string) => boolean // isDeviceCheckSupported() && appVersion !== '0.0.0'
}
// ── sentinel prepare request ─────────────────────────────────────────────
export interface SentinelPrepareBody { p: string } // 'gAAAAAC' + base64(fingerprint JSON)
export interface SentinelPrepareHeaders {
'OAI-Language': 'en-US'
'oai-did': string
originator: 'Codex Browser' // NOT 'Codex Desktop' — the fallback originator
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36'
'sec-ch-ua': '"Chromium";v="151", "Google Chrome";v="151", "Not=A?Brand";v="24"'
'sec-ch-ua-mobile': '?0'
'sec-ch-ua-platform': '"macOS"'
'Content-Type': 'application/json'
Authorization: `Bearer ${string}`
'ChatGPT-Account-Id': string
}
// ── fingerprint (24 slots, the real values) ──────────────────────────────
export type FingerprintSlot = number | string | null | boolean | unknown[]
export interface Fingerprint {
0: number // screen.width + screen.height (derived from viewport)
1: string // new Date().toString()
2: number // performance.memory.jsHeapSizeLimit (real; 4395630592 observed)
3: number // counter (solver-mutated)
4: string // navigator.userAgent (webview UA observed)
5: string // one of the real webview script srcs ('app://-/assets/index-rLYmMQgS.js')
6: string // the data-build value — '<!-- PROD_BUILD_TAG_HERE -->' CONFIRMED leaving the client
7: string // navigator.language ('en-US')
8: string // navigator.languages ('en-US,en')
9: number // the time slot (solver-mutated)
10: unknown // random navigator prototype property + value
11: unknown // pick(keys(document))
12: unknown // pick(keys(window))
13: number // performance.now()
14: string // uuid
15: string // search params join ('')
16: number // hardwareConcurrency
17: number // performance.timeOrigin
18-23: unknown[] // flags: ai / createPRNG / cache / data / solana / dump / InstallTrigger
}
export type FingerprintArray = Array<FingerprintSlot & { [key: number]: unknown }>
// ── the PoW chain (captured stack) ───────────────────────────────────────
export interface PoWStack {
// net.fetch ← net.fetch ← net.fetch ← y ← hI.performDesktopFetch ← hI.handleRequest ← kge.handleMessage
frames: string[]
}
// ── the Windows sandbox (the flip side effect) ───────────────────────────
export type WindowsSandboxReadiness =
| 'CODEX_WINDOWS_SANDBOX_READINESS_UNSPECIFIED'
| 'CODEX_WINDOWS_SANDBOX_READINESS_READY'
| 'CODEX_WINDOWS_SANDBOX_READINESS_NOT_CONFIGURED'
export interface WindowsSandboxEvents {
setupStart: { mode: string; allowUnelevatedFallback: boolean }
readiness: { readiness: WindowsSandboxReadiness }
}
export const WINDOWS_SANDBOX_BANNER_IDS = {
title: 'codex.windowsSandboxBanner.title', // "Finish Windows setup to continue"
backgroundSetupTitle: 'codex.windowsSandboxBanner.backgroundSetup.title',
backgroundSetupDetail: 'codex.windowsSandboxBanner.backgroundSetup.detail',
backupCta: 'codex.windowsSandboxBanner.backupCta', // "Continue without administrator access"
readinessError: 'codex.windowsSandboxBanner.readinessError',
} as const
Cross-references
- attestation-flow — the primary Apple path
- sentinel-fingerprint-comparison — the desktop vs OmniRoute fingerprint
- chat-submode-protocol — the protocol where these requests appear
- bezetacil — the runtime harness used
The platform-persisted onboarding side effect
Flipping the process.platform gate (to force the sentinel path on macOS) had a
side effect: the app re-ran the "Finish Windows setup" onboarding screen — a
User Account Control prompt for ChatGPT and a "Click Yes in the next step to
finish setup" confirmation. This proves the app keeps persisted platform
state beyond the in-memory gate: the sentinel path is not merely a
"non-Apple" fallback — it has its own per-platform onboarding bootstrap.
Correction on "defaults to Windows": the flip preload sets
process.platform = 'win32' explicitly — the app did NOT pick Windows by
itself. The Windows sandbox behavior is the app-server agent mode following the
declared platform (windows_sandbox feature + UAC elevation, see below). A
future experiment for the B flow: flip to 'linux' instead (the app-server has
use_linux_sandbox_bwrap; the agent mode likely requires bwrap → expected
failure on macOS — test carefully under the same discipline).
The state was cleaned by disabling the flip preload (.js.disabled) and
relaunching the app; the canonical state returned to the attestation flow
(UA Codex Desktop/26.820.60940 (Mac OS; arm64) — the app self-updated during
the session, 26.818 → 26.820).