diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 636aeed1b92..01724348ba6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -119,6 +119,12 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + # Why both: the ratchet proves nothing reachable from the runtime imports electron, + # which is a property of the import graph. This proves the Node artifact it enables + # actually boots, pairs, creates a worktree and round-trips a real PTY. + - name: Boot orcad and round-trip a terminal + run: pnpm run smoke:orcad-terminal + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt index d3af9f74c23..f392f70ecc9 100644 --- a/config/runtime-electron-baseline.txt +++ b/config/runtime-electron-baseline.txt @@ -1,7 +1,6 @@ # Modules reachable from the Orca runtime that import `electron`. # Generated by config/scripts/check-runtime-electron-ratchet.mjs. -# This list may only SHRINK. Adding an entry means the runtime got less -# portable; migrate the module behind a host port instead (src/main/host/). +# This list is EMPTY and must stay that way: the runtime boots on plain Node +# (see `pnpm run build:orcad`). Any entry means the runtime got less portable; +# migrate the module behind a host port instead (src/main/host/). -src/main/ipc/filesystem-watcher.ts -src/main/speech/model-manager.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index c679e2351ba..989efd2d8e7 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -14,21 +14,19 @@ import process from 'node:process' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') -const ENTRY = join(ROOT, 'src', 'main', 'orcad', 'orcad-entry.ts') +const ENTRY = join(ROOT, 'src/main/orcad/main.ts') // Native addons must exist on the host; they cannot be bundled. // `electron` is external so a residual import fails loudly at require() time rather // than silently bundling the npm package's installer shim, which is what happened the // first time and made the bundle look clean while it was not. -const EXTERNAL = [ - 'electron', - 'node-pty', - '@parcel/watcher', - 'better-sqlite3', - 'keytar', - 'fsevents', - 'cpu-features' -] +// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad +// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the +// server boots without it but every watch install fails. `fsevents` is macOS-only and +// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here +// defensively and appear nowhere in the graph; listing them implied a shipping burden +// that does not exist. +const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] /** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */ const jsoncParserEsm = { diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 5fe3fb18c1a..c50d5df8f46 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -112,8 +112,9 @@ function renderBaseline(files) { return [ '# Modules reachable from the Orca runtime that import `electron`.', '# Generated by config/scripts/check-runtime-electron-ratchet.mjs.', - '# This list may only SHRINK. Adding an entry means the runtime got less', - '# portable; migrate the module behind a host port instead (src/main/host/).', + '# This list is EMPTY and must stay that way: the runtime boots on plain Node', + '# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;', + '# migrate the module behind a host port instead (src/main/host/).', '', ...files ].join('\n') diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index ba75875c20f..e993ca32fc5 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -45,9 +45,12 @@ describe('the checked-in baseline', () => { expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] }) }, 120_000) - it('only lists modules under src/, so a node_modules path cannot pad the count', () => { + // Why an exact-empty assertion now: the reachable set reached zero, so "may only + // shrink" has no room left and any entry at all is a regression. This is strictly + // stronger than the old under-src/ check, which only stopped a node_modules path from + // padding a non-empty count. + it('stays empty, so nothing reachable from the runtime imports electron', () => { const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) - expect(baseline.length).toBeGreaterThan(0) - expect(baseline.filter((entry) => !entry.startsWith('src/'))).toEqual([]) + expect(baseline).toEqual([]) }) }) diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a9e0dca7ced..21b6cea6b0b 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -20,14 +20,15 @@ * - the server exits when asked. */ import { spawn, spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import process from 'node:process' const projectDir = resolve(import.meta.dirname, '../..') const serveEntry = join(projectDir, 'out', 'main', 'index.js') +const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js') const READY_TIMEOUT_MS = 120_000 const OUTPUT_TIMEOUT_MS = 30_000 const SHUTDOWN_TIMEOUT_MS = 15_000 @@ -43,14 +44,30 @@ function fail(message) { process.exitCode = 1 } +/** + * Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the + * version under test, and a CI runner has no installed Orca app to fall back on. + */ +function resolveCli() { + const built = join(projectDir, 'out', 'cli', 'index.js') + return existsSync(built) + ? { command: process.execPath, prefix: [built] } + : { command: 'orca', prefix: [] } +} + /** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */ function orca(pairingCode, args) { - const result = spawnSync('orca', [...args, '--pairing-code', pairingCode, '--json'], { - encoding: 'utf8', - // Why not shell:true — argument encoding is handled by spawnSync; a shell would - // re-split the pairing code, which is base64url and can contain '='. - shell: false - }) + const cli = resolveCli() + const result = spawnSync( + cli.command, + [...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'], + { + encoding: 'utf8', + // Why not shell:true — argument encoding is handled by spawnSync; a shell would + // re-split the pairing code, which is base64url and can contain '='. + shell: false + } + ) if (result.error) { throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`) } @@ -70,10 +87,17 @@ function orca(pairingCode, args) { function waitForReady(child) { return new Promise((resolvePromise, rejectPromise) => { let buffered = '' + let serverErr = '' const timer = setTimeout( () => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)), READY_TIMEOUT_MS ) + // Why read stderr at all: an unread pipe can fill and block the child, and without it + // a boot failure surfaces only as "exited with 1", which says nothing actionable. + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + serverErr += chunk + }) child.stdout.setEncoding('utf8') child.stdout.on('data', (chunk) => { buffered += chunk @@ -95,7 +119,13 @@ function waitForReady(child) { }) child.on('exit', (code) => { clearTimeout(timer) - rejectPromise(new Error(`server exited with ${code} before signalling ready`)) + rejectPromise( + new Error( + `server exited with ${code} before signalling ready${ + serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)' + }` + ) + ) }) }) } @@ -125,50 +155,127 @@ async function waitForNonce(pairingCode, terminalHandle, nonce) { return false } +/** + * The two hosts this acceptance drives. + * + * Everything after boot — pairing, worktree list, terminal create, the nonce round trip — + * is identical for both. That is the point: the Node artifact has to satisfy the same + * contract as the Electron server, proven by the same code rather than a parallel test + * that could drift into asserting less. + */ +function resolveLaunch(userDataDir) { + // Why a flag and not just an env var: package scripts have to set this on Windows too, + // and `FOO=bar cmd` is not portable there. + const flagIndex = process.argv.indexOf('--target') + const target = + flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron') + if (target === 'orcad') { + return { + label: `orcad (${ORCAD_ENTRY})`, + command: process.execPath, + args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], + env: { ORCA_USER_DATA: userDataDir } + } + } + if (target !== 'electron') { + throw new Error( + `--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'` + ) + } + const serveArgs = [ + serveEntry, + '--serve', + '--serve-port', + String(PORT), + '--serve-json', + `--user-data-dir=${userDataDir}` + ] + const override = process.env.ORCA_SMOKE_ELECTRON + return { + label: `electron (${serveEntry})`, + command: override ?? 'npx', + args: override ? serveArgs : ['electron', ...serveArgs], + env: {} + } +} + +/** A throwaway git repo with one commit, so `repo add` has something real to register. */ +function seedGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-')) + writeFileSync(join(dir, 'README.md'), '# orca smoke\n') + const git = (...args) => { + const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`) + } + } + git('init', '-b', 'main') + git('config', 'user.email', 'smoke@orca.test') + git('config', 'user.name', 'Orca Smoke') + git('add', '-A') + git('commit', '-m', 'seed') + return dir +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-')) - log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`) + const launch = resolveLaunch(userDataDir) + log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`) - const child = spawn( - process.env.ORCA_SMOKE_ELECTRON ?? 'npx', - process.env.ORCA_SMOKE_ELECTRON - ? [ - serveEntry, - '--serve', - '--serve-port', - String(PORT), - '--serve-json', - `--user-data-dir=${userDataDir}` - ] - : [ - 'electron', - serveEntry, - '--serve', - '--serve-port', - String(PORT), - '--serve-json', - `--user-data-dir=${userDataDir}` - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ) + // Why tracked out here: the worktree lands in the real workspaces root, not the temp + // profile, so the finally block has to remove it explicitly or every run leaks one. + let seeded = null + let pairing = null + + const child = spawn(launch.command, launch.args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...launch.env } + }) try { const ready = await waitForReady(child) log(`ready: ${ready.advertisedEndpoint}`) const pairingCode = pairingCodeFrom(ready) + pairing = pairingCode - const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? [] - if (worktrees.length === 0) { - throw new Error('paired client saw no worktrees; cannot create a terminal') + // Why seed instead of using whatever the profile already holds: a hermetic repo makes + // this runnable on a clean CI box, keeps the assertion deterministic, and exercises + // repo.add + worktree.create rather than assuming someone else registered a worktree. + const repoPath = seedGitRepo() + seeded = { repoPath } + log(`seeded repo at ${repoPath}`) + const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo + if (!repo?.id) { + throw new Error('repo.add returned no repo id') } - log(`paired client sees ${worktrees.length} worktree(s)`) - const terminal = orca(pairingCode, [ - 'terminal', + const worktreeName = `smoke-${randomBytes(4).toString('hex')}` + const created = orca(pairingCode, [ + 'worktree', 'create', - '--worktree', - worktrees[0].id - ])?.terminal + '--repo', + `id:${repo.id}`, + '--name', + worktreeName, + '--setup', + 'skip' + ])?.worktree + if (!created?.id) { + throw new Error('worktree.create returned no worktree id') + } + seeded.worktreeId = created.id + log(`created worktree ${created.id}`) + + // Why `show` and not membership in `list`: list is capped, and the Electron target + // reads a shared dev profile that can already hold more worktrees than the cap. The + // point is that the server persisted and can resolve THIS worktree. + const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree + if (shown?.id !== created.id) { + throw new Error('worktree.create succeeded but worktree.show cannot resolve it') + } + log(`server resolves ${shown.id}`) + + const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal if (!terminal?.handle) { throw new Error('terminal.create returned no handle') } @@ -196,16 +303,55 @@ async function main() { } catch (error) { fail(error instanceof Error ? error.message : String(error)) } finally { - child.kill('SIGTERM') - const exited = await Promise.race([ - new Promise((r) => child.on('exit', () => r(true))), - new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) - ]) - if (!exited) { - child.kill('SIGKILL') - fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`) + // Why before SIGTERM: worktree removal is a server operation, so it needs the server. + if (seeded?.worktreeId && pairing) { + const cleanupCli = resolveCli() + const removed = spawnSync( + cleanupCli.command, + [ + ...cleanupCli.prefix, + 'worktree', + 'rm', + '--worktree', + seeded.worktreeId, + '--pairing-code', + pairing, + '--force', + '--json' + ], + { encoding: 'utf8' } + ) + // Why the parent too: `worktree rm` removes the worktree directory, leaving the + // empty `//` container behind. Every run would leak one. + const worktreePath = seeded.worktreeId.split('::')[1] + if (removed.status === 0 && worktreePath) { + rmSync(dirname(worktreePath), { recursive: true, force: true }) + } + if (removed.status !== 0) { + log( + `WARN: could not remove seeded worktree ${seeded.worktreeId}: ` + + `${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})` + ) + } + } + // Why the exitCode guard: a server that died during boot has already exited, and + // waiting for a second 'exit' that will never fire reported a bogus shutdown failure + // stacked on top of the real error. + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM') + const exited = await Promise.race([ + new Promise((r) => child.on('exit', () => r(true))), + new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) + ]) + if (!exited) { + child.kill('SIGKILL') + fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`) + } } rmSync(userDataDir, { recursive: true, force: true }) + if (seeded?.repoPath) { + rmSync(seeded.repoPath, { recursive: true, force: true }) + } } if (!process.exitCode) { diff --git a/package.json b/package.json index 5c20829e8b0..a026bdb7824 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", + "build:orcad": "node config/scripts/build-orcad.mjs", + "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 27f96400258..0b546d5a46e 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -21,6 +22,8 @@ vi.mock('electron', () => ({ async function createStore() { vi.resetModules() + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') initDataPath() return new Store() diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts new file mode 100644 index 00000000000..52ccf9099c1 --- /dev/null +++ b/src/main/host/electron-speech-services.ts @@ -0,0 +1,9 @@ +import { ModelManager } from '../speech/model-manager' +import { SttService } from '../speech/stt-service' +import type { SpeechServiceFactories } from '../speech/speech-runtime-service' + +/** The desktop speech factories. Importing this file is what pulls Electron's net in. */ +export const electronSpeechServiceFactories: SpeechServiceFactories = { + createModelManager: (customModelsDir) => new ModelManager(customModelsDir), + createSttService: (models) => new SttService(models) +} diff --git a/src/main/index.ts b/src/main/index.ts index fc141e98988..42abcd75590 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,9 @@ import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-c import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' import { electronHttpClient } from './host/electron-http-client' import { setMainHttpClient } from './network/http-client' +import { electronSpeechServiceFactories } from './host/electron-speech-services' +import { setSpeechServiceFactories } from './speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' @@ -67,7 +70,7 @@ import { isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' -import { closeAllWatchers } from './ipc/filesystem-watcher' +import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' import { registerCoreHandlers } from './ipc/register-core-handlers' @@ -902,6 +905,10 @@ if (hasSingleInstanceLock) { // falls back to the platform default, which is a real behavioural difference (proxy // read from the environment, Node's user agent) rather than a transparent swap. setMainHttpClient(electronHttpClient) + // Why here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. const shouldCoupleToDevParent = is.dev && !isServeMode installDevParentDisconnectQuit(shouldCoupleToDevParent) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 0a337967386..71830efc7c2 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -39,6 +39,7 @@ import { } from './watcher-removal-drain' // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' +import type { WorktreeWatcherRemoval } from './worktree-watcher-removal' // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -1959,3 +1960,16 @@ export async function closeAllWatchers(): Promise { } remoteWatchers.clear() } + +/** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */ +export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = { + closeLocal: (worktreePath, deadline) => + deadline + ? closeLocalWatcherForWorktreePath(worktreePath, deadline) + : closeLocalWatcherForWorktreePath(worktreePath), + restoreLocal: restoreLocalWatcherAfterFailedRemoval, + forgetLocal: forgetLocalWatcherRemovalSnapshot, + closeRemote: closeRemoteWatcherForWorktreePath, + restoreRemote: restoreRemoteWatcherAfterFailedRemoval, + forgetRemote: forgetRemoteWatcherRemovalSnapshot +} diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts index 73726dd2b52..e16d949dc1a 100644 --- a/src/main/ipc/orca-profile-auth-handlers.test.ts +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -21,7 +21,6 @@ const { vi.mock('electron', () => ({ app: { exit: vi.fn(), - getPath: () => '/tmp/orca-user-data', relaunch: vi.fn() }, ipcMain: { @@ -56,9 +55,13 @@ vi.mock('../orca-profiles/profile-cloud-service', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers auth channels', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) handlers.clear() createCloudLinkedOrcaProfileMock.mockReset() connectCurrentOrcaProfileMock.mockReset() diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index fe69ce2e138..215b559327d 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -30,8 +30,7 @@ vi.mock('electron', () => ({ app: { exit: appExitMock, quit: appQuitMock, - relaunch: appRelaunchMock, - getPath: () => '/tmp/orca-user-data' + relaunch: appRelaunchMock }, ipcMain: { handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { @@ -68,9 +67,13 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() appExitMock.mockReset() diff --git a/src/main/ipc/worktree-watcher-removal.ts b/src/main/ipc/worktree-watcher-removal.ts new file mode 100644 index 00000000000..b5de15592cd --- /dev/null +++ b/src/main/ipc/worktree-watcher-removal.ts @@ -0,0 +1,42 @@ +import type { WatcherRemovalDeadline } from './watcher-removal-drain' + +/** + * Removal-time coordination for the renderer-facing filesystem watchers. + * + * Worktree removal has to close any watcher holding the directory open, then either + * restore it (removal failed) or drop the snapshot (removal succeeded). The desktop + * implementation lives in `./filesystem-watcher`, where that bookkeeping is inseparable + * from the `WebContents` map it suspends and replays. + * + * Why the default is inert rather than a throw: every entry in those maps arrives through + * an `ipcMain` handler carrying a renderer `sender`. A host with no renderer never + * installs one, so there is nothing to close, restore, or forget — the no-op is what the + * desktop code itself would do against empty maps, not a silent stub hiding lost work. + */ +export type WorktreeWatcherRemoval = { + closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise + restoreLocal(worktreePath: string): Promise + forgetLocal(worktreePath: string): void + closeRemote(connectionId: string, worktreePath: string): Promise + restoreRemote(connectionId: string, worktreePath: string): Promise + forgetRemote(connectionId: string, worktreePath: string): void +} + +const inert: WorktreeWatcherRemoval = { + closeLocal: async () => {}, + restoreLocal: async () => {}, + forgetLocal: () => {}, + closeRemote: async () => {}, + restoreRemote: async () => {}, + forgetRemote: () => {} +} + +let current: WorktreeWatcherRemoval = inert + +export function setWorktreeWatcherRemoval(next: WorktreeWatcherRemoval | null): void { + current = next ?? inert +} + +export function getWorktreeWatcherRemoval(): WorktreeWatcherRemoval { + return current +} diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 128dc56cea3..06f2efe0903 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -12,11 +13,10 @@ import { const testState = { dir: '' } -vi.mock('electron', () => ({ - app: { - getPath: () => testState.dir - } -})) +// Why the port and not vi.mock('electron'): profile path resolution reads AppEnvironment +// now, so an electron mock would be inert and every case would share the global fake's +// one temp dir instead of its own. +installFakeAppEnvironment({ getPath: () => testState.dir }) async function loadProfileIndexStore() { vi.resetModules() @@ -30,6 +30,8 @@ function readJson(path: string): unknown { describe('profile index store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-')) + // Why re-install per test: the global setup's beforeEach reinstates its own fake. + installFakeAppEnvironment({ getPath: () => testState.dir }) }) afterEach(() => { diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 5f8dc0df241..81dc990b0e1 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' @@ -13,12 +13,12 @@ export const LEGACY_BACKUP_COUNT = 5 let profileUserDataPath: string | null = null export function initOrcaProfilePaths(): void { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } export function getProfileUserDataPath(): string { if (!profileUserDataPath) { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } return profileUserDataPath } diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts new file mode 100644 index 00000000000..e76ed873cb0 --- /dev/null +++ b/src/main/orcad/main.ts @@ -0,0 +1,8 @@ +/** Executable entry for `orcad`. See `./orcad-entry.ts`. */ +import process from 'node:process' +import { main } from './orcad-entry' + +main().catch((error: unknown) => { + console.error('orcad: failed to start:', error) + process.exit(1) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 9080288cd53..621e89be5dc 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import type { ServeReadiness } from '../server/serve-readiness' /** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ function resolveUserDataPath(): string { @@ -83,16 +84,56 @@ export function installOrcadHostAdapters(): void { setSecretStore(createNodeSecretStore()) } -/** Boot the runtime and serve RPC. Returns once the transport is listening. */ -export async function startOrcad(options: { port?: number } = {}): Promise { +export type OrcadOptions = { + port?: number + json?: boolean + noPairing?: boolean + pairingAddress?: string +} + +export type OrcadHandle = { + readiness: ServeReadiness + stop(): Promise +} + +/** + * Boot the runtime and serve RPC. Resolves once the transport is listening and the + * readiness payload has been published, mirroring the desktop `--serve` contract byte + * for byte so the same harnesses can drive either host. + */ +export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') - const { registerPtyHandlers } = await import('../ipc/pty') + const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = + await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') + const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') + const { ServeReadinessPublisher } = await import('../server/serve-readiness') + const { Store } = await import('../persistence/loading-store/store') + const { ensureActiveOrcaProfile, initOrcaProfilePaths } = + await import('../orca-profiles/profile-index-store') + const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') - const runtime = new OrcaRuntimeService(null, undefined, { + const userDataPath = getAppEnvironment().getPath('userData') + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` + // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — + // a server that pairs and lists nothing looks healthy and is not. + const store = new Store({ dataFile: profile.dataFile }) + // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, + // which is safe but silently discards accept records on every launch. + initSshHostKeyStoreFile(profile.dataFile) + + const runtime = new OrcaRuntimeService(store, undefined, { + // Why lazy: a daemon swap replaces the provider after construction, so an eager + // reference would freeze the pre-daemon one. + getLocalProvider: () => getLocalPtyProvider(), + // Why: destructive worktree removal refuses to run without a provider to stop + // processes through — correctly, since it cannot otherwise verify the tree is idle. + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), // Why false: this host does not run the terminal daemon, so persistent local PTYs // cannot be recovered. The constructor defaults this to true, which would claim a // capability orcad does not have. @@ -103,16 +144,125 @@ export async function startOrcad(options: { port?: number } = {}): Promise getDesktopWindowStatus: () => 'blocked' }) - // Why null: no renderer. This installs the RuntimePtyController that terminal.create - // spawns through — the whole reason this module had to stop importing electron. - registerPtyHandlers(null, runtime) + // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // same call `--serve` makes, and it threads the store through. Without the store the + // handlers install fine and every terminal.create then fails at persistence time. + // + // Codex-home and Claude-auth preparation are left unset: both are desktop account + // flows. A launch that needs one fails with its own message rather than silently + // spawning an unauthenticated agent. + registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + + // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves + // restored orchestration rows claiming an authority this host never took over. + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() const rpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: getAppEnvironment().getPath('userData'), + userDataPath, enableWebSocket: true, exposeNetworkByDefault: true, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) - } as never) + }) await rpc.start() + + const boundEndpoint = rpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const offer = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : rpc.createPairingOffer({ + address: options.pairingAddress, + name: `CLI ${new Date().toLocaleDateString()}`, + scope: 'runtime' + }) + + const readiness: ServeReadiness = { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + // Why 'settled': the WSL CLI reconciliation barrier is a desktop-launch concern. + // orcad never runs it, so there is no pending repair a client could race. + managedWslCliReconciliation: 'settled', + pairing: offer.available + ? { + available: true, + url: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId, + webClientUrl: offer.webClientUrl, + scope: 'runtime', + qr: null + } + : offer + } + + await new ServeReadinessPublisher().publish(readiness, { + mode: options.json ? 'json' : 'human' + }) + + return { + readiness, + stop: async () => { + await rpc.stop() + } + } +} + +function parseArgs(argv: string[]): OrcadOptions { + const options: OrcadOptions = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--port') { + const raw = argv[i + 1] + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) + } + options.port = port + i += 1 + } else if (arg === '--json') { + options.json = true + } else if (arg === '--no-pairing') { + options.noPairing = true + } else if (arg === '--pairing-address') { + const value = argv[i + 1] + if (!value) { + throw new Error('--pairing-address expects a value') + } + options.pairingAddress = value + i += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + return options +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const handle = await startOrcad(parseArgs(argv)) + let stopping = false + const shutdown = (signal: NodeJS.Signals): void => { + if (stopping) { + return + } + stopping = true + handle + .stop() + .then(() => process.exit(0)) + // Why not rethrow: we are already tearing down on a signal, and an exit code is + // the only thing a supervisor can act on. + .catch((error) => { + console.error(`orcad: shutdown after ${signal} failed:`, error) + process.exit(1) + }) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) } diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 2d6d581599e..3a4c1f11e87 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -13,6 +13,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { SshRemotePtyLeaseState } from '../shared/ssh-types' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -177,6 +178,9 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() 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() as unknown as TestStore } diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index f1a63e876ea..0da93c3a979 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -52,6 +53,9 @@ async function createStoreFromState(state: Record) { ) vi.resetModules() 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() } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 9d407ab505b..c69233bb760 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' @@ -56,6 +57,9 @@ async function createStore() { describeUnavailable: () => 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() } diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index 89849d22dde..e21cc02fd56 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } const cipherState = { available: true } @@ -53,6 +54,9 @@ async function createStore() { describeUnavailable: () => 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() } diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index faa6f65822e..fb9c74c68be 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync import { dirname, 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: '' } @@ -63,6 +64,9 @@ async function createStore() { describeUnavailable: () => 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() } diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 06512f9a089..00becc26d4b 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -8,6 +8,7 @@ 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: '' } @@ -58,6 +59,9 @@ async function createStore() { describeUnavailable: () => 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() } diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 94b2c62d238..89f5f03a88f 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' @@ -13,6 +14,10 @@ export const testState = { dir: '' } /** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ export async function createStore() { vi.resetModules() + // Why here and not a per-file vi.mock('electron'): the data-file path resolves through + // AppEnvironment now, and it must point at this test's dir rather than the global + // fake's shared one. Re-installed after resetModules so the fresh graph sees it. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 7bb676ffe34..1ff2cda74ee 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -12,6 +12,7 @@ import { readDataFile, makeRepo } from './persistence-test-harness' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -50,6 +51,9 @@ async function createStore() { describeUnavailable: () => 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() } diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index 1c98d9525a3..f57a2a1b603 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -18,6 +18,7 @@ import { makeSessionWithTerminalBuffers, makeSessionWithBrowserHistory } from './persistence-session-fixtures' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -188,6 +189,9 @@ describe('Store', () => { mkdirSync(profileDataDirectory, { recursive: true }) vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -218,6 +222,9 @@ describe('Store', () => { writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index dcfb35a5954..2f89775ce87 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -8,6 +8,7 @@ import { createRetiredNameLookup } from '../shared/worktree/retired-name-registr import type { SshTarget } from '../shared/ssh-types' import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -45,6 +46,9 @@ function sshTarget(id: string, overrides: Partial = {}): SshTarget { async function reloadStore() { vi.resetModules() 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() } diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts index db62f706b7b..6dd4b50b1c8 100644 --- a/src/main/persistence/loading-store/user-data-path.ts +++ b/src/main/persistence/loading-store/user-data-path.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -11,7 +11,7 @@ let _dataFile: string | null = null let _userDataDir: string | null = null export function initDataPath(): void { - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -19,7 +19,7 @@ export function initDataPath(): void { export function getDataFile(): string { if (!_dataFile) { // Safety fallback — should not be hit in normal startup. - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -51,14 +51,14 @@ export function readGithubCacheSnapshot(dataFile: string): PersistedState['githu } /** - * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. + * Return the userData directory captured at initDataPath() time, before app.setName() can change how getAppEnvironment().getPath('userData') resolves. * * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. */ export function getCanonicalUserDataPath(): string { if (!_userDataDir) { // Safety fallback — should not be hit in normal startup. - _userDataDir = app.getPath('userData') + _userDataDir = getAppEnvironment().getPath('userData') } return _userDataDir } diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts index 35ebe7460c3..58e055c5297 100644 --- a/src/main/quit-path-durable-write-blocking.test.ts +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -4,6 +4,7 @@ import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously, // before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park @@ -115,6 +116,8 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() as unknown as TestStore } diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts index be13c1ebbc3..441b5a57eb2 100644 --- a/src/main/runtime/mobile-pairing-userdata-path.test.ts +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -6,6 +6,7 @@ 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 @@ -15,8 +16,12 @@ import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairin // 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', () => ({ - app: { getPath: () => appState.userData }, safeStorage: { isEncryptionAvailable: () => false, encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), @@ -38,6 +43,8 @@ describe('mobile pairing userData path stability', () => { 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() }) @@ -55,8 +62,9 @@ describe('mobile pairing userData path stability', () => { appState.userData = lateDir expect(getCanonicalUserDataPath()).toBe(canonicalDir) - const { app } = await import('electron') - expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData')) + // 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 () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d1eaee981a0..3c7e27b062c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -136,6 +136,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -219,14 +220,17 @@ const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => electronMocks) -vi.mock('../ipc/filesystem-watcher', () => ({ - closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock, - closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock, - restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock, - restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock, - forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock, - forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock -})) +// Why install the port instead of mocking ../ipc/filesystem-watcher: the runtime calls +// WorktreeWatcherRemoval now, so a module mock would be inert and every assertion below +// would silently pass against the inert default. Same mocks, same expectations. +setWorktreeWatcherRemoval({ + closeLocal: closeLocalWatcherForWorktreePathMock, + closeRemote: closeRemoteWatcherForWorktreePathMock, + restoreLocal: restoreLocalWatcherAfterFailedRemovalMock, + restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock, + forgetLocal: forgetLocalWatcherRemovalSnapshotMock, + forgetRemote: forgetRemoteWatcherRemovalSnapshotMock +}) const { MOCK_GIT_WORKTREES, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 03ba37f0d98..8ddbfa4fdf8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1136,14 +1136,7 @@ import { } from '../worktree-removal-repo-owner' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { - closeLocalWatcherForWorktreePath, - closeRemoteWatcherForWorktreePath, - forgetLocalWatcherRemovalSnapshot, - forgetRemoteWatcherRemovalSnapshot, - restoreLocalWatcherAfterFailedRemoval, - restoreRemoteWatcherAfterFailedRemoval -} from '../ipc/filesystem-watcher' +import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' import { createWatcherRemovalDeadline, @@ -10234,11 +10227,11 @@ export class OrcaRuntimeService { const results = await Promise.allSettled([ connectionId ? drainBeforeWatcherRemoval( - closeRemoteWatcherForWorktreePath(connectionId, worktreePath), + getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath), deadline, `remote watcher close for ${worktreePath}` ) - : closeLocalWatcherForWorktreePath(worktreePath, deadline), + : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline), drainBeforeWatcherRemoval( this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), deadline, @@ -10260,16 +10253,16 @@ export class OrcaRuntimeService { ): Promise => { await Promise.all([ connectionId - ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath) - : restoreLocalWatcherAfterFailedRemoval(worktreePath), + ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath) + : getWorktreeWatcherRemoval().restoreLocal(worktreePath), this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) ]) } forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { if (connectionId) { - forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath) + getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath) } else { - forgetLocalWatcherRemovalSnapshot(worktreePath) + getWorktreeWatcherRemoval().forgetLocal(worktreePath) } this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) } diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts index 97f963ae57b..f5a65a939dc 100644 --- a/src/main/speech/speech-runtime-service.ts +++ b/src/main/speech/speech-runtime-service.ts @@ -1,28 +1,60 @@ -import { ModelManager } from './model-manager' -import { SttService } from './stt-service' +import type { ModelManager } from './model-manager' +import type { SttService } from './stt-service' import type { VoiceSettings } from '../../shared/speech-types' +/** + * Lazy accessors for the speech services. + * + * Why the construction is injected: `ModelManager` downloads models through Electron's + * streaming `net.request` — byte-range resume, progress events, a manual idle timeout — + * and `SttService` resolves paths inside the packaged app. Importing either for its + * *type* is free; constructing one is what pulls Electron in. + * + * The desktop installs the factories. A host without them rejects per call rather than + * returning a stub that silently does nothing: speech is a desktop feature, and a + * headless host saying so is more useful than one that appears to transcribe. + */ + type SpeechSettingsStore = { getSettings(): { voice?: VoiceSettings } } +export type SpeechServiceFactories = { + createModelManager(customModelsDir: string | undefined): ModelManager + createSttService(models: ModelManager): SttService +} + +let factories: SpeechServiceFactories | null = null let modelManager: ModelManager | null = null let sttService: SttService | null = null +export function setSpeechServiceFactories(next: SpeechServiceFactories | null): void { + factories = next + modelManager = null + sttService = null +} + +function requireFactories(): SpeechServiceFactories { + if (!factories) { + throw new Error('speech_unavailable: this host has no speech services') + } + return factories +} + export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager { if (!modelManager) { const settings = store.getSettings() const customDir = settings.voice?.modelsDir || undefined - modelManager = new ModelManager(customDir || undefined) + modelManager = requireFactories().createModelManager(customDir || undefined) } return modelManager } export function getSpeechSttService(store: SpeechSettingsStore): SttService { if (!sttService) { - sttService = new SttService(getSpeechModelManager(store)) + sttService = requireFactories().createSttService(getSpeechModelManager(store)) } return sttService }