diff --git a/src/main/host/deferred-secret-protection-report.test.ts b/src/main/host/deferred-secret-protection-report.test.ts index 97141a6f736..2e70d5268a0 100644 --- a/src/main/host/deferred-secret-protection-report.test.ts +++ b/src/main/host/deferred-secret-protection-report.test.ts @@ -73,7 +73,11 @@ describe('scheduleSecretProtectionGapReport', () => { }) const schedule = (): void => - scheduleSecretProtectionGapReport({ dataFile, log: (m) => void logged.push(m) }) + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: true + }) /** Runs an already-queued setImmediate; the 1ms is slack, not a delay under test. */ const drain = (): void => void vi.advanceTimersByTime(1) @@ -127,4 +131,27 @@ describe('scheduleSecretProtectionGapReport', () => { vi.runAllTimers() expect(probes).toBe(1) }) + + it('reports inline in headless serve, before the runtime is advertised as ready', () => { + // Why not deferred: serve opens no window, so the fallback would fire only after + // clients could already have paired, and a frozen main thread reads as a dead host. + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: false + }) + expect(probes).toBe(1) + expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) + }) + + it('leaves no timer armed in headless serve', () => { + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: false + }) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(probes).toBe(1) + }) }) diff --git a/src/main/host/deferred-secret-protection-report.ts b/src/main/host/deferred-secret-protection-report.ts index cf2c7da2493..01a7277ec61 100644 --- a/src/main/host/deferred-secret-protection-report.ts +++ b/src/main/host/deferred-secret-protection-report.ts @@ -19,11 +19,27 @@ import { reportSecretProtectionGap } from './secret-protection-report' // serve has no window to wait on. const REPORT_FALLBACK_MS = 15_000 -export function scheduleSecretProtectionGapReport(options: { +export function scheduleSecretProtectionGapReport({ + deferUntilFirstWindow, + ...options +}: { dataFile: string force?: boolean log?: (message: string) => void + /** + * Why headless serve reports inline instead: it never opens a window, so the fallback + * timer is the only path — and by then the runtime has been advertised as ready and + * clients may have paired. Freezing the main thread under a live client stalls pings + * and PTY pumps, which reads as a dead host. Blocking before anything is advertised is + * the timing serve already had, and the safer of the two. + */ + deferUntilFirstWindow: boolean }): void { + if (!deferUntilFirstWindow) { + reportSecretProtectionGap(options) + return + } + let ran = false const run = (): void => { if (ran) { diff --git a/src/main/index.ts b/src/main/index.ts index dc8eeaccd91..39d27bada72 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2352,7 +2352,8 @@ void app.whenReady().then(async () => { // and must not gate the first window (STA-5765). scheduleSecretProtectionGapReport({ dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1' + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !isServeMode }) // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults // it. Left unbound it reports nothing trusted, which is safe but silently discards our own