From 642fe5eada50ce66dab55be474347d5ff2d85993 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:18:55 -0700 Subject: [PATCH] Report the keyring gap inline in headless serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring the probe to the first window is right for the desktop app, but serve never opens one, so the fallback timer became its only path. That moved the stall to after `printServeReady`: the runtime advertises itself, a relay or mobile client pairs, and only then does the main thread freeze on the keyring — stalling pings and PTY pumps, which a client reads as a dead host. Serve now reports inline, which is the timing it already had, and blocks before anything is advertised rather than under a live client. STA-5765 --- .../deferred-secret-protection-report.test.ts | 29 ++++++++++++++++++- .../host/deferred-secret-protection-report.ts | 18 +++++++++++- src/main/index.ts | 3 +- 3 files changed, 47 insertions(+), 3 deletions(-) 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