---
type: research
title: Sentinel Fallback Validation — Exercising the PoW Path on macOS
description: Runtime validation of the sentinel/PoW fallback by flipping the DeviceCheck gate — real fingerprint values, real headers, placeholder confirmation
tags: [openai, sentinel, pow, fallback, devicecheck, validation, fingerprint]
timestamp: 2026-08-26
---

# Sentinel Fallback Validation — Exercising the PoW Path on macOS

Part of [openai-research](openai-research.md). 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:

```js
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:

```js
// 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

1. **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.
2. **Originator switch**: the sentinel path uses `originator: Codex Browser` (not `Codex Desktop`), with a browser-like UA + full sec-ch-ua set. The attestation path uses `Codex Desktop` + `Codex Desktop/26.818.61809 (Mac OS; arm64)` UA.
3. **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.
4. **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`; features `experimental_windows_sandbox`, `elevated_windows_sandbox`; requirements `windows.sandbox_private_desktop`; metrics `codex.windows_sandbox.fallback_prompt_shown`, `codex.windows_sandbox.elevated_setup_duration_ms`
- **Renderer**: `createWindowsSandboxSetupSession` → `startWindowsSandboxSetup` (runtime lifecycle → `windowsSandbox/setupStart`); statsig `codex_windows_sandbox_setup_attempted {sandbox_mode}`
- **Analytics event**: `CodexWindowsSandboxStateObserved` with `readiness: 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" — the `allowUnelevatedFallback` path), `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 when `readiness` = 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 bwrap `use_linux_sandbox_bwrap`).

## TypeScript types (the sentinel fallback surface)

```ts
// ── 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](attestation-flow.md) — the primary Apple path
- [sentinel-fingerprint-comparison](sentinel-fingerprint-comparison.md) — the desktop vs OmniRoute fingerprint
- [chat-submode-protocol](chat-submode-protocol.md) — the protocol where these requests appear
- [bezetacil](../bezetacil/bezetacil.md) — 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).
