mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
test(startup): pin the keyring-deferral gates and the dropped-lease save
This commit is contained in:
+2
-1
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user