diff --git a/src/main/index.ts b/src/main/index.ts index 6f27111f8..8f5ecc584 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,7 +8,12 @@ import os from 'node:os' import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' -import { Store, initDataPath } from './persistence' +import { + Store, + initDataPath, + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from './persistence' import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' @@ -1693,9 +1698,17 @@ app.whenReady().then(async () => { app.exit(1) return } + // Why: existing installs may have already written mobile pairing credentials + // under the late app.getPath('userData') directory. Copy any missing files + // forward before the runtime switches exclusively to the canonical path. + migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) runtimeRpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: app.getPath('userData'), + // Why: mobile pairing (DeviceRegistry + E2EE keypair + runtime metadata) + // must share the stable path captured before app.setName(), not a late + // app.getPath('userData') that resolves elsewhere and drops paired devices + // across restarts/updates. See persistence.ts:getCanonicalUserDataPath. + userDataPath: getCanonicalUserDataPath(), enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), @@ -1841,7 +1854,9 @@ app.on('will-quit', (e) => { .then(() => awaitRuntimeFileWatcherUnsubscribes()) .then(() => { if (ownedRuntimeId) { - clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId) + // Why: must match the path the runtime server wrote metadata to + // (getCanonicalUserDataPath), not late app.getPath('userData'). + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) } }) .catch((error) => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 99ebeb1f7..c2651a3c0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -78,6 +78,8 @@ import { buildWorkspaceRunContext } from '../shared/task-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' +import { hardenExistingSecureFile } from '../shared/secure-file' import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -300,19 +302,76 @@ function retireLegacyInstructionsForClearedTextActionRecipes( // Solution: index.ts calls initDataPath() right after configureDevUserDataPath() // but before app.setName(), capturing the correct path at the right moment. let _dataFile: string | null = null +let _userDataDir: string | null = null export function initDataPath(): void { - _dataFile = join(app.getPath('userData'), 'orca-data.json') + const userDataDir = app.getPath('userData') + _userDataDir = userDataDir + _dataFile = join(userDataDir, 'orca-data.json') } function getDataFile(): string { if (!_dataFile) { // Safety fallback — should not be hit in normal startup. - _dataFile = join(app.getPath('userData'), 'orca-data.json') + const userDataDir = app.getPath('userData') + _userDataDir = userDataDir + _dataFile = join(userDataDir, 'orca-data.json') } return _dataFile } +/** + * Return the userData directory captured at initDataPath() time, before + * app.setName() can change how app.getPath('userData') resolves. + * + * Subsystems that must share storage with orca-data.json (mobile pairing's + * DeviceRegistry, E2EE keypair, runtime metadata) read this instead of + * resolving the path late, which on case-sensitive filesystems can land in a + * different directory and lose paired devices across restarts/updates. + */ +export function getCanonicalUserDataPath(): string { + if (!_userDataDir) { + // Safety fallback — should not be hit in normal startup. + _userDataDir = app.getPath('userData') + } + return _userDataDir +} + +/** + * Copy legacy mobile pairing credentials into the canonical userData directory. + * + * Existing installs may already have credentials in the late app.getPath('userData') + * directory. Before switching the runtime server to the canonical path, copy the + * registry and E2EE keypair forward as a pair so an update does not force one + * last re-pair or mix devices with the wrong key. + */ +export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { + const targetUserDataDir = getCanonicalUserDataPath() + if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { + return + } + + const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ + sourcePath: join(sourceUserDataDir, fileName), + targetPath: join(targetUserDataDir, fileName) + })) + if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { + return + } + if (migrations.some(({ targetPath }) => existsSync(targetPath))) { + return + } + + mkdirSync(targetUserDataDir, { recursive: true }) + for (const { sourcePath, targetPath } of migrations) { + copyFileSync(sourcePath, targetPath) + // Why: these are credential files (device tokens, E2EE secret key). copyFileSync + // does not carry Windows ACLs, so re-assert the current-user-only restriction on + // the copy instead of relying on the runtime's later lazy re-harden on read. + hardenExistingSecureFile(targetPath) + } +} + // Why (issue #1158): keep 5 rolling backups of orca-data.json so a corrupt or // empty write leaves at least one earlier copy recoverable. Five snapshots at // >=1-hour spacing cover recent work without churning disk on every debounce. diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 5a03d05a1..b55188072 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -6,8 +6,7 @@ import { randomBytes, randomUUID } from 'crypto' import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' - -const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' export type DeviceScope = 'mobile' | 'runtime' diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index f138bbf2b..d4e5d1f0d 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -5,8 +5,9 @@ import { existsSync, readFileSync, statSync } from 'fs' import { join } from 'path' import nacl from 'tweetnacl' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import { E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' -const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +const KEYPAIR_FILENAME = E2EE_KEYPAIR_FILENAME const KEYPAIR_VERSION = 1 const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 diff --git a/src/main/runtime/mobile-pairing-files.ts b/src/main/runtime/mobile-pairing-files.ts new file mode 100644 index 000000000..40e145417 --- /dev/null +++ b/src/main/runtime/mobile-pairing-files.ts @@ -0,0 +1,8 @@ +export const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' +export const E2EE_KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' + +// Migrate these together so device tokens and E2EE material never split across dirs. +export const MOBILE_PAIRING_USERDATA_FILES = [ + DEVICE_REGISTRY_FILENAME, + E2EE_KEYPAIR_FILENAME +] as const diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts new file mode 100644 index 000000000..835460248 --- /dev/null +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +// Import from the production source of truth so a filename rename can't silently +// pass these tests against stale names. +import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' + +// Mutable userData the electron mock resolves. We flip it mid-test to simulate +// app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from +// lowercase 'orca' to uppercase 'Orca' on a case-sensitive filesystem) — the +// divergence that drops paired devices. We use two genuinely distinct directory +// names rather than case variants so the assertion is deterministic regardless +// of whether the test host's filesystem is case-sensitive. +const appState = { userData: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => appState.userData }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +describe('mobile pairing userData path stability', () => { + let root: string + // The path persistence captures early, before app.setName(). + let canonicalDir: string + // The path app.getPath('userData') resolves to after app.setName() — a + // distinct directory standing in for the post-rename resolution. + let lateDir: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-pairing-path-')) + canonicalDir = join(root, 'userdata-early') + lateDir = join(root, 'userdata-late') + mkdirSync(canonicalDir, { recursive: true }) + mkdirSync(lateDir, { recursive: true }) + vi.resetModules() + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.resetModules() + }) + + it('keeps returning the path captured before app.setName changes resolution', async () => { + appState.userData = canonicalDir + const { initDataPath, getCanonicalUserDataPath } = await import('../persistence') + initDataPath() + + // app.setName('Orca') happens later in startup, changing late resolution. + appState.userData = lateDir + + expect(getCanonicalUserDataPath()).toBe(canonicalDir) + const { app } = await import('electron') + expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData')) + }) + + it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => { + appState.userData = canonicalDir + const { initDataPath, getCanonicalUserDataPath } = await import('../persistence') + initDataPath() + + appState.userData = lateDir // app.setName('Orca') has run by the time the runtime starts + + const { DeviceRegistry } = await import('./device-registry') + const { loadOrCreateE2EEKeypair } = await import('./e2ee-keypair') + + // Mirrors OrcaRuntimeRpcServer.start(): both read from the same userDataPath. + const registry = new DeviceRegistry(getCanonicalUserDataPath()) + registry.addDevice('iPhone') + loadOrCreateE2EEKeypair(getCanonicalUserDataPath()) + + // Pairing credentials land beside orca-data.json so they survive restarts/updates. + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(true) + expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(true) + // The bug being guarded: the late path would have captured these instead. + expect(existsSync(join(lateDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(lateDir, E2EE_KEYPAIR_FILENAME))).toBe(false) + }) + + it('migrates existing mobile pairing files from the late path as an all-or-nothing pair', async () => { + appState.userData = canonicalDir + const { + initDataPath, + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath + } = await import('../persistence') + initDataPath() + + appState.userData = lateDir + const lateDevices = JSON.stringify([ + { + deviceId: 'late-phone', + name: 'iPhone', + token: 'late-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const lateKeypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'), + secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64') + }) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair) + + migrateMobilePairingDataToCanonicalUserDataPath(appState.userData) + + expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(lateKeypair) + + const { DeviceRegistry } = await import('./device-registry') + const registry = new DeviceRegistry(getCanonicalUserDataPath()) + expect(registry.getDevice('late-phone')?.token).toBe('late-token') + + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), JSON.stringify([])) + migrateMobilePairingDataToCanonicalUserDataPath(appState.userData) + expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) + }) + + it('skips legacy migration when only part of the canonical credential pair exists', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + appState.userData = lateDir + const lateDevices = JSON.stringify([ + { + deviceId: 'late-phone', + name: 'iPhone', + token: 'late-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const lateKeypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'), + secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64') + }) + const canonicalKeypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + secretKeyB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64') + }) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair) + writeFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), canonicalKeypair) + + migrateMobilePairingDataToCanonicalUserDataPath(appState.userData) + + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(canonicalKeypair) + }) + + it('no-ops when the source path equals the canonical path (no rename happened)', async () => { + // Case-insensitive filesystems (macOS/Windows) resolve both paths to the same + // dir, so migration must be a clean no-op rather than copy a file onto itself. + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + const devices = JSON.stringify([ + { deviceId: 'phone', name: 'iPhone', token: 't', scope: 'mobile', pairedAt: 1, lastSeenAt: 2 } + ]) + writeFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), devices) + + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(canonicalDir)).not.toThrow() + expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(devices) + }) + + it('no-ops on a fresh install with no legacy pairing files to migrate', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + appState.userData = lateDir + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).not.toThrow() + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false) + }) + + it('a previously paired device is still found after a restart on the canonical path', async () => { + // First launch: pair a device while userData resolves to the canonical path. + appState.userData = canonicalDir + { + const { initDataPath, getCanonicalUserDataPath } = await import('../persistence') + initDataPath() + appState.userData = lateDir + const { DeviceRegistry } = await import('./device-registry') + new DeviceRegistry(getCanonicalUserDataPath()).addDevice('iPhone') + } + + // Second launch (e.g. after an update): fresh module state, path captured again. + vi.resetModules() + appState.userData = canonicalDir + const { initDataPath, getCanonicalUserDataPath } = await import('../persistence') + initDataPath() + appState.userData = lateDir + const { DeviceRegistry } = await import('./device-registry') + const registry = new DeviceRegistry(getCanonicalUserDataPath()) + + expect(registry.listDevices().map((d) => d.name)).toContain('iPhone') + }) +})