From ddd34a98f3e80783245ec8973365f71d29b7e9da Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:37:21 -0700 Subject: [PATCH] fix(desktop): harden Windows sandbox fallback against false-positive sandbox loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the salvaged #66803 (@HexLab98): - Two-strike boot marker: a single mid-boot abort (task-manager kill, power loss) no longer disables the sandbox — only a second consecutive abort, or a signature-confirmed GPU/renderer STATUS_BREAKPOINT death, engages --no-sandbox. - Version-scoped stickiness: the fallback marker records the app version and re-probes the sandbox once after an update (new Electron or installer ACL repair may have fixed the host) instead of degrading forever. A failed re-probe returns straight to fallback. - Launch-time icacls repair now runs only when the marker shows a prior aborted boot (icacls /T recurses the whole install tree — healthy launches skip it; the installer grants the ACE at install time), and targets the install dir only. The userData grant is dropped: granting S-1-15-2-2 RX on userData would expose Hermes sessions/config to every AppContainer app on the machine. - Renderer crash-loop recovery (same class as #56726, credit @Sahil-SS9 in PR #57414): a Windows renderer crash loop bearing the breakpoint exit code gets the same one-shot --no-sandbox relaunch instead of a dead window; unrelated crash loops keep the sandbox. - Manual --no-sandbox launches are honored but never made sticky. Tests: 15/15 windows-sandbox-fallback vitest; full desktop electron suite 432 passed / 1 skipped. --- apps/desktop/electron/main.ts | 109 ++++++-- .../electron/windows-sandbox-fallback.test.ts | 264 ++++++++++++++---- .../electron/windows-sandbox-fallback.ts | 251 +++++++++++++---- 3 files changed, 492 insertions(+), 132 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4eeb3a01d889..5df87851c7b8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -147,13 +147,16 @@ import { import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, + decideWindowsSandboxLaunch, + fallbackMarker, grantAllApplicationPackagesAcl, markerAfterSuccessfulBoot, - nextSandboxMarkerAfterLaunchDecision, readSandboxMarker, - shouldEnableWindowsNoSandbox, + shouldAttemptAclRepair, shouldRelaunchForGpuSandboxCrash, - writeSandboxMarker + shouldRelaunchForRendererSandboxCrashLoop, + writeSandboxMarker, + type SandboxFallbackReason } from './windows-sandbox-fallback' import { installWindowsSystemCaTrust } from './windows-system-ca' import { readWindowsUserEnvVar } from './windows-user-env' @@ -222,33 +225,52 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { // 0x80000003. After enough GPU deaths the browser process FATAL-exits before the // UI is usable. Must run before app `ready` so `--no-sandbox` applies to child // processes. The sticky marker recovers Start Menu / shortcut launches that -// never go through `hermes desktop`. +// never go through `hermes desktop`; it is version-scoped so an app update +// re-probes the sandbox instead of degrading forever. +// +// `windowsSandboxFallbackActive` = this process runs without the Chromium +// sandbox (any cause, including a manual --no-sandbox flag) — guards the +// relaunch handlers. `windowsSandboxFallbackSticky` = the fallback machinery +// engaged and the marker must stay `fallback` after a successful boot; a +// manual flag alone is honored but never made sticky. let windowsSandboxFallbackActive = false +let windowsSandboxFallbackSticky = false +let windowsSandboxFallbackReason: SandboxFallbackReason = 'boot-loop' let windowsNoSandboxRelaunchAttempted = false if (IS_WINDOWS) { const windowsUserData = app.getPath('userData') - const exeDir = path.dirname(process.execPath) + const priorMarker = readSandboxMarker(windowsUserData) - // Best-effort ACL repair first — may be enough without disabling the sandbox. - for (const target of [exeDir, windowsUserData]) { - const acl = grantAllApplicationPackagesAcl(target, { execFileSync }) + // Best-effort ACL repair, only when the last boot aborted or the fallback is + // engaged — icacls /T recurses the whole install tree, so healthy launches + // skip it (the installer already granted the ACE at install time). Repair + // targets the install dir only: granting AppContainer read on userData would + // expose Hermes sessions/config to every packaged app on the machine. + if (shouldAttemptAclRepair(priorMarker)) { + const exeDir = path.dirname(process.execPath) + const acl = grantAllApplicationPackagesAcl(exeDir, { execFileSync }) if (acl.ok) { - console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${target}`) + console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${exeDir} (#38216)`) } else if (acl.error && acl.error !== 'missing-target-or-exec') { - console.warn(`[hermes] AppContainer ACL grant failed on ${target}: ${acl.error}`) + console.warn(`[hermes] AppContainer ACL grant failed on ${exeDir}: ${acl.error}`) } } - const priorMarker = readSandboxMarker(windowsUserData) - const sandboxDecision = shouldEnableWindowsNoSandbox({ + const sandboxDecision = decideWindowsSandboxLaunch({ argv: process.argv, env: process.env, - marker: priorMarker + marker: priorMarker, + appVersion: app.getVersion() }) windowsSandboxFallbackActive = sandboxDecision.enable + windowsSandboxFallbackSticky = sandboxDecision.nextMarker.state === 'fallback' + + if (sandboxDecision.nextMarker.state === 'fallback' && sandboxDecision.nextMarker.reason) { + windowsSandboxFallbackReason = sandboxDecision.nextMarker.reason + } if (sandboxDecision.enable && sandboxDecision.reason !== 'already-enabled') { app.commandLine.appendSwitch('no-sandbox') @@ -258,9 +280,7 @@ if (IS_WINDOWS) { ) } - writeSandboxMarker(windowsUserData, nextSandboxMarkerAfterLaunchDecision({ - enabledNoSandbox: windowsSandboxFallbackActive - })) + writeSandboxMarker(windowsUserData, sandboxDecision.nextMarker) // Catch the first GPU breakpoint death and relaunch before Chromium's // "GPU process isn't usable" FATAL abort ends the process with no recovery. @@ -277,9 +297,11 @@ if (IS_WINDOWS) { windowsNoSandboxRelaunchAttempted = true windowsSandboxFallbackActive = true + windowsSandboxFallbackSticky = true + windowsSandboxFallbackReason = 'gpu-breakpoint' try { - writeSandboxMarker(app.getPath('userData'), { state: 'fallback' }) + writeSandboxMarker(app.getPath('userData'), fallbackMarker('gpu-breakpoint', app.getVersion())) } catch { void 0 } @@ -7421,12 +7443,17 @@ function createWindow() { // #38216: clear the mid-boot marker only after a window is actually usable. // Keep sticky `fallback` when we launched with --no-sandbox so the next - // Start Menu click does not re-enter the GPU FATAL crash loop. + // Start Menu click does not re-enter the GPU FATAL crash loop. The marker + // records the app version so the next update re-probes the sandbox. if (IS_WINDOWS) { try { writeSandboxMarker( app.getPath('userData'), - markerAfterSuccessfulBoot({ fallbackActive: windowsSandboxFallbackActive }) + markerAfterSuccessfulBoot({ + fallbackActive: windowsSandboxFallbackSticky, + reason: windowsSandboxFallbackReason, + appVersion: app.getVersion() + }) ) } catch (error) { rememberLog(`[sandbox] marker update after ready-to-show failed: ${error?.message || error}`) @@ -7473,6 +7500,46 @@ function createWindow() { `[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)` ) + // #38216 renderer flavor (same recovery as #56726, credit @Sahil-SS9): + // a deterministic Windows renderer crash loop with the sandbox + // breakpoint signature gets one --no-sandbox relaunch instead of a + // dead window. Gated on the exit code so unrelated crash loops don't + // silently drop the sandbox. + if ( + shouldRelaunchForRendererSandboxCrashLoop({ + reason: details?.reason, + exitCode: details?.exitCode, + alreadyNoSandbox: + windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env), + relaunchAttempted: windowsNoSandboxRelaunchAttempted + }) + ) { + windowsNoSandboxRelaunchAttempted = true + windowsSandboxFallbackActive = true + windowsSandboxFallbackSticky = true + windowsSandboxFallbackReason = 'renderer-crash-loop' + + try { + writeSandboxMarker( + app.getPath('userData'), + fallbackMarker('renderer-crash-loop', app.getVersion()) + ) + } catch { + void 0 + } + + rememberLog( + '[renderer] Windows sandbox crash loop detected; relaunching once with --no-sandbox (#38216)' + ) + + try { + app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) }) + app.exit(0) + } catch (err) { + rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`) + } + } + return } @@ -9366,7 +9433,9 @@ function configureSpellChecker() { app.on('before-quit', () => { // Clean quit mid-boot should not trip next-launch --no-sandbox (#38216). // FATAL GPU aborts skip before-quit, leaving the `booting` marker in place. - if (IS_WINDOWS && !windowsSandboxFallbackActive) { + // Keyed on sticky (not active): a manual --no-sandbox run still records a + // clean quit, while an engaged fallback keeps its sticky marker. + if (IS_WINDOWS && !windowsSandboxFallbackSticky) { try { writeSandboxMarker(app.getPath('userData'), markerAfterSuccessfulBoot({ fallbackActive: false })) } catch { diff --git a/apps/desktop/electron/windows-sandbox-fallback.test.ts b/apps/desktop/electron/windows-sandbox-fallback.test.ts index 19206a496533..1b24aaa1769d 100644 --- a/apps/desktop/electron/windows-sandbox-fallback.test.ts +++ b/apps/desktop/electron/windows-sandbox-fallback.test.ts @@ -7,20 +7,23 @@ import { test } from 'vitest' import { ALL_APPLICATION_PACKAGES_SID, - WINDOWS_SANDBOX_BREAKPOINT_EXIT, - WINDOWS_SANDBOX_MARKER_FILENAME, alreadyHasNoSandbox, + BOOT_ABORTS_BEFORE_FALLBACK, buildIcaclsGrantArgs, buildNoSandboxRelaunchArgs, + decideWindowsSandboxLaunch, + fallbackMarker, grantAllApplicationPackagesAcl, isWindowsSandboxBreakpointExit, markerAfterSuccessfulBoot, - nextSandboxMarkerAfterLaunchDecision, parseSandboxMarker, readSandboxMarker, sandboxMarkerPath, - shouldEnableWindowsNoSandbox, + shouldAttemptAclRepair, shouldRelaunchForGpuSandboxCrash, + shouldRelaunchForRendererSandboxCrashLoop, + WINDOWS_SANDBOX_BREAKPOINT_EXIT, + WINDOWS_SANDBOX_MARKER_FILENAME, writeSandboxMarker } from './windows-sandbox-fallback' @@ -39,60 +42,148 @@ test('alreadyHasNoSandbox honors argv and ELECTRON_DISABLE_SANDBOX', () => { assert.equal(alreadyHasNoSandbox(['--disable-gpu'], {}), false) }) -test('shouldEnableWindowsNoSandbox stays off outside Windows and on clean markers', () => { - assert.deepEqual( - shouldEnableWindowsNoSandbox({ platform: 'linux', marker: { state: 'booting' } }), - { enable: false, reason: null } - ) - assert.deepEqual( - shouldEnableWindowsNoSandbox({ platform: 'win32', marker: { state: 'ok' }, argv: [], env: {} }), - { enable: false, reason: null } - ) - assert.deepEqual( - shouldEnableWindowsNoSandbox({ platform: 'win32', marker: null, argv: [], env: {} }), - { enable: false, reason: null } - ) +test('decideWindowsSandboxLaunch stays off outside Windows and on clean markers', () => { + assert.equal(decideWindowsSandboxLaunch({ platform: 'linux', marker: { state: 'booting' } }).enable, false) + + const cleanOk = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'ok' }, + argv: [], + env: {} + }) + + assert.equal(cleanOk.enable, false) + assert.deepEqual(cleanOk.nextMarker, { state: 'booting' }) + + const noMarker = decideWindowsSandboxLaunch({ platform: 'win32', marker: null, argv: [], env: {} }) + assert.equal(noMarker.enable, false) + assert.deepEqual(noMarker.nextMarker, { state: 'booting' }) }) -test('shouldEnableWindowsNoSandbox recovers from uncleared boot and sticky fallback', () => { - assert.deepEqual( - shouldEnableWindowsNoSandbox({ - platform: 'win32', - marker: { state: 'booting' }, - argv: [], - env: {} - }), - { enable: true, reason: 'uncleared-boot-marker' } - ) - assert.deepEqual( - shouldEnableWindowsNoSandbox({ - platform: 'win32', - marker: { state: 'fallback' }, - argv: [], - env: {} - }), - { enable: true, reason: 'sticky-fallback' } - ) - assert.deepEqual( - shouldEnableWindowsNoSandbox({ - platform: 'win32', - marker: { state: 'ok' }, - argv: ['--no-sandbox'], - env: {} - }), - { enable: true, reason: 'already-enabled' } - ) +test('a single mid-boot abort does NOT drop the sandbox (two-strike rule)', () => { + // First abort: prior launch left `booting` with no abort count. Could be a + // task-manager kill or power loss — sandbox stays ON, strike recorded. + const first = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'booting' }, + argv: [], + env: {} + }) + + assert.equal(first.enable, false) + assert.deepEqual(first.nextMarker, { state: 'booting', bootAborts: 1 }) + + // Second consecutive abort: deterministic crash loop → fallback engages. + const second = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: first.nextMarker, + argv: [], + env: {}, + appVersion: '1.2.3' + }) + + assert.equal(second.enable, true) + assert.equal(second.reason, 'boot-loop') + assert.deepEqual(second.nextMarker, { state: 'fallback', reason: 'boot-loop', version: '1.2.3' }) + + assert.equal(BOOT_ABORTS_BEFORE_FALLBACK, 2) }) -test('marker transitions preserve sticky fallback after a successful recovered boot', () => { - assert.deepEqual(nextSandboxMarkerAfterLaunchDecision({ enabledNoSandbox: false }), { - state: 'booting' +test('sticky fallback persists within one app version', () => { + const decision = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' }, + argv: [], + env: {}, + appVersion: '1.2.3' }) - assert.deepEqual(nextSandboxMarkerAfterLaunchDecision({ enabledNoSandbox: true }), { - state: 'fallback' + + assert.equal(decision.enable, true) + assert.equal(decision.reason, 'sticky-fallback') + assert.equal(decision.nextMarker.state, 'fallback') + assert.equal(decision.nextMarker.reason, 'gpu-breakpoint') +}) + +test('an app update re-probes the sandbox once instead of degrading forever', () => { + // Version changed since the fallback engaged → probe with sandbox ON. + const reprobe = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'fallback', reason: 'boot-loop', version: '1.2.3' }, + argv: [], + env: {}, + appVersion: '1.3.0' }) + + assert.equal(reprobe.enable, false) + assert.equal(reprobe.nextMarker.state, 'booting') + assert.equal(reprobe.nextMarker.reprobe, true) + + // The re-probe boot aborted → straight back to fallback, no second strike. + const failedReprobe = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: reprobe.nextMarker, + argv: [], + env: {}, + appVersion: '1.3.0' + }) + + assert.equal(failedReprobe.enable, true) + assert.equal(failedReprobe.reason, 'reprobe-failed') + assert.equal(failedReprobe.nextMarker.state, 'fallback') + assert.equal(failedReprobe.nextMarker.version, '1.3.0') + + // A legacy fallback marker without a version stays sticky (no re-probe). + const legacy = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'fallback' }, + argv: [], + env: {}, + appVersion: '1.3.0' + }) + + assert.equal(legacy.enable, true) + assert.equal(legacy.reason, 'sticky-fallback') +}) + +test('manual --no-sandbox is honored but never made sticky', () => { + const manual = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'ok' }, + argv: ['--no-sandbox'], + env: {} + }) + + assert.equal(manual.enable, true) + assert.equal(manual.reason, 'already-enabled') + assert.equal(manual.nextMarker.state, 'booting') + + // But a relaunch-written fallback marker is preserved through the flagged boot. + const relaunched = decideWindowsSandboxLaunch({ + platform: 'win32', + marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' }, + argv: ['--no-sandbox'], + env: {}, + appVersion: '1.2.3' + }) + + assert.equal(relaunched.enable, true) + assert.equal(relaunched.nextMarker.state, 'fallback') +}) + +test('marker transitions after a successful boot', () => { assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: false }), { state: 'ok' }) - assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: true }), { state: 'fallback' }) + assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: true, reason: 'gpu-breakpoint', appVersion: '1.2.3' }), { + state: 'fallback', + reason: 'gpu-breakpoint', + version: '1.2.3' + }) +}) + +test('shouldAttemptAclRepair only fires on evidence of trouble', () => { + assert.equal(shouldAttemptAclRepair(null), false) + assert.equal(shouldAttemptAclRepair({ state: 'ok' }), false) + assert.equal(shouldAttemptAclRepair({ state: 'booting' }), true) + assert.equal(shouldAttemptAclRepair({ state: 'fallback' }), true) }) test('sandbox marker round-trips through the userData file', () => { @@ -102,10 +193,22 @@ test('sandbox marker round-trips through the userData file', () => { assert.equal(sandboxMarkerPath(dir), path.join(dir, WINDOWS_SANDBOX_MARKER_FILENAME)) assert.equal(readSandboxMarker(dir), null) - writeSandboxMarker(dir, { state: 'booting' }) - assert.deepEqual(readSandboxMarker(dir), { state: 'booting' }) + writeSandboxMarker(dir, { state: 'booting', bootAborts: 1 }) + assert.deepEqual(readSandboxMarker(dir), { state: 'booting', bootAborts: 1 }) + + writeSandboxMarker(dir, fallbackMarker('renderer-crash-loop', '1.2.3')) + assert.deepEqual(readSandboxMarker(dir), { + state: 'fallback', + reason: 'renderer-crash-loop', + version: '1.2.3' + }) + assert.equal(parseSandboxMarker({ state: 'fallback' })?.state, 'fallback') assert.equal(parseSandboxMarker({ state: 'nope' }), null) + // Unknown reason strings and junk fields are dropped, not fatal. + assert.deepEqual(parseSandboxMarker({ state: 'fallback', reason: 'weird', bootAborts: -3 }), { + state: 'fallback' + }) } finally { fs.rmSync(dir, { recursive: true, force: true }) } @@ -126,6 +229,7 @@ test('grantAllApplicationPackagesAcl is a no-op off Windows and reports exec fai assert.deepEqual(grantAllApplicationPackagesAcl('C:\\x', { platform: 'darwin' }), { ok: false }) const calls: Array<{ file: string; args: readonly string[] }> = [] + const ok = grantAllApplicationPackagesAcl('C:\\Hermes', { platform: 'win32', execFileSync(file, args) { @@ -199,6 +303,60 @@ test('shouldRelaunchForGpuSandboxCrash only fires once for GPU breakpoint deaths ) }) +test('renderer crash-loop relaunch requires the sandbox breakpoint signature', () => { + assert.equal( + shouldRelaunchForRendererSandboxCrashLoop({ + platform: 'win32', + reason: 'crashed', + exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT, + alreadyNoSandbox: false, + relaunchAttempted: false + }), + true + ) + // Unrelated renderer crash loops (plain crash, OOM churn) keep the sandbox. + assert.equal( + shouldRelaunchForRendererSandboxCrashLoop({ + platform: 'win32', + reason: 'crashed', + exitCode: 1, + alreadyNoSandbox: false, + relaunchAttempted: false + }), + false + ) + assert.equal( + shouldRelaunchForRendererSandboxCrashLoop({ + platform: 'win32', + reason: 'oom', + exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT, + alreadyNoSandbox: false, + relaunchAttempted: false + }), + false + ) + assert.equal( + shouldRelaunchForRendererSandboxCrashLoop({ + platform: 'win32', + reason: 'crashed', + exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT, + alreadyNoSandbox: true, + relaunchAttempted: false + }), + false + ) + assert.equal( + shouldRelaunchForRendererSandboxCrashLoop({ + platform: 'linux', + reason: 'crashed', + exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT, + alreadyNoSandbox: false, + relaunchAttempted: false + }), + false + ) +}) + test('buildNoSandboxRelaunchArgs appends a single --no-sandbox flag', () => { assert.deepEqual(buildNoSandboxRelaunchArgs(['--foo', '--no-sandbox', 'hermes://x']), [ '--foo', diff --git a/apps/desktop/electron/windows-sandbox-fallback.ts b/apps/desktop/electron/windows-sandbox-fallback.ts index f1ec674cd6ca..815f47189e1c 100644 --- a/apps/desktop/electron/windows-sandbox-fallback.ts +++ b/apps/desktop/electron/windows-sandbox-fallback.ts @@ -5,15 +5,21 @@ * (`0x80000003` / exit `-2147483645`). Chromium then FATAL-exits * ("GPU process isn't usable. Goodbye.") before the UI is usable. * - * Two complementary recoveries, both scoped to win32: + * Recovery ladder, all scoped to win32: * - * 1. Grant `S-1-15-2-2` (ALL APPLICATION PACKAGES) RX on the install / - * userData trees. Missing that ACE plus orphan AppContainer SIDs is a - * known Chromium CHECK failure (electron/electron#51761). - * 2. Sticky boot marker: write `booting` before sandbox bring-up; clear it - * only on a clean ready. An uncleared marker on the next launch means the - * previous process aborted mid-boot → enable `--no-sandbox` (the only flag - * reporters verified as fully stable for AMD RX 6000 / hybrid GPU hosts). + * 1. ACL repair (first line): grant `S-1-15-2-2` (ALL APPLICATION PACKAGES) + * RX on the install tree. A missing ACE plus orphan AppContainer SIDs is a + * known Chromium CHECK failure (electron/electron#51761). Runs at install + * time, and again at launch ONLY when the marker shows a prior aborted + * boot — never on healthy launches (icacls /T recursion is not free). + * 2. `--no-sandbox` (second line): enabled only on strong evidence — + * a signature-confirmed GPU/renderer breakpoint death, or TWO consecutive + * mid-boot aborts (a single abort can be a task-manager kill or power + * loss; the reported failure mode is a deterministic 100% crash loop). + * 3. The fallback is sticky per app version, not forever: after an update + * the sandbox is re-probed once (a new Electron or an installer-applied + * ACL grant may have fixed the host). If the re-probe boot aborts, the + * next launch goes straight back to `--no-sandbox`. * * Pure helpers stay injectable so tests never boot Electron or touch real ACLs. */ @@ -29,10 +35,24 @@ export const ALL_APPLICATION_PACKAGES_SID = 'S-1-15-2-2' /** STATUS_BREAKPOINT as a signed Win32 exit code (WER / Chromium). */ export const WINDOWS_SANDBOX_BREAKPOINT_EXIT = -2147483645 +/** Consecutive mid-boot aborts required before enabling --no-sandbox. */ +export const BOOT_ABORTS_BEFORE_FALLBACK = 2 + export type SandboxMarkerState = 'booting' | 'fallback' | 'ok' +export type SandboxFallbackReason = 'gpu-breakpoint' | 'renderer-crash-loop' | 'boot-loop' + export interface SandboxMarker { state: SandboxMarkerState + /** Why the fallback engaged (state === 'fallback'). */ + reason?: SandboxFallbackReason + /** App version that entered fallback — a version change triggers a re-probe. */ + version?: string + /** Consecutive aborted boots observed so far (state === 'booting'). */ + bootAborts?: number + /** This boot is a sandbox re-probe after an app update; an abort returns + * straight to fallback instead of restarting the two-strike count. */ + reprobe?: boolean } export function sandboxMarkerPath(userDataDir: string): string { @@ -47,13 +67,10 @@ export function isWindowsSandboxBreakpointExit(exitCode: unknown): boolean { } // Signed STATUS_BREAKPOINT, or the same 32-bit pattern as unsigned. - return n === WINDOWS_SANDBOX_BREAKPOINT_EXIT || (n >>> 0) === 0x80000003 + return n === WINDOWS_SANDBOX_BREAKPOINT_EXIT || n >>> 0 === 0x80000003 } -export function alreadyHasNoSandbox( - argv: readonly string[] = [], - env: NodeJS.ProcessEnv = process.env -): boolean { +export function alreadyHasNoSandbox(argv: readonly string[] = [], env: NodeJS.ProcessEnv = process.env): boolean { if (Array.isArray(argv) && argv.some(arg => arg === '--no-sandbox')) { return true } @@ -65,24 +82,44 @@ export function alreadyHasNoSandbox( return disable === '1' || disable === 'true' || disable === 'yes' || disable === 'on' } +const FALLBACK_REASONS: readonly string[] = ['gpu-breakpoint', 'renderer-crash-loop', 'boot-loop'] + export function parseSandboxMarker(raw: unknown): SandboxMarker | null { if (!raw || typeof raw !== 'object') { return null } - const state = (raw as { state?: unknown }).state + const record = raw as Record + const state = record.state - if (state === 'booting' || state === 'fallback' || state === 'ok') { - return { state } + if (state !== 'booting' && state !== 'fallback' && state !== 'ok') { + return null } - return null + const marker: SandboxMarker = { state } + + if (typeof record.reason === 'string' && FALLBACK_REASONS.includes(record.reason)) { + marker.reason = record.reason as SandboxFallbackReason + } + + if (typeof record.version === 'string' && record.version) { + marker.version = record.version + } + + const aborts = Number(record.bootAborts) + + if (Number.isInteger(aborts) && aborts > 0) { + marker.bootAborts = aborts + } + + if (record.reprobe === true) { + marker.reprobe = true + } + + return marker } -export function readSandboxMarker( - userDataDir: string, - { readFileSync = fs.readFileSync } = {} -): SandboxMarker | null { +export function readSandboxMarker(userDataDir: string, { readFileSync = fs.readFileSync } = {}): SandboxMarker | null { try { const raw = JSON.parse(readFileSync(sandboxMarkerPath(userDataDir), 'utf8')) @@ -113,64 +150,138 @@ export function writeSandboxMarker( writeFileSync(sandboxMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8') } +export interface SandboxLaunchDecision { + enable: boolean + reason: string | null + /** Marker to persist immediately, before GPU/sandbox children start. */ + nextMarker: SandboxMarker +} + /** - * Decide whether this Windows launch should disable the Chromium sandbox. + * Single launch-time transition: decide whether this Windows launch disables + * the Chromium sandbox AND what the marker becomes for crash-detection on the + * next launch. * - * `booting` left from a prior launch → previous process aborted before ready. - * `fallback` → we already recovered once; keep the workaround sticky so the - * Start Menu shortcut does not crash every other launch. + * - `booting` left behind → the prior launch aborted mid-boot. One abort is + * tolerated (could be a kill/power loss); the SECOND consecutive abort — or + * a single abort during a post-update re-probe — engages the fallback. + * - `fallback` is sticky within one app version. A version change re-probes + * the sandbox once so a fixed host (new Electron, installer ACL repair) + * returns to full sandboxing instead of degrading forever. + * - A manual `--no-sandbox` / ELECTRON_DISABLE_SANDBOX launch is honored but + * NOT made sticky: the marker keeps its normal lifecycle so the flag's + * removal restores the sandbox. */ -export function shouldEnableWindowsNoSandbox(options: { - platform?: NodeJS.Platform | string - argv?: readonly string[] - env?: NodeJS.ProcessEnv - marker?: SandboxMarker | null -} = {}): { enable: boolean; reason: string | null } { +export function decideWindowsSandboxLaunch( + options: { + platform?: NodeJS.Platform | string + argv?: readonly string[] + env?: NodeJS.ProcessEnv + marker?: SandboxMarker | null + appVersion?: string + } = {} +): SandboxLaunchDecision { + const appVersion = String(options.appVersion || '') + if ((options.platform ?? process.platform) !== 'win32') { - return { enable: false, reason: null } + return { enable: false, reason: null, nextMarker: { state: 'booting' } } } const argv = options.argv ?? process.argv const env = options.env ?? process.env + const marker = options.marker ?? null if (alreadyHasNoSandbox(argv, env)) { - return { enable: true, reason: 'already-enabled' } + // Honor the explicit flag; keep the marker lifecycle unchanged. When the + // relaunch path set the flag, the fallback marker it wrote is preserved. + const nextMarker: SandboxMarker = marker?.state === 'fallback' ? marker : { state: 'booting' } + + return { enable: true, reason: 'already-enabled', nextMarker } } - const state = options.marker?.state + if (marker?.state === 'fallback') { + if (marker.version && appVersion && marker.version !== appVersion) { + // App updated since the fallback engaged — re-probe the sandbox once. + return { + enable: false, + reason: null, + nextMarker: { state: 'booting', reprobe: true, bootAborts: 0 } + } + } - if (state === 'booting') { - return { enable: true, reason: 'uncleared-boot-marker' } + return { + enable: true, + reason: 'sticky-fallback', + nextMarker: { ...marker, version: marker.version || appVersion || undefined } + } } - if (state === 'fallback') { - return { enable: true, reason: 'sticky-fallback' } + if (marker?.state === 'booting') { + const abortsObserved = (marker.bootAborts ?? 0) + 1 + + if (marker.reprobe) { + // The one post-update sandboxed re-probe aborted → back to fallback. + return { + enable: true, + reason: 'reprobe-failed', + nextMarker: fallbackMarker('boot-loop', appVersion) + } + } + + if (abortsObserved >= BOOT_ABORTS_BEFORE_FALLBACK) { + return { + enable: true, + reason: 'boot-loop', + nextMarker: fallbackMarker('boot-loop', appVersion) + } + } + + return { + enable: false, + reason: null, + nextMarker: { state: 'booting', bootAborts: abortsObserved } + } } - return { enable: false, reason: null } + // No marker, or a clean `ok` from the previous run. + return { enable: false, reason: null, nextMarker: { state: 'booting' } } +} + +export function fallbackMarker(reason: SandboxFallbackReason, appVersion?: string): SandboxMarker { + const marker: SandboxMarker = { state: 'fallback', reason } + + if (appVersion) { + marker.version = appVersion + } + + return marker } /** - * Marker to persist immediately after the launch decision, before GPU/sandbox - * child processes start. Successful boots rewrite this later. + * After the main window reaches ready-to-show: keep the sticky fallback when + * we launched with `--no-sandbox`, otherwise mark a clean boot so future + * launches trust the sandbox again. */ -export function nextSandboxMarkerAfterLaunchDecision(options: { - enabledNoSandbox: boolean +export function markerAfterSuccessfulBoot(options: { + fallbackActive: boolean + reason?: SandboxFallbackReason + appVersion?: string }): SandboxMarker { - if (options.enabledNoSandbox) { - return { state: 'fallback' } + if (!options.fallbackActive) { + return { state: 'ok' } } - return { state: 'booting' } + return fallbackMarker(options.reason ?? 'boot-loop', options.appVersion) } /** - * After the main window reaches ready-to-show: keep sticky fallback when we - * launched with `--no-sandbox`, otherwise mark a clean boot so the next - * launch can try the sandbox again (e.g. after an ACL grant fixed the host). + * ACL repair is not free (`icacls /T` recurses the whole install tree), so it + * only runs when there is evidence of trouble: a prior launch aborted + * mid-boot, or the fallback already engaged. Healthy hosts never pay for it — + * the installer already granted the ACE at install time. */ -export function markerAfterSuccessfulBoot(options: { fallbackActive: boolean }): SandboxMarker { - return options.fallbackActive ? { state: 'fallback' } : { state: 'ok' } +export function shouldAttemptAclRepair(marker: SandboxMarker | null | undefined): boolean { + return marker?.state === 'booting' || marker?.state === 'fallback' } /** @@ -179,14 +290,7 @@ export function markerAfterSuccessfulBoot(options: { fallbackActive: boolean }): * errors; `/Q` stays quiet for installer logs. */ export function buildIcaclsGrantArgs(targetDir: string): string[] { - return [ - String(targetDir), - '/grant', - `*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`, - '/T', - '/C', - '/Q' - ] + return [String(targetDir), '/grant', `*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`, '/T', '/C', '/Q'] } export function grantAllApplicationPackagesAcl( @@ -252,6 +356,35 @@ export function shouldRelaunchForGpuSandboxCrash(options: { return isWindowsSandboxBreakpointExit(options.details?.exitCode) } +/** + * True when a renderer crash loop carries the sandbox breakpoint signature + * and a one-shot `--no-sandbox` relaunch should replace the dead window + * (#38216 renderer flavor; same recovery as #56726). Gated on the breakpoint + * exit code so unrelated renderer crash loops (bad extension, OOM churn) + * don't silently drop the sandbox. + */ +export function shouldRelaunchForRendererSandboxCrashLoop(options: { + platform?: NodeJS.Platform | string + reason?: string + exitCode?: number | string + alreadyNoSandbox?: boolean + relaunchAttempted?: boolean +}): boolean { + if ((options.platform ?? process.platform) !== 'win32') { + return false + } + + if (options.alreadyNoSandbox || options.relaunchAttempted) { + return false + } + + if (String(options.reason || '') !== 'crashed') { + return false + } + + return isWindowsSandboxBreakpointExit(options.exitCode) +} + export function buildNoSandboxRelaunchArgs(argv: readonly string[]): string[] { const args = (Array.isArray(argv) ? argv : []).filter(arg => arg !== '--no-sandbox')