// Why this file exists: persistence.test.ts mocks safeStorage.encryptString // deterministically, which cannot catch the real-world hazard the single- // stringify save guard must survive — encrypt() uses a random IV, so identical // state produces different on-disk bytes each save. These tests mock a // nondeterministic cipher and pin the guard + payload invariants. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), sshConfigHostsToTargets: vi.fn() })) // Nondeterministic cipher: same plaintext → different ciphertext every call, // like safeStorage's random IV. encryptionAvailable is toggleable per test. // deterministic=true mimics macOS/legacy-Linux OSCrypt (constant IV → same // plaintext always yields the same ciphertext), which is what makes the // cross-field blob-collision reachable by a user. const cipherState = { encryptionAvailable: true, deterministic: false } const DETERMINISTIC_IV = 'd'.repeat(36) vi.mock('electron', () => ({ app: { getPath: () => testState.dir } })) vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) })) async function createStore() { vi.resetModules() const { setSecretStore } = await import('../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => cipherState.encryptionAvailable, encryptString: (plaintext) => Buffer.from( `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, 'utf-8' ), decryptString: (ciphertext) => { const decoded = ciphertext.toString('utf-8') if (!decoded.startsWith('enc:')) { throw new Error('invalid ciphertext') } return decoded.slice('enc:'.length + 36 + 1) }, describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } function dataFile(): string { return join(testState.dir, 'orca-data.json') } const SECRETS = { opencodeSessionCookie: 'cookie-$&-value', httpProxyUrl: 'http://user:p@ss@proxy.local:8080' } as const const KAGI_LINK = 'https://kagi.com/session?token=abc123' describe('persistence single-serialize save guard', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) cipherState.encryptionAvailable = true cipherState.deterministic = false vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() rmSync(testState.dir, { recursive: true, force: true }) }) async function seedStoreWithSecrets() { const store = await createStore() store.updateSettings({ ...SECRETS }) store.updateUI({ browserKagiSessionLink: KAGI_LINK }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() return store } it('skips the disk write when state is identical, even with secrets set (random-IV cipher)', async () => { const store = await seedStoreWithSecrets() const inoBefore = statSync(dataFile()).ino // Net no-op mutation burst: the encrypted payload bytes would differ // (random IV), but the normalized guard hash must not. const originalWidth = store.getUI().sidebarWidth store.updateUI({ sidebarWidth: 512 }) store.updateUI({ sidebarWidth: originalWidth }) store.updateSettings({ ...SECRETS }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).toBe(inoBefore) }) it('still writes when state actually changes (including a secret change)', async () => { const store = await seedStoreWithSecrets() const inoBefore = statSync(dataFile()).ino store.updateSettings({ opencodeSessionCookie: 'rotated-cookie' }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() const inoAfter = statSync(dataFile()).ino expect(inoAfter).not.toBe(inoBefore) store.updateUI({ sidebarWidth: 777 }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).not.toBe(inoAfter) }) it('writes encrypted secrets to disk and round-trips them through a reload', async () => { await seedStoreWithSecrets() const raw = readFileSync(dataFile(), 'utf-8') const persisted = JSON.parse(raw) as { settings: { opencodeSessionCookie: string; httpProxyUrl: string } ui: { browserKagiSessionLink: string } } // Secrets are ciphertext on disk, plaintext nowhere in the payload. expect(persisted.settings.opencodeSessionCookie).not.toBe(SECRETS.opencodeSessionCookie) expect(persisted.settings.httpProxyUrl).not.toBe(SECRETS.httpProxyUrl) expect(persisted.ui.browserKagiSessionLink).not.toBe(KAGI_LINK) expect(raw).not.toContain(SECRETS.opencodeSessionCookie) expect(raw).not.toContain(KAGI_LINK) const reloaded = await createStore() expect(reloaded.getSettings().opencodeSessionCookie).toBe(SECRETS.opencodeSessionCookie) expect(reloaded.getSettings().httpProxyUrl).toBe(SECRETS.httpProxyUrl) expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) }) it('omits new secrets when encryption is unavailable and still skips an identical state', async () => { cipherState.encryptionAvailable = false const store = await createStore() store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { opencodeSessionCookie: string; httpProxyUrl: string } } expect(persisted.settings.opencodeSessionCookie).toBe('') expect(persisted.settings.httpProxyUrl).toBe('') const inoBefore = statSync(dataFile()).ino store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).toBe(inoBefore) }) it('sync flush also skips on identical state with secrets set', async () => { const store = await seedStoreWithSecrets() const inoBefore = statSync(dataFile()).ino store.flushOrThrow() expect(statSync(dataFile()).ino).toBe(inoBefore) }) it('performs exactly one full-state JSON.stringify per save (was two)', async () => { const store = await seedStoreWithSecrets() // Count full-state serializations: only the durable-state payload is // anywhere near this size in the save path (tiny stringifies elsewhere // stay far below the threshold). const original = JSON.stringify.bind(JSON) let fullStateSerializations = 0 const spy = vi.spyOn(JSON, 'stringify').mockImplementation((( value: unknown, ...rest: unknown[] ) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = original(value as any, ...(rest as [any?, any?])) if (typeof out === 'string' && out.length > 1_000) { fullStateSerializations++ } return out }) as typeof JSON.stringify) try { store.updateUI({ sidebarWidth: 640 }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() } finally { spy.mockRestore() } expect(fullStateSerializations).toBe(1) }) // Regression (adversarial review, gpt-5.6-sol round 1): the guard hash // normalizes encrypted secrets back to plaintext. A user-controlled plaintext // VALUE (httpProxyBypassRules) can equal a secret's ciphertext; on a // deterministic cipher (macOS/legacy-Linux OSCrypt constant IV) a string // search for the ciphertext would substitute the wrong site, letting two // distinct states hash equal → a silently dropped write (data loss). The // position-exact sentinel substitution must keep the hashes distinct. it('persists a swap between a plaintext field and a secret when the plaintext equals the secret ciphertext (deterministic cipher)', async () => { cipherState.deterministic = true const store = await createStore() const P = 'cookie-plaintext-value' // Persist cookie=P, then read its on-disk ciphertext C (what a user could // copy out of orca-data.json). store.updateSettings({ opencodeSessionCookie: P }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const C = ( JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { opencodeSessionCookie: string } } ).settings.opencodeSessionCookie expect(C).not.toBe(P) // C is ciphertext // State 1: the plaintext bypass-rules field literally holds ciphertext C; // cookie is still P (which also encrypts to C under the deterministic IV). store.updateSettings({ httpProxyBypassRules: C, opencodeSessionCookie: P }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() const inoState1 = statSync(dataFile()).ino // State 2 (distinct): swap the two values. Must be written, not skipped. store.updateSettings({ httpProxyBypassRules: P, opencodeSessionCookie: C }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).not.toBe(inoState1) // The swap round-trips through a reload — nothing was lost. const reloaded = await createStore() expect(reloaded.getSettings().httpProxyBypassRules).toBe(P) expect(reloaded.getSettings().opencodeSessionCookie).toBe(C) }) // Regression (adversarial review, gpt-5.6-sol round 2): the deeper variant of // the same class — a user-controlled JSON KEY. agentDefaultEnv lets the user // name an env var exactly after a secret field, so `"browserKagiSessionLink": // ""` can appear (inside settings, before ui) as a non-secret // entry. A key-anchored string replace would substitute that site instead of // the real ui field. The per-slot sentinel is unguessable, so it only ever // matches the real secret slot and the two distinct states stay distinct. it('persists a swap when an agentDefaultEnv var is named after a secret field and holds its ciphertext (deterministic cipher)', async () => { cipherState.deterministic = true const store = await createStore() const K = 'https://kagi.com/session?token=SECRET' store.updateUI({ browserKagiSessionLink: K }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const C = ( JSON.parse(readFileSync(dataFile(), 'utf-8')) as { ui: { browserKagiSessionLink: string } } ).ui.browserKagiSessionLink expect(C).not.toBe(K) // C is ciphertext // State 1: env var literally named after the secret field, value = C; the // real ui secret is still K. store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: C } } }) store.updateUI({ browserKagiSessionLink: K }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() const inoState1 = statSync(dataFile()).ino // State 2 (distinct): swap — env var value = K, ui secret = C. Must write. store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: K } } }) store.updateUI({ browserKagiSessionLink: C }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).not.toBe(inoState1) const reloaded = await createStore() expect(reloaded.getSettings().agentDefaultEnv?.claude?.browserKagiSessionLink).toBe(K) expect(reloaded.getUI().browserKagiSessionLink).toBe(C) }) })