From a6083c47dc14fdd70bba7f232c67ba3bdf83dbc5 Mon Sep 17 00:00:00 2001 From: Jason Weingardt Date: Mon, 29 Jun 2026 02:35:16 -0400 Subject: [PATCH] Keep mobile pairing data on stable userData path (#6622) * Keep mobile pairing data on stable userData path Use the canonical userData path captured before app.setName() when starting the runtime WebSocket server so paired mobile device tokens and E2EE keys survive restarts/updates. Add regression coverage for app-name-driven userData path changes. Co-Authored-By: Claude * Migrate mobile pairing files from legacy path Co-Authored-By: Claude * Address mobile pairing migration review Co-Authored-By: Claude * Document mobile pairing path helpers Co-Authored-By: Claude * Explain paired mobile credential migration Co-Authored-By: Claude * Harden migrated mobile pairing credentials and tighten tests Re-assert secure-file permissions after the migration copyFileSync, which does not carry Windows ACLs, so the copied device tokens and E2EE keypair keep the current-user-only restriction instead of relying on the runtime's later lazy re-harden on read. Import the pairing filename constants from the shared source of truth so a rename can't silently pass the tests against stale names, and add no-op regression coverage for the source==target (case-insensitive FS) and fresh-install (no legacy files) paths. Co-authored-by: Orca --------- Co-authored-by: Claude Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/index.ts | 21 +- src/main/persistence.ts | 63 +++++- src/main/runtime/device-registry.ts | 3 +- src/main/runtime/e2ee-keypair.ts | 3 +- src/main/runtime/mobile-pairing-files.ts | 8 + .../mobile-pairing-userdata-path.test.ts | 214 ++++++++++++++++++ 6 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/mobile-pairing-files.ts create mode 100644 src/main/runtime/mobile-pairing-userdata-path.test.ts 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') + }) +})