mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* 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
264 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|