diff --git a/src/main/host/deferred-secret-protection-report.test.ts b/src/main/host/deferred-secret-protection-report.test.ts new file mode 100644 index 00000000000..562f33eda60 --- /dev/null +++ b/src/main/host/deferred-secret-protection-report.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../../shared/secret-store' + +type Listener = (...args: unknown[]) => void + +const appListeners = new Map() + +vi.mock('electron', () => ({ + app: { + once: (event: string, listener: Listener) => { + const existing = appListeners.get(event) ?? [] + existing.push(listener) + appListeners.set(event, existing) + } + } +})) + +const { scheduleSecretProtectionGapReport } = await import('./deferred-secret-protection-report') + +/** Stands in for the BrowserWindow the app creates first. */ +function fakeWindow(): { once: (event: string, listener: Listener) => void; reveal: () => void } { + const listeners: Listener[] = [] + return { + once: (event, listener) => { + if (event === 'ready-to-show') { + listeners.push(listener) + } + }, + reveal: () => listeners.splice(0).forEach((listener) => listener()) + } +} + +function createWindow(): ReturnType { + const window = fakeWindow() + appListeners.get('browser-window-created')?.splice(0).forEach((listener) => listener({}, window)) + return window +} + +describe('scheduleSecretProtectionGapReport', () => { + let dir: string + let dataFile: string + let probes: number + let logged: string[] + + beforeEach(() => { + vi.useFakeTimers() + appListeners.clear() + dir = mkdtempSync(join(tmpdir(), 'orca-deferred-secret-report-')) + dataFile = join(dir, 'orca-data.json') + probes = 0 + logged = [] + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(plainText), + decryptString: (cipher) => cipher.toString(), + describeProtectionGap: () => { + // Why count here: this is the call that blocks on the OS keyring. + probes += 1 + return 'The OS keyring is unavailable.' + } + }) + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + }) + + const schedule = (): void => + scheduleSecretProtectionGapReport({ dataFile, log: (m) => void logged.push(m) }) + + it('does not probe the keyring while the first window is still being created', () => { + // Why this is the regression: probing here blocked the window for 76s on a locked + // Linux keyring, which reads to the user as "the app will not open" (STA-5765). + schedule() + createWindow() + expect(probes).toBe(0) + expect(logged).toEqual([]) + }) + + it('probes once the window is ready to show', () => { + schedule() + const window = createWindow() + window.reveal() + vi.runAllTimers() + expect(probes).toBe(1) + expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) + }) + + it('still reports when ready-to-show never fires, so a failed reveal is not silent', () => { + // Why: a GPU/driver failure can keep ready-to-show from ever firing, and headless + // serve has no window at all. + schedule() + createWindow() + vi.advanceTimersByTime(15_000) + vi.runAllTimers() + expect(probes).toBe(1) + }) + + it('probes only once when the window reveals and the fallback also elapses', () => { + schedule() + const window = createWindow() + window.reveal() + vi.runAllTimers() + vi.advanceTimersByTime(60_000) + 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 new file mode 100644 index 00000000000..cf2c7da2493 --- /dev/null +++ b/src/main/host/deferred-secret-protection-report.ts @@ -0,0 +1,46 @@ +import { app, type BrowserWindow } from 'electron' +import { reportSecretProtectionGap } from './secret-protection-report' + +/** + * Run the at-rest secret protection report once the app is up, never before. + * + * Why deferred: `describeProtectionGap()` asks Electron `safeStorage` whether the OS + * keyring is usable, and on Linux that is a blocking D-Bus round trip to + * `org.freedesktop.secrets`. A keyring that is present but locked with no unlock + * prompter never answers, so the call sits until D-Bus times it out — measured at 76s + * to first window on Ubuntu 24.04, against 1s on the build before the report existed + * (STA-5765). Run before the first window, that reads to the user as "the app will not + * open"; the window is gated behind a diagnostic whose result nothing on the startup + * path consumes. + */ + +// Why a fallback as well as the window event: `ready-to-show` can fail to fire at all +// when the GPU/driver cannot present (see main-window-state-lifecycle), and headless +// serve has no window to wait on. +const REPORT_FALLBACK_MS = 15_000 + +export function scheduleSecretProtectionGapReport(options: { + dataFile: string + force?: boolean + log?: (message: string) => void +}): void { + let ran = false + const run = (): void => { + if (ran) { + return + } + ran = true + clearTimeout(fallback) + // Why setImmediate: keep the blocking keyring probe off the event handler that + // reveals the window, so the reveal paints first. + setImmediate(() => { + reportSecretProtectionGap(options) + }) + } + + const fallback = setTimeout(run, REPORT_FALLBACK_MS) + fallback.unref?.() + app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => { + window.once('ready-to-show', run) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 79f3f53f3a5..dc8eeaccd91 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,7 +36,7 @@ import { setSpeechServiceFactories } from './speech/speech-runtime-service' import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' -import { reportSecretProtectionGap } from './host/secret-protection-report' +import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' @@ -2346,9 +2346,11 @@ void app.whenReady().then(async () => { dataFile: activeOrcaProfile.dataFile, storageAuthority: isServeMode ? 'runtime' : 'desktop' }) - // Why here and not at install time: the report remembers what it last said, and that - // state lives beside the profile data file, which does not exist until now. - reportSecretProtectionGap({ + // Why armed here and not at install time: the report remembers what it last said, and + // that state lives beside the profile data file, which does not exist until now. + // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux + // and must not gate the first window (STA-5765). + scheduleSecretProtectionGapReport({ dataFile: activeOrcaProfile.dataFile, force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1' })