Files
orca/src/main/runtime/mobile-pairing-userdata-path.test.ts
T
Neil 03fcfdfb92 feat(orcad): boot the Orca runtime on plain Node (#15968)
* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* feat(orcad): boot the Orca runtime on plain Node

Closes the last two Electron couplings and makes `orcad` a working artifact:
a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git
worktree and round-trips a PTY — with zero `require("electron")`.

Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its
test asserts exactly that: any reachable electron import is a regression.

- speech: inject the service factories, so importing ModelManager for its type
  no longer drags Electron's streaming net.request into the graph
- filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those
  maps arrives through an ipcMain handler carrying a renderer sender, so a host
  with no renderer has nothing to close, restore or forget — the inert default
  is what the desktop code does against empty maps, not a stub hiding work
- user-data-path / profile-storage-paths: resolve userData through
  AppEnvironment. These surfaced only once orcad pulled the store in

Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives
the re-imported graph a fresh module copy, so a binding installed before the
reset silently read back as uninstalled.

The acceptance smoke drives both hosts through one code path (`--target
orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the
same contract of each. Wired into PR CI.

* test(smoke): remove the seeded workspace container, not just the worktree

* test(smoke): surface the server's stderr when it dies before ready

* fix(smoke): build node-pty for Node before booting orcad in CI

* fix(smoke): drive the CLI built from this checkout, not one on PATH

* docs(ratchet): say the baseline must stay empty, not merely shrink

* build(orcad): externalize only the native modules actually in the graph
2026-08-22 21:47:46 -07:00

264 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node: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'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
// 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: '' }
// Why the port for getPath: userData now resolves through AppEnvironment, so an
// electron mock would be inert here. safeStorage is still mocked because the modules
// under test seal through it directly.
installFakeAppEnvironment({ getPath: () => appState.userData })
vi.mock('electron', () => ({
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 })
// Why re-install: the global setup's beforeEach reinstates its own fake.
installFakeAppEnvironment({ getPath: () => appState.userData })
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)
// Why the port: this is the "resolve late" path the captured value must differ from.
const { getAppEnvironment } = await import('../../shared/app-environment')
expect(getCanonicalUserDataPath()).not.toBe(getAppEnvironment().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('rolls back the first copy when the second file fails to migrate', async () => {
// A half-copied pair would leave the registry without its E2EE key and, worse,
// trip the existing-target guard so the next launch never retries.
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
let copies = 0
return {
...actual,
default: actual,
copyFileSync: (source: string, target: string) => {
copies += 1
if (copies === 2) {
throw new Error('simulated copy failure')
}
actual.copyFileSync(source, target)
}
}
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
appState.userData = canonicalDir
const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } =
await import('../persistence')
initDataPath()
appState.userData = lateDir
writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), JSON.stringify([]))
writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), JSON.stringify({ v: 1 }))
expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).not.toThrow()
expect(errorSpy).toHaveBeenCalled()
expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false)
expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false)
} finally {
errorSpy.mockRestore()
vi.doUnmock('node:fs')
}
})
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')
})
})