From 5debca3e29eebffa604f4f97dc09bdc74eb1f4f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:01:02 -0700 Subject: [PATCH] test(startup): pin the keyring-deferral gates and the dropped-lease save --- src/main/index.ts | 3 +- .../deferred-secret-hydration.test.ts | 50 ++++++++++++++++++- .../startup/keyring-probe-deferral.test.ts | 25 ++++++++++ src/main/startup/keyring-probe-deferral.ts | 21 ++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/main/startup/keyring-probe-deferral.test.ts create mode 100644 src/main/startup/keyring-probe-deferral.ts diff --git a/src/main/index.ts b/src/main/index.ts index 2d381070bcd..0f43efb4d15 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,6 +39,7 @@ import { ElectronSecretStore } from './host/electron-secret-store' import { reportSecretProtectionGap } from './host/secret-protection-report' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { deferUntilFirstWindowShown } from './window/defer-until-first-window-shown' +import { shouldDeferKeyringProbe } from './startup/keyring-probe-deferral' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' @@ -2347,7 +2348,7 @@ void app.whenReady().then(async () => { // OS keyring — on Linux a blocking D-Bus round trip that a locked keyring never answers, run // here with no window on screen to explain the wait (STA-5782). Serve is excluded: it opens no // window, so deferring would move the block past the point clients can already pair. - const deferKeyringProbe = process.platform === 'linux' && !isServeMode + const deferKeyringProbe = shouldDeferKeyringProbe({ platform: process.platform, isServeMode }) store = new Store({ dataFile: activeOrcaProfile.dataFile, storageAuthority: isServeMode ? 'runtime' : 'desktop', diff --git a/src/main/persistence/loading-store/deferred-secret-hydration.test.ts b/src/main/persistence/loading-store/deferred-secret-hydration.test.ts index dfb4d96d48e..58a0b953999 100644 --- a/src/main/persistence/loading-store/deferred-secret-hydration.test.ts +++ b/src/main/persistence/loading-store/deferred-secret-hydration.test.ts @@ -37,6 +37,7 @@ function seal(plaintext: string): string { } let probes = 0 +let encryptions = 0 function installBlockingKeyring(): void { setSecretStore({ @@ -51,13 +52,16 @@ function installBlockingKeyring(): void { } return true }, - encryptString: (plainText) => Buffer.from(`encrypted:${plainText}`, 'utf-8'), + // Why a nonce: a real keyring seals with a fresh IV, so re-encrypting a value yields + // different bytes. Without that, a save that decrypted and re-encrypted a secret would be + // indistinguishable from one that preserved the stored ciphertext untouched. + encryptString: (plainText) => Buffer.from(`encrypted:${plainText}#${++encryptions}`, 'utf-8'), decryptString: (cipher) => { const decoded = cipher.toString('utf-8') if (!decoded.startsWith('encrypted:')) { throw new Error('invalid ciphertext') } - return decoded.slice('encrypted:'.length) + return decoded.slice('encrypted:'.length).replace(/#\d+$/, '') }, describeProtectionGap: () => null }) @@ -90,6 +94,7 @@ describe('deferred protected-secret hydration', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-deferred-secret-')) probes = 0 + encryptions = 0 installBlockingKeyring() }) @@ -199,6 +204,47 @@ describe('deferred protected-secret hydration', () => { store.freezeWrites() }) + it('persists the drop when hydration finds an unusable SSH owner lease', async () => { + // Why: the lease decodes to a value normalization rejects, so hydration drops the record. + // Dropping it in memory only would resurrect it next launch and re-present a lease the + // relay will refuse, so the drop has to reach disk on hydration's own save. + writeDataFile({ + sshPtyConsumerRecoveries: [ + { + targetId: 'target-1', + clientInstanceId: 'client-1', + serverBuildId: 'build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: seal('') + } + ] + }) + const store = createStore({ deferKeyringProbe: true }) + // Why flush first: load-time migrations schedule their own save, and a hydration that + // scheduled nothing would still reach disk on the back of it. + store.flushOrThrow() + expect( + (JSON.parse(readFileSync(dataFile(), 'utf-8')) as { sshPtyConsumerRecoveries: unknown[] }) + .sshPtyConsumerRecoveries + ).toHaveLength(1) + + const result = store.hydrateDeferredProtectedSecrets() + + expect(result.needsSave).toBe(true) + await vi.waitFor( + async () => { + await store.waitForPendingWrite() + const saved = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + sshPtyConsumerRecoveries: unknown[] + } + expect(saved.sshPtyConsumerRecoveries).toEqual([]) + }, + { timeout: 10_000, interval: 100 } + ) + store.freezeWrites() + }) + it('probes inline when the host opens no window, so nothing pairs against a stalled main thread', () => { // Why pinned: headless serve advertises readiness with no window to defer behind, and an // earlier deferral moved this block to after clients could already connect (STA-5765). diff --git a/src/main/startup/keyring-probe-deferral.test.ts b/src/main/startup/keyring-probe-deferral.test.ts new file mode 100644 index 00000000000..66ca99a5616 --- /dev/null +++ b/src/main/startup/keyring-probe-deferral.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { shouldDeferKeyringProbe } from './keyring-probe-deferral' + +describe('shouldDeferKeyringProbe', () => { + it('defers on Linux desktop, the only host where the probe can block forever', () => { + expect(shouldDeferKeyringProbe({ platform: 'linux', isServeMode: false })).toBe(true) + }) + + it('never defers in serve mode, which advertises readiness with no window to defer behind', () => { + // Why pinned separately from the platform gate: this is the regression a reviewer caught on + // the sibling leg (STA-5765) — deferring here moves the block past the point clients pair. + expect(shouldDeferKeyringProbe({ platform: 'linux', isServeMode: true })).toBe(false) + }) + + it('never defers on macOS or Windows, whose keychains answer promptly', () => { + // Why both listed: a platform gate that accidentally widened to every desktop would withhold + // secrets for the whole pre-window startup on hosts that never had the hazard. + expect(shouldDeferKeyringProbe({ platform: 'darwin', isServeMode: false })).toBe(false) + expect(shouldDeferKeyringProbe({ platform: 'win32', isServeMode: false })).toBe(false) + }) + + it('never defers on a non-Linux serve host either', () => { + expect(shouldDeferKeyringProbe({ platform: 'darwin', isServeMode: true })).toBe(false) + }) +}) diff --git a/src/main/startup/keyring-probe-deferral.ts b/src/main/startup/keyring-probe-deferral.ts new file mode 100644 index 00000000000..f56195a2a35 --- /dev/null +++ b/src/main/startup/keyring-probe-deferral.ts @@ -0,0 +1,21 @@ +/** The host facts that decide whether the profile load may skip the OS keyring probe. */ +export type KeyringProbeDeferralHost = { + platform: NodeJS.Platform + isServeMode: boolean +} + +/** + * Whether the profile load may answer "keyring unavailable" without asking the keyring, + * leaving every protected slot sealed until the first window has painted (STA-5782). + * + * Linux only: `safeStorage.isEncryptionAvailable()` is a blocking D-Bus round trip there, and a + * keyring that is present but locked with no unlock prompter accepts the call and never replies. + * macOS and Windows answer promptly, so deferring would only widen the window in which secrets + * are withheld for no gain. + * + * Never in serve mode: a headless host opens no window to defer behind, so the probe would land + * after `printServeReady`, and a client could pair against a main thread already stalled on it. + */ +export function shouldDeferKeyringProbe(host: KeyringProbeDeferralHost): boolean { + return host.platform === 'linux' && !host.isServeMode +}