mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(desktop): harden Windows sandbox fallback against false-positive sandbox loss
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.
This commit is contained in:
parent
2b0c4d69ea
commit
ddd34a98f3
3 changed files with 492 additions and 132 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
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')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue