diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 4163150f47e..f99693d626d 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -209,6 +209,10 @@ runs: git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml fi + - name: Build internal packages + shell: bash + run: pnpm run build:packages + # pnpm checks the cached record's policy and validity; never bypass verification. - name: Save pnpm verification record on main if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml index b86846fac9d..980998117ca 100644 --- a/.github/workflows/macos-updater-tests.yml +++ b/.github/workflows/macos-updater-tests.yml @@ -10,7 +10,7 @@ on: - 'src/main/startup/main-process-quit*' - 'src/main/window/main-window-state-lifecycle*' - 'src/main/window/dashboard-popout-window*' - - 'src/shared/child-process/**' + - 'src/packages/process-host/**' - 'src/shared/update-status-types.ts' - 'pnpm-lock.yaml' workflow_dispatch: diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 78047f09d2e..56058ce9961 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -12,6 +12,8 @@ on: # recordings and the host params check, so any shared edit can move a golden or break mobile's # typecheck. Why the root lockfile: those modules resolve their packages from the root install. - 'src/shared/**' + # Mobile's Node RPC recording tools import this package. + - 'src/packages/process-host/**' - 'pnpm-lock.yaml' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. - 'src/main/agent-launch/**' @@ -40,6 +42,7 @@ on: paths: - 'mobile/**' - 'src/shared/**' + - 'src/packages/process-host/**' - 'pnpm-lock.yaml' - 'src/main/agent-launch/**' - 'src/main/runtime/rpc/**' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 25a738a0489..cae41e3e217 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -268,7 +268,9 @@ jobs: - name: Check reliability gate manifest if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:reliability-gates + run: | + pnpm run check:reliability-gates + pnpm run check:process-host-imports - name: Enforce dead design-system classes if: needs.code_paths.outputs.static_analysis == 'true' @@ -1009,6 +1011,10 @@ jobs: - name: Smoke managed-hook companions run: node config/scripts/smoke-managed-hook-runtime-node18.mjs + # The process-host package declares engines >=18; load its staged public output on Node 18. + - name: Smoke process-host package + run: node config/scripts/smoke-process-host-node18.mjs + package: name: package needs: [code_paths, preflight] @@ -1252,9 +1258,9 @@ jobs: src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts - src/shared/child-process/windows-command-line.win32.test.ts - src/shared/child-process/windows-cmd-shim-resolution.test.ts - src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts + src/shared/__tests__/process-host/windows-command-line.win32.test.ts + src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts + src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts diff --git a/.github/workflows/preflight-cli-platforms.yml b/.github/workflows/preflight-cli-platforms.yml index b395df46298..bd3c08e14c9 100644 --- a/.github/workflows/preflight-cli-platforms.yml +++ b/.github/workflows/preflight-cli-platforms.yml @@ -8,7 +8,7 @@ on: - 'src/main/ipc/command-path-resolver*' - 'src/main/preflight/**' - 'src/main/startup/*shell*' - - 'src/shared/child-process/**' + - 'src/packages/process-host/**' - '.github/actions/install-node-dependencies/**' - '.github/workflows/preflight-cli-platforms.yml' workflow_dispatch: @@ -53,6 +53,6 @@ jobs: src/main/startup/hydrate-shell-path.windows.test.ts src/main/startup/login-shell-environment.test.ts src/main/runtime/rpc/methods/preflight.test.ts - src/shared/child-process/process-tree-termination.test.ts - src/shared/child-process/run-process.test.ts - src/shared/child-process/run-process-termination-failure.test.ts + src/packages/process-host/src/process-tree-termination.test.ts + src/packages/process-host/src/run-process.test.ts + src/packages/process-host/src/run-process-termination-failure.test.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 2fc86af5115..b37f58207ab 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -18,6 +18,8 @@ on: - 'src/main/ssh/ssh-windows-host-*' - 'src/main/ssh/ssh-session-command-audit.ts' - 'src/relay/**' + - 'src/packages/process-host/**' + - 'pnpm-workspace.yaml' - 'src/shared/node-runtime-pin.ts' - 'src/shared/orcad-artifacts.ts' - 'config/scripts/build-orcad-*.mjs' diff --git a/.github/workflows/terminal-perf.yml b/.github/workflows/terminal-perf.yml index 2f3a37524eb..58d93671d31 100644 --- a/.github/workflows/terminal-perf.yml +++ b/.github/workflows/terminal-perf.yml @@ -81,11 +81,15 @@ jobs: const actionPath = '.github/actions/install-node-dependencies/action.yml' const action = fs.existsSync(actionPath) ? fs.readFileSync(actionPath, 'utf8') : '' const inputs = action.split(/^inputs:[ \t]*\r?$/m)[1]?.split(/^\S/m)[0] ?? '' + const supportedPostinstalls = [ + 'node config/scripts/rebuild-native-deps.mjs', + 'pnpm run build:packages && node config/scripts/rebuild-native-deps.mjs' + ] const shared = process.env.RUNNER_KIND === 'github-hosted' && !process.env.JOB_CONTAINER && process.env.RUNNER_OS === 'Linux' && process.env.RUNNER_ARCH === 'X64' && manifest.engines?.node === '24' && typeof manifest.packageManager === 'string' && manifest.packageManager.split('+')[0] === 'pnpm@12.8.1' && - manifest.scripts?.postinstall === 'node config/scripts/rebuild-native-deps.mjs' && + supportedPostinstalls.includes(manifest.scripts?.postinstall) && /^ native-runtime:/m.test(inputs) && /^ cache-pnpm-store-lookup-only:/m.test(inputs) && fs.existsSync('.github/actions/prepare-native-runtime/action.yml') && fs.existsSync('config/scripts/ensure-native-runtime.mjs') diff --git a/AGENTS.md b/AGENTS.md index e4161628503..bd9ca722ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. - **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. - **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). -- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely. +- **Windows child processes**: start them through `runProcess`/`spawnProcess` from `@orca/process-host` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely. - **Ripgrep**: Orca bundles `rg` for every platform, WSL, and SSH remotes. Spawn it through `spawnBundledRipgrep` (main) or `resolveRelayRipgrepCommand` (relay), never a bare `'rg'` — Windows resolves a bare name in the spawn cwd before PATH. Don't add git/readdir fallbacks locally; the relay's chain exists only for hosts an upload never reached. - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. - **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. diff --git a/config/build-plugins/process-host-dev-rebuild.ts b/config/build-plugins/process-host-dev-rebuild.ts new file mode 100644 index 00000000000..cf7bf0b6590 --- /dev/null +++ b/config/build-plugins/process-host-dev-rebuild.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import ts from 'typescript-api' +import type { Plugin } from 'rolldown' +import { createWorkspaceSourceResolver } from '../scripts/workspace-source-exports.mjs' + +function configError(diagnostic: ts.Diagnostic): Error { + return new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) +} + +function processHostInputs(root: string): { + directory: string + files: string[] + directories: ts.MapLike +} { + const source = createWorkspaceSourceResolver(root).resolve('@orca/process-host') + if (!source) { + throw new Error('The process-host workspace source export is missing') + } + const manifest = resolve(root, source.manifest) + const directory = dirname(manifest) + const config = join(directory, 'tsconfig.json') + const parsed = ts.getParsedCommandLineOfConfigFile(config, undefined, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw configError(diagnostic) + } + }) + if (!parsed || parsed.errors.length > 0) { + throw parsed?.errors[0] ? configError(parsed.errors[0]) : new Error(`Cannot read ${config}`) + } + const configSource = parsed.options.configFile + const inheritedConfigs = + configSource && typeof configSource === 'object' && 'extendedSourceFiles' in configSource + ? configSource.extendedSourceFiles + : [] + return { + directory, + files: [manifest, config, ...parsed.fileNames, ...(inheritedConfigs ?? [])], + directories: parsed.wildcardDirectories ?? {} + } +} + +export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin { + const directoryWatchers = new Map() + let signalDirectory: string | undefined + let signalFile: string | undefined + let signalVersion = 0 + return { + name: 'orca-process-host-dev-rebuild', + buildStart() { + if (!this.meta.watchMode) { + return + } + const inputs = processHostInputs(root) + for (const file of inputs.files) { + this.addWatchFile(file) + } + // The installed bundler invalidates exact files; source creation needs a private watch signal. + if (!signalDirectory) { + signalDirectory = mkdtempSync(join(tmpdir(), 'orca-process-host-watch-')) + signalFile = join(signalDirectory, 'source-directory-change') + writeFileSync(signalFile, String(signalVersion)) + } + if (signalFile) { + this.addWatchFile(signalFile) + } + for (const [directory, watched] of directoryWatchers) { + if (inputs.directories[directory] === undefined) { + watched.watcher.close() + directoryWatchers.delete(directory) + } + } + for (const [directory, flags] of Object.entries(inputs.directories)) { + const recursive = flags === ts.WatchDirectoryFlags.Recursive + const previous = directoryWatchers.get(directory) + if (previous?.recursive === recursive) { + continue + } + previous?.watcher.close() + if (!ts.sys.watchDirectory) { + throw new Error('The process-host source directory watcher is unavailable') + } + const watcher = ts.sys.watchDirectory( + directory, + () => { + if (signalFile) { + writeFileSync(signalFile, String(++signalVersion)) + } + }, + recursive, + { watchDirectory: ts.WatchDirectoryKind.UseFsEvents } + ) + directoryWatchers.set(directory, { recursive, watcher }) + } + // The external package must finish emitting before electron-vite restarts the app. The + // package's own build keeps its compiler and per-file atomic dist sync identical to build:packages. + const result = runProcessSync({ + program: process.execPath, + args: [ + createRequire(join(inputs.directory, 'package.json')).resolve('tsx/cli'), + '--conditions=orca-source', + join(inputs.directory, 'scripts', 'build-dist.mjs') + ], + cwd: inputs.directory, + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error( + `[process-host-dev-rebuild] Package compilation failed: ${result.stderr || result.stdout || result.signal || result.code}` + ) + } + }, + closeWatcher() { + signalFile = undefined + for (const { watcher } of directoryWatchers.values()) { + watcher.close() + } + directoryWatchers.clear() + if (signalDirectory) { + rmSync(signalDirectory, { recursive: true, force: true }) + signalDirectory = undefined + } + } + } +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 4a949eddf81..30b61e6b2e7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -8,6 +8,7 @@ const { } = require('./scripts/verify-packaged-daemon-entry.cjs') const { assertPackagedNativeVariantsInstalled, + assertProcessHostOutputBuilt, createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps @@ -343,6 +344,7 @@ module.exports = { beforePack: (context, mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR) => { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) assertBundledRipgrepInstalled() + assertProcessHostOutputBuilt() assertOrcadTemplateBuilt() assertMobileWebBundleBuilt(mobileWebBundleDir) }, diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index 6d6dd069592..bcb94911a2f 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -59,7 +59,7 @@ ; Clean up the relocated terminal daemon on a REAL uninstall. ; ; Why: the daemon host is deliberately copied OUT of the install dir into -; %LOCALAPPDATA%\Orca\daemon-host so that app UPDATES cannot kill it — +; %LOCALAPPDATA%\Orca\daemon-host-profiles so that app UPDATES cannot kill it — ; electron-builder's kill sweep selects processes whose image path is under ; $INSTDIR, and that relocation is what keeps terminals alive across updates. ; The same design means a normal uninstall's process sweep and file removal both @@ -103,6 +103,7 @@ ; Give the OS a moment to release the image lock before removing the tree. Sleep 500 RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + RMDir /r "$LOCALAPPDATA\Orca\daemon-host-profiles" ${endIf} ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 9b1418cf28c..b22664b83c2 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -18,6 +18,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@anthropic-ai/claude-agent-sdk', '@electron-toolkit/utils', '@linear/sdk', + '@orca/process-host', '@parcel/watcher', 'electron-updater', 'i18next', @@ -214,10 +215,55 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform) function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) { return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({ from: packageDir, - to: join('node_modules', ...packageName.split('/')) + to: join('node_modules', ...packageName.split('/')), + ...(packageName === '@orca/process-host' ? { filter: ['package.json', 'dist/**/*'] } : {}) })) } +function listProcessHostModules(directory, extension, prefix = '') { + if (!existsSync(directory)) { + return [] + } + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relativePath = `${prefix}${entry.name}` + if (entry.isDirectory()) { + return entry.name === '__fixtures__' + ? [] + : listProcessHostModules(join(directory, entry.name), extension, `${relativePath}/`) + } + return entry.name.endsWith(extension) && !entry.name.endsWith(`.test${extension}`) + ? [relativePath.slice(0, -extension.length)] + : [] + }) +} + +// Why: the resource filter copies whatever dist holds, so a missing, partial, or stale build +// would ship a package main cannot load. Mirrors the package tsconfig include/exclude. +function assertProcessHostOutputBuilt(packageDir = readPackage('@orca/process-host').packageDir) { + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) + const publicTargets = [ + manifest.main, + ...Object.values(manifest.exports ?? {}).map((target) => + typeof target === 'string' ? target : target?.default + ) + ].filter((target) => typeof target === 'string') + const sources = listProcessHostModules(join(packageDir, 'src'), '.ts') + const sourceSet = new Set(sources) + const missing = [ + ...new Set([...publicTargets, ...sources.map((module) => `./dist/${module}.js`)]) + ].filter((target) => !existsSync(join(packageDir, target))) + const stale = listProcessHostModules(join(packageDir, 'dist'), '.js') + .filter((module) => !sourceSet.has(module)) + .map((module) => `./dist/${module}.js`) + if (missing.length > 0 || stale.length > 0) { + throw new Error( + `@orca/process-host output in ${packageDir} does not match its source ` + + `(missing: ${missing.join(', ') || 'none'}; stale: ${stale.join(', ') || 'none'}). ` + + 'Run `pnpm build:packages` before packaging.' + ) + } +} + function normalizeAsarEntryPath(entry) { return entry.replace(/\\/g, '/').replace(/^\/+/, '') } @@ -617,6 +663,7 @@ function pruneMatchingFiles(directory, shouldPrune) { module.exports = { PACKAGED_RUNTIME_PACKAGE_ROOTS, assertPackagedNativeVariantsInstalled, + assertProcessHostOutputBuilt, createPackagedRuntimeNodeModuleResources, findAsarEntry, isPackagedExternalSpecifier, diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt new file mode 100644 index 00000000000..abbad1d1a47 --- /dev/null +++ b/config/process-host-direct-import-baseline.txt @@ -0,0 +1,142 @@ +# Existing production source files importing child_process outside @orca/process-host. +# Migration debt: this list may only shrink; new callers use the package public API. + +src/cli/handlers/account.ts +src/cli/handlers/core.ts +src/cli/handlers/interactive-login-interruption.ts +src/cli/handlers/skills.ts +src/cli/runtime/launch.ts +src/cli/runtime/serve-update-supervisor.ts +src/main/agent-hooks/managed-hook-owner-identity.ts +src/main/agent-hooks/managed-hook-runtime.ts +src/main/agent-hooks/wsl-hook-relay-launch.ts +src/main/agent-hooks/wsl-hook-relay-link.ts +src/main/agent-hooks/wsl-hook-relay-manager.ts +src/main/agent-hooks/wsl-hook-relay-sentinel.ts +src/main/ai-vault/session-scanner-service-client-state.ts +src/main/ai-vault/session-scanner-service-client.ts +src/main/ai-vault/session-scanner-service-spawn.ts +src/main/app-icon.ts +src/main/automations/precheck-runner.ts +src/main/browser/agent-browser-bridge-execution.ts +src/main/browser/agent-browser-bridge-raw-process.ts +src/main/browser/agent-browser-bridge-state.ts +src/main/browser/agent-browser-bridge-types.ts +src/main/browser/browser-route-egress-electron-launch.ts +src/main/browser/browser-route-persisted-worker-electron-process.ts +src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts +src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts +src/main/codex-accounts/service.ts +src/main/codex/codex-app-server-session.ts +src/main/codex/codex-wsl-hook-install-plan.ts +src/main/computer/desktop-script-provider-bridge.ts +src/main/computer/macos-computer-use-permission-status.ts +src/main/computer/macos-computer-use-permissions.ts +src/main/computer/macos-native-provider-transport.ts +src/main/computer/sidecar-client.ts +src/main/daemon/daemon-launched-child.ts +src/main/daemon/daemon-process-identity-query.ts +src/main/daemon/daemon-process-inspection.ts +src/main/daemon/daemon-process-start-time.ts +src/main/emulator/android/android-avd-boot.ts +src/main/emulator/android/android-command-runner.ts +src/main/emulator/android/scrcpy-stream-session.ts +src/main/emulator/serve-sim-helper-processes.ts +src/main/emulator/serve-sim-runtime-materializer.ts +src/main/emulator/simctl-simulator-devices.ts +src/main/emulator/simulator-app-visibility.ts +src/main/external-editor-launch.ts +src/main/git/command-runner/exec-file-capture.ts +src/main/git/command-runner/git-exec-file.ts +src/main/git/command-runner/git-spawn.ts +src/main/git/command-runner/git-stream-stdout.ts +src/main/git/command-runner/spawn-command-capture.ts +src/main/git/command-runner/spawned-command-tree-kill.ts +src/main/git/command-runner/wsl-aware-spawn.ts +src/main/git/wsl-git-read-environment.ts +src/main/github/gh-utils.ts +src/main/hooks.ts +src/main/ipc/developer-permissions.ts +src/main/ipc/filesystem-list-files.ts +src/main/ipc/filesystem-watcher-wsl.ts +src/main/ipc/filesystem/filesystem-handler-context.ts +src/main/ipc/local-network-connection-test.ts +src/main/ipc/macos-keyboard-input-source.ts +src/main/ipc/macos-keyboard-layout-snapshot.ts +src/main/ipc/notification-authorization-status.ts +src/main/ipc/parcel-watcher-cancellation-restart.ts +src/main/ipc/parcel-watcher-cancellation-tracker.ts +src/main/ipc/parcel-watcher-child-launch.ts +src/main/ipc/parcel-watcher-child-messaging.ts +src/main/ipc/parcel-watcher-child-recovery.ts +src/main/ipc/parcel-watcher-child-termination.ts +src/main/ipc/parcel-watcher-host-subscriptions.ts +src/main/ipc/parcel-watcher-pending-cancellation.ts +src/main/ipc/parcel-watcher-process-supervisor.ts +src/main/ipc/parcel-watcher-supervisor-disposal.ts +src/main/ipc/parcel-watcher-supervisor-message.ts +src/main/ipc/parcel-watcher-supervisor-subscribe.ts +src/main/ipc/preflight-command-exec.ts +src/main/ipc/repos/repo-clone-lifecycle.ts +src/main/ipc/worktree-apfs-clone.ts +src/main/ipc/wsl-watcher-process-exit.ts +src/main/local-builds/local-build-candidate.ts +src/main/macos-keychain/generic-password.ts +src/main/macos-system-sleep-assertion.ts +src/main/macos-tcc-prompt-watch.ts +src/main/memory/collector.ts +src/main/native-chat/wsl-transcript-fs-process-slot.ts +src/main/native-chat/wsl-transcript-fs-process-spawn.ts +src/main/network/macos-system-resolver-health.ts +src/main/network/macos-tailscale-dns-diagnostic.ts +src/main/plugins/plugin-host-process.ts +src/main/plugins/plugin-private-marketplace-ssh-shim.cjs +src/main/ports/port-scan-command-execution.ts +src/main/providers/macos-login-session-pty-probe.ts +src/main/providers/process-cwd.ts +src/main/providers/windows-console-attached-processes.ts +src/main/pty-descendant-termination.ts +src/main/pty/posix-pty-foreground-group.ts +src/main/pty/windows-environment-path.ts +src/main/rate-limits/gemini-cli-oauth-extractor.ts +src/main/runtime/tls-certificate.ts +src/main/runtime/windows-default-route-interfaces.ts +src/main/runtime/windows-mobile-firewall.ts +src/main/skills/skill-update-run.ts +src/main/ssh/ssh-connection.ts +src/main/ssh/ssh-proxy-command.ts +src/main/ssh/ssh-remote-cli-host-passthrough.ts +src/main/ssh/system-ssh-command.ts +src/main/ssh/system-ssh-dynamic-forward-process.ts +src/main/ssh/system-ssh-file-transfer.ts +src/main/ssh/system-ssh-forward-process.ts +src/main/ssh/system-ssh-operation-lifecycle.ts +src/main/startup/ensure-virtual-display.ts +src/main/startup/hydrate-shell-path.ts +src/main/startup/windows-install-dir-acl-probe.ts +src/main/startup/windows-user-data-acl.ts +src/main/win32-utils.ts +src/main/window/clipboard-ipc-handlers.ts +src/main/windows-process-tree-kill.ts +src/main/workspace-space-analysis.ts +src/main/wsl-availability.ts +src/main/wsl-unc-delete.ts +src/main/wsl.ts +src/main/zsh-startup-hook-pty-harness.ts +src/relay/agent-exec-handler.ts +src/relay/ai-vault-service-client-state.ts +src/relay/ai-vault-service-client.ts +src/relay/ai-vault-service-spawn.ts +src/relay/external-automations-handler.ts +src/relay/fs-handler-git-fallback.ts +src/relay/fs-handler-list-files.ts +src/relay/preflight-handler.ts +src/relay/pty-shell-utils.ts +src/relay/subprocess-tree-termination.ts +src/relay/workspace-space-scan.ts +src/shared/ephemeral-vm-recipe-process.ts +src/shared/ephemeral-vm-recipe-runner.ts +src/shared/fish-binary-requirement.ts +src/shared/process-table-snapshot-reader.ts +src/shared/pty-slave-line-discipline-echo.ts +src/shared/shell-process-readiness.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ccf73b43961..7898bf89175 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -760,12 +760,12 @@ "invariant": "Decoded stdout and stderr callback contents and order remain unchanged, final tails are the last capped run of raw output bytes with any leading partial UTF-8 sequence dropped, and success or failure releases capture storage without recapturing late output.", "oracle": "Compare byte-bounded tails against the old per-chunk model and an independent code-point model across compaction, growth, oversized chunks and mid-character splits. Feed split and malformed UTF-8 through actual stream decoding. Require at most 5 MiB moved through the capture for 4 MiB of output with zero encoding, collectible backing buffers after close and error, zero late capture work with preserved callbacks, one decode per stream, unchanged tails on forced cancellation or synchronous kill-close, and every capture limit clamped to a bounded byte count.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/packages/process-host/src/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts" ], "testFiles": [ "src/shared/ephemeral-vm-recipe-process.test.ts", "src/shared/ephemeral-vm-recipe-output-capture.test.ts", - "src/shared/growing-byte-buffer.test.ts", + "src/packages/process-host/src/growing-byte-buffer.test.ts", "src/main/ephemeral-vm-recipe-runner.test.ts" ], "assertionRefs": [ @@ -782,7 +782,7 @@ ] }, { - "file": "src/shared/growing-byte-buffer.test.ts", + "file": "src/packages/process-host/src/growing-byte-buffer.test.ts", "assertions": [ "reads and writes correctly after the head offset has advanced", "keeps a bounded suffix stable across many small appends without unbounded growth" @@ -794,7 +794,7 @@ "date": "2026-09-26", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/packages/process-host/src/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts", "result": "passed", "durationSeconds": 0.48, "summary": "60 tests across four suites passed, including forced-GC lifetime, synchronous SIGKILL-close and the shared byte buffer's head-offset cases." diff --git a/config/scripts/adhoc-build-pinned-commit.test.mjs b/config/scripts/adhoc-build-pinned-commit.test.mjs index b2062f9b05c..2768779aad0 100644 --- a/config/scripts/adhoc-build-pinned-commit.test.mjs +++ b/config/scripts/adhoc-build-pinned-commit.test.mjs @@ -6,7 +6,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const workflow = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8')) const PINNED = '${{ needs.resolve-ref.outputs.sha }}' diff --git a/config/scripts/benchmark-cli-error-imports.mjs b/config/scripts/benchmark-cli-error-imports.mjs index a4648f84aec..8ff5e59b756 100644 --- a/config/scripts/benchmark-cli-error-imports.mjs +++ b/config/scripts/benchmark-cli-error-imports.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { createRequire } from 'node:module' +import { runProcessSync } from '@orca/process-host' import { existsSync, realpathSync } from 'node:fs' import { delimiter, join, resolve } from 'node:path' @@ -20,10 +20,6 @@ for (const entry of Object.values(entries)) { assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`) } -const { runProcessSync } = createRequire(import.meta.url)( - join(resolve(afterDir), 'shared', 'child-process', 'run-process.js') -) - const child = String.raw` const { performance } = require('node:perf_hooks') const { writeSync } = require('node:fs') diff --git a/config/scripts/build-native-for-platform.test.mjs b/config/scripts/build-native-for-platform.test.mjs index 88858bf3b6e..2398798a0ec 100644 --- a/config/scripts/build-native-for-platform.test.mjs +++ b/config/scripts/build-native-for-platform.test.mjs @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { spawnProcess } from '../../src/shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' const buildScript = fileURLToPath(new URL('./build-native-for-platform.mjs', import.meta.url)) const directories = [] diff --git a/config/scripts/build-orcad-node.mjs b/config/scripts/build-orcad-node.mjs index 32cc1b8068f..bc7f38da9ee 100644 --- a/config/scripts/build-orcad-node.mjs +++ b/config/scripts/build-orcad-node.mjs @@ -12,7 +12,7 @@ import { orcadNodeRuntimeRelativePath } from '../../src/shared/orcad-artifacts.t import { ORCAD_PREBUILDS_DIR } from './build-orcad-prebuilds.mjs' import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { currentTarget } from './server-build-target.mjs' const root = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index 899116c07f8..83b6665107a 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -35,7 +35,7 @@ import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs' const root = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index e1e93b3d53b..91d6f64d4cf 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' import { OXLINT_SCANS, diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs index 9a0da15d06f..0ee7bab3a54 100644 --- a/config/scripts/check-max-lines-ratchet.test.mjs +++ b/config/scripts/check-max-lines-ratchet.test.mjs @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' import { diff --git a/config/scripts/check-process-host-imports.mjs b/config/scripts/check-process-host-imports.mjs new file mode 100644 index 00000000000..8967f3f186c --- /dev/null +++ b/config/scripts/check-process-host-imports.mjs @@ -0,0 +1,158 @@ +import { globSync, readFileSync } from 'node:fs' +import { isBuiltin } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { collectModuleSpecifiers } from './static-module-specifiers.mjs' +import { isTestOnlySourcePath } from './test-only-source-path.mjs' + +const ROOT = path.resolve(import.meta.dirname, '..', '..') +const PACKAGE_NAME = '@orca/process-host' +const PACKAGE_DIRECTORY = 'src/packages/process-host' +const PACKAGE_SOURCE = `${PACKAGE_DIRECTORY}/src/` +const BASELINE_PATH = 'config/process-host-direct-import-baseline.txt' +const SOURCE_PATTERNS = [ + '*.{ts,tsx,mts,cts,js,mjs,cjs}', + ...['src', 'config', 'tests', 'mobile/src', 'mobile/app', 'mobile/scripts'].map( + (directory) => `${directory}/**/*.{ts,tsx,mts,cts,js,mjs,cjs}` + ) +] + +function normalized(file) { + return file.replaceAll('\\', '/') +} + +function packageName(specifier) { + return specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0] +} + +function publicEntry(specifier, manifest) { + const subpath = specifier === PACKAGE_NAME ? '.' : `.${specifier.slice(PACKAGE_NAME.length)}` + return Object.hasOwn(manifest.exports ?? {}, subpath) +} + +function packageRelativeImport(file, specifier) { + if (!specifier.startsWith('.')) { + return null + } + return path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) +} + +export function assessProcessHostImports(sources, manifest, baseline) { + const direct = new Set() + const violations = [] + const dependencies = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}) + ]) + for (const [file, contents] of sources) { + const ownSource = file.startsWith(PACKAGE_SOURCE) + for (const sourceSpecifier of collectModuleSpecifiers(file, contents)) { + const specifier = normalized(sourceSpecifier) + const local = packageRelativeImport(file, specifier) + const selfImport = specifier === PACKAGE_NAME || specifier.startsWith(`${PACKAGE_NAME}/`) + const testCorpusImport = + isTestOnlySourcePath(file) && local?.startsWith(`${PACKAGE_SOURCE}__fixtures__/`) + if (selfImport && !publicEntry(specifier, manifest)) { + violations.push(`${file}: ${sourceSpecifier} is not a public process-host export`) + } + if ( + !ownSource && + !testCorpusImport && + (local === PACKAGE_DIRECTORY || local?.startsWith(`${PACKAGE_DIRECTORY}/`)) + ) { + violations.push( + `${file}: import process-host through ${PACKAGE_NAME}, not ${sourceSpecifier}` + ) + } + if (ownSource) { + if (local !== null && !local.startsWith(PACKAGE_SOURCE)) { + violations.push( + `${file}: process-host cannot import outside its source: ${sourceSpecifier}` + ) + } else if (path.posix.isAbsolute(specifier) || path.win32.isAbsolute(specifier)) { + violations.push( + `${file}: process-host cannot import an absolute path: ${sourceSpecifier}` + ) + } else if ( + local === null && + !selfImport && + !isBuiltin(specifier) && + !dependencies.has(packageName(specifier)) && + !( + isTestOnlySourcePath(file) && + Object.hasOwn(manifest.devDependencies ?? {}, packageName(specifier)) + ) + ) { + violations.push(`${file}: undeclared process-host dependency: ${sourceSpecifier}`) + } + } else if ( + file.startsWith('src/') && + !isTestOnlySourcePath(file) && + (specifier === 'child_process' || specifier === 'node:child_process') + ) { + direct.add(file) + } + } + } + const current = [...direct].sort() + const baselineSet = new Set(baseline) + return { + direct: current, + added: current.filter((file) => !baselineSet.has(file)), + stale: baseline.filter((file) => !direct.has(file)).sort(), + violations: violations.sort() + } +} + +export function readProcessHostBaseline(contents) { + return contents + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) +} + +export function collectProcessHostSources(root = ROOT) { + return new Map( + globSync(SOURCE_PATTERNS, { + cwd: root, + exclude: ['**/node_modules/**', '**/.*/**', '**/dist/**', '**/out/**', '**/build/**'] + }) + .map(normalized) + .sort() + .map((file) => [file, readFileSync(path.join(root, file), 'utf8')]) + ) +} + +export function main(root = ROOT) { + const manifest = JSON.parse( + readFileSync(path.join(root, PACKAGE_DIRECTORY, 'package.json'), 'utf8') + ) + const baseline = readProcessHostBaseline(readFileSync(path.join(root, BASELINE_PATH), 'utf8')) + const sources = collectProcessHostSources(root) + if ( + ![...sources.keys()].some( + (file) => file.startsWith(PACKAGE_SOURCE) && !isTestOnlySourcePath(file) + ) + ) { + throw new Error(`Missing process-host implementation under ${PACKAGE_SOURCE}`) + } + const result = assessProcessHostImports(sources, manifest, baseline) + const failures = [ + ...result.violations, + ...result.added.map((file) => `${file}: new direct child_process import; use ${PACKAGE_NAME}`), + ...result.stale.map((file) => `${file}: remove stale direct-import baseline entry`) + ] + if (failures.length > 0) { + console.error(`[process-host-imports]\n${failures.join('\n')}`) + return 1 + } + console.log(`[process-host-imports] ok; ${result.direct.length} legacy direct importers remain`) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + process.exitCode = main() +} diff --git a/config/scripts/check-process-host-imports.test.mjs b/config/scripts/check-process-host-imports.test.mjs new file mode 100644 index 00000000000..32bd657f9f6 --- /dev/null +++ b/config/scripts/check-process-host-imports.test.mjs @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + assessProcessHostImports, + collectProcessHostSources, + main, + readProcessHostBaseline +} from './check-process-host-imports.mjs' +import { collectModuleSpecifiers } from './static-module-specifiers.mjs' + +const PACKAGE_SOURCE = 'src/packages/process-host/src/' +const manifest = { + exports: { '.': './src/run-process.ts', './fork-process': './src/fork-process.ts' }, + devDependencies: { vitest: '*' } +} +const roots = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function assess(files, baseline = [], packageManifest = manifest) { + return assessProcessHostImports(new Map(Object.entries(files)), packageManifest, baseline) +} + +function fixture(files) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-process-host-imports-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const target = path.join(root, file) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, source) + } + return root +} + +describe('process-host import boundary', () => { + it('reads actual imports and reexports while ignoring comments and embedded programs', () => { + expect( + collectModuleSpecifiers( + 'file.ts', + ` + // import { spawn } from 'node:child_process' + const script = "require('node:child_process')" + import type { ProcessSpec } from '@orca/process-host/process-spec' + export { forkProcess } from '@orca/process-host/fork-process' + type Child = import('child_process').ChildProcess + import legacy = require('legacy-package') + import('dynamic-package') + require('required-package') + require.resolve('resolved-package') + ` + ) + ).toEqual([ + '@orca/process-host/process-spec', + '@orca/process-host/fork-process', + 'child_process', + 'legacy-package', + 'dynamic-package', + 'required-package', + 'resolved-package' + ]) + }) + + it('fails closed on source it cannot parse', () => { + expect(() => collectModuleSpecifiers('broken.ts', 'import {')).toThrow('Cannot parse broken.ts') + }) + + it('allows Node implementation imports and public consumer entry points', () => { + expect( + assess({ + [`${PACKAGE_SOURCE}run-process.ts`]: "import { spawn } from 'node:child_process'", + [`${PACKAGE_SOURCE}run-process.test.ts`]: "import { it } from 'vitest'", + 'src/main/consumer.ts': "import { spawnProcess } from '@orca/process-host'", + 'config/scripts/consumer.mjs': + "import { forkProcess } from '@orca/process-host/fork-process'" + }) + ).toEqual({ direct: [], added: [], stale: [], violations: [] }) + }) + + it.each([ + ['src/main/consumer.ts', '../packages/process-host'], + ['src/main/consumer.ts', '../packages/process-host/src/run-process'], + ['src/main/consumer.test.ts', '../packages/process-host/src/run-process'], + ['config/scripts/consumer.mjs', '../../src/packages/process-host/src/run-process'], + ['tests/e2e/consumer.ts', '../../src/packages/process-host/src/run-process'], + ['mobile/src/consumer.ts', '../../src/packages/process-host/src/run-process'], + ['src/main/consumer.ts', '@orca/process-host/src/run-process'], + ['src/main/consumer.ts', '@orca/process-host/private-process-state'] + ])('rejects private package access in %s', (file, specifier) => { + expect(assess({ [file]: `import '${specifier}'` }).violations).toHaveLength(1) + }) + + it.each([ + ['src/main/consumer.ts', '..\\packages\\process-host'], + ['src/main/consumer.ts', '..\\packages\\process-host\\src\\run-process'], + ['src/main/consumer.ts', '..\\packages\\process-host\\dist\\run-process.js'], + ['config/scripts/consumer.mjs', '..\\..\\src\\packages\\process-host\\src\\run-process'], + ['src/main/consumer.ts', '@orca\\process-host\\src\\run-process'], + ['src/main/consumer.ts', '@orca/process-host\\private-process-state'] + ])('rejects Windows private package access in %s through %s', (file, specifier) => { + expect(assess({ [file]: `require(${JSON.stringify(specifier)})` }).violations).toHaveLength(1) + }) + + it('shares fixture corpora only with test-only importers, keeping implementation imports public', () => { + const corpus = '../packages/process-host/src/__fixtures__/windows-argument-corpus' + expect(assess({ 'src/main/consumer.test.ts': `import '${corpus}'` }).violations).toEqual([]) + expect(assess({ 'src/main/consumer.ts': `import '${corpus}'` }).violations).toHaveLength(1) + expect( + assess({ + 'src/main/consumer.test.ts': + "import '../packages/process-host/src/__fixtures__/../run-process'" + }).violations + ).toHaveLength(1) + }) + + it.each([ + ['../main/runtime', 'cannot import outside'], + ['../../../main/runtime', 'cannot import outside'], + ['/repo/src/main/runtime.ts', 'absolute path'], + ['@renderer/store', 'undeclared'], + ['@/store', 'undeclared'], + ['electron', 'undeclared'], + ['vitest', 'undeclared'], + ['undeclared-package/subpath', 'undeclared'] + ])('rejects package dependence on %s', (specifier, reason) => { + expect( + assess({ [`${PACKAGE_SOURCE}runner.ts`]: `import '${specifier}'` }).violations[0] + ).toContain(reason) + }) + + it.each([ + ['..\\..\\..\\main\\runtime', 'cannot import outside'], + ['.\\..\\..\\..\\main\\runtime', 'cannot import outside'], + ['C:\\repo\\src\\main\\runtime.ts', 'absolute path'], + ['\\\\server\\repo\\src\\main\\runtime.ts', 'absolute path'], + ['@renderer\\store', 'undeclared'] + ])('rejects package dependence on Windows specifier %s', (specifier, reason) => { + expect( + assess({ [`${PACKAGE_SOURCE}runner.ts`]: `require(${JSON.stringify(specifier)})` }) + .violations[0] + ).toContain(reason) + }) + + it('permits Windows relative imports within package source and test fixture corpora', () => { + const corpus = '..\\packages\\process-host\\src\\__fixtures__\\windows-argument-corpus' + const traversal = '..\\packages\\process-host\\src\\__fixtures__\\..\\run-process' + expect( + assess({ + [`${PACKAGE_SOURCE}runner.ts`]: `require(${JSON.stringify('.\\process-spec')})`, + 'src/main/consumer.test.ts': `require(${JSON.stringify(corpus)})` + }).violations + ).toEqual([]) + expect( + assess({ + 'src/main/consumer.test.ts': `require(${JSON.stringify(traversal)})` + }).violations + ).toHaveLength(1) + }) + + it('allows only declared runtime dependencies in production package source', () => { + expect( + assess( + { [`${PACKAGE_SOURCE}runner.ts`]: "import 'declared/subpath'; import './process-spec'" }, + [], + { ...manifest, dependencies: { declared: '*' } } + ).violations + ).toEqual([]) + }) + + it('reports new and removed legacy direct importers without exempting type imports', () => { + expect( + assess( + { + 'src/main/legacy.ts': "import { spawn } from 'node:child_process'", + 'src/relay/new.ts': "import type { ChildProcess } from 'child_process'", + 'src/main/consumer.test.ts': "import { spawn } from 'node:child_process'", + 'config/scripts/build.mjs': "import { spawn } from 'node:child_process'" + }, + ['src/main/legacy.ts', 'src/main/removed.ts'] + ) + ).toEqual({ + direct: ['src/main/legacy.ts', 'src/relay/new.ts'], + added: ['src/relay/new.ts'], + stale: ['src/main/removed.ts'], + violations: [] + }) + }) + + it('scans nested package, consumer, test, config, and mobile source without generated checkouts', () => { + const files = [ + `${PACKAGE_SOURCE}nested/runner.ts`, + 'src/main/consumer.ts', + 'electron.vite.config.ts', + 'config/build-plugins/consumer.ts', + 'config/scripts/nested/build.mjs', + 'tests/e2e/consumer.ts', + 'mobile/src/consumer.ts', + 'mobile/app/consumer.tsx', + 'mobile/scripts/consumer.mts' + ] + const ignored = [ + 'tests/e2e/.cross-version-checkouts/src/runtime.ts', + 'src/packages/process-host/node_modules/dep/index.js' + ] + const root = fixture( + Object.fromEntries([...files, ...ignored].map((file) => [file, 'export {}'])) + ) + expect([...collectProcessHostSources(root).keys()]).toEqual(files.sort()) + }) + + it('checks the command-line path against the source tree and baseline', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const files = { + 'src/packages/process-host/package.json': JSON.stringify(manifest), + 'config/process-host-direct-import-baseline.txt': 'src/main/legacy.ts\n', + [`${PACKAGE_SOURCE}runner.ts`]: "import { spawn } from 'node:child_process'", + 'src/main/legacy.ts': "import { spawn } from 'node:child_process'" + } + expect(main(fixture(files))).toBe(0) + expect(log).toHaveBeenCalledWith(expect.stringContaining('1 legacy direct importers remain')) + expect( + main(fixture({ ...files, 'src/main/new.ts': "import { spawn } from 'node:child_process'" })) + ).toBe(1) + expect(error).toHaveBeenCalledWith( + expect.stringContaining('src/main/new.ts: new direct child_process import') + ) + }) + + it('fails loudly when the package implementation disappears', () => { + const root = fixture({ + 'src/packages/process-host/package.json': JSON.stringify(manifest), + 'config/process-host-direct-import-baseline.txt': '' + }) + expect(() => main(root)).toThrow('Missing process-host implementation') + }) + + it('reads a commented baseline without allowing formatting to invent entries', () => { + expect(readProcessHostBaseline('# migration debt\n\n src/main/legacy.ts \n')).toEqual([ + 'src/main/legacy.ts' + ]) + }) +}) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index d3a86cbb210..facd6fd562f 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -21,6 +21,7 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' import { isTestOnlyDirectoryName, isTestOnlySourcePath } from './test-only-source-path.mjs' +import { createWorkspaceSourceResolver } from './workspace-source-exports.mjs' // Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and // editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack @@ -47,6 +48,7 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'main', 'claude'] }, { directory: ['src', 'main', 'codex'] }, { directory: ['src', 'shared'] }, + { directory: ['src', 'packages', 'process-host', 'src'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, { directory: ['src', 'main', 'acp'] }, @@ -114,6 +116,16 @@ const externalNativeAddons = { // Why `plugins`: lets a test add an Electron import to a real file in memory, never on disk. export async function collectElectronImporters(entryPoints, { plugins = [] } = {}) { + const workspace = createWorkspaceSourceResolver(ROOT) + const workspaceSources = { + name: 'runtime-workspace-sources', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^@orca\// }, ({ path: specifier }) => { + const source = workspace.resolve(specifier) + return source ? { path: source.path } : undefined + }) + } + } const result = await build({ // Export every entry through one bundle so shared dependencies are emitted once. stdin: { @@ -137,7 +149,7 @@ export async function collectElectronImporters(entryPoints, { plugins = [] } = { metafile: true, absWorkingDir: ROOT, logLevel: 'silent', - plugins: [externalNativeAddons, ...plugins] + plugins: [externalNativeAddons, workspaceSources, ...plugins] }) const importers = new Set() for (const [file, info] of Object.entries(result.metafile.inputs)) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 82941bab290..cb1e06b3a34 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -41,7 +41,8 @@ describe('structured chat coverage', () => { 'src/main/acp/acp-structured-session-adapter.ts': 'export {}', 'src/main/jsonl-rpc/peer.ts': 'export {}', 'src/main/pi/rpc-launch.ts': 'export {}', - 'src/shared/agent-session-record.ts': 'export {}' + 'src/shared/agent-session-record.ts': 'export {}', + 'src/packages/process-host/src/run-process.ts': 'export {}' } it('covers whole lane directories, structured runtime files at any depth, and no test code', () => { @@ -53,6 +54,7 @@ describe('structured chat coverage', () => { 'src/main/codex/codex-provider-timeline-identity.ts', 'src/shared/nested/agent-session-account-home.ts', 'src/shared/relay-runtime-self-test-report.ts', + 'src/packages/process-host/src/nested/process-state.ts', 'src/main/runtime/agent-session-record.ts', 'src/main/runtime/structured-agent-runtime-registrations.ts', 'src/main/runtime/rpc/methods/structured-agent-session-agents.ts', @@ -75,6 +77,8 @@ describe('structured chat coverage', () => { 'src/main/native-chat/test-support/reader.ts', 'src/main/runtime/orca-runtime-tests/structured-agent-session-host.ts', 'src/shared/types.d.ts', + 'src/packages/process-host/src/run-process.test.ts', + 'src/packages/process-host/src/__fixtures__/spawned-child.ts', 'src/main/runtime/other.ts', 'src/main/runtime/rpc/methods/browser.ts', 'src/main/jsonl-rpc/peer.test.ts', @@ -142,6 +146,24 @@ describe('structured chat coverage', () => { } ) + it('checks public workspace imports against source without requiring a built package', async () => { + const root = fixture({ 'entry.ts': "import '@orca/process-host'" }) + const target = path.join(process.cwd(), 'src/packages/process-host/src/run-process.ts') + const current = await collectElectronImporters([path.join(root, 'entry.ts')], { + plugins: [ + { + name: 'inject-process-host-electron-import', + setup(pluginBuild) { + pluginBuild.onLoad({ filter: /run-process\.ts$/ }, (args) => + args.path === target ? { contents: "import 'electron'", loader: 'ts' } : undefined + ) + } + } + ] + }) + expect(current).toEqual(['src/packages/process-host/src/run-process.ts']) + }) + it('bounds shared dependency output without dropping any entry point', async () => { const entries = Array.from({ length: 40 }, (_, index) => `entry-${index}.ts`) const root = fixture({ @@ -186,7 +208,7 @@ describe('the default entry points', () => { 'src/main/runtime/runtime-rpc.ts', 'src/main/orcad/main.ts' ]) - for (const lane of [...lanes, 'src/shared/']) { + for (const lane of [...lanes, 'src/shared/', 'src/packages/process-host/src/']) { expect(entries.some((file) => file.startsWith(lane))).toBe(true) } }) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index f04aff20b35..4e7121d8956 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -32,6 +32,19 @@ describe('CI dependency download caches', () => { ]) }) + it('builds internal packages before native preparation after a script-free install', () => { + const steps = action.runs.steps + const buildIndex = steps.findIndex((step) => step.name === 'Build internal packages') + expect(buildIndex).toBeGreaterThan( + steps.findIndex((step) => step.name === 'Install dependencies') + ) + expect(buildIndex).toBeLessThan( + steps.findIndex((step) => step.name === 'Prepare native runtime') + ) + expect(steps[buildIndex].if).toBeUndefined() + expect(steps[buildIndex].run).toBe('pnpm run build:packages') + }) + it('restores PR stores except measured Windows, Linux and macOS installs, without a post-job save', () => { const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store') const restore = action.runs.steps.find( diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index bedb18dc24a..5c4863712da 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { classifyE2eJobs, DEDICATED_E2E_SPECS, diff --git a/config/scripts/ci-e2e-shard-selection.test.mjs b/config/scripts/ci-e2e-shard-selection.test.mjs index de4778e9790..b7fc2f5c8d7 100644 --- a/config/scripts/ci-e2e-shard-selection.test.mjs +++ b/config/scripts/ci-e2e-shard-selection.test.mjs @@ -3,7 +3,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs' const require = createRequire(import.meta.url) diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 774fc4cc386..91a00225079 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' import { classifyPrJobs } from './pr-code-change-scope.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const installerPath = '.github/actions/install-node-dependencies/action.yml' const nativePath = '.github/actions/prepare-native-runtime/action.yml' diff --git a/config/scripts/ci-pnpm-store-benchmark.mjs b/config/scripts/ci-pnpm-store-benchmark.mjs index 8536eafe1d5..0e613965a75 100644 --- a/config/scripts/ci-pnpm-store-benchmark.mjs +++ b/config/scripts/ci-pnpm-store-benchmark.mjs @@ -10,11 +10,12 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { workspacePackageManifests } from './workspace-source-exports.mjs' const { values } = parseArgs({ options: { samples: { type: 'string', default: '3' }, output: { type: 'string' } } @@ -27,7 +28,12 @@ const checkout = join(temporary, 'checkout') const store = join(temporary, 'store') const archive = join(temporary, 'store.tar.zst') const uncompressed = join(temporary, 'store.tar') -const manifests = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'] +const manifests = [ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + ...workspacePackageManifests(repository) +] const pnpm = resolvePnpmCliInvocation() const results = [] @@ -61,6 +67,15 @@ function install() { .milliseconds } +function resetModules() { + const packageDirs = new Set( + manifests.filter((file) => file.endsWith('package.json')).map(dirname) + ) + for (const directory of packageDirs) { + rmSync(join(checkout, directory, 'node_modules'), { recursive: true, force: true }) + } +} + function transferArchive(restore) { const start = performance.now() // Windows BSD tar needs a separate zstd process, as in the Actions cache toolkit. @@ -86,7 +101,7 @@ function transferArchive(restore) { try { mkdirSync(checkout) - for (const file of [...manifests, 'native/windows-registry/package.json', 'config/patches']) { + for (const file of [...manifests, 'config/patches']) { const target = join(checkout, file) mkdirSync(resolve(target, '..'), { recursive: true }) cpSync(join(repository, file), target, { recursive: true }) @@ -99,7 +114,7 @@ try { for (let sample = 0; sample < samples; sample++) { const policies = sample % 2 === 0 ? ['save', 'restore-only'] : ['restore-only', 'save'] for (const policy of policies) { - rmSync(join(checkout, 'node_modules'), { recursive: true, force: true }) + resetModules() rmSync(store, { recursive: true, force: true }) const installMs = install() assert.equal(digest(), sourceDigest, 'frozen install changed a manifest') @@ -123,7 +138,7 @@ try { } // Both policies restore identical bytes on a hit; measure that common cost separately. - rmSync(join(checkout, 'node_modules'), { recursive: true, force: true }) + resetModules() rmSync(store, { recursive: true, force: true }) mkdirSync(store) const restoreMs = transferArchive(true) diff --git a/config/scripts/ci-pnpm-store-mode.test.mjs b/config/scripts/ci-pnpm-store-mode.test.mjs index d8402612b04..ffd2dffb415 100644 --- a/config/scripts/ci-pnpm-store-mode.test.mjs +++ b/config/scripts/ci-pnpm-store-mode.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { runInNewContext } from 'node:vm' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const mode = action.runs.steps.find((step) => step.id === 'pnpm-store-mode') diff --git a/config/scripts/ci-pnpm-verification-cache.test.mjs b/config/scripts/ci-pnpm-verification-cache.test.mjs index b878b3ef046..95029d23eb5 100644 --- a/config/scripts/ci-pnpm-verification-cache.test.mjs +++ b/config/scripts/ci-pnpm-verification-cache.test.mjs @@ -5,7 +5,7 @@ import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs, PR_CHECK_JOBS } from './pr-code-change-scope.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const steps = action.runs.steps diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 41039fa66f3..130c77c5e14 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -9077,18 +9077,17 @@ "src/shared/check-job-log-byte-cap.test.ts": 77, "src/shared/check-job-log-retention.test.ts": 186, "src/shared/check-job-log-tail-slice.test.ts": 55, - "src/shared/child-process/bounded-output-sink.test.ts": 48, - "src/shared/child-process/child-process-import-boundary.test.ts": 1004, - "src/shared/child-process/close-process-registry.test.ts": 40, - "src/shared/child-process/posix-lsof-probe.test.ts": 4289, - "src/shared/child-process/process-tree-termination.test.ts": 68, - "src/shared/child-process/retryable-process-exit-proof.test.ts": 38, - "src/shared/child-process/run-process-termination-failure.test.ts": 49, - "src/shared/child-process/run-process.test.ts": 7245, - "src/shared/child-process/windows-cmd-shim-resolution.test.ts": 57, - "src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts": 42, - "src/shared/child-process/windows-command-line.test.ts": 55, - "src/shared/child-process/windows-command-line.win32.test.ts": 39, + "src/packages/process-host/src/bounded-output-sink.test.ts": 48, + "src/packages/process-host/src/close-process-registry.test.ts": 40, + "src/packages/process-host/src/posix-lsof-probe.test.ts": 4289, + "src/packages/process-host/src/process-tree-termination.test.ts": 68, + "src/packages/process-host/src/retryable-process-exit-proof.test.ts": 38, + "src/packages/process-host/src/run-process-termination-failure.test.ts": 49, + "src/packages/process-host/src/run-process.test.ts": 7245, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts": 57, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts": 42, + "src/packages/process-host/src/windows-command-line.test.ts": 55, + "src/shared/__tests__/process-host/windows-command-line.win32.test.ts": 39, "src/shared/child-process/windows-console-visibility.test.ts": 3436, "src/shared/claimed-agent-pty-owner.test.ts": 94, "src/shared/claude-agent-teams-tmux-compat.test.ts": 57, @@ -9231,7 +9230,7 @@ "src/shared/gitlab-projects.test.ts": 40, "src/shared/grok-model-list-probe.test.ts": 49, "src/shared/grok-session-paths.test.ts": 87, - "src/shared/growing-byte-buffer.test.ts": 96, + "src/packages/process-host/src/growing-byte-buffer.test.ts": 96, "src/shared/handled-wire-discriminant.test.ts": 36, "src/shared/harness-injected-user-turns.test.ts": 42, "src/shared/hermes-run-ref-retention.test.ts": 29, diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index 7a3e035e45a..766550679c7 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -1,6 +1,7 @@ import { globSync, readFileSync } from 'node:fs' import { posix, join } from 'node:path' import ts from 'typescript-api' +import { createWorkspaceSourceResolver } from './workspace-source-exports.mjs' const EXTENSIONS = [ '', @@ -30,15 +31,22 @@ function localPath(file, specifier) { return null } -export function buildUnitDependencyGraph(sources) { +export function buildUnitDependencyGraph(sources, workspace = { resolve: () => null }) { const reverse = new Map() const opaque = new Set() + function addConsumer(input, file) { + if (!reverse.has(input)) { + reverse.set(input, new Set()) + } + reverse.get(input).add(file) + } for (const [file, source] of sources) { if (INDIRECT_INPUT.test(source) || file.startsWith('config/') || file.startsWith('tests/')) { opaque.add(file) } for (const imported of ts.preProcessFile(source, true, true).importedFiles) { - const path = localPath(file, imported.fileName) + const workspaceSource = workspace.resolve(imported.fileName) + const path = workspaceSource?.file ?? localPath(file, imported.fileName) if (path === null) { continue } @@ -54,10 +62,10 @@ export function buildUnitDependencyGraph(sources) { opaque.add(file) continue } - if (!reverse.has(resolved)) { - reverse.set(resolved, new Set()) + if (workspaceSource) { + addConsumer(workspaceSource.manifest, file) } - reverse.get(resolved).add(file) + addConsumer(resolved, file) } } return { reverse, opaque } @@ -70,12 +78,13 @@ export function collectUnitDependencyGraph(root = process.cwd()) { 'config/**/*.{ts,tsx,js,mjs,cjs,json}', 'tests/**/*.{ts,tsx,js,mjs,cjs,json}' ], - { cwd: root } + { cwd: root, exclude: ['**/node_modules/**', '**/dist/**'] } ) const sources = new Map( files.map((file) => [file.replaceAll('\\', '/'), readFileSync(join(root, file), 'utf8')]) ) - return { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) } + const workspace = createWorkspaceSourceResolver(root) + return { ...buildUnitDependencyGraph(sources, workspace), files: new Set(sources.keys()) } } export function unitConsumers(seeds, reverse) { diff --git a/config/scripts/ci-unit-dependency-graph.test.mjs b/config/scripts/ci-unit-dependency-graph.test.mjs new file mode 100644 index 00000000000..96a53192b77 --- /dev/null +++ b/config/scripts/ci-unit-dependency-graph.test.mjs @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { selectUnitFiles } from './ci-unit-selection.mjs' + +const temporary = [] +afterEach(() => { + for (const root of temporary.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(extra = {}) { + const root = mkdtempSync(join(tmpdir(), 'unit-workspace-graph-')) + temporary.push(root) + const manifest = (name, source) => + JSON.stringify({ + name, + exports: { + '.': { 'orca-source': source, types: './dist/index.d.ts', default: './dist/index.js' } + } + }) + const files = { + 'pnpm-workspace.yaml': 'packages:\n - src/packages/*\n', + 'src/packages/process-host/package.json': manifest( + '@orca/process-host', + './src/run-process.ts' + ), + 'src/packages/process-host/src/run-process.ts': + "import { value } from '@orca/byte-buffer'; export { value }; throw Error('source must not execute')", + 'src/packages/process-host/dist/index.js': 'throw Error("stale output")', + 'src/packages/byte-buffer/package.json': manifest('@orca/byte-buffer', './src/byte-buffer.ts'), + 'src/packages/byte-buffer/src/byte-buffer.ts': 'export const value = 1', + 'src/consumer.test.ts': "import '@orca/process-host'", + 'src/dynamic.test.ts': "import('@orca/process-host')", + 'src/require.test.ts': "require('@orca/process-host')", + 'src/type.test.ts': "import type { value } from '@orca/process-host'", + 'src/unrelated.test.ts': 'export const unrelated = true', + ...extra + } + for (const [file, source] of Object.entries(files)) { + const path = join(root, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +it('tracks transitive public workspace source consumers including type and dynamic imports', () => { + const graph = collectUnitDependencyGraph(fixture()) + const tests = [...graph.files].filter((file) => file.endsWith('.test.ts')).sort() + expect( + selectUnitFiles(tests, ['src/packages/byte-buffer/src/byte-buffer.ts'], graph) + ).toMatchObject({ + full: false, + files: [ + 'src/consumer.test.ts', + 'src/dynamic.test.ts', + 'src/require.test.ts', + 'src/type.test.ts' + ] + }) + expect(graph.files.has('src/packages/process-host/dist/index.js')).toBe(false) +}) + +it('tracks package manifests as inputs to all public consumers', () => { + const graph = collectUnitDependencyGraph(fixture()) + const tests = [...graph.files].filter((file) => file.endsWith('.test.ts')).sort() + for (const manifest of [ + 'src/packages/process-host/package.json', + 'src/packages/byte-buffer/package.json' + ]) { + const selection = selectUnitFiles(tests, [manifest], graph) + expect(selection.full).toBe(false) + expect(selection.files).toEqual(tests.filter((file) => file !== 'src/unrelated.test.ts')) + } +}) + +it.each(['@orca/unknown', '@orca/process-host/private'])( + 'does not silently drop unresolved internal imports: %s', + (specifier) => { + expect(() => + collectUnitDependencyGraph(fixture({ 'src/consumer.test.ts': `import '${specifier}'` })) + ).toThrow('No public orca-source export') + } +) diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs index 8b6ffebc874..32c1ac1dbb3 100644 --- a/config/scripts/ci-unit-plan.mjs +++ b/config/scripts/ci-unit-plan.mjs @@ -1,6 +1,6 @@ import { appendFileSync, readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' import { discoverUnitFiles } from './ci-unit-files.mjs' import { FULL_SHARD_COUNT, planUnitSelection } from './ci-unit-selection.mjs' diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs index b5022628927..ffbe1ce3139 100644 --- a/config/scripts/ci-unit-plan.test.mjs +++ b/config/scripts/ci-unit-plan.test.mjs @@ -3,12 +3,16 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { expect, it } from 'vitest' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { FULL_SHARD_COUNT } from './ci-unit-selection.mjs' -it.each([true, false])( - 'plans from a real Git diff, with parent evidence available: %s', - (withParent) => { +it.each([ + { withParent: true, unresolvedPackage: false }, + { withParent: false, unresolvedPackage: false }, + { withParent: true, unresolvedPackage: true } +])( + 'plans from a real Git diff, retaining full coverage for incomplete package evidence: %j', + ({ withParent, unresolvedPackage }) => { const root = mkdtempSync(join(tmpdir(), 'orca-unit-plan-')) const git = (args) => { const result = runProcessSync({ program: 'git', args, cwd: root }) @@ -18,7 +22,10 @@ it.each([true, false])( try { mkdirSync(join(root, 'src')) writeFileSync(join(root, 'src/value.ts'), 'export const value = 1') - writeFileSync(join(root, 'src/consumer.test.ts'), "import './value'") + writeFileSync( + join(root, 'src/consumer.test.ts'), + unresolvedPackage ? "import '@orca/missing-package'" : "import './value'" + ) writeFileSync(join(root, 'src/unrelated.test.ts'), 'export const unrelated = true') git(['init', '--quiet']) git(['add', 'src']) @@ -62,16 +69,19 @@ it.each([true, false])( expect(result.code, result.stderr).toBe(0) const plan = JSON.parse(readFileSync(join(root, 'ci-shards/unit-selection.json'), 'utf8')) expect(plan.sourceSha).toBe(sourceSha) - expect(plan.mode).toBe(withParent ? 'selected' : 'shadow') + const selected = withParent && !unresolvedPackage + expect(plan.mode).toBe(selected ? 'selected' : 'shadow') expect(plan.executionFiles).toEqual( - withParent ? ['src/consumer.test.ts'] : ['src/consumer.test.ts', 'src/unrelated.test.ts'] + selected ? ['src/consumer.test.ts'] : ['src/consumer.test.ts', 'src/unrelated.test.ts'] ) expect(plan.reason).toBe( - withParent - ? 'Transitive imports plus indirect-input consumers' - : 'Error: Changed paths unavailable' + !withParent + ? 'Error: Changed paths unavailable' + : unresolvedPackage + ? 'Error: No public orca-source export for @orca/missing-package' + : 'Transitive imports plus indirect-input consumers' ) - expect(plan.shards).toHaveLength(withParent ? 1 : FULL_SHARD_COUNT) + expect(plan.shards).toHaveLength(selected ? 1 : FULL_SHARD_COUNT) } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/config/scripts/cloud-security-history-workflow.test.mjs b/config/scripts/cloud-security-history-workflow.test.mjs index fc28e55b996..eb9c306653f 100644 --- a/config/scripts/cloud-security-history-workflow.test.mjs +++ b/config/scripts/cloud-security-history-workflow.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const workflow = parse( readFileSync(new URL('../../.github/workflows/cloud-verify.yml', import.meta.url), 'utf8') diff --git a/config/scripts/e2e-cli-artifact-capability.test.mjs b/config/scripts/e2e-cli-artifact-capability.test.mjs index af39b8da753..c90b1cba913 100644 --- a/config/scripts/e2e-cli-artifact-capability.test.mjs +++ b/config/scripts/e2e-cli-artifact-capability.test.mjs @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const build = workflow.jobs.build.steps.find((step) => step.id === 'e2e-cli') diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index d5ddc5df43a..5f13ad29e1c 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -142,6 +142,8 @@ describe('Electron Vite output contract', () => { expect(external('@parcel/watcher', undefined, false)).toBe(true) expect(external('electron', undefined, false)).toBe(true) expect(external('node:fs', undefined, false)).toBe(true) + expect(external('@orca/process-host', undefined, false)).toBe(true) + expect(external('@orca/process-host/spawn-observer', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) expect(external('tldts', undefined, false)).toBe(false) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index a91b4f528c4..5f0e44998bc 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -13,7 +13,8 @@ import { stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' const require = createRequire(import.meta.url) diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 68a10b5bb20..c65706638c9 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -9,13 +9,12 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' import { isAbsolute, join, parse } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' -import { - DEFAULT_MAX_OUTPUT_BYTES, - runProcessSync -} from '../../src/shared/child-process/run-process.ts' +import { runProcessSync } from '@orca/process-host' +import { DEFAULT_MAX_OUTPUT_BYTES } from '@orca/process-host/process-spec' import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' @@ -277,11 +276,16 @@ function mkTempProject() { // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture // missing it fails every case with a module-resolution error instead of the defect under test. copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) + const processPackage = join(projectDir, 'node_modules', '@orca', 'process-host') + mkdirSync(processPackage, { recursive: true }) writeFileSync( - join(projectDir, 'config', 'scripts', 'script-child-process.mjs'), + join(processPackage, 'package.json'), + JSON.stringify({ name: '@orca/process-host', type: 'module', exports: './index.mjs' }) + ) + writeFileSync( + join(processPackage, 'index.mjs'), `import { appendFileSync } from 'node:fs' -import { describeProcessFailure, runProcessSync as run } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)} -export { describeProcessFailure } +import { runProcessSync as run } from ${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve('@orca/process-host')).href)} export function runProcessSync(options) { appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp timeout=\${options.timeoutMs}\\n\`) return run(options) diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs index b83c9c088af..fb904fbb806 100644 --- a/config/scripts/foreign-sqlite-reader-worker-smoke.mjs +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs @@ -1,7 +1,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../src/shared/orcad-artifacts.ts' // Why a child process: the read must run under the runtime orcad ships, which may not be diff --git a/config/scripts/headless-detector-compiler-cache.mjs b/config/scripts/headless-detector-compiler-cache.mjs index 9ef4d5cb6b5..bac5625a3df 100644 --- a/config/scripts/headless-detector-compiler-cache.mjs +++ b/config/scripts/headless-detector-compiler-cache.mjs @@ -61,6 +61,7 @@ export function packCompilerCache({ root = ROOT, identity = compilerCacheIdentit const esbuildDir = dirname(require.resolve('esbuild/package.json')) const nativeName = `@esbuild/${process.platform}-${process.arch}` const nativeDir = dirname(require.resolve(`${nativeName}/package.json`, { paths: [esbuildDir] })) + const yamlDir = dirname(require.resolve('yaml/package.json')) rmSync(identity.path, { recursive: true, force: true }) mkdirSync(join(identity.path, 'node_modules', '@esbuild'), { recursive: true }) cpSync(esbuildDir, join(identity.path, 'node_modules', 'esbuild'), { @@ -71,6 +72,10 @@ export function packCompilerCache({ root = ROOT, identity = compilerCacheIdentit recursive: true, dereference: true }) + cpSync(yamlDir, join(identity.path, 'node_modules', 'yaml'), { + recursive: true, + dereference: true + }) writeFileSync( join(identity.path, 'manifest.json'), JSON.stringify({ @@ -103,7 +108,7 @@ export async function activateCompilerCache({ } mkdirSync(join(dependencies, '@esbuild'), { recursive: true }) created = true - for (const name of ['esbuild', `@esbuild/${process.platform}-${process.arch}`]) { + for (const name of ['esbuild', `@esbuild/${process.platform}-${process.arch}`, 'yaml']) { symlinkSync(join(identity.path, 'node_modules', name), join(dependencies, name), 'dir') } const require = createRequire(join(root, 'package.json')) diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs index be7f7d2ba8a..69d29189cd9 100644 --- a/config/scripts/headless-detector-compiler-cache.test.mjs +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -20,7 +20,7 @@ import { packCompilerCache } from './headless-detector-compiler-cache.mjs' import { collectNodeServerInputs } from './node-server-change-scope.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const temporary = [] afterEach(() => { @@ -66,15 +66,16 @@ it('separates policy, Node, platform and architecture identities with the same a } }) -it('activates only the actual compiler packages and preserves import graph behavior', async () => { +it('activates the compiler and workspace-policy parser and preserves import graph behavior', async () => { const { root, identity } = fixture() expect(await activateCompilerCache({ root, identity })).toEqual({ available: true }) - expect(readdirSync(join(root, 'node_modules')).sort()).toEqual(['@esbuild', 'esbuild']) + expect(readdirSync(join(root, 'node_modules')).sort()).toEqual(['@esbuild', 'esbuild', 'yaml']) for (const name of [ 'node-server-change-scope', 'node-server-test-paths', 'node-server-qualification', - 'orcad-entry-build' + 'orcad-entry-build', + 'workspace-source-exports' ]) { mkdirSync(join(root, 'config', 'scripts'), { recursive: true }) cpSync( @@ -89,7 +90,20 @@ it('activates only the actual compiler packages and preserves import graph behav ) writeFileSync( join(root, 'entry.ts'), - "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'" + "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'; import '@orca/process-host'" + ) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - src/packages/*\n') + mkdirSync(join(root, 'src/packages/process-host/src'), { recursive: true }) + writeFileSync( + join(root, 'src/packages/process-host/package.json'), + JSON.stringify({ + name: '@orca/process-host', + exports: { '.': { 'orca-source': './src/run-process.ts', default: './dist/run-process.js' } } + }) + ) + writeFileSync( + join(root, 'src/packages/process-host/src/run-process.ts'), + 'export const value = 1' ) for (const name of ['first', 'exports', 'dynamic', 'required']) { writeFileSync(join(root, `${name}.ts`), 'export const value = 1') @@ -99,6 +113,8 @@ it('activates only the actual compiler packages and preserves import graph behav "import { collectNodeServerInputs } from './config/scripts/node-server-change-scope.mjs'; console.log(JSON.stringify([...(await collectNodeServerInputs({ root: process.cwd(), entryPoints: ['entry.ts'] }))].sort()))" ) const baseline = [...(await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }))].sort() + expect(baseline).toContain('src/packages/process-host/src/run-process.ts') + expect(baseline).toContain('src/packages/process-host/package.json') const candidate = runProcessSync({ program: process.execPath, args: ['probe.mjs'], diff --git a/config/scripts/hourly-preflight-workflow.test.mjs b/config/scripts/hourly-preflight-workflow.test.mjs index 4d453f07103..9145383559e 100644 --- a/config/scripts/hourly-preflight-workflow.test.mjs +++ b/config/scripts/hourly-preflight-workflow.test.mjs @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const workflow = parse( readFileSync(new URL('../../.github/workflows/hourly-mac-build.yml', import.meta.url), 'utf8') diff --git a/config/scripts/linear-sdk-runtime.test.mjs b/config/scripts/linear-sdk-runtime.test.mjs index 896b8c7b7d5..77251209c9c 100644 --- a/config/scripts/linear-sdk-runtime.test.mjs +++ b/config/scripts/linear-sdk-runtime.test.mjs @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path' import { build } from 'vite' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { electronViteConfig } from '../../electron.vite.config' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const projectDir = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) diff --git a/config/scripts/linux-package-fixture-cache.test.mjs b/config/scripts/linux-package-fixture-cache.test.mjs index 0a1e62fb63a..704d00a863e 100644 --- a/config/scripts/linux-package-fixture-cache.test.mjs +++ b/config/scripts/linux-package-fixture-cache.test.mjs @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const action = parse( readFileSync('.github/actions/prepare-linux-package-fixture/action.yml', 'utf8') diff --git a/config/scripts/managed-data-account-runtime.test.ts b/config/scripts/managed-data-account-runtime.test.ts index 452629732ac..5ee245e41b5 100644 --- a/config/scripts/managed-data-account-runtime.test.ts +++ b/config/scripts/managed-data-account-runtime.test.ts @@ -5,7 +5,8 @@ import { join, resolve } from 'node:path' import { resolveConfig } from 'electron-vite' import { build } from 'vite' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import { stagePackagedProcessHost } from './packaged-process-host-fixture.mjs' const projectDir = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -36,6 +37,7 @@ beforeAll(async () => { } } }) + await stagePackagedProcessHost(outputDir) mkdirSync(join(outputDir, 'source', 'devin'), { recursive: true }) writeFileSync( join(outputDir, 'source', 'devin', 'credentials.toml'), @@ -59,6 +61,8 @@ describe('managed account credentials in the production main bundle', () => { const environment: Record = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', + NODE_PATH: '', + NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: '1' } for (const key of [ diff --git a/config/scripts/mobile-process-host-tooling.test.mjs b/config/scripts/mobile-process-host-tooling.test.mjs new file mode 100644 index 00000000000..fc2d0ef2f5a --- /dev/null +++ b/config/scripts/mobile-process-host-tooling.test.mjs @@ -0,0 +1,129 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { runProcessSync } from '@orca/process-host' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' + +const repository = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +const tsx = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs') +const manifest = JSON.parse(readFileSync(join(repository, 'mobile', 'package.json'), 'utf8')) +const temporary = [] + +afterEach(() => { + for (const root of temporary.splice(0)) { + removeTreeSync(root) + } +}) + +function standaloneMobile() { + const root = mkdtempSync(join(tmpdir(), 'orca-mobile-process-tooling-')) + temporary.push(root) + const mobile = join(root, 'mobile') + const dependency = manifest.devDependencies['@orca/process-host'] + expect(dependency).toBe('link:../src/packages/process-host') + const packageDir = resolve(mobile, dependency.slice('link:'.length)) + mkdirSync(packageDir, { recursive: true }) + for (const file of ['package.json', 'src']) { + cpSync(join(repository, 'src', 'packages', 'process-host', file), join(packageDir, file), { + recursive: true + }) + } + for (const file of [ + 'scripts', + 'src/test-support/rpc-recording', + 'rpc-foundation/pilot-scenarios.json' + ]) { + const destination = join(mobile, file) + mkdirSync(dirname(destination), { recursive: true }) + cpSync(join(repository, 'mobile', file), destination, { recursive: true }) + } + writeFileSync(join(mobile, 'package.json'), JSON.stringify(manifest)) + mkdirSync(join(mobile, 'rpc-foundation', 'goldens'), { recursive: true }) + mkdirSync(join(mobile, 'node_modules', '@orca'), { recursive: true }) + symlinkSync( + packageDir, + join(mobile, 'node_modules', '@orca', 'process-host'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + expect(existsSync(join(root, 'node_modules'))).toBe(false) + expect(existsSync(join(packageDir, 'dist'))).toBe(false) + return { root, mobile } +} + +function command(cwd, program, args) { + return runProcessSync({ + program, + args, + cwd, + timeoutMs: 15_000, + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '', ORCA_BACKGROUND_LAUNCH: '1' } + }) +} + +function rpcCommand(mobile, script, args) { + const [launcher, ...invocation] = manifest.scripts[script].split(' ') + expect(launcher).toBe('tsx') + return command(mobile, process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, [ + tsx, + ...invocation, + ...args + ]) +} + +it('runs the RPC diff with a mobile-only dependency link and no compiled host package', () => { + const { root, mobile } = standaloneMobile() + const init = command(root, 'git', ['init']) + expect(init.code, init.stderr).toBe(0) + const commit = command(root, 'git', [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca-test@example.invalid', + '-c', + 'commit.gpgsign=false', + '-c', + `core.hooksPath=${join(root, 'no-hooks')}`, + 'commit', + '--allow-empty', + '-m', + 'fixture' + ]) + expect(commit.code, commit.stderr).toBe(0) + const result = rpcCommand(mobile, 'rpc:diff', ['HEAD']) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('No recorded behaviour moved') +}) + +it('launches the recording runner with a mobile-only dependency link and no compiled host package', () => { + const { mobile } = standaloneMobile() + const vitest = join(mobile, 'node_modules', 'vitest') + mkdirSync(vitest) + writeFileSync(join(vitest, 'package.json'), '{"name":"vitest","version":"0.0.0"}') + writeFileSync( + join(vitest, 'vitest.mjs'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))' + ) + const { scenarios } = JSON.parse( + readFileSync(join(mobile, 'rpc-foundation', 'pilot-scenarios.json'), 'utf8') + ) + const result = rpcCommand(mobile, 'rpc:record', [scenarios[0].id]) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + 'run', + 'src/test-support/rpc-recording/pilot-recordings.test.ts', + 'src/test-support/rpc-recording/family-recordings.test.ts', + '-t', + expect.any(String) + ]) +}) diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 334e2267cbf..232084b7a4b 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join, matchesGlob, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { shouldRunMobileReleaseChecks } from './mobile-release-check-scope.mjs' const root = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/mobile-test-change-scope.test.mjs b/config/scripts/mobile-test-change-scope.test.mjs index 48e584e66cc..5e4e75b7685 100644 --- a/config/scripts/mobile-test-change-scope.test.mjs +++ b/config/scripts/mobile-test-change-scope.test.mjs @@ -29,6 +29,7 @@ it.each([ 'pnpm-lock.yaml', 'src/main/runtime/runtime-rpc.ts', 'src/shared/protocol-version.ts', + 'src/packages/process-host/src/run-process.ts', '.github/workflows/mobile.yml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/mobile-test-change-scope.mjs', @@ -43,14 +44,22 @@ it('retains tests on missing evidence and a move out of the source tree', () => expect(shouldRunMobileTests(['mobile/src/deleted.ts', 'mobile/docs/moved.ts'])).toBe(true) }) -it('runs Mobile Checks on any shared-module or root lockfile change, on a pull request and on main', () => { +it('runs Mobile Checks for shared modules, process-host tooling and the root lockfile on PRs and main', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/mobile.yml', import.meta.url), 'utf8') ) for (const paths of [workflow.on.pull_request.paths, workflow.on.push.paths]) { - for (const file of ['src/shared/any/module.ts', 'pnpm-lock.yaml']) { + for (const file of [ + 'src/shared/any/module.ts', + 'src/packages/process-host/src/run-process.ts', + 'src/packages/process-host/package.json', + 'pnpm-lock.yaml' + ]) { expect(paths.some((pattern) => matchesGlob(file, pattern))).toBe(true) } + expect( + paths.some((pattern) => matchesGlob('src/packages/unrelated/src/index.ts', pattern)) + ).toBe(false) } }) diff --git a/config/scripts/mobile-web-app-preview-asset-server.mjs b/config/scripts/mobile-web-app-preview-asset-server.mjs index e3524f0c7ed..efbf16ff710 100644 --- a/config/scripts/mobile-web-app-preview-asset-server.mjs +++ b/config/scripts/mobile-web-app-preview-asset-server.mjs @@ -1,7 +1,7 @@ import { createServer } from 'node:https' import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' /** * A real TLS origin for the artifact's images, because interception could not measure them. diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index dba502df98a..ffaf8f1dce6 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -31,9 +31,15 @@ const ALWAYS_FILES = new Set([ '.github/workflows/node-server-tests.yml', 'config/scripts/node-server-change-scope.mjs', 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/workspace-source-exports.mjs', + 'config/scripts/workspace-source-exports.test.mjs', 'config/scripts/headless-detector-compiler-cache.mjs', 'config/scripts/node-server-qualification.mjs', - 'config/scripts/node-server-qualification.test.mjs' + 'config/scripts/node-server-qualification.test.mjs', + // The source graph sees process-host source, not the build that emits the dist servers load. + 'src/packages/process-host/.gitignore', + 'src/packages/process-host/scripts/build-dist.mjs', + 'src/packages/process-host/tsconfig.json' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', @@ -72,8 +78,28 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT, ORCAD_LAUNCHER_ENTRY_POINT + }, + { createWorkspaceSourceResolver } + ] = await Promise.all([ + import('esbuild'), + import('./orcad-entry-build.mjs'), + import('./workspace-source-exports.mjs') + ]) + const workspace = createWorkspaceSourceResolver(root) + const manifests = new Set() + const workspaceSources = { + name: 'workspace-source-exports', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^[^./]/ }, ({ path }) => { + const source = workspace.resolve(path) + if (!source) { + return null + } + manifests.add(source.manifest) + return { path: source.path } + }) } - ] = await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) + } const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, ORCAD_LAUNCHER_ENTRY_POINT, @@ -92,18 +118,19 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) splitting: true, packages: 'external', loader: { '.svg': 'empty', '.png': 'empty', '.webp': 'empty', '.css': 'empty' }, - plugins: [externalNativeAddons], + plugins: [workspaceSources, externalNativeAddons], metafile: true, logLevel: 'silent' }) if (result.warnings.length > 0) { throw new Error(result.warnings.map((warning) => warning.text).join('\n')) } - return new Set( - Object.keys(result.metafile.inputs).map((file) => + return new Set([ + ...manifests, + ...Object.keys(result.metafile.inputs).map((file) => file.replaceAll('\\', '/').replace(/\?.*$/, '') ) - ) + ]) } function isSourceUnitTest(file) { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 32b96b94da2..d8460d811de 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -11,7 +11,7 @@ import { import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' const temporaryDirs = [] @@ -63,6 +63,82 @@ it('runs the matrix when a dependency is deleted or graph analysis fails', async expect((await classifyNodeServerChanges([])).shouldRun).toBe(true) }) +function workspaceTree(imported = '@orca/process-host') { + const manifest = (name, source) => + JSON.stringify({ + name, + exports: { '.': { 'orca-source': source, default: './dist/index.js' } } + }) + return moduleTree({ + 'entry.ts': `export * from '${imported}'`, + 'pnpm-workspace.yaml': 'packages:\n - src/packages/*\n', + 'src/packages/process-host/package.json': manifest( + '@orca/process-host', + './src/run-process.ts' + ), + 'src/packages/process-host/src/run-process.ts': + "import('@orca/byte-buffer'); require('./private'); export const value = 1", + 'src/packages/process-host/src/private.ts': 'module.exports = 2', + 'src/packages/process-host/dist/index.js': "require('./missing-stale-output')", + 'src/packages/byte-buffer/package.json': manifest('@orca/byte-buffer', './src/byte-buffer.ts'), + 'src/packages/byte-buffer/src/byte-buffer.ts': 'export const value = 3', + 'src/packages/unrelated/package.json': manifest('@orca/unrelated', './src/unrelated.ts'), + 'src/packages/unrelated/src/unrelated.ts': 'throw Error("unrelated")' + }) +} + +it('tracks workspace source and manifest closures without trusting dist or widening to unrelated packages', async () => { + const root = workspaceTree() + const inputs = await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) + expect([...inputs].sort()).toEqual([ + 'entry.ts', + 'src/packages/byte-buffer/package.json', + 'src/packages/byte-buffer/src/byte-buffer.ts', + 'src/packages/process-host/package.json', + 'src/packages/process-host/src/private.ts', + 'src/packages/process-host/src/run-process.ts' + ]) + for (const file of [ + 'src/packages/process-host/package.json', + 'src/packages/process-host/src/private.ts', + 'src/packages/byte-buffer/src/byte-buffer.ts' + ]) { + expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(true) + } + expect( + ( + await classifyNodeServerChanges( + ['src/packages/unrelated/src/unrelated.ts'], + async () => inputs + ) + ).shouldRun + ).toBe(false) +}) + +it.each([ + 'src/packages/process-host/.gitignore', + 'src/packages/process-host/scripts/build-dist.mjs', + 'src/packages/process-host/tsconfig.json' +])('runs for a change to the process-host build input %s', async (file) => { + const result = await classifyNodeServerChanges([file], async () => new Set()) + expect(result.shouldRun).toBe(true) + expect(result.reason).toBe(`Build or CI input changed: ${file}`) +}) + +it.each(['@orca/unknown', '@orca/process-host/private'])( + 'retains qualification for an unresolved internal package export: %s', + async (specifier) => { + const root = workspaceTree(specifier) + const result = await classifyNodeServerChanges( + ['src/packages/process-host/src/run-process.ts'], + () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) + ) + expect(result.shouldRun).toBe(true) + expect(result.graphUnavailable).toBe(true) + expect(result.reason).toContain('No public orca-source export') + } +) + it.each([ ['tests/e2e/daemon-running-work-probe.unit.test.ts'], ['config/scripts/zip-extractor-command.test.mjs'], @@ -88,6 +164,8 @@ it.each([ '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-headless-compiler/action.yml', 'config/scripts/headless-detector-compiler-cache.mjs', + 'config/scripts/workspace-source-exports.mjs', + 'config/scripts/workspace-source-exports.test.mjs', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 777a2feb314..2135da349b5 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -24,7 +24,7 @@ const PLATFORM_PREFIXES = [ 'src/main/orcad/', 'src/main/daemon/', 'src/main/wsl/', - 'src/shared/child-process/' + 'src/packages/process-host/' ] const PLATFORM_FAMILIES = [ diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index be4e0e4d9cd..018d565e2b1 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -32,7 +32,9 @@ it.each([ '.github/actions/prepare-orcad-prebuilds/action.yml', 'src/main/ssh/ssh-provider.ts', 'src/main/providers/local-pty-provider.ts', - 'src/shared/child-process/run-process.ts', + 'src/packages/process-host/src/run-process.ts', + 'src/packages/process-host/src/growing-byte-buffer.ts', + 'src/packages/process-host/package.json', 'src/main/persistence/profile-state/store.ts', 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', @@ -70,6 +72,16 @@ it.each([ false ], ['src/main/wsl/runner.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false], + [ + 'src/packages/process-host/src/windows-command-line.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + [ + 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], [ 'src/main/orcad/orcad-launcher.win32.test.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], diff --git a/config/scripts/nsis-process-check.test.mjs b/config/scripts/nsis-process-check.test.mjs index c469ffbc529..f6e0a0be0e4 100644 --- a/config/scripts/nsis-process-check.test.mjs +++ b/config/scripts/nsis-process-check.test.mjs @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const hooks = readFileSync(new URL('../nsis/orca-installer-hooks.nsh', import.meta.url), 'utf8') const processCheck = readFileSync( diff --git a/config/scripts/orcad-prebuild-node-gyp.mjs b/config/scripts/orcad-prebuild-node-gyp.mjs index 4391b93bbee..317157104d4 100644 --- a/config/scripts/orcad-prebuild-node-gyp.mjs +++ b/config/scripts/orcad-prebuild-node-gyp.mjs @@ -22,7 +22,7 @@ export function stageNodeAddonApi(sourceDir, stagedDir) { export async function nodeGypRebuild({ stagedDir, workDir, nodeDir, staticCxxRuntime }) { const compileGypi = join(workDir, 'prebuild-compile.gypi') writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime })) - const { runProcessSync } = await import('./script-child-process.mjs') + const { runProcessSync } = await import('@orca/process-host') const result = runProcessSync({ program: process.execPath, args: [ diff --git a/config/scripts/orcad-prebuild-smoke.mjs b/config/scripts/orcad-prebuild-smoke.mjs index 52987a15eb6..8af400964b3 100644 --- a/config/scripts/orcad-prebuild-smoke.mjs +++ b/config/scripts/orcad-prebuild-smoke.mjs @@ -10,7 +10,7 @@ import { readManifest } from './orcad-prebuild-slot-contents.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const require = createRequire(import.meta.url) diff --git a/config/scripts/orcad-windows-prebuild-cache.mjs b/config/scripts/orcad-windows-prebuild-cache.mjs index 2cd7d7636b1..24bc071e639 100644 --- a/config/scripts/orcad-windows-prebuild-cache.mjs +++ b/config/scripts/orcad-windows-prebuild-cache.mjs @@ -31,17 +31,22 @@ export const WINDOWS_PREBUILD_CACHE_INPUTS = [ 'config/scripts/windows-pe-machine.cjs', 'config/scripts/pinned-node-downloads.mjs', 'config/scripts/update-node-runtime-pin.mjs', - 'config/scripts/script-child-process.mjs', - 'src/shared/child-process/run-process.ts', - 'src/shared/child-process/spawn-resolution.ts', - 'src/shared/child-process/process-tree-termination.ts', - 'src/shared/child-process/process-tree-kill-gate.ts', - 'src/shared/child-process/spawn-observer.ts', - 'src/shared/child-process/bounded-output-sink.ts', - 'src/shared/child-process/child-termination-reporter.ts', - 'src/shared/child-process/process-spec.ts', - 'src/shared/child-process/windows-command-line.ts', - 'src/shared/child-process/windows-cmd-shim-resolution.ts' + 'src/packages/process-host/.gitignore', + 'src/packages/process-host/package.json', + 'src/packages/process-host/scripts/build-dist.mjs', + 'src/packages/process-host/tsconfig.json', + 'src/packages/process-host/src/run-process.ts', + 'src/packages/process-host/src/spawn-resolution.ts', + 'src/packages/process-host/src/process-tree-termination.ts', + 'src/packages/process-host/src/process-tree-kill-gate.ts', + 'src/packages/process-host/src/spawn-observer.ts', + 'src/packages/process-host/src/bounded-output-sink.ts', + 'src/packages/process-host/src/growing-byte-buffer.ts', + 'src/packages/process-host/src/child-termination-reporter.ts', + 'src/packages/process-host/src/process-spec.ts', + 'src/packages/process-host/src/windows-command-line.ts', + 'src/packages/process-host/src/windows-cmd-shim-resolution.ts', + 'src/packages/process-host/src/windows-system-binary.ts' ] function windowsSlot(platform, arch) { diff --git a/config/scripts/orcad-windows-prebuild-cache.test.mjs b/config/scripts/orcad-windows-prebuild-cache.test.mjs index 9a39778c7b7..25da9dbfedb 100644 --- a/config/scripts/orcad-windows-prebuild-cache.test.mjs +++ b/config/scripts/orcad-windows-prebuild-cache.test.mjs @@ -219,13 +219,23 @@ describe('Windows server prebuild cache inputs', () => { 'process-tree-kill-gate', 'spawn-observer', 'bounded-output-sink', + 'growing-byte-buffer', 'child-termination-reporter', 'process-spec', 'windows-command-line', - 'windows-cmd-shim-resolution' - ].map((name) => `src/shared/child-process/${name}.ts`) + 'windows-cmd-shim-resolution', + 'windows-system-binary' + ].map((name) => `src/packages/process-host/src/${name}.ts`) ) ) + expect(WINDOWS_PREBUILD_CACHE_INPUTS).toEqual( + expect.arrayContaining([ + 'src/packages/process-host/.gitignore', + 'src/packages/process-host/package.json', + 'src/packages/process-host/scripts/build-dist.mjs', + 'src/packages/process-host/tsconfig.json' + ]) + ) }) it.each(WINDOWS_PREBUILD_CACHE_INPUTS)('invalidates a changed %s input', (file) => { diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index d8402ca0c40..696aa26ed05 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) const steps = workflow.jobs.persistence.steps diff --git a/config/scripts/package-linux-formats-appimage.mjs b/config/scripts/package-linux-formats-appimage.mjs index a6a3ae6e7b6..1c400aca00b 100644 --- a/config/scripts/package-linux-formats-appimage.mjs +++ b/config/scripts/package-linux-formats-appimage.mjs @@ -1,7 +1,8 @@ import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' const require = createRequire(import.meta.url) diff --git a/config/scripts/package-linux-formats-appimage.test.mjs b/config/scripts/package-linux-formats-appimage.test.mjs index 3fb845ab31f..b5b5075d148 100644 --- a/config/scripts/package-linux-formats-appimage.test.mjs +++ b/config/scripts/package-linux-formats-appimage.test.mjs @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { preparePrAppImageTools } from './package-linux-formats-appimage.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const require = createRequire(import.meta.url) const configuration = { toolsets: { appimage: '1.0.3' } } diff --git a/config/scripts/package-linux-formats.mjs b/config/scripts/package-linux-formats.mjs index a759a86d7a3..405eaea6fa2 100644 --- a/config/scripts/package-linux-formats.mjs +++ b/config/scripts/package-linux-formats.mjs @@ -13,7 +13,7 @@ import { } from 'node:fs' import { join, resolve } from 'node:path' import { copyPrivateTree } from './space-sharing-copy.mjs' -import { spawnProcess } from './script-child-process.mjs' +import { spawnProcess } from '@orca/process-host' import { preparePrAppImageTools } from './package-linux-formats-appimage.mjs' const require = createRequire(import.meta.url) diff --git a/config/scripts/packaged-process-host-fixture.mjs b/config/scripts/packaged-process-host-fixture.mjs new file mode 100644 index 00000000000..f31fc7491d6 --- /dev/null +++ b/config/scripts/packaged-process-host-fixture.mjs @@ -0,0 +1,102 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { runProcessSync } from '@orca/process-host' + +const require = createRequire(import.meta.url) +const requireFromBuilder = createRequire(require.resolve('electron-builder/package.json')) +const { copyFiles, FileMatcher } = requireFromBuilder('app-builder-lib/out/fileMatcher') +const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs') + +export async function stagePackagedProcessHost(resourcesDir) { + const resource = createPackagedRuntimeNodeModuleResources().find( + (entry) => entry.to === join('node_modules', '@orca', 'process-host') + ) + if (!resource) { + throw new Error('Packaged process-host resource is missing') + } + const packageDir = join(resourcesDir, resource.to) + await copyFiles([new FileMatcher(resource.from, packageDir, (value) => value, resource.filter)]) + return packageDir +} + +/** Writes an unpacked-CLI consumer that checks require/import identity, shared state, and subprocesses. */ +export async function writePackagedProcessHostConsumer(resourcesDir) { + const outDir = join(resourcesDir, 'app.asar.unpacked', 'out') + const cliDir = join(outDir, 'cli') + await mkdir(cliDir, { recursive: true }) + await writeFile(join(outDir, 'package.json'), '{"type":"commonjs"}\n', 'utf8') + const entry = join(cliDir, 'index.js') + await writeFile( + entry, + [ + "const assert = require('node:assert/strict')", + "const { once } = require('node:events')", + "const { runProcessSync, runProcess, spawnProcess } = require('@orca/process-host')", + "const { setSpawnObserver } = require('@orca/process-host/spawn-observer')", + "const { setProcessTreeKillGate } = require('@orca/process-host/process-tree-kill-gate')", + "const { signalProcessTree } = require('@orca/process-host/process-tree-termination')", + "import('@orca/process-host').then(async ({ runProcessSync: esmRunProcessSync }) => {", + 'assert.equal(runProcessSync, esmRunProcessSync)', + 'const result = runProcessSync({', + ' program: process.execPath,', + " args: ['-e', 'process.stdout.write(\"copied-runtime\")']", + '})', + 'assert.equal(result.code, 0)', + 'const observedCommands = []', + 'const gateCalls = []', + 'setSpawnObserver((command) => observedCommands.push(command))', + 'let child, closed, watchdog', + 'try {', + 'const observed = await runProcess({', + ' program: process.execPath,', + " args: ['-e', 'process.stdout.write(\"observed-spawn\")']", + '})', + 'assert.equal(observed.code, 0)', + 'assert.equal(observed.stdout, "observed-spawn")', + 'assert.deepEqual(observedCommands, [process.execPath])', + 'child = spawnProcess({', + ' program: process.execPath,', + " args: ['-e', 'setInterval(() => {}, 1000)'],", + ' detached: true', + '})', + 'closed = once(child, "close")', + 'watchdog = setTimeout(() => child.kill("SIGKILL"), 5000)', + 'const rootSignals = []', + 'const killRoot = child.kill.bind(child)', + 'child.kill = (signal) => { rootSignals.push(signal); return killRoot(signal) }', + 'setProcessTreeKillGate((request) => { gateCalls.push(request); return false })', + 'const verified = await signalProcessTree(child, "SIGTERM")', + 'await closed', + 'assert.equal(verified, false)', + 'assert.equal(gateCalls.length, 1)', + 'assert.equal(gateCalls[0].pid, child.pid)', + 'assert.equal(gateCalls[0].scope, process.platform === "win32" ? "win-taskkill-tree" : "posix-process-group")', + 'assert.deepEqual(rootSignals, ["SIGTERM"])', + 'assert.deepEqual(observedCommands, [process.execPath, process.execPath])', + '} finally {', + 'clearTimeout(watchdog)', + 'setSpawnObserver(null)', + 'setProcessTreeKillGate(null)', + 'if (child && child.exitCode === null && child.signalCode === null) {', + ' child.kill("SIGKILL")', + ' await closed', + '}', + '}', + 'process.stdout.write(result.stdout)', + '}).catch((error) => { console.error(error); process.exitCode = 1 })' + ].join('\n'), + 'utf8' + ) + return { outDir, entry } +} + +export function runPackagedProcessHostConsumer(nodeExecutable, resourcesDir, entry) { + return runProcessSync({ + program: nodeExecutable, + args: [entry], + cwd: resourcesDir, + timeoutMs: 15_000, + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '', ORCA_BACKGROUND_LAUNCH: '1' } + }) +} diff --git a/config/scripts/packaged-process-host.test.mjs b/config/scripts/packaged-process-host.test.mjs new file mode 100644 index 00000000000..1b993fac2da --- /dev/null +++ b/config/scripts/packaged-process-host.test.mjs @@ -0,0 +1,119 @@ +import { existsSync, realpathSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { removeTree } from '../../src/shared/windows-transient-lock-removal.ts' +import { + runPackagedProcessHostConsumer, + stagePackagedProcessHost, + writePackagedProcessHostConsumer +} from './packaged-process-host-fixture.mjs' + +const require = createRequire(import.meta.url) +const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs') +const { collectRuntimeClosure } = require('./verify-skills-cli-runtime.cjs') +const nodeExecutable = process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath + +it('loads the copied process package from an unpacked CLI without checkout dependencies', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-process-host-')) + try { + const resource = createPackagedRuntimeNodeModuleResources().find( + (entry) => entry.to === join('node_modules', '@orca', 'process-host') + ) + expect(resource).toBeDefined() + expect(resource.filter).toEqual(['package.json', 'dist/**/*']) + const packageDir = await stagePackagedProcessHost(resourcesDir) + expect(existsSync(join(packageDir, 'src'))).toBe(false) + + const { outDir, entry } = await writePackagedProcessHostConsumer(resourcesDir) + const closure = collectRuntimeClosure(outDir, resourcesDir) + expect(closure).toContain(realpathSync(join(packageDir, 'dist', 'run-process.js'))) + const result = runPackagedProcessHostConsumer(nodeExecutable, resourcesDir, entry) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toBe('copied-runtime') + } finally { + await removeTree(resourcesDir) + } +}) + +describe('process-host output packaging guard', () => { + const { assertProcessHostOutputBuilt } = require('../packaged-runtime-node-modules.cjs') + const electronBuilderConfig = require('../electron-builder.config.cjs') + + async function builtPackage() { + const packageDir = await mkdtemp(join(tmpdir(), 'orca-process-host-output-')) + await mkdir(join(packageDir, 'src', '__fixtures__'), { recursive: true }) + await mkdir(join(packageDir, 'dist')) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: './dist/entry.js', + exports: { + '.': { types: './dist/entry.d.ts', default: './dist/entry.js' }, + './feature': { default: './dist/feature.js' } + } + }) + ) + for (const module of ['entry', 'feature', 'internal']) { + await writeFile(join(packageDir, 'src', `${module}.ts`), '') + await writeFile(join(packageDir, 'dist', `${module}.js`), '') + } + await writeFile(join(packageDir, 'src', 'entry.test.ts'), '') + await writeFile(join(packageDir, 'src', '__fixtures__', 'child.ts'), '') + return packageDir + } + + it('accepts complete output and ignores test and fixture sources', async () => { + const packageDir = await builtPackage() + try { + expect(() => assertProcessHostOutputBuilt(packageDir)).not.toThrow() + } finally { + await removeTree(packageDir) + } + }) + + it('accepts the checkout package', () => { + expect(() => assertProcessHostOutputBuilt()).not.toThrow() + }) + + it.each([ + ['a public export target', 'feature.js', /missing: \.\/dist\/feature\.js/], + ['an internal module', 'internal.js', /missing: \.\/dist\/internal\.js/] + ])('fails when %s was never emitted', async (_label, file, message) => { + const packageDir = await builtPackage() + try { + await rm(join(packageDir, 'dist', file)) + expect(() => assertProcessHostOutputBuilt(packageDir)).toThrow(message) + } finally { + await removeTree(packageDir) + } + }) + + it('fails when dist is absent', async () => { + const packageDir = await builtPackage() + try { + await rm(join(packageDir, 'dist'), { recursive: true }) + expect(() => assertProcessHostOutputBuilt(packageDir)).toThrow(/pnpm build:packages/) + } finally { + await removeTree(packageDir) + } + }) + + it('fails when dist still holds output of a removed source', async () => { + const packageDir = await builtPackage() + try { + await writeFile(join(packageDir, 'dist', 'renamed-away.js'), '') + expect(() => assertProcessHostOutputBuilt(packageDir)).toThrow( + /stale: \.\/dist\/renamed-away\.js/ + ) + } finally { + await removeTree(packageDir) + } + }) + + it('runs in beforePack', () => { + expect(String(electronBuilderConfig.beforePack)).toContain('assertProcessHostOutputBuilt()') + }) +}) diff --git a/config/scripts/patched-dependencies-frozen-install.test.mjs b/config/scripts/patched-dependencies-frozen-install.test.mjs index 53061c1dbd7..071b58240af 100644 --- a/config/scripts/patched-dependencies-frozen-install.test.mjs +++ b/config/scripts/patched-dependencies-frozen-install.test.mjs @@ -10,10 +10,11 @@ import { import { tmpdir } from 'node:os' import { isAbsolute, join, parse, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcessSync } from '../../src/shared/child-process/run-process.ts' +import { runProcessSync } from '@orca/process-host' import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' +import { workspacePackageManifests } from './workspace-source-exports.mjs' /** * Run the command that actually consumes the patch hashes. @@ -58,14 +59,15 @@ describe('patched dependencies', () => { // lockfile this repo does not track, and the real one must not move. const scratch = mkdtempSync(join(tmpdir(), 'orca-frozen-install-')) try { - for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { + for (const file of [ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + ...workspacePackageManifests(PROJECT_DIR) + ]) { + mkdirSync(resolve(scratch, file, '..'), { recursive: true }) copyFileSync(join(PROJECT_DIR, file), join(scratch, file)) } - mkdirSync(join(scratch, 'native', 'windows-registry'), { recursive: true }) - copyFileSync( - join(PROJECT_DIR, 'native', 'windows-registry', 'package.json'), - join(scratch, 'native', 'windows-registry', 'package.json') - ) mkdirSync(join(scratch, 'config'), { recursive: true }) cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), { recursive: true diff --git a/config/scripts/playwright-focused-workflow-selection.test.mjs b/config/scripts/playwright-focused-workflow-selection.test.mjs index b78cbec4480..36ee3045e1e 100644 --- a/config/scripts/playwright-focused-workflow-selection.test.mjs +++ b/config/scripts/playwright-focused-workflow-selection.test.mjs @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const require = createRequire(import.meta.url) const projectDir = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/pr-code-change-scope-process-host.test.mjs b/config/scripts/pr-code-change-scope-process-host.test.mjs new file mode 100644 index 00000000000..bdf128d8956 --- /dev/null +++ b/config/scripts/pr-code-change-scope-process-host.test.mjs @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +it.each([ + 'src/packages/process-host/package.json', + 'src/packages/process-host/src/run-process.ts', + 'src/packages/process-host/src/growing-byte-buffer.ts' +])('runs the packaged surfaces and mobile web build checks for %s', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ + should_run: true, + test: true, + typecheck: true, + package: true, + package_windows: true, + mobile_web_app: true + }) +}) + +it('keeps unrelated host packages out of the mobile web build checks', () => { + expect(classifyPrJobs(['src/packages/unrelated/src/index.ts']).mobile_web_app).toBe(false) +}) + +it.each([ + 'src/packages/process-host/src/run-process.ts', + 'config/scripts/smoke-process-host-node18.mjs', + 'config/scripts/packaged-process-host-fixture.mjs' +])('qualifies the Node 18 package floor for %s', (file) => { + expect(classifyPrJobs([file]).managed_hook_node18).toBe(true) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 9e386542264..938e50f6efe 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -150,8 +150,7 @@ const ORCAD_BROWSER_PREFIXES = [ const MOBILE_WEB_APP_PREFIXES = [ 'config/scripts/build-mobile-web-app', 'config/scripts/run-mobile-web-app-checks', - 'config/scripts/script-child-process.mjs', - 'src/shared/child-process/', + 'src/packages/process-host/', 'config/scripts/verify-mobile-web-app-bundle', 'config/scripts/mobile-web-app-', 'config/scripts/mobile-web-bundle-', @@ -246,6 +245,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [ const MANAGED_HOOK_PREFIXES = [ 'config/scripts/smoke-managed-hook-runtime-node18', + 'config/scripts/smoke-process-host-node18', + 'config/scripts/packaged-process-host-fixture', + 'src/packages/process-host/', 'config/scripts/build-relay', 'src/relay/', 'src/shared/agent-hook', @@ -321,7 +323,7 @@ const SHARED_PACKAGE_PREFIXES = [ const LINUX_PACKAGE_PREFIXES = [ ...SHARED_PACKAGE_PREFIXES, 'config/scripts/package-linux-formats', - 'config/scripts/script-child-process.mjs', + 'config/scripts/process-failure-message.mjs', 'config/scripts/space-sharing-copy.mjs', '.github/actions/prepare-linux-package-fixture/', 'config/docker/cli-launch-contract/', @@ -369,9 +371,9 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', - 'src/shared/child-process/windows-command-line.win32.test.ts', - 'src/shared/child-process/windows-cmd-shim-resolution.test.ts', - 'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts', + 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts', + 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts', + 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts', 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 71f442b8ce5..70b1db76ac4 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -216,7 +216,7 @@ describe('per-job path classification', () => { for (const file of [ 'config/scripts/package-linux-formats.mjs', 'config/scripts/package-linux-formats-appimage.mjs', - 'config/scripts/script-child-process.mjs', + 'config/scripts/process-failure-message.mjs', 'config/scripts/space-sharing-copy.mjs', '.github/actions/prepare-linux-package-fixture/action.yml', 'config/docker/cli-launch-contract/Dockerfile', @@ -229,8 +229,7 @@ describe('per-job path classification', () => { 'config/scripts/static-appimage-package-contract.cjs' ]) { expectClassification([file], { - package: true, - mobile_web_app: file === 'config/scripts/script-child-process.mjs' + package: true }) } }) @@ -244,8 +243,7 @@ describe('per-job path classification', () => { 'config/scripts/run-daemon-shutdown-descendants-docker.mjs' ]) { expectClassification([file], { - package: true, - mobile_web_app: file === 'config/scripts/script-child-process.mjs' + package: true }) } for (const file of [ @@ -329,8 +327,7 @@ describe('per-job path classification', () => { for (const file of [ 'config/scripts/build-mobile-web-app-bundle.mjs', 'config/scripts/run-mobile-web-app-checks.mjs', - 'config/scripts/script-child-process.mjs', - 'src/shared/child-process/run-process.ts', + 'src/packages/process-host/src/run-process.ts', 'config/scripts/mobile-web-app-route-manifest.mjs', 'mobile/web-entry/index.tsx', 'mobile/app/h/[hostId]/index.tsx', diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index ade7fada96a..726706be229 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6c5ae84429c..463abc16040 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -53,6 +53,14 @@ const realZshUsage = /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { + it('enforces process-host boundaries in the required static-analysis checks', () => { + const guard = workflow.jobs.preflight.steps.find((step) => + step.run?.includes('pnpm run check:process-host-imports') + ) + expect(guard?.if).toBe("needs.code_paths.outputs.static_analysis == 'true'") + expect(guard?.['continue-on-error']).toBeUndefined() + }) + it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') @@ -294,9 +302,14 @@ describe('PR workflow parallelism', () => { (step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs' ) + const processHostIndex = steps.findIndex( + (step) => step.run === 'node config/scripts/smoke-process-host-node18.mjs' + ) + expect(installIndex).toBeLessThan(buildIndex) expect(buildIndex).toBeLessThan(node18Index) expect(node18Index).toBeLessThan(smokeIndex) + expect(node18Index).toBeLessThan(processHostIndex) }) it('restores the pnpm store before dependency installation', () => { diff --git a/config/scripts/process-failure-message.mjs b/config/scripts/process-failure-message.mjs new file mode 100644 index 00000000000..95b8b54d51e --- /dev/null +++ b/config/scripts/process-failure-message.mjs @@ -0,0 +1,11 @@ +/** Why a failed child failed, for CI logs where an empty stderr alone says nothing. */ +export function describeProcessFailure(result) { + const detail = [ + `code=${result.code ?? 'none'}`, + `signal=${result.signal ?? 'none'}`, + ...(result.timedOut ? ['timed out'] : []), + ...(result.outputTruncated ? ['output truncated'] : []) + ].join(' ') + const stream = (name, text) => (text?.trim() ? `\n${name}:\n${text.trim().slice(-4000)}` : '') + return `${detail}${stream('stdout', result.stdout)}${stream('stderr', result.stderr)}` +} diff --git a/config/scripts/script-child-process.test.mjs b/config/scripts/process-failure-message.test.mjs similarity index 92% rename from config/scripts/script-child-process.test.mjs rename to config/scripts/process-failure-message.test.mjs index 161124037c1..ede7c203cb3 100644 --- a/config/scripts/script-child-process.test.mjs +++ b/config/scripts/process-failure-message.test.mjs @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { describeProcessFailure } from './script-child-process.mjs' +import { describeProcessFailure } from './process-failure-message.mjs' it('names a timeout and both streams when stderr is empty', () => { expect( diff --git a/config/scripts/process-host-build-dist.test.mjs b/config/scripts/process-host-build-dist.test.mjs new file mode 100644 index 00000000000..115f8ccf30c --- /dev/null +++ b/config/scripts/process-host-build-dist.test.mjs @@ -0,0 +1,377 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { runProcess } from '@orca/process-host' +import { afterEach, describe, expect, it } from 'vitest' + +const realPackageDir = resolve('src/packages/process-host') +const buildScript = join(realPackageDir, 'scripts', 'build-dist.mjs') +const packageRequire = createRequire(join(realPackageDir, 'package.json')) +const tsxCli = packageRequire.resolve('tsx/cli') +const realCompilerDir = dirname(packageRequire.resolve('typescript/package.json')) +const nodeExecutable = process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath +const temporaryRoots = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const packageDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-process-host-build-'))) + temporaryRoots.push(packageDir) + mkdirSync(join(packageDir, 'src')) + mkdirSync(join(packageDir, 'node_modules')) + symlinkSync(realCompilerDir, join(packageDir, 'node_modules', 'typescript'), 'junction') + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'fixture' })) + writeFileSync( + join(packageDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'Node16', + moduleResolution: 'Node16', + rootDir: 'src', + outDir: 'dist', + declaration: true, + declarationMap: true, + sourceMap: true, + noEmitOnError: true, + types: [] + }, + include: ['src/**/*.ts'] + }) + ) + writeFileSync(join(packageDir, 'src', 'entry.ts'), "export { value } from './internal'\n") + writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const value = 1\n') + return packageDir +} + +function runBuild(packageDir, { script = buildScript, cwd = realPackageDir } = {}) { + const args = [tsxCli, '--conditions=orca-source', script, ...(packageDir ? [packageDir] : [])] + return runProcess({ program: nodeExecutable, args, cwd, timeoutMs: 120_000 }) +} + +// Emits replacement output, then takes over or deletes the build lock as a competing build would. +function installLockStealingCompiler(packageDir, { recreateLock }) { + const compilerDir = join(packageDir, 'node_modules', 'typescript') + rmSync(compilerDir, { recursive: true }) + mkdirSync(join(compilerDir, 'bin'), { recursive: true }) + writeFileSync( + join(compilerDir, 'package.json'), + JSON.stringify({ name: 'typescript', bin: { tsc: './bin/tsc' } }) + ) + writeFileSync( + join(compilerDir, 'bin', 'tsc'), + `const { mkdirSync, rmSync, utimesSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const outDir = process.argv[process.argv.indexOf('--outDir') + 1] +mkdirSync(outDir, { recursive: true }) +writeFileSync(join(outDir, 'entry.js'), 'module.exports = "from a build that lost its lock"') +writeFileSync(join(outDir, 'added.js'), '') +rmSync('.dist-build.lock', { recursive: true, force: true }) +${recreateLock ? "mkdirSync('.dist-build.lock')\nconst foreignMtime = new Date(Date.now() + 60_000)\nutimesSync('.dist-build.lock', foreignMtime, foreignMtime)" : ''} +// Hold compilation past proper-lockfile's 15-second refresh so compromise is observed. +setTimeout(() => {}, 17_000) +` + ) +} + +async function build(packageDir) { + const result = await runBuild(packageDir) + expect(result).toMatchObject({ code: 0, stderr: '' }) + return result +} + +function distSnapshot(packageDir) { + const distDir = join(packageDir, 'dist') + return Object.fromEntries( + readdirSync(distDir) + .sort() + .map((file) => { + const stat = statSync(join(distDir, file)) + return [file, { ino: stat.ino, mtimeMs: stat.mtimeMs }] + }) + ) +} + +function leftovers(packageDir) { + return readdirSync(packageDir).filter((name) => name.startsWith('.dist-')) +} + +describe('process-host dist build', () => { + it('leaves an up-to-date dist untouched on repeated builds', async () => { + const packageDir = fixture() + await build(packageDir) + const before = distSnapshot(packageDir) + + const repeated = await build(packageDir) + + expect(repeated.stdout).toBe('') + expect(distSnapshot(packageDir)).toEqual(before) + expect(leftovers(packageDir)).toEqual([]) + }) + + it('rewrites only changed outputs and keeps source maps pointing at src', async () => { + const packageDir = fixture() + await build(packageDir) + const before = distSnapshot(packageDir) + + writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const value = 2\n') + const changed = await build(packageDir) + + expect(changed.stdout).toContain('2 written, 0 removed, 6 unchanged') + const after = distSnapshot(packageDir) + for (const file of ['entry.js', 'entry.d.ts', 'internal.js.map']) { + expect(after[file]).toEqual(before[file]) + } + expect(after['internal.js']).not.toEqual(before['internal.js']) + const map = JSON.parse(readFileSync(join(packageDir, 'dist', 'internal.js.map'), 'utf8')) + expect(map.sources).toEqual(['../src/internal.ts']) + }) + + it('removes outputs of renamed sources', async () => { + const packageDir = fixture() + await build(packageDir) + renameSync(join(packageDir, 'src', 'internal.ts'), join(packageDir, 'src', 'renamed.ts')) + writeFileSync(join(packageDir, 'src', 'entry.ts'), "export { value } from './renamed'\n") + + await build(packageDir) + + expect(readdirSync(join(packageDir, 'dist')).sort()).toEqual([ + 'entry.d.ts', + 'entry.d.ts.map', + 'entry.js', + 'entry.js.map', + 'renamed.d.ts', + 'renamed.d.ts.map', + 'renamed.js', + 'renamed.js.map' + ]) + }) + + it('preserves the previous dist when compilation fails', async () => { + const packageDir = fixture() + await build(packageDir) + const before = distSnapshot(packageDir) + writeFileSync(join(packageDir, 'src', 'internal.ts'), "export const value: number = 'bad'\n") + + const failed = await runBuild(packageDir) + + expect(failed.code).toBe(1) + expect(failed.stderr).toContain('compilation failed') + expect(distSnapshot(packageDir)).toEqual(before) + expect(leftovers(packageDir)).toEqual([]) + }) + + it('publishes new dependencies before replacing their importer and removes stale output last', async () => { + const packageDir = fixture() + await build(packageDir) + renameSync(join(packageDir, 'src', 'internal.ts'), join(packageDir, 'src', 'z-new.ts')) + writeFileSync(join(packageDir, 'src', 'z-new.ts'), 'export const value = 2\n') + writeFileSync(join(packageDir, 'src', 'entry.ts'), "export { value } from './z-new'\n") + const script = join(packageDir, 'read-during-publication.mjs') + const observation = join(packageDir, 'publication-read.json') + const entry = join(packageDir, 'dist', 'entry.js') + const oldDependency = join(packageDir, 'dist', 'internal.js') + // Pause immediately after replacing the importer; the owned reader exits before publication resumes. + writeFileSync( + script, + `import fs from 'node:fs' +import { spawnSync } from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +const rename = fs.renameSync +fs.renameSync = (source, target) => { + const result = rename(source, target) + if (target === ${JSON.stringify(entry)}) { + const reader = spawnSync(process.execPath, ['-e', ${JSON.stringify( + `console.log(JSON.stringify({ value: require(${JSON.stringify(entry)}).value, oldDependencyExists: require('node:fs').existsSync(${JSON.stringify(oldDependency)}) }))` + )}], { encoding: 'utf8', timeout: 10_000 }) + fs.writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ code: reader.status, stdout: reader.stdout, stderr: reader.stderr })) + } + return result +} +syncBuiltinESMExports() +const { buildPackageDist } = await import(${JSON.stringify(pathToFileURL(buildScript).href)}) +await buildPackageDist(process.argv[2]) +` + ) + + const result = await runBuild(packageDir, { script }) + + expect(result).toMatchObject({ code: 0, stderr: '' }) + const reader = JSON.parse(readFileSync(observation, 'utf8')) + expect(reader).toMatchObject({ code: 0, stderr: '' }) + expect(JSON.parse(reader.stdout)).toEqual({ value: 2, oldDependencyExists: true }) + expect(existsSync(oldDependency)).toBe(false) + expect(leftovers(packageDir)).toEqual([]) + }) + + it('keeps dist loadable while concurrent builds and a reader run', async () => { + const packageDir = fixture() + await build(packageDir) + writeFileSync( + join(packageDir, 'src', 'internal.ts'), + `export const value = ${'2 + '.repeat(10_000)}2\n` + ) + const entry = join(packageDir, 'dist', 'entry.js') + const finished = join(packageDir, 'builds-finished') + const reader = runProcess({ + program: nodeExecutable, + args: [ + '-e', + `const { existsSync } = require('node:fs') +let reads = 0 +while (!existsSync(${JSON.stringify(finished)}) || reads === 0) { + for (const key of Object.keys(require.cache)) delete require.cache[key] + if (typeof require(${JSON.stringify(entry)}).value !== 'number') process.exit(2) + reads += 1 +}` + ], + timeoutMs: 60_000 + }) + + let builds + let read + try { + builds = await Promise.all([1, 2, 3].map(() => runBuild(packageDir))) + } finally { + writeFileSync(finished, '') + read = await reader + } + + expect(builds.map((result) => [result.code, result.stderr])).toEqual(builds.map(() => [0, ''])) + expect(read).toMatchObject({ code: 0, stderr: '' }) + expect(readFileSync(join(packageDir, 'dist', 'internal.js'), 'utf8')).toContain('2 + 2') + expect(leftovers(packageDir)).toEqual([]) + }) + + it('serializes builds so a later source snapshot is the one published', async () => { + const packageDir = fixture() + await build(packageDir) + // Hold the build lock while the source changes, as an in-flight earlier build would. + mkdirSync(join(packageDir, '.dist-build.lock')) + const waiting = runBuild(packageDir) + await new Promise((resolveDelay) => setTimeout(resolveDelay, 500)) + writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const value = 9\n') + rmSync(join(packageDir, '.dist-build.lock'), { recursive: true }) + + expect(await waiting).toMatchObject({ code: 0, stderr: '' }) + expect(readFileSync(join(packageDir, 'dist', 'internal.js'), 'utf8')).toContain('value = 9') + }) + + it.each([ + ['taken over by another build', true], + ['removed', false] + ])( + 'fails without publishing when the held build lock is %s', + async (_label, recreateLock) => { + const packageDir = fixture() + await build(packageDir) + const before = distSnapshot(packageDir) + const entry = readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8') + installLockStealingCompiler(packageDir, { recreateLock }) + + const result = await runBuild(packageDir) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('build lock was compromised') + expect(distSnapshot(packageDir)).toEqual(before) + expect(readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8')).toBe(entry) + // The other build's lock is left in place. + expect(existsSync(join(packageDir, '.dist-build.lock'))).toBe(recreateLock) + expect(leftovers(packageDir).filter((name) => name.startsWith('.dist-staging-'))).toEqual([]) + }, + 30_000 + ) + + it('reclaims staging directories abandoned by a killed build', async () => { + const packageDir = fixture() + mkdirSync(join(packageDir, '.dist-staging-abandoned')) + writeFileSync(join(packageDir, '.dist-staging-abandoned', 'partial.js'), '') + + await build(packageDir) + + expect(existsSync(join(packageDir, 'dist', 'entry.js'))).toBe(true) + expect(existsSync(join(packageDir, 'dist', 'partial.js'))).toBe(false) + expect(leftovers(packageDir)).toEqual([]) + }) + + it('compiles with the package-local TypeScript', async () => { + const packageDir = fixture() + rmSync(join(packageDir, 'node_modules', 'typescript')) + const localCompilerDir = join(packageDir, 'node_modules', 'typescript') + mkdirSync(join(localCompilerDir, 'bin'), { recursive: true }) + writeFileSync( + join(localCompilerDir, 'package.json'), + JSON.stringify({ name: 'typescript', bin: { tsc: './bin/tsc' } }) + ) + writeFileSync( + join(localCompilerDir, 'bin', 'tsc'), + "require('node:fs').writeFileSync('compiled-by', 'package')\nprocess.exit(1)\n" + ) + + const failed = await runBuild(packageDir) + + expect(failed.code).toBe(1) + expect(readFileSync(join(packageDir, 'compiled-by'), 'utf8')).toBe('package') + }) + + it('builds its own package when reached through a symlinked directory', async () => { + const packageDir = fixture() + mkdirSync(join(packageDir, 'scripts')) + copyFileSync(buildScript, join(packageDir, 'scripts', 'build-dist.mjs')) + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ + name: '@orca/process-host', + exports: { '.': { 'orca-source': './src/run-process.ts' } } + }) + ) + // The script imports runProcess through its own package name; forward to the real one. + writeFileSync( + join(packageDir, 'src', 'run-process.ts'), + `export { runProcess } from ${JSON.stringify(join(realPackageDir, 'src', 'run-process.ts'))}\n` + ) + writeFileSync( + join(packageDir, 'tsconfig.json'), + readFileSync(join(packageDir, 'tsconfig.json'), 'utf8').replace( + '"include":["src/**/*.ts"]', + '"include":["src/entry.ts","src/internal.ts"]' + ) + ) + symlinkSync( + dirname(packageRequire.resolve('proper-lockfile/package.json')), + join(packageDir, 'node_modules', 'proper-lockfile'), + 'junction' + ) + const linkRoot = mkdtempSync(join(tmpdir(), 'orca-process-host-link-')) + temporaryRoots.push(linkRoot) + const linkDir = join(linkRoot, 'package') + symlinkSync(packageDir, linkDir, 'junction') + + const result = await runBuild(undefined, { + script: join(linkDir, 'scripts', 'build-dist.mjs'), + cwd: linkDir + }) + + expect(result).toMatchObject({ code: 0, stderr: '' }) + expect(existsSync(join(packageDir, 'dist', 'entry.js'))).toBe(true) + }) +}) diff --git a/config/scripts/process-host-dev-rebuild.test.ts b/config/scripts/process-host-dev-rebuild.test.ts new file mode 100644 index 00000000000..dd44b254a5a --- /dev/null +++ b/config/scripts/process-host-dev-rebuild.test.ts @@ -0,0 +1,297 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from '@orca/process-host' +import { rolldown, watch } from 'rolldown' +import type { Plugin, RolldownWatcher } from 'rolldown' +import { afterEach, describe, expect, it } from 'vitest' +import { createProcessHostDevRebuildPlugin } from '../build-plugins/process-host-dev-rebuild' + +const realPackageDir = resolve('src/packages/process-host') +const temporaryRoots: string[] = [] +const watchers: RolldownWatcher[] = [] + +afterEach(async () => { + await Promise.all(watchers.splice(0).map((watcher) => watcher.close())) + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(): { root: string; packageDir: string; main: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-process-host-dev-')) + temporaryRoots.push(root) + const packageDir = join(root, 'src', 'packages', 'process-host') + const sourceDir = join(packageDir, 'src') + mkdirSync(sourceDir, { recursive: true }) + mkdirSync(join(packageDir, 'scripts')) + // Runs the real package build against this fixture package. + writeFileSync( + join(packageDir, 'scripts', 'build-dist.mjs'), + `const { buildPackageDist } = await import(${JSON.stringify(pathToFileURL(resolve(realPackageDir, 'scripts', 'build-dist.mjs')).href)})\nawait buildPackageDist(${JSON.stringify(packageDir)})\n` + ) + mkdirSync(join(root, 'node_modules', '@orca'), { recursive: true }) + const packageRequire = createRequire(join(realPackageDir, 'package.json')) + for (const tool of ['typescript', 'tsx']) { + symlinkSync( + dirname(packageRequire.resolve(`${tool}/package.json`)), + join(root, 'node_modules', tool), + 'junction' + ) + } + symlinkSync(packageDir, join(root, 'node_modules', '@orca', 'process-host'), 'junction') + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - src/packages/*\n') + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ + name: '@orca/process-host', + type: 'commonjs', + exports: { '.': { 'orca-source': './src/entry.ts', default: './dist/entry.js' } } + }) + ) + writeFileSync( + join(root, 'tsconfig.base.json'), + JSON.stringify({ compilerOptions: { strict: true } }) + ) + writeFileSync( + join(packageDir, 'tsconfig.json'), + JSON.stringify({ + extends: '../../../tsconfig.base.json', + compilerOptions: { + target: 'ES2022', + module: 'Node16', + moduleResolution: 'Node16', + rootDir: 'src', + outDir: 'dist', + noEmitOnError: true, + types: [] + }, + include: ['src/**/*.ts'] + }) + ) + writeFileSync(join(sourceDir, 'entry.ts'), "export { version } from './internal'\n") + writeFileSync(join(sourceDir, 'internal.ts'), '// preserved comment\nexport const version = 1\n') + const main = join(root, 'main.cjs') + writeFileSync(main, "module.exports = require('@orca/process-host')\n") + return { root, packageDir, main } +} + +function nextEvent(watcher: RolldownWatcher, code: 'END' | 'ERROR'): Promise { + return new Promise((resolveEvent, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${code}`)), 15_000) + watcher.on('event', function listener(event) { + if (event.code === code) { + clearTimeout(timeout) + watcher.off('event', listener) + resolveEvent() + } else if (event.code === 'ERROR' && code !== 'ERROR') { + clearTimeout(timeout) + watcher.off('event', listener) + reject(event.error) + } + }) + }) +} + +function closeSuccessfulBundles(watcher: RolldownWatcher): void { + // Vite closes successful watch outputs, which invokes electron-vite's reload hook. + watcher.on('event', async (event) => { + if (event.code === 'BUNDLE_END') { + await event.result.close() + } + }) +} + +function readEmittedVersion(root: string): number { + const result = runProcessSync({ + program: process.execPath, + args: ['-e', `console.log(require(${JSON.stringify(join(root, 'out', 'main.cjs'))}).version)`], + cwd: root, + env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } + }) + if (result.code !== 0) { + throw new Error(result.stderr || 'The emitted main entry did not load') + } + return Number(result.stdout.trim()) +} + +describe('External process-host development rebuilds', () => { + it('emits private source edits and new imports before the Electron reload hook', async () => { + const { root, packageDir, main } = fixture() + const loadedVersions: number[] = [] + const reloadHook: Plugin = { + name: 'fake-electron-reload', + closeBundle() { + loadedVersions.push(readEmittedVersion(root)) + } + } + const watcher = watch({ + input: main, + external: ['@orca/process-host'], + plugins: [createProcessHostDevRebuildPlugin(root), reloadHook], + output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } + }) + watchers.push(watcher) + closeSuccessfulBundles(watcher) + await nextEvent(watcher, 'END') + expect(loadedVersions).toEqual([1]) + expect(readFileSync(join(root, 'out', 'main.cjs'), 'utf8')).toContain('@orca/process-host') + + writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const version = 2\n') + await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(2) + + writeFileSync(join(packageDir, 'src', 'new-feature.ts'), 'export const added = 3\n') + writeFileSync( + join(packageDir, 'src', 'internal.ts'), + "import { added } from './new-feature'\nexport const version = added\n" + ) + await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(3) + await expect + .poll(() => existsSync(join(packageDir, 'dist', 'new-feature.js')), { timeout: 15_000 }) + .toBe(true) + expect(readFileSync(join(packageDir, 'dist', 'new-feature.js'), 'utf8')).toContain('added = 3') + }) + + it('rebuilds when an inherited compiler configuration changes', async () => { + const { root, packageDir, main } = fixture() + const watcher = watch({ + input: main, + external: ['@orca/process-host'], + plugins: [createProcessHostDevRebuildPlugin(root)], + output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } + }) + watchers.push(watcher) + closeSuccessfulBundles(watcher) + await nextEvent(watcher, 'END') + const emittedSource = join(packageDir, 'dist', 'internal.js') + expect(readFileSync(emittedSource, 'utf8')).toContain('preserved comment') + + writeFileSync( + join(root, 'tsconfig.base.json'), + JSON.stringify({ compilerOptions: { strict: true, removeComments: true } }) + ) + await expect + .poll(() => readFileSync(emittedSource, 'utf8'), { timeout: 15_000 }) + .not.toContain('preserved comment') + }) + + it('reports a failed compilation, preserves prior output, and recovers after a fix', async () => { + const { root, packageDir, main } = fixture() + const loadedVersions: number[] = [] + const watcher = watch({ + input: main, + external: ['@orca/process-host'], + plugins: [ + createProcessHostDevRebuildPlugin(root), + { + name: 'fake-electron-reload', + closeBundle() { + loadedVersions.push(readEmittedVersion(root)) + } + } + ], + output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } + }) + watchers.push(watcher) + closeSuccessfulBundles(watcher) + await nextEvent(watcher, 'END') + + const failed = nextEvent(watcher, 'ERROR') + writeFileSync(join(packageDir, 'src', 'internal.ts'), "export const version: number = 'bad'\n") + await failed + expect(loadedVersions.at(-1)).toBe(1) + expect(readEmittedVersion(root)).toBe(1) + + writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const version = 4\n') + await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(4) + }) + + it('recovers when a missing import is created in a new source directory', async () => { + const { root, packageDir, main } = fixture() + const loadedVersions: number[] = [] + const watchSignals = new Set() + const watcher = watch({ + input: main, + external: ['@orca/process-host'], + plugins: [ + createProcessHostDevRebuildPlugin(root), + { + name: 'fake-electron-reload', + closeBundle() { + loadedVersions.push(readEmittedVersion(root)) + } + } + ], + output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } + }) + watcher.on('change', (file) => { + if (basename(file) === 'source-directory-change') { + watchSignals.add(file) + } + }) + watchers.push(watcher) + closeSuccessfulBundles(watcher) + await nextEvent(watcher, 'END') + + const failed = nextEvent(watcher, 'ERROR') + writeFileSync( + join(packageDir, 'src', 'internal.ts'), + "export { version } from './features/new-feature'\n" + ) + await failed + expect(loadedVersions.at(-1)).toBe(1) + expect(readEmittedVersion(root)).toBe(1) + + mkdirSync(join(packageDir, 'src', 'features')) + writeFileSync( + join(packageDir, 'src', 'features', 'new-feature.ts'), + 'export const version = 5\n' + ) + await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(5) + expect(existsSync(join(packageDir, 'dist', 'features', 'new-feature.js'))).toBe(true) + expect(watchSignals.size).toBe(1) + + const failedAgain = nextEvent(watcher, 'ERROR') + writeFileSync( + join(packageDir, 'src', 'internal.ts'), + "export { version } from './features/still-missing'\n" + ) + await failedAgain + expect(readEmittedVersion(root)).toBe(5) + + await watcher.close() + watchers.splice(watchers.indexOf(watcher), 1) + for (const signal of watchSignals) { + expect(existsSync(dirname(signal))).toBe(false) + } + }) + + it('leaves non-watch release compilation to the build:packages prerequisite', async () => { + const bundle = await rolldown({ + input: resolve('src/packages/process-host/src/process-spec.ts'), + plugins: [createProcessHostDevRebuildPlugin('/unavailable-development-root')] + }) + try { + await bundle.generate({ format: 'cjs' }) + } finally { + await bundle.close() + } + }) + + it('leaves pnpm dev in no-watch mode unless the caller passes --watch', () => { + // Watch mode restarts Electron on every main-process save, so it stays opt-in. + const launcher = readFileSync(resolve('config/scripts/run-electron-vite-dev.mjs'), 'utf8') + expect(launcher).toContain("const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]") + expect(launcher).not.toMatch(/['"](?:--watch|-w)['"]/) + }) +}) diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index 18ac442ddca..56025417139 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -6,7 +6,8 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Worker } from 'node:worker_threads' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { ORCAD_PROFILE_PREFLIGHT_FLAG, parseOrcadProfilePreflight diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs index bdb813b5893..0b7525fb378 100644 --- a/config/scripts/project-renderer-web-client.test.mjs +++ b/config/scripts/project-renderer-web-client.test.mjs @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { afterEach, describe, expect, it } from 'vitest' const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs') diff --git a/config/scripts/relay-windows-process-tree-prepared-addon.mjs b/config/scripts/relay-windows-process-tree-prepared-addon.mjs index b4d9ff4ced5..396f527e762 100644 --- a/config/scripts/relay-windows-process-tree-prepared-addon.mjs +++ b/config/scripts/relay-windows-process-tree-prepared-addon.mjs @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' const root = join(import.meta.dirname, '../..') diff --git a/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs b/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs index 106147f9cf5..951bcc59479 100644 --- a/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs +++ b/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' import { peImage } from './windows-pe-image-fixture.mjs' import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const directories = [] afterEach(() => { diff --git a/config/scripts/release-javascript-artifact.mjs b/config/scripts/release-javascript-artifact.mjs index 13b4eb06604..5a7f610594b 100644 --- a/config/scripts/release-javascript-artifact.mjs +++ b/config/scripts/release-javascript-artifact.mjs @@ -12,7 +12,8 @@ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' -import { runProcessSync, describeProcessFailure } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { isDirectInvocation } from './script-entry-detection.mjs' import { DESKTOP_RC_TAG, DESKTOP_STABLE_TAG } from './release-tag-patterns.mjs' import { getTarProgram } from './zip-extractor-command.mjs' diff --git a/config/scripts/release-javascript-benchmark.mjs b/config/scripts/release-javascript-benchmark.mjs index 6cbd6e1e63f..4fd991ae2ae 100644 --- a/config/scripts/release-javascript-benchmark.mjs +++ b/config/scripts/release-javascript-benchmark.mjs @@ -8,7 +8,8 @@ import { restoreReleaseJavascript } from './release-javascript-artifact.mjs' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { annotateJavascriptParityFiles, compareJavascriptParityFiles diff --git a/config/scripts/run-mobile-web-app-checks.mjs b/config/scripts/run-mobile-web-app-checks.mjs index 666b7d6d68d..f2813f5ac01 100644 --- a/config/scripts/run-mobile-web-app-checks.mjs +++ b/config/scripts/run-mobile-web-app-checks.mjs @@ -6,7 +6,7 @@ import { createRequire } from 'node:module' import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs' import { readRouteSnapshot } from './mobile-web-app-route-snapshot.mjs' import { PAGE_ROUTE_MODULES } from './mobile-web-app-page-route-modules.mjs' -import { spawnProcess } from './script-child-process.mjs' +import { spawnProcess } from '@orca/process-host' const require = createRequire(import.meta.url) const mobileWebCheckArgs = [ diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 948635f1c18..e51c0c273a9 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -15,7 +15,8 @@ import { packagedNodeRuntimePath } from './build-orcad-node.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' import { currentTarget } from './server-build-target.mjs' import { UNIT_INCLUDE } from './ci-unit-files.mjs' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { CROSS_RUNTIME_TEST_PATHS, nodeServerTestPaths, diff --git a/config/scripts/run-terminal-layout-parity.mjs b/config/scripts/run-terminal-layout-parity.mjs index c1d5033d75d..7d8a48cd8f3 100644 --- a/config/scripts/run-terminal-layout-parity.mjs +++ b/config/scripts/run-terminal-layout-parity.mjs @@ -16,11 +16,12 @@ import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' const repoRoot = resolve(import.meta.dirname, '../..') const BUILD_STAMP = join('out', '.terminal-layout-parity-build') -// Bundled like script-child-process.mjs: importing the .ts directly makes Node warn MODULE_TYPELESS_PACKAGE_JSON. +// Bundle TypeScript parity modules so plain Node does not need a loader. async function importParityModules() { const temporary = mkdtempSync(join(tmpdir(), 'orca-layout-parity-')) try { diff --git a/config/scripts/run-typecheck-projects-in-parallel.mjs b/config/scripts/run-typecheck-projects-in-parallel.mjs index 1174195a771..0296aac9e20 100644 --- a/config/scripts/run-typecheck-projects-in-parallel.mjs +++ b/config/scripts/run-typecheck-projects-in-parallel.mjs @@ -1,5 +1,8 @@ import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { availableParallelism, totalmem } from 'node:os' +import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const BYTES_PER_GIB = 1024 ** 3 @@ -9,9 +12,11 @@ const BYTES_PER_GIB = 1024 ** 3 // out-of-memory runner is killed mid-check, so the job reports a lost runner instead of a // type error. Admission is therefore by memory, not by core count alone. export const TYPECHECK_PROJECTS = [ - { config: 'tsconfig.node.json', heapGib: 7 }, - { config: 'tsconfig.tc.web.json', heapGib: 6 }, - { config: 'tsconfig.tc.cli.json', heapGib: 2 } + { config: 'config/tsconfig.node.json', heapGib: 7 }, + { config: 'config/tsconfig.tc.web.json', heapGib: 6 }, + { config: 'config/tsconfig.tc.cli.json', heapGib: 2 }, + // Root projects exclude workspace package tests; each runs with its package's compiler. + { config: 'src/packages/process-host/tsconfig.test.json', heapGib: 1 } ] // The OS, node itself, and the runner agent need their share; the rest is what tsc may hold. @@ -53,21 +58,31 @@ export function planTypecheckBatches(projects, { budgetGib, parallelism }) { } const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) -const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url)) -function checkProject(project) { +export function resolveProjectCompiler(config, root = repoRoot) { + const require = createRequire(join(root, dirname(config), 'package.json')) + const manifestPath = require.resolve('typescript/package.json') + const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) + return join(dirname(manifestPath), typeof bin === 'string' ? bin : bin.tsc) +} + +function checkProject(config) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], { - cwd: repoRoot, - stdio: 'inherit' - }) + const child = spawn( + process.execPath, + [resolveProjectCompiler(config), '--noEmit', '-p', config], + { + cwd: repoRoot, + stdio: 'inherit' + } + ) child.on('error', reject) child.on('exit', (code, signal) => { if (signal) { - reject(new Error(`tsc ${project} exited with signal ${signal}`)) + reject(new Error(`tsc ${config} exited with signal ${signal}`)) } else if (code !== 0) { - reject(new Error(`tsc ${project} exited with code ${code}`)) + reject(new Error(`tsc ${config} exited with code ${code}`)) } else { resolve() } diff --git a/config/scripts/run-typecheck-projects-in-parallel.test.mjs b/config/scripts/run-typecheck-projects-in-parallel.test.mjs index b83ebbec8b4..c268810179a 100644 --- a/config/scripts/run-typecheck-projects-in-parallel.test.mjs +++ b/config/scripts/run-typecheck-projects-in-parallel.test.mjs @@ -1,10 +1,25 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative, sep } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { TYPECHECK_PROJECTS, admissibleHeapGib, - planTypecheckBatches + planTypecheckBatches, + resolveProjectCompiler } from './run-typecheck-projects-in-parallel.mjs' +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) + const CI_RUNNER = { totalBytes: 16 * 1024 ** 3, parallelism: 4 } const DEV_LAPTOP = { totalBytes: 64 * 1024 ** 3, parallelism: 18 } @@ -22,8 +37,8 @@ function batchOf(batches, config) { describe('typecheck project admission', () => { it('keeps the two expensive projects off the same CI runner', () => { const batches = planFor(CI_RUNNER) - expect(batchOf(batches, 'tsconfig.node.json')).not.toBe( - batchOf(batches, 'tsconfig.tc.web.json') + expect(batchOf(batches, 'config/tsconfig.node.json')).not.toBe( + batchOf(batches, 'config/tsconfig.tc.web.json') ) }) @@ -72,3 +87,42 @@ describe('typecheck project admission', () => { expect(heaviest).toBeLessThanOrEqual(admissibleHeapGib(CI_RUNNER.totalBytes)) }) }) + +describe('workspace package test typecheck', () => { + const packagesDir = join(repoRoot, 'src', 'packages') + const packageTestProjects = readdirSync(packagesDir) + .map((name) => join(packagesDir, name, 'tsconfig.test.json')) + .filter((config) => existsSync(config)) + .map((config) => relative(repoRoot, config).split(sep).join('/')) + + it('type-checks every package test project, which no root project includes', () => { + expect(packageTestProjects).toContain('src/packages/process-host/tsconfig.test.json') + const scheduled = TYPECHECK_PROJECTS.map((project) => project.config) + expect(scheduled).toEqual(expect.arrayContaining(packageTestProjects)) + }) + + it("resolves a package project's compiler from that package, not the root", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-typecheck-compiler-'))) + try { + const writeCompiler = (directory, version) => { + const compilerDir = join(root, directory, 'node_modules', 'typescript') + mkdirSync(join(compilerDir, 'bin'), { recursive: true }) + writeFileSync( + join(compilerDir, 'package.json'), + JSON.stringify({ name: 'typescript', version, bin: { tsc: './bin/tsc' } }) + ) + return join(compilerDir, 'bin', 'tsc') + } + const rootCompiler = writeCompiler('.', '6.0.0') + const packageCompiler = writeCompiler('src/packages/example', '7.0.0') + mkdirSync(join(root, 'config')) + + expect(resolveProjectCompiler('src/packages/example/tsconfig.test.json', root)).toBe( + packageCompiler + ) + expect(resolveProjectCompiler('config/tsconfig.node.json', root)).toBe(rootCompiler) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/run-vitest.mjs b/config/scripts/run-vitest.mjs index 35bb720259a..082ced138be 100644 --- a/config/scripts/run-vitest.mjs +++ b/config/scripts/run-vitest.mjs @@ -1,5 +1,5 @@ import { resolve } from 'node:path' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const root = resolve(import.meta.dirname, '../..') try { diff --git a/config/scripts/script-child-process.mjs b/config/scripts/script-child-process.mjs deleted file mode 100644 index 5486a78ba8d..00000000000 --- a/config/scripts/script-child-process.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { build } from 'esbuild' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' - -const root = resolve(import.meta.dirname, '../..') -const temporary = mkdtempSync(join(tmpdir(), 'orca-script-child-process-')) -const output = join(temporary, 'child-process.mjs') -let implementation - -try { - await build({ - stdin: { - contents: [ - `export { runProcessSync, spawnProcess } from ${JSON.stringify(join(root, 'src/shared/child-process/run-process.ts'))}` - ].join('\n'), - resolveDir: root, - sourcefile: 'script-child-process-entry.ts' - }, - bundle: true, - platform: 'node', - target: 'node20', - format: 'esm', - outfile: output, - logLevel: 'silent' - }) - implementation = await import(pathToFileURL(output).href) -} finally { - rmSync(temporary, { recursive: true, force: true }) -} - -export const runProcessSync = implementation.runProcessSync - -export const spawnProcess = implementation.spawnProcess - -/** Why a failed child failed, for CI logs where an empty stderr alone says nothing. */ -export function describeProcessFailure(result) { - const detail = [ - `code=${result.code ?? 'none'}`, - `signal=${result.signal ?? 'none'}`, - ...(result.timedOut ? ['timed out'] : []), - ...(result.outputTruncated ? ['output truncated'] : []) - ].join(' ') - const stream = (name, text) => (text?.trim() ? `\n${name}:\n${text.trim().slice(-4000)}` : '') - return `${detail}${stream('stdout', result.stdout)}${stream('stderr', result.stderr)}` -} diff --git a/config/scripts/session-scanner-service-smoke.mjs b/config/scripts/session-scanner-service-smoke.mjs index d226e2f09b7..146a88ad17b 100644 --- a/config/scripts/session-scanner-service-smoke.mjs +++ b/config/scripts/session-scanner-service-smoke.mjs @@ -1,7 +1,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' import { ORCAD_SESSION_SCANNER_SERVICE_ENTRY } from '../../src/shared/orcad-artifacts.ts' import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from '../../src/main/ai-vault/session-scanner-service-protocol.ts' diff --git a/config/scripts/session-search-file-id-e2e/run.ts b/config/scripts/session-search-file-id-e2e/run.ts index 9df3efc5e3e..bfb08ac26bb 100644 --- a/config/scripts/session-search-file-id-e2e/run.ts +++ b/config/scripts/session-search-file-id-e2e/run.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises' import { resolve, join, relative } from 'node:path' import { createHash } from 'node:crypto' import { build } from 'esbuild' -import { runProcess } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const root = process.cwd() const evidence = resolve('notes/search-ipc') @@ -53,7 +53,7 @@ export { resetAiVaultScannerServiceForTests, reconcileSessionSearchInService } f export { setAppEnvironment } from ${source('shared/app-environment.ts')}; export { isolatedScanRoots } from ${source('main/ai-vault/session-scanner-test-fixtures.ts')}; export { claudeLines } from ${source('main/ai-vault-search/session-search-indexer-test-fixture.ts')}; -export { runProcess } from ${source('shared/child-process/run-process.ts')}; +export { runProcess } from '@orca/process-host'; export { UnixSocketTransport } from ${source('main/runtime/rpc/unix-socket-transport.ts')}; export { RpcDispatcher } from ${source('main/runtime/rpc/dispatcher.ts')}; export { AI_VAULT_METHODS } from ${source('main/runtime/rpc/methods/ai-vault.ts')}; diff --git a/config/scripts/smoke-process-host-node18.mjs b/config/scripts/smoke-process-host-node18.mjs new file mode 100644 index 00000000000..0b9dc2455df --- /dev/null +++ b/config/scripts/smoke-process-host-node18.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + runPackagedProcessHostConsumer, + stagePackagedProcessHost, + writePackagedProcessHostConsumer +} from './packaged-process-host-fixture.mjs' + +// Qualifies the package's engines floor against the staged public output, not the checkout source. +assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18') + +const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-process-host-node18-')) +try { + await stagePackagedProcessHost(resourcesDir) + const { entry } = await writePackagedProcessHostConsumer(resourcesDir) + const result = runPackagedProcessHostConsumer(process.execPath, resourcesDir, entry) + assert.equal(result.code, 0, result.stderr) + assert.equal(result.stdout, 'copied-runtime') +} finally { + await rm(resourcesDir, { recursive: true, force: true }) +} + +console.log(`Node ${process.versions.node} process-host package smoke passed.`) diff --git a/config/scripts/static-module-specifiers.mjs b/config/scripts/static-module-specifiers.mjs new file mode 100644 index 00000000000..e2fe2f1abe3 --- /dev/null +++ b/config/scripts/static-module-specifiers.mjs @@ -0,0 +1,240 @@ +import ts from 'typescript-api' + +const MODULE_BUILTINS = new Set(['module', 'node:module']) +const PROCESS_BUILTINS = new Set(['process', 'node:process']) + +function unwrap(node) { + while ( + node && + (ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isAwaitExpression(node)) + ) { + node = node.expression + } + return node +} + +function isNamed(node, name) { + return ts.isIdentifier(node) && node.text === name +} + +function literalString(node) { + node = unwrap(node) + return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : null +} + +function propertyName(node) { + if (ts.isPropertyAccessExpression(node)) { + return node.name.text + } + return ts.isElementAccessExpression(node) ? literalString(node.argumentExpression) : null +} + +function isConstDeclaration(node) { + return ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0 +} + +function collectBindings(source) { + const strings = new Map() + const declarations = [] + const destructures = [] + const imports = [] + function visit(node) { + if (ts.isVariableDeclaration(node) && node.initializer) { + const initializer = unwrap(node.initializer) + if (ts.isIdentifier(node.name)) { + declarations.push({ name: node.name.text, initializer }) + const value = literalString(initializer) + if (isConstDeclaration(node) && value !== null) { + strings.set(node.name.text, [...(strings.get(node.name.text) ?? []), value]) + } + } else if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + const key = element.propertyName ?? element.name + const name = ts.isIdentifier(key) ? key.text : literalString(key) + if (ts.isIdentifier(element.name) && name !== null) { + destructures.push({ name: element.name.text, key: name, initializer }) + } + } + } + } else if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + imports.push({ module: node.moduleSpecifier.text, clause: node.importClause }) + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + ts.isStringLiteral(node.moduleReference.expression) + ) { + imports.push({ module: node.moduleReference.expression.text, namespace: node.name }) + } + ts.forEachChild(node, visit) + } + visit(source) + return { strings, declarations, destructures, imports } +} + +/** + * Specifiers a file statically loads: imports, re-exports, `import()`, `require`/`require.resolve`, + * `module.require`, `createRequire(...)` loaders and their aliases, and `process.getBuiltinModule`. + * A specifier argument may be a literal or a file-level `const` string. Name bindings are + * file-wide, not scope-aware. Literal property keys are resolved; computed key values are not. + * Computed specifiers (concatenation, templates with substitutions, parameters, values imported + * from other files), loaders stored in objects or passed as arguments, and `eval`/`Function` + * are not resolved. + */ +export function collectModuleSpecifiers(file, contents) { + const source = ts.createSourceFile(file, contents, ts.ScriptTarget.Latest, true) + if (source.parseDiagnostics.length > 0) { + throw new Error(`Cannot parse ${file}; the process-host import check must not skip it.`) + } + const { strings, declarations, destructures, imports } = collectBindings(source) + const loaders = new Set(['require']) + const createRequires = new Set() + const moduleNamespaces = new Set() + const processes = new Set(['process']) + const builtinLoaders = new Set() + + function specifierValues(node) { + node = unwrap(node) + const value = literalString(node) + if (value !== null) { + return [value] + } + return node && ts.isIdentifier(node) ? (strings.get(node.text) ?? []) : [] + } + function isProcess(node) { + node = unwrap(node) + return ( + (ts.isIdentifier(node) && processes.has(node.text)) || + (propertyName(node) === 'process' && isNamed(node.expression, 'globalThis')) || + loadsAny(node, PROCESS_BUILTINS) + ) + } + function isBuiltinLoader(node) { + node = unwrap(node) + return ( + (ts.isIdentifier(node) && builtinLoaders.has(node.text)) || + (propertyName(node) === 'getBuiltinModule' && isProcess(node.expression)) + ) + } + function isCreateRequire(node) { + node = unwrap(node) + return ( + (ts.isIdentifier(node) && createRequires.has(node.text)) || + (propertyName(node) === 'createRequire' && isModuleNamespace(node.expression)) + ) + } + function isLoader(node) { + node = unwrap(node) + return ( + (ts.isIdentifier(node) && loaders.has(node.text)) || + (propertyName(node) === 'require' && isNamed(node.expression, 'module')) || + (ts.isCallExpression(node) && isCreateRequire(node.expression)) + ) + } + function loadsAny(node, specifiers) { + return ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + isLoader(node.expression) || + isBuiltinLoader(node.expression)) && + specifierValues(node.arguments[0]).some((specifier) => specifiers.has(specifier)) + ) + } + function isModuleNamespace(node) { + node = unwrap(node) + return ( + (ts.isIdentifier(node) && moduleNamespaces.has(node.text)) || loadsAny(node, MODULE_BUILTINS) + ) + } + + for (const { module, clause, namespace } of imports) { + const named = clause?.namedBindings + const local = [ + namespace, + clause?.name, + named && ts.isNamespaceImport(named) ? named.name : null + ] + for (const name of local.filter(Boolean)) { + if (MODULE_BUILTINS.has(module)) { + moduleNamespaces.add(name.text) + } + if (PROCESS_BUILTINS.has(module)) { + processes.add(name.text) + } + } + for (const element of named && ts.isNamedImports(named) ? named.elements : []) { + const imported = (element.propertyName ?? element.name).text + if (MODULE_BUILTINS.has(module) && (imported === 'default' || imported === 'Module')) { + moduleNamespaces.add(element.name.text) + } + if (PROCESS_BUILTINS.has(module) && imported === 'default') { + processes.add(element.name.text) + } + if (MODULE_BUILTINS.has(module) && imported === 'createRequire') { + createRequires.add(element.name.text) + } + if (PROCESS_BUILTINS.has(module) && imported === 'getBuiltinModule') { + builtinLoaders.add(element.name.text) + } + } + } + // Aliases can chain (`const m = require('module'); const r = m.createRequire(x)`). + for (let changed = true; changed;) { + changed = false + const bind = (set, name, matches) => { + if (matches && !set.has(name)) { + set.add(name) + changed = true + } + } + for (const { name, initializer } of declarations) { + bind(loaders, name, isLoader(initializer)) + bind(createRequires, name, isCreateRequire(initializer)) + bind(moduleNamespaces, name, isModuleNamespace(initializer)) + bind(processes, name, isProcess(initializer)) + bind(builtinLoaders, name, isBuiltinLoader(initializer)) + } + for (const { name, key, initializer } of destructures) { + bind(createRequires, name, key === 'createRequire' && isModuleNamespace(initializer)) + bind(builtinLoaders, name, key === 'getBuiltinModule' && isProcess(initializer)) + } + } + + const specifiers = new Set() + function add(node) { + for (const specifier of specifierValues(node)) { + specifiers.add(specifier) + } + } + function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + add(node.moduleSpecifier) + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + add(node.moduleReference.expression) + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + add(node.argument.literal) + } else if (ts.isCallExpression(node)) { + const callee = unwrap(node.expression) + if ( + callee.kind === ts.SyntaxKind.ImportKeyword || + isLoader(callee) || + isBuiltinLoader(callee) || + (propertyName(callee) === 'resolve' && isLoader(callee.expression)) + ) { + add(node.arguments[0]) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return [...specifiers] +} diff --git a/config/scripts/static-module-specifiers.test.mjs b/config/scripts/static-module-specifiers.test.mjs new file mode 100644 index 00000000000..53a1508721d --- /dev/null +++ b/config/scripts/static-module-specifiers.test.mjs @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { assessProcessHostImports } from './check-process-host-imports.mjs' +import { collectModuleSpecifiers } from './static-module-specifiers.mjs' + +function specifiers(contents) { + return collectModuleSpecifiers('file.ts', contents) +} + +describe('static module specifiers', () => { + it.each([ + [ + 'a direct createRequire call', + "import { createRequire } from 'node:module'\ncreateRequire(import.meta.url)('child_process')" + ], + [ + 'an assigned createRequire loader', + "import { createRequire } from 'node:module'\nconst load = createRequire(__filename)\nload('node:child_process')" + ], + [ + 'a renamed createRequire import', + "import { createRequire as make } from 'module'\nconst load = make(__filename)\nload.resolve('child_process')" + ], + [ + 'a namespace createRequire', + "import * as nodeModule from 'node:module'\nnodeModule.createRequire(__filename)('child_process')" + ], + [ + 'a default-import createRequire', + "import Module from 'module'\nconst load = Module.createRequire(__filename)\nload('child_process')" + ], + [ + 'a named default-import createRequire', + "import { default as Module } from 'node:module'\nModule.createRequire(__filename)('child_process')" + ], + [ + 'a named Module constructor', + "import { Module } from 'node:module'\nModule.createRequire(__filename)('child_process')" + ], + [ + 'a renamed Module constructor', + "import { Module as HostModule } from 'module'\nHostModule.createRequire(__filename)('node:child_process')" + ], + [ + 'an import-equals createRequire', + "import Module = require('node:module')\nconst load = Module.createRequire(__filename)\nload('node:child_process')" + ], + [ + 'a literal-property createRequire', + "import * as Module from 'node:module'\nModule['createRequire'](__filename)('child_process')" + ], + [ + 'a required createRequire', + "const { createRequire: make } = require('node:module')\nmake(__filename)('child_process')" + ], + [ + 'a quoted destructured createRequire', + "const { 'createRequire': make } = require('node:module')\nmake(__filename)('child_process')" + ], + [ + 'a chained module alias', + "const nodeModule = require('module')\nconst load = nodeModule.createRequire(__filename)\nload('child_process')" + ], + [ + 'an awaited module import', + "const { createRequire } = await import('node:module')\ncreateRequire(import.meta.url)('child_process')" + ], + ['a reassigned require', "const load = require\nload('child_process')"], + ['module.require', "module.require('child_process')"], + ['a literal-property module.require', "module['require']('child_process')"], + ['a literal-property require.resolve', "require['resolve']('child_process')"], + ['process.getBuiltinModule', "process.getBuiltinModule('node:child_process')"], + ['a literal-property getBuiltinModule', "process['getBuiltinModule']('node:child_process')"], + ['a template-property getBuiltinModule', 'process[`getBuiltinModule`]("child_process")'], + ['globalThis.process.getBuiltinModule', "globalThis.process.getBuiltinModule('child_process')"], + [ + 'literal properties on globalThis.process', + "globalThis['process']['getBuiltinModule']('child_process')" + ], + [ + 'an imported process', + "import nodeProcess from 'node:process'\nnodeProcess.getBuiltinModule('child_process')" + ], + [ + 'a named default-import process', + "import { default as nodeProcess } from 'node:process'\nnodeProcess.getBuiltinModule('child_process')" + ], + [ + 'an import-equals process', + "import nodeProcess = require('node:process')\nnodeProcess.getBuiltinModule('child_process')" + ], + [ + 'a destructured getBuiltinModule', + "const { getBuiltinModule } = process\ngetBuiltinModule('child_process')" + ], + [ + 'a quoted destructured getBuiltinModule', + "const { 'getBuiltinModule': load } = process\nload('child_process')" + ], + [ + 'a builtin module with a literal-property factory', + "process['getBuiltinModule']('node:module')['createRequire'](__filename)('child_process')" + ], + [ + 'an imported getBuiltinModule', + "import { getBuiltinModule as load } from 'process'\nload('child_process')" + ], + [ + 'a const specifier', + "const CHILD_PROCESS = 'node:child_process' as const\nrequire(CHILD_PROCESS)" + ], + [ + 'a const template specifier', + 'const CHILD_PROCESS = `child_process`\nprocess.getBuiltinModule(CHILD_PROCESS)' + ] + ])('resolves child_process loaded through %s', (_label, contents) => { + expect( + specifiers(contents).filter((specifier) => specifier.endsWith('child_process')) + ).toHaveLength(1) + }) + + it('ignores unrelated objects, computed property values, and non-constant specifiers', () => { + expect( + specifiers( + [ + 'const registry = { getBuiltinModule: (name) => name, createRequire: () => () => null }', + "registry.getBuiltinModule('child_process')", + "registry.createRequire(__filename)('child_process')", + "registry['getBuiltinModule']('child_process')", + "registry['createRequire'](__filename)('child_process')", + "const method = 'getBuiltinModule'", + "process[method]('child_process')", + "let mutable = 'child_process'", + 'require(mutable)', + "require('child' + '_process')", + "const name = 'child'", + 'require(`${name}_process`)' + ].join('\n') + ) + ).toEqual([]) + }) + + it('flags a new production file that bypasses the import form', () => { + expect( + assessProcessHostImports( + new Map([ + [ + 'src/main/bypass.ts', + "import { createRequire } from 'node:module'\nconst load = createRequire(import.meta.url)\nload('child_process')" + ], + ['src/main/builtin.ts', "process['getBuiltinModule']('node:child_process')"], + [ + 'src/main/import-equals.ts', + "import Module = require('node:module')\nconst load = Module.createRequire(__filename)\nload('node:child_process')" + ], + [ + 'src/main/named-default-module.ts', + "import { default as Module } from 'node:module'\nModule.createRequire(__filename)('child_process')" + ], + [ + 'src/main/named-default-process.ts', + "import { default as nodeProcess } from 'node:process'\nnodeProcess.getBuiltinModule('child_process')" + ] + ]), + { exports: {} }, + [] + ).added + ).toEqual([ + 'src/main/builtin.ts', + 'src/main/bypass.ts', + 'src/main/import-equals.ts', + 'src/main/named-default-module.ts', + 'src/main/named-default-process.ts' + ]) + }) + + it.each([ + "import { createRequire } from 'node:module'\nconst ELECTRON = 'electron'\ncreateRequire(__filename)(ELECTRON)", + "import Module = require('node:module')\nModule.createRequire(__filename)('electron')", + "import { Module as HostModule } from 'module'\nHostModule.createRequire(__filename)('electron')", + "import * as Module from 'node:module'\nModule['createRequire'](__filename)('electron')", + "const { 'createRequire': make } = require('node:module')\nmake(__filename)('electron')" + ])('applies package dependency rules to dynamically loaded specifiers: %s', (contents) => { + expect( + assessProcessHostImports( + new Map([['src/packages/process-host/src/runner.ts', contents]]), + { exports: {} }, + [] + ).violations + ).toEqual([ + 'src/packages/process-host/src/runner.ts: undeclared process-host dependency: electron' + ]) + }) +}) diff --git a/config/scripts/terminal-perf-dependency-preparation.test.mjs b/config/scripts/terminal-perf-dependency-preparation.test.mjs index 3f4c5955f7a..ad8fa4d3474 100644 --- a/config/scripts/terminal-perf-dependency-preparation.test.mjs +++ b/config/scripts/terminal-perf-dependency-preparation.test.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { runInNewContext } from 'node:vm' import { parse } from 'yaml' import { expect, it } from 'vitest' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' const workflow = parse(readFileSync('.github/workflows/terminal-perf.yml', 'utf8')) const steps = workflow.jobs['terminal-perf'].steps @@ -14,7 +14,9 @@ const supportedAction = readFileSync('.github/actions/install-node-dependencies/ const supportedManifest = { engines: { node: '24' }, packageManager: 'pnpm@12.8.1', - scripts: { postinstall: 'node config/scripts/rebuild-native-deps.mjs' } + scripts: { + postinstall: 'pnpm run build:packages && node config/scripts/rebuild-native-deps.mjs' + } } function select(options = {}) { @@ -64,6 +66,17 @@ it('selects the measured current root profile with the actual installer metadata ).toBe('shared=true') }) +it('keeps the shared preparation route for the qualified older lifecycle', () => { + expect( + select({ + manifest: { + ...supportedManifest, + scripts: { postinstall: 'node config/scripts/rebuild-native-deps.mjs' } + } + }) + ).toBe('shared=true') +}) + it.each([ ['historical Node', { manifest: { ...supportedManifest, engines: { node: '22' } } }], ['historical pnpm', { manifest: { ...supportedManifest, packageManager: 'pnpm@10.0.0' } }], @@ -73,6 +86,15 @@ it.each([ 'extra lifecycle work', { manifest: { ...supportedManifest, scripts: { postinstall: 'generate' } } } ], + [ + 'additional package lifecycle work', + { + manifest: { + ...supportedManifest, + scripts: { postinstall: `${supportedManifest.scripts.postinstall} && generate` } + } + } + ], ['self-hosted runner', { kind: 'self-hosted' }], ['job container', { container: 'container-id' }], ['another OS', { os: 'Windows' }], diff --git a/config/scripts/update-node-runtime-pin.mjs b/config/scripts/update-node-runtime-pin.mjs index 86f8ce75bb9..d70a2b909e2 100644 --- a/config/scripts/update-node-runtime-pin.mjs +++ b/config/scripts/update-node-runtime-pin.mjs @@ -26,7 +26,7 @@ import { } from '../../src/shared/node-runtime-pin.ts' import { currentTarget } from './server-build-target.mjs' import { nodeDistArchiveName, windowsImportLibFile } from './node-dist-archive-name.mjs' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { getTarProgram, getZipExtractorCommand } from './zip-extractor-command.mjs' const root = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/verify-localization-catalogs.test.mjs b/config/scripts/verify-localization-catalogs.test.mjs index d83eaf1dcff..b0d7548507a 100644 --- a/config/scripts/verify-localization-catalogs.test.mjs +++ b/config/scripts/verify-localization-catalogs.test.mjs @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { main as verifyTogether } from './verify-localization-catalogs.mjs' import { main as verifyCatalog } from './verify-localization-catalog.mjs' import { main as verifyRuntimeCatalog } from './generate-runtime-required-english-catalog.mjs' diff --git a/config/scripts/verify-localization-extraction.mjs b/config/scripts/verify-localization-extraction.mjs index 5cb92480397..c3cc7774be6 100644 --- a/config/scripts/verify-localization-extraction.mjs +++ b/config/scripts/verify-localization-extraction.mjs @@ -79,7 +79,10 @@ async function extractToTemporaryCatalog(root, tempDir) { const outputPattern = path.join(tempDir, '{{language}}.json') // Why: extraction output is evidence for this check, not another committed // catalog that feature authors must keep synchronized. - const { runProcessSync, describeProcessFailure } = await import('./script-child-process.mjs') + const [{ runProcessSync }, { describeProcessFailure }] = await Promise.all([ + import('@orca/process-host'), + import('./process-failure-message.mjs') + ]) let program = process.execPath try { const version = (await fs.readFile(path.join(root, 'config', '.bun-version'), 'utf8')).trim() diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index a5ea5a72ef3..36763a4a3a9 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -7,7 +7,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'config/scripts/skill-recipe-shell.test.mjs', 'src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts', 'src/main/ssh/ssh-remote-commands.test.ts', - 'src/shared/child-process/run-process.test.ts', + 'src/packages/process-host/src/run-process.test.ts', 'tests/e2e/cursor-quota-transport.unit.test.ts', 'src/main/ai-vault-search/session-search-index-writer.test.ts', 'src/main/ai-vault/session-scanner-unlimited-dedup.test.ts', @@ -95,6 +95,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx', 'src/main/claude/claude-agent-sdk-contract-pins.test.ts', 'config/scripts/profile-state-worker-smoke.test.mjs', + 'config/scripts/process-host-dev-rebuild.test.ts', 'config/scripts/vitest-node-runtime-boundary.test.ts', 'config/scripts/vitest-node-runtime-pool.test.ts' ] diff --git a/config/scripts/vitest-node-runtime-pool.test.ts b/config/scripts/vitest-node-runtime-pool.test.ts index e81533a4594..7e3f6c9b3c3 100644 --- a/config/scripts/vitest-node-runtime-pool.test.ts +++ b/config/scripts/vitest-node-runtime-pool.test.ts @@ -2,11 +2,12 @@ import { once } from 'node:events' import { resolve } from 'node:path' import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' import { createVitest, type PoolOptions, type Vitest } from 'vitest/node' -import * as processes from '../../src/shared/child-process/run-process' +import * as processes from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { nodeRuntimePool } from './vitest-node-runtime-pool' let context: Vitest -const children: processes.ChildProcessHandle[] = [] +const children: ChildProcessHandle[] = [] const spawn = processes.spawnProcess beforeAll(async () => { diff --git a/config/scripts/vitest-node-runtime-pool.ts b/config/scripts/vitest-node-runtime-pool.ts index 4c47e674ed3..445a0e86c4a 100644 --- a/config/scripts/vitest-node-runtime-pool.ts +++ b/config/scripts/vitest-node-runtime-pool.ts @@ -1,7 +1,8 @@ import { isAbsolute, resolve } from 'node:path' import { parse, stringify } from 'devalue' import type { PoolOptions, PoolWorker, WorkerRequest } from 'vitest/node' -import { spawnProcess, type ChildProcessHandle } from '../../src/shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' class NodeRuntimeWorker implements PoolWorker { readonly name = 'node-runtime' diff --git a/config/scripts/vitest-runtime-boundary.test.ts b/config/scripts/vitest-runtime-boundary.test.ts index 5823d80fe57..f7daeab0110 100644 --- a/config/scripts/vitest-runtime-boundary.test.ts +++ b/config/scripts/vitest-runtime-boundary.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import { access } from 'node:fs/promises' import { NODE_RUNTIME_INCLUDE } from './vitest-node-runtime-files.mjs' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' it('uses Bun for ordinary suites when the Bun project is selected', () => { expect(Boolean(process.versions.bun)).toBe(process.env.ORCA_VITEST_RUNTIME === 'bun') diff --git a/config/scripts/win32-test-lane-registration.test.mjs b/config/scripts/win32-test-lane-registration.test.mjs index 0cd947f722b..dcff04783d8 100644 --- a/config/scripts/win32-test-lane-registration.test.mjs +++ b/config/scripts/win32-test-lane-registration.test.mjs @@ -385,7 +385,9 @@ describe('Windows-gated test files are registered in the Windows CI lane', () => // Both discovery paths, proven against real files rather than fixtures: one // found by filename plus ternary alias, one found only by its gate // expression because its name says nothing about Windows gating. - expect(gatedFiles).toContain('src/shared/child-process/windows-command-line.win32.test.ts') + expect(gatedFiles).toContain( + 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts' + ) expect(gatedFiles).toContain('src/main/agent-hooks/windows-hook-payload-delivery.test.ts') // And a compound gate, the case this guard was blind to at first. expect(gatedFiles).toContain('src/main/git/runner-wsl-linked-gitdir-windows.test.ts') diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 8ebb2fced9d..a8333d76947 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcessSync } from '../../src/shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const projectDir = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs index 84a0b167986..81a8168f1d4 100644 --- a/config/scripts/workflow-ref-reachability.test.mjs +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const readWorkflow = (name) => parse(readFileSync(`.github/workflows/${name}.yml`, 'utf8')) const windowsVet = readWorkflow('dev-channel-win-build').jobs['build-win'].steps.find( diff --git a/config/scripts/workspace-source-exports.mjs b/config/scripts/workspace-source-exports.mjs new file mode 100644 index 00000000000..a49b5bbdbe4 --- /dev/null +++ b/config/scripts/workspace-source-exports.mjs @@ -0,0 +1,104 @@ +import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { parse } from 'yaml' + +export function workspacePackageManifests(root) { + const policy = join(root, 'pnpm-workspace.yaml') + if (!existsSync(policy)) { + return [] + } + const { packages = [] } = parse(readFileSync(policy, 'utf8')) ?? {} + if (!Array.isArray(packages) || packages.some((pattern) => typeof pattern !== 'string')) { + throw new Error('Workspace package patterns are unavailable') + } + const manifests = (patterns) => + patterns.map((pattern) => `${pattern.replace(/\/$/, '')}/package.json`) + return globSync(manifests(packages.filter((pattern) => !pattern.startsWith('!'))), { + cwd: root, + exclude: manifests( + packages.filter((pattern) => pattern.startsWith('!')).map((pattern) => pattern.slice(1)) + ) + }) + .map((manifest) => manifest.split(sep).join('/')) + .sort() +} + +function sourceTarget(root, manifest, target) { + if ( + typeof target !== 'string' || + !target.startsWith('./') || + target.includes('\\') || + target.split('/').some((segment) => ['..', 'node_modules', 'dist'].includes(segment)) || + !/\.(?:ts|tsx|js|mjs|cjs|json)$/.test(target) || + target.endsWith('.d.ts') + ) { + throw new Error(`Invalid orca-source export in ${manifest}`) + } + const packageDir = realpathSync(join(root, dirname(manifest))) + const path = realpathSync(resolve(packageDir, target)) + const withinPackage = relative(packageDir, path) + if (withinPackage.startsWith(`..${sep}`) || isAbsolute(withinPackage)) { + throw new Error(`orca-source export escapes ${manifest}`) + } + return { path, file: relative(root, path).split(sep).join('/'), manifest } +} + +function packageExports(manifest, metadata) { + const exports = metadata.exports + if (!exports || typeof exports !== 'object' || Array.isArray(exports)) { + return [] + } + const entries = Object.keys(exports).some((key) => key.startsWith('.')) + ? Object.entries(exports) + : [['.', exports]] + if (entries.some(([key]) => key.includes('*') || (key !== '.' && !key.startsWith('./')))) { + throw new Error(`Workspace source exports must name public entry points: ${manifest}`) + } + return entries +} + +export function createWorkspaceSourceResolver(root = process.cwd()) { + root = realpathSync(root) + const packages = new Map() + const sources = new Map() + for (const manifest of workspacePackageManifests(root)) { + const metadata = JSON.parse(readFileSync(join(root, manifest), 'utf8')) + if (typeof metadata.name !== 'string' || packages.has(metadata.name)) { + throw new Error(`Missing or duplicate workspace package name: ${manifest}`) + } + packages.set(metadata.name, { manifest, native: manifest.startsWith('native/') }) + for (const [key, conditions] of packageExports(manifest, metadata)) { + if ( + !conditions || + typeof conditions !== 'object' || + !Object.hasOwn(conditions, 'orca-source') + ) { + continue + } + const specifier = metadata.name + (key === '.' ? '' : key.slice(1)) + sources.set(specifier, sourceTarget(root, manifest, conditions['orca-source'])) + } + } + return { + esbuildAliases: Object.fromEntries( + [...sources].map(([specifier, source]) => [specifier, source.path]) + ), + aliases: [...sources].map(([specifier, source]) => ({ + find: new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + replacement: source.path + })), + resolve(specifier) { + const name = specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0] + const workspace = packages.get(name) + if (sources.has(specifier)) { + return sources.get(specifier) + } + if (workspace?.native || (!workspace && !specifier.startsWith('@orca/'))) { + return null + } + throw new Error(`No public orca-source export for ${specifier}`) + } + } +} diff --git a/config/scripts/workspace-source-exports.test.mjs b/config/scripts/workspace-source-exports.test.mjs new file mode 100644 index 00000000000..618b45b61e3 --- /dev/null +++ b/config/scripts/workspace-source-exports.test.mjs @@ -0,0 +1,159 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { + createWorkspaceSourceResolver, + workspacePackageManifests +} from './workspace-source-exports.mjs' + +const temporary = [] +afterEach(() => { + for (const root of temporary.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture({ exports = {}, patterns = ['src/packages/*'], extra = {} } = {}) { + const root = mkdtempSync(join(tmpdir(), 'workspace-source-exports-')) + temporary.push(root) + const files = { + 'pnpm-workspace.yaml': `packages:\n${patterns.map((pattern) => ` - '${pattern}'`).join('\n')}\n`, + 'src/packages/process-host/package.json': JSON.stringify({ + name: '@orca/process-host', + exports + }), + 'src/packages/process-host/src/run-process.ts': 'export const value = 1', + 'src/packages/process-host/src/process-spec.ts': 'export type Value = number', + 'src/packages/process-host/dist/run-process.js': 'throw Error("stale build must not execute")', + ...extra + } + for (const [file, source] of Object.entries(files)) { + const path = join(root, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +const publicExports = { + '.': { + 'orca-source': './src/run-process.ts', + types: './dist/run-process.d.ts', + default: './dist/run-process.js' + }, + './process-spec': { + 'orca-source': './src/process-spec.ts', + types: './dist/process-spec.d.ts', + default: './dist/process-spec.js' + } +} + +it('inventories every declared package manifest without requiring built or source exports', () => { + const root = fixture({ + patterns: ['src/packages/*', 'native/windows-registry'], + extra: { + 'native/windows-registry/package.json': JSON.stringify({ name: '@orca/windows-registry' }), + 'mobile/packages/model/package.json': JSON.stringify({ name: '@orca/mobile-model' }) + } + }) + expect(workspacePackageManifests(root)).toEqual([ + 'native/windows-registry/package.json', + 'src/packages/process-host/package.json' + ]) +}) + +it('maps only explicit public source exports with exact aliases independent of compiled output', () => { + const resolver = createWorkspaceSourceResolver(fixture({ exports: publicExports })) + expect(resolver.resolve('@orca/process-host')).toMatchObject({ + file: 'src/packages/process-host/src/run-process.ts', + manifest: 'src/packages/process-host/package.json' + }) + expect(resolver.resolve('@orca/process-host/process-spec').file).toBe( + 'src/packages/process-host/src/process-spec.ts' + ) + expect(resolver.aliases.map((alias) => alias.find.test('@orca/process-host'))).toEqual([ + true, + false + ]) + expect(resolver.aliases.map((alias) => alias.find.test('@orca/process-host/private'))).toEqual([ + false, + false + ]) + expect(resolver.aliases.every((alias) => !alias.replacement.includes('/dist/'))).toBe(true) + expect(resolver.esbuildAliases).toEqual({ + '@orca/process-host': resolver.resolve('@orca/process-host').path, + '@orca/process-host/process-spec': resolver.resolve('@orca/process-host/process-spec').path + }) + expect(resolver.resolve('node:child_process')).toBeNull() + expect(resolver.resolve('zod')).toBeNull() +}) + +it.each(['@orca/unknown', '@orca/process-host/private', '@orca/process-host/src/run-process'])( + 'rejects unknown and private internal imports: %s', + (specifier) => { + const resolver = createWorkspaceSourceResolver(fixture({ exports: publicExports })) + expect(() => resolver.resolve(specifier)).toThrow('No public orca-source export') + } +) + +it('requires source metadata instead of trusting a public built export', () => { + const resolver = createWorkspaceSourceResolver( + fixture({ exports: { '.': './dist/run-process.js' } }) + ) + expect(() => resolver.resolve('@orca/process-host')).toThrow('No public orca-source export') +}) + +it('respects excluded workspace packages and independent workspaces', () => { + const root = fixture({ + exports: publicExports, + patterns: ['src/packages/*', '!src/packages/process-host'], + extra: { + 'mobile/packages/model/package.json': JSON.stringify({ + name: '@orca/mobile-model', + exports: publicExports + }) + } + }) + const resolver = createWorkspaceSourceResolver(root) + expect(resolver.aliases).toEqual([]) + expect(() => resolver.resolve('@orca/process-host')).toThrow('No public orca-source export') + expect(() => resolver.resolve('@orca/mobile-model')).toThrow('No public orca-source export') +}) + +it('retains deliberately external native workspace packages', () => { + const root = fixture({ + patterns: ['native/windows-registry'], + extra: { + 'native/windows-registry/package.json': JSON.stringify({ + name: '@orca/windows-registry', + main: 'index.js' + }) + } + }) + expect(createWorkspaceSourceResolver(root).resolve('@orca/windows-registry')).toBeNull() +}) + +it.each([ + '../outside.ts', + './dist/run-process.js', + './src/missing.ts', + './src/../src/run-process.ts', + './src/run-process.d.ts' +])('rejects unsafe, stale or missing source targets: %s', (target) => { + const root = fixture({ exports: { '.': { 'orca-source': target } } }) + expect(() => createWorkspaceSourceResolver(root)).toThrow() +}) + +it('rejects wildcard source exports and duplicate workspace identities', () => { + expect(() => + createWorkspaceSourceResolver( + fixture({ exports: { './*': { 'orca-source': './src/run-process.ts' } } }) + ) + ).toThrow('must name public entry points') + const root = fixture({ + exports: publicExports, + extra: { 'src/packages/duplicate/package.json': JSON.stringify({ name: '@orca/process-host' }) } + }) + expect(() => createWorkspaceSourceResolver(root)).toThrow('duplicate workspace package name') +}) diff --git a/config/scripts/zip-extractor-command.test.mjs b/config/scripts/zip-extractor-command.test.mjs index fde9521c101..939d6c44836 100644 --- a/config/scripts/zip-extractor-command.test.mjs +++ b/config/scripts/zip-extractor-command.test.mjs @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from './script-child-process.mjs' +import { runProcessSync } from '@orca/process-host' import { getTarProgram, getZipExtractorCommand } from './zip-extractor-command.mjs' const directories = [] diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index d5411484d97..d38e7039c6c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -3,6 +3,7 @@ "include": [ "../src/cli/**/*", "../src/shared/**/*", + "../src/packages/process-host/src/__fixtures__/**/*", "../src/main/agent-state-file-reader.ts", "../src/main/gitlab/project-ref-parser.ts", "../src/main/agent-hooks/grok-replay-guard.ts", diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index b44dcb2852a..bcdf260737a 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -19,6 +19,7 @@ "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/preload/**/*", "../src/shared/**/*", + "../src/packages/process-host/src/__fixtures__/**/*", "../src/relay/**/*", "../src/types/**/*" ], diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 70b3439d7c9..361a3479c6e 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -18,6 +18,7 @@ "../src/preload/runtime-environment-subscriptions.ts", "../src/preload/usage-provider-api.ts", "../src/shared/**/*", + "../src/packages/process-host/src/__fixtures__/**/*", "../src/main/gitlab/mappers.ts", "../src/main/ipc/deferred-emoji-shortcode-dataset.ts", "../src/main/ipc/worktree-branch-name.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 4e49cfc175e..4c2a42fac45 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -5,19 +5,21 @@ import TimingSequencer from './scripts/ci-unit-sequencer.mjs' import RuntimeSequencer from './scripts/vitest-runtime-sequencer.mjs' import { NODE_RUNTIME_INCLUDE } from './scripts/vitest-node-runtime-files.mjs' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' +import { createWorkspaceSourceResolver } from './scripts/workspace-source-exports.mjs' const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1' const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts' const transforms = { define: { ORCA_FEATURE_WALL_ENABLED: 'true' }, resolve: { - alias: { - '@renderer': resolve('src/renderer/src'), - '@': resolve('src/renderer/src'), - 'fs/promises': 'node:fs/promises', - fs: 'node:fs', - os: 'node:os' - } + alias: [ + ...createWorkspaceSourceResolver().aliases, + { find: '@renderer', replacement: resolve('src/renderer/src') }, + { find: '@', replacement: resolve('src/renderer/src') }, + { find: 'fs/promises', replacement: 'node:fs/promises' }, + { find: 'fs', replacement: 'node:fs' }, + { find: 'os', replacement: 'node:os' } + ] } } const testOptions = { diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 263268833ba..6757676b711 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,6 +6,7 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner' import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets' +import { createProcessHostDevRebuildPlugin } from './config/build-plugins/process-host-dev-rebuild' import { CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin @@ -290,7 +291,11 @@ export const electronViteConfig: UserConfig = { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' }, - plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] + plugins: [ + createMainBootstrapPlugin(), + createPlainNodeEntryGuardPlugin(), + createProcessHostDevRebuildPlugin() + ] } }, // Why: compile-time substitution for the telemetry gate. See the block diff --git a/mobile/README.md b/mobile/README.md index c27b22e11fd..1a75063d673 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -9,6 +9,8 @@ Local development uses two processes: Unless a command says otherwise, run mobile app commands from the `mobile/` directory. +The Node commands `pnpm rpc:diff` and `pnpm rpc:record` use a development dependency linked to `../src/packages/process-host`. Mobile's install supplies the link, and `tsx` runs its source with the `orca-source` condition, so these command wrappers do not require a desktop install or package build. Keep that source directory in sparse checkouts. The recording suites still need root dependencies when they exercise desktop modules. The React Native app does not import this host package. + ## Prerequisites - Node.js 24+ diff --git a/mobile/package.json b/mobile/package.json index a254bed7645..cdf57a8b98f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "check:tests-typecheck": "node scripts/check-tests-typecheck-ratchet.mjs", - "rpc:record": "tsx scripts/rpc-recording.mts", - "rpc:diff": "tsx scripts/rpc-diff.mts", + "rpc:record": "tsx --conditions=orca-source scripts/rpc-recording.mts", + "rpc:diff": "tsx --conditions=orca-source scripts/rpc-diff.mts", "lint": "oxlint", "format": "oxfmt --write .", "format:check": "oxfmt --check .", @@ -73,6 +73,7 @@ "zustand": "^5.0.15" }, "devDependencies": { + "@orca/process-host": "link:../src/packages/process-host", "@types/react": "~19.2.18", "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6574a53c1cd..7d8e218e58b 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -327,6 +327,9 @@ importers: specifier: ^5.0.15 version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: + '@orca/process-host': + specifier: link:../src/packages/process-host + version: link:../src/packages/process-host '@types/react': specifier: ~19.2.18 version: 19.2.18 diff --git a/mobile/scripts/check-tests-typecheck-ratchet.mjs b/mobile/scripts/check-tests-typecheck-ratchet.mjs index 5a28034b7bd..bd1cfae6a39 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.mjs +++ b/mobile/scripts/check-tests-typecheck-ratchet.mjs @@ -36,7 +36,7 @@ export const TESTS_OUTSIDE_PROGRAM = new Map([ ], [ 'src/transport/mobile-relay-browser-cancel-budget.test.ts', - 'Node-side: imports src/shared/child-process, checked against @types/node rather than RN libs' + 'Node-side: imports @orca/process-host, checked against @types/node rather than RN libs' ] ]) diff --git a/mobile/scripts/rpc-diff.mts b/mobile/scripts/rpc-diff.mts index 8e4f2463941..7761ed43dfa 100644 --- a/mobile/scripts/rpc-diff.mts +++ b/mobile/scripts/rpc-diff.mts @@ -11,7 +11,7 @@ */ import { appendFileSync, readdirSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { runProcess } from '@orca/process-host' import { diffGoldenSets, formatChangeMarkdown, formatChangeText } from './rpc-diff-report.ts' import { decodeGoldenFile, diff --git a/mobile/scripts/rpc-recording.mts b/mobile/scripts/rpc-recording.mts index 6e4e921184d..3b69d192399 100644 --- a/mobile/scripts/rpc-recording.mts +++ b/mobile/scripts/rpc-recording.mts @@ -11,7 +11,7 @@ import { createRequire } from 'node:module' import { readdirSync, rmSync } from 'node:fs' import { resolve } from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { runProcess } from '@orca/process-host' import { derivedGoldens } from '../src/test-support/rpc-recording/derived-goldens.ts' import { RECORDING_DRIVERS } from '../src/test-support/rpc-recording/recording-drivers.ts' import { readScenarios } from '../src/test-support/rpc-recording/scenario-input.ts' diff --git a/mobile/tsconfig.test.json b/mobile/tsconfig.test.json index 0c1097c1fe8..64f9bf78b10 100644 --- a/mobile/tsconfig.test.json +++ b/mobile/tsconfig.test.json @@ -6,7 +6,7 @@ "exclude": [ "node_modules", // These four are Node-side, not React Native: they import the desktop main process or - // src/shared/child-process, whose modules are written against @types/node. This program's libs + // @orca/process-host, whose modules are written against @types/node. This program's libs // are React Native's, where `setTimeout` answers a number rather than a NodeJS.Timeout, so // pulling that graph in reports ~280 errors about the desktop rather than about mobile. Their // own runtime coverage is vitest, which runs them under Node. diff --git a/package.json b/package.json index a7c729b6f09..8d4a05ff04a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -29,8 +29,8 @@ "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "prepare": "husky", - "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", - "test:node": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", + "test": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", + "test:node": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:node-server": "node config/scripts/run-node-server-tests.mjs", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", @@ -43,9 +43,9 @@ "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", "check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs", "check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs", - "build:orcad": "node config/scripts/build-orcad-node.mjs", - "build:orcad-template": "node config/scripts/build-orcad-template.mjs", - "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", + "build:orcad": "pnpm run build:packages && node config/scripts/build-orcad-node.mjs", + "build:orcad-template": "pnpm run build:packages && node config/scripts/build-orcad-template.mjs", + "build:orcad-prebuilds": "pnpm run build:packages && node config/scripts/build-orcad-prebuilds.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", @@ -63,21 +63,21 @@ "tc:cli": "pnpm run typecheck:cli", "tc:web": "pnpm run typecheck:web", "tc": "pnpm run typecheck", - "typecheck:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json", - "typecheck:cli": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.cli.json", - "typecheck:web": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json", - "typecheck:e2e": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json", - "typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs", - "typecheck:tsc:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json --composite false", - "typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false", - "typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false", - "typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", + "typecheck:node": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json", + "typecheck:cli": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.cli.json", + "typecheck:web": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json", + "typecheck:e2e": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json", + "typecheck": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs", + "typecheck:tsc:node": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json --composite false", + "typecheck:tsc:cli": "pnpm run build:packages && tsc --noEmit -p config/tsconfig.cli.json --composite false", + "typecheck:tsc:web": "pnpm run build:packages && tsc --noEmit -p config/tsconfig.web.json --composite false", + "typecheck:tsc": "pnpm run build:packages && tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", "ensure:electron-runtime": "node config/scripts/ensure-native-runtime.mjs --runtime=electron", - "start": "pnpm run ensure:electron-runtime && electron-vite preview", - "dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", - "dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", + "start": "pnpm run build:packages && pnpm run ensure:electron-runtime && electron-vite preview", + "dev": "pnpm run build:packages && pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", + "dev-stable-name": "pnpm run build:packages && pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", - "build:relay": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs", + "build:relay": "pnpm run build:packages && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", "build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs", "build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs", @@ -95,13 +95,13 @@ "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", - "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && pnpm run prepare:cli-output", + "build:cli": "pnpm run build:packages && tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && pnpm run prepare:cli-output", "prepare:cli-output": "node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", "test:linux-cli-contract": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", - "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", - "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", - "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", + "build:electron-vite": "pnpm run build:packages && node config/scripts/run-electron-vite-build.mjs", + "build:electron-vite:parallel": "pnpm run build:packages && node config/scripts/run-electron-vite-targets-in-parallel.mjs", + "build:web": "pnpm run build:packages && node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", "build:mobile-web": "node config/scripts/build-mobile-web-app-bundle.mjs && node config/scripts/verify-mobile-web-app-bundle.mjs", "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", @@ -110,7 +110,7 @@ "build:release:javascript": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "build:release:host": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run verify:built-skills-cli", "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", - "postinstall": "node config/scripts/rebuild-native-deps.mjs", + "postinstall": "pnpm run build:packages && node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", "reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs", @@ -180,13 +180,16 @@ "repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs", "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs", "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings", - "sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs" + "sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs", + "build:packages": "pnpm --filter @orca/process-host build", + "check:process-host-imports": "node config/scripts/check-process-host-imports.mjs" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.284", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "1.8.0", "@linear/sdk": "^97.0.0", + "@orca/process-host": "workspace:*", "@parcel/watcher": "^2.5.6", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef6f038cb1b..b83463c0177 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: '@linear/sdk': specifier: ^97.0.0 version: 97.0.0(graphql@16.14.2) + '@orca/process-host': + specifier: workspace:* + version: link:src/packages/process-host '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 @@ -653,6 +656,24 @@ importers: specifier: ^8.5.0 version: 8.9.2 + src/packages/process-host: + devDependencies: + '@types/node': + specifier: ^18.19.130 + version: 18.19.130 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: 5.0.3 + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@20.14.5)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + packages: '@adobe/css-tools@4.5.0': @@ -8677,6 +8698,14 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 + '@inquirer/confirm@6.3.2(@types/node@18.19.130)': + dependencies: + '@inquirer/core': 12.0.3(@types/node@18.19.130) + '@inquirer/type': 4.1.1(@types/node@18.19.130) + optionalDependencies: + '@types/node': 18.19.130 + optional: true + '@inquirer/confirm@6.3.2(@types/node@25.9.5)': dependencies: '@inquirer/core': 12.0.3(@types/node@25.9.5) @@ -8684,6 +8713,19 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 + '@inquirer/core@12.0.3(@types/node@18.19.130)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@18.19.130) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 18.19.130 + optional: true + '@inquirer/core@12.0.3(@types/node@25.9.5)': dependencies: '@inquirer/ansi': 2.0.8 @@ -8781,6 +8823,11 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 + '@inquirer/type@4.1.1(@types/node@18.19.130)': + optionalDependencies: + '@types/node': 18.19.130 + optional: true + '@inquirer/type@4.1.1(@types/node@25.9.5)': optionalDependencies: '@types/node': 25.9.5 @@ -11140,6 +11187,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/mocker@5.0.3(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + msw: 2.14.3(@types/node@18.19.130)(typescript@7.0.2) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + '@vitest/mocker@5.0.3(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -13848,6 +13905,32 @@ snapshots: ms@2.1.3: {} + msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2): + dependencies: + '@inquirer/confirm': 6.3.2(@types/node@18.19.130) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2): dependencies: '@inquirer/confirm': 6.3.2(@types/node@25.9.5) @@ -14782,6 +14865,26 @@ snapshots: robust-predicates@3.0.3: {} + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): + dependencies: + '@oxc-project/runtime': 0.101.0 + fdir: 6.5.0(picomatch@4.0.7) + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.25 + rolldown: 1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 18.19.130 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.15 + yaml: 2.9.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): dependencies: '@oxc-project/runtime': 0.101.0 @@ -15292,7 +15395,6 @@ snapshots: esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 - optional: true tw-animate-css@1.4.0: {} @@ -15473,6 +15575,29 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@20.14.5)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 18.19.130 + happy-dom: 20.14.5 + transitivePeerDependencies: + - msw + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d673455655..226a7ef9c1c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ -# The desktop app is a single-project install. `mobile/` is a separate workspace +# The desktop host and internal packages share an install. `mobile/` is a separate workspace # with its own pnpm-workspace.yaml and lockfile, so keep it out of the root # package graph. Without this file `pnpm -r` auto-discovers mobile/ and its # nested package, and the root's patchedDependencies fail as ERR_PNPM_UNUSED_PATCH. @@ -9,6 +9,7 @@ # binding.gyp mid-rebuild on Windows CI. A workspace link is resolved once instead. packages: - native/windows-registry + - src/packages/* minimumReleaseAge: 4320 shamefullyHoist: true diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts index e79b72d74d2..11687285326 100644 --- a/src/cli/orchestration-mutation-recovery.test.ts +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { orchestrationMutationRecoveryError, renderCommand, diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 31439fae7e4..3777b04755e 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -3,7 +3,7 @@ import { recoverableOrchestrationArgs, resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' -import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line' +import { quoteWindowsCmdArgument } from '@orca/process-host/windows-command-line' import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import type { AgentStartupShell } from '../shared/tui-agent-startup-shell' diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index c5b4205cfd4..a4d24de8bd2 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,7 +1,7 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index 4c2c1a20c12..b9e0b1304f5 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -10,7 +10,7 @@ import { } from './profile-state-recovery-launch' const mocks = vi.hoisted(() => ({ run: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.run })) +vi.mock('@orca/process-host', () => ({ runProcess: mocks.run })) vi.mock('./launch', () => ({ resolveForegroundOrcaExecutable: () => '/packaged/Orca', resolveAppRoot: () => '/application', diff --git a/src/cli/runtime/profile-state-recovery-launch.ts b/src/cli/runtime/profile-state-recovery-launch.ts index dd0d68ff2e4..285c78bd414 100644 --- a/src/cli/runtime/profile-state-recovery-launch.ts +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -1,5 +1,5 @@ import { realpathSync } from 'node:fs' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { PROFILE_STATE_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_RESULT_PREFIX, diff --git a/src/cli/runtime/serve-orcad-launch.test.ts b/src/cli/runtime/serve-orcad-launch.test.ts index 95bc44a8c44..b38150d2805 100644 --- a/src/cli/runtime/serve-orcad-launch.test.ts +++ b/src/cli/runtime/serve-orcad-launch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { formatServeRuntimeSelection } from '../../shared/orcad-local-serve-selection' -import type { runProcess } from '../../shared/child-process/run-process' +import type { runProcess } from '@orca/process-host' import { orcadServeArgs, resolveLocalServeRuntime, serveOptionArgs } from './serve-orcad-launch' type RunProcess = typeof runProcess diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts index 0db6898db90..1f8495b7b3d 100644 --- a/src/cli/runtime/serve-orcad-launch.ts +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -3,7 +3,7 @@ * app-side by `src/main/orcad/orcad-local-serve-selection.ts`; the CLI only asks and runs. */ import { dirname, join } from 'node:path' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY, ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS, diff --git a/src/cli/runtime/serve-recipe-json.ts b/src/cli/runtime/serve-recipe-json.ts index 70899c8f31f..3fea331d86b 100644 --- a/src/cli/runtime/serve-recipe-json.ts +++ b/src/cli/runtime/serve-recipe-json.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { StringDecoder } from 'node:string_decoder' import { getEphemeralVmRecipeResultConnection, diff --git a/src/main/acp/acp-agent-connection.test.ts b/src/main/acp/acp-agent-connection.test.ts index 8f99c7a8f1a..c6eaebf6344 100644 --- a/src/main/acp/acp-agent-connection.test.ts +++ b/src/main/acp/acp-agent-connection.test.ts @@ -1,7 +1,7 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' @@ -30,16 +30,15 @@ function fixture( behavior: { pid?: number | null; exitOnEnd?: boolean } = {} ) { const agent = new AcpScriptedAgent() - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: behavior.pid === null ? undefined : (behavior.pid ?? 9_999_999), stdout: agent.stdout, stdin: agent.stdin, stderr: new PassThrough(), kill: vi.fn(() => true) }) - const spawn = vi.fn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The supervised connection reads events, pid, piped stdio and kill; this fixture supplies each. - return child as unknown as ReturnType + const spawn = vi.fn(() => { + return child }) agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) @@ -285,7 +284,7 @@ describe('ACP process-owning connection', () => { }) it('rejects invalid peer options before starting any process', () => { - const spawn = vi.fn() + const spawn = vi.fn() expect(() => createAcpAgentConnection( { command: 'fixture', args: [] }, diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index e3b6b1f8b5a..5eb804a1921 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -1,4 +1,5 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess, type ManagedProviderProcess, @@ -21,7 +22,7 @@ export type AcpAgentConnectionOptions = Omit & export function createAcpAgentConnection( launch: ProviderProcessLaunch, options: AcpAgentConnectionOptions = {}, - spawnImpl: typeof spawnProcess = spawnProcess + spawnImpl: PipedProcessSpawner = spawnProcess ): AcpAgentConnection { return new AcpAgentConnection(launch, options, spawnImpl) } @@ -35,7 +36,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { constructor( launch: ProviderProcessLaunch, options: AcpAgentConnectionOptions = {}, - spawnImpl: typeof spawnProcess = spawnProcess + spawnImpl: PipedProcessSpawner = spawnProcess ) { // Validate before spawning so invalid limits cannot leave an unowned child. const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false }) diff --git a/src/main/acp/acp-structured-host-subagent-stop.test.ts b/src/main/acp/acp-structured-host-subagent-stop.test.ts index f6c4091333d..f2c47a4ecb4 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -1,8 +1,8 @@ +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { createFakeSpawnedChild } from '../../shared/child-process/__fixtures__/fake-spawned-child' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' import { isSubagentGroupBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' @@ -40,15 +40,13 @@ const timestamp = z.object({ _meta: z.object({ agentTimestampMs: z.number() }) } async function stoppingChild(reportsOutcome: boolean) { const agent = new AcpScriptedAgent() const stderr = new PassThrough() - const child = Object.assign(createFakeSpawnedChild(9_999_999), { + const child = Object.assign(createFakePipedChild(9_999_999), { stdin: agent.stdin, stdout: agent.stdout, stderr, - stdio: [agent.stdin, agent.stdout, stderr, null, null] satisfies ReturnType< - typeof spawnProcess - >['stdio'] + stdio: [agent.stdin, agent.stdout, stderr, null, null] satisfies PipedChildProcess['stdio'] }) - const spawn = vi.fn(() => child) + const spawn = vi.fn(() => child) // Teardown is simulated: no signal or process-table operation can reach the machine. teardown.mockImplementation(async () => { child.emit('exit', 0, null) diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts index d04480f71dc..a68a7c48a45 100644 --- a/src/main/agent-cli-version-probe.test.ts +++ b/src/main/agent-cli-version-probe.test.ts @@ -6,7 +6,7 @@ import { } from './agent-cli-version-probe' const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess })) +vi.mock('@orca/process-host', () => ({ runProcess })) const INPUT = { program: '/opt/bin/agent', cwd: '/repo', env: { PATH: '/opt/bin' } } const anyVersion = (): boolean => true diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index 4cf45ce0e4d..8dc5c902262 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -3,7 +3,8 @@ import { isPrereleaseAppVersion, parseCliVersion } from '../shared/app-version' -import { runProcess, type ProcessSpec } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import type { ProcessSpec } from '@orca/process-host/process-spec' /** One line naming why a probe said no; the binary's own words, bounded. */ function warnRefused(program: string, why: string, output = ''): void { diff --git a/src/main/agent-hooks/hook-process-capture.test.ts b/src/main/agent-hooks/hook-process-capture.test.ts index 6cf965ae261..caf801059ca 100644 --- a/src/main/agent-hooks/hook-process-capture.test.ts +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' import { buildHookProcessCapture } from './hook-process-capture' import { readAgentProcessIdentity } from '../../shared/agent-process-presence' diff --git a/src/main/agent-hooks/windows-batch-hook-launcher.test.ts b/src/main/agent-hooks/windows-batch-hook-launcher.test.ts index d12a92344fd..1d3ecb094ff 100644 --- a/src/main/agent-hooks/windows-batch-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-batch-hook-launcher.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { getWindowsManagedLifecycleHook } from '../claude/hook-settings' import { wrapWindowsCmdHookCommand, wrapWindowsHookCommand } from './installer-utils' diff --git a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts index d323e3e9fbf..f1012140933 100644 --- a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts +++ b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts @@ -4,7 +4,7 @@ import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as osModule from 'node:os' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { isPlainObject, diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts index e86fb53b66e..e0d8897a1b3 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts @@ -1,6 +1,6 @@ // The registered command must not depend on the shell Claude selects. import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/agent-hooks/windows-hook-command-hosts.test.ts b/src/main/agent-hooks/windows-hook-command-hosts.test.ts index 8707dd251a9..a0892ae839d 100644 --- a/src/main/agent-hooks/windows-hook-command-hosts.test.ts +++ b/src/main/agent-hooks/windows-hook-command-hosts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getWindowsManagedLifecycleHook } from '../claude/hook-settings' const command = getWindowsManagedLifecycleHook( diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts index ea71fd13eaf..f242e8815ca 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -5,7 +5,7 @@ // through it, because none of them ever piped a payload. This suite pipes one and follows // it to the listener, so a launcher that takes stdin away from the hook fails here (#14818). import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { createServer, type Server } from 'node:http' import { mkdtempSync, writeFileSync } from 'node:fs' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e0052aa8b45..192a7f93d7c 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -12,7 +12,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { runCodexAppServerSession } from './codex/codex-app-server-session' const testState = { diff --git a/src/main/ai-vault/antigravity-ide-history.test.ts b/src/main/ai-vault/antigravity-ide-history.test.ts index 3a10b67c7b4..02787c85e83 100644 --- a/src/main/ai-vault/antigravity-ide-history.test.ts +++ b/src/main/ai-vault/antigravity-ide-history.test.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts index 9e5a734370b..3675b1a28d3 100644 --- a/src/main/ai-vault/antigravity-index-file-admission.test.ts +++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const { afterStat } = vi.hoisted(() => { const afterStat: { run: null | (() => Promise) } = { run: null } diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index da6703667d1..8ba01bf4617 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type * as preparationModule from './opencode-wsl-runtime-preparation' const mocks = vi.hoisted(() => ({ diff --git a/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts b/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts index 5f8920043a1..33aa253f91d 100644 --- a/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client' const mocked = vi.hoisted(() => ({ spawn: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: mocked.spawn })) +vi.mock('@orca/process-host', () => ({ spawnProcess: mocked.spawn })) function child() { const process = Object.assign(new EventEmitter(), { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts index 59e12071dec..953fe7633f8 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { WorkerRequestTransport } from '../lazy-worker-thread-host' import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client' diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts index 5740baf1de8..e41f89b2434 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts @@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import * as processRunner from '../../shared/child-process/run-process' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import * as processRunner from '@orca/process-host' +import { runProcess, spawnProcess } from '@orca/process-host' import SyncDatabase from '../sqlite/sync-database' import { appendTurns, writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client' diff --git a/src/main/antigravity/native-macos-credentials.real.test.ts b/src/main/antigravity/native-macos-credentials.real.test.ts index 46ff6ab044a..183a7d65fd3 100644 --- a/src/main/antigravity/native-macos-credentials.real.test.ts +++ b/src/main/antigravity/native-macos-credentials.real.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { credential } from './native-account-test-fixtures' import { readAntigravityMacOSCredential, diff --git a/src/main/antigravity/native-macos-credentials.test.ts b/src/main/antigravity/native-macos-credentials.test.ts index 32f70f57951..37bd7002cce 100644 --- a/src/main/antigravity/native-macos-credentials.test.ts +++ b/src/main/antigravity/native-macos-credentials.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { encodeAntigravityKeychainValue } from './native-credential-codec' import { readAntigravityMacOSCredential, writeAntigravityMacOSCredential } from './native-macos-credentials' -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) const contents = JSON.stringify({ auth_method: 'consumer', token: { access_token: 'synthetic' } }) const success: ProcessResult = { diff --git a/src/main/antigravity/native-macos-credentials.ts b/src/main/antigravity/native-macos-credentials.ts index 2b57959be4b..a00f25c1b60 100644 --- a/src/main/antigravity/native-macos-credentials.ts +++ b/src/main/antigravity/native-macos-credentials.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { decodeAntigravityKeychainValue, encodeAntigravityKeychainValue, diff --git a/src/main/antigravity/posix-hook-invocation.test.ts b/src/main/antigravity/posix-hook-invocation.test.ts index 113f91d6248..9d5fd51bff2 100644 --- a/src/main/antigravity/posix-hook-invocation.test.ts +++ b/src/main/antigravity/posix-hook-invocation.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { createServer } from 'node:http' import { z } from 'zod' import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { AntigravityHookService } from './hook-service' const { homeMock } = vi.hoisted(() => ({ homeMock: vi.fn<() => string>() })) diff --git a/src/main/antigravity/windows-hook-json-post.test.ts b/src/main/antigravity/windows-hook-json-post.test.ts index 17cff6887fc..ed944bd16c1 100644 --- a/src/main/antigravity/windows-hook-json-post.test.ts +++ b/src/main/antigravity/windows-hook-json-post.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, expect, it } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post' const directory = mkdtempSync(join(tmpdir(), 'orca-agy-json-post-')) diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts index 7c07520576e..bb1a5d2596f 100644 --- a/src/main/artifacts/artifact-create-intent-store.test.ts +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -15,11 +15,11 @@ import { getOrCreateArtifactCreateIntent, removeArtifactCreateIntent } from './artifact-create-intent-store' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { __resetSecureFileWindowsUserSidForTests } from '../../shared/secure-file' import type { ArtifactShareScope } from './artifact-share-record-store' -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), runProcessSync: vi.fn() })) diff --git a/src/main/automations/external-manager-local-command.ts b/src/main/automations/external-manager-local-command.ts index 44d81e68ec8..f4844370cbb 100644 --- a/src/main/automations/external-manager-local-command.ts +++ b/src/main/automations/external-manager-local-command.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000 diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts index 5e4d5e6bdb5..97dc69c061c 100644 --- a/src/main/automations/external-manager-scoped.test.ts +++ b/src/main/automations/external-manager-scoped.test.ts @@ -16,7 +16,7 @@ import type * as Fs from 'node:fs' const runProcessMock = vi.hoisted(() => vi.fn()) const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('fs', async () => { const actual = await vi.importActual('fs') return { ...actual, existsSync: existsSyncMock } diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index fdbbcc8dac1..86981ec4e59 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -17,7 +17,7 @@ import type * as Fs from 'node:fs' const runProcessMock = vi.hoisted(() => vi.fn()) const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('fs', async () => { const actual = await vi.importActual('fs') return { diff --git a/src/main/browser/agent-browser-orphan-sweep.test.ts b/src/main/browser/agent-browser-orphan-sweep.test.ts index ce565e2fa6e..5f9cb5649d0 100644 --- a/src/main/browser/agent-browser-orphan-sweep.test.ts +++ b/src/main/browser/agent-browser-orphan-sweep.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.fn() -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (spec: unknown) => runProcessMock(spec) })) diff --git a/src/main/browser/agent-browser-orphan-sweep.ts b/src/main/browser/agent-browser-orphan-sweep.ts index 062eb1235db..2112d30b63c 100644 --- a/src/main/browser/agent-browser-orphan-sweep.ts +++ b/src/main/browser/agent-browser-orphan-sweep.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' /** Session-name namespace Orca gives one daemon per browser tab. */ export const ORCA_TAB_SESSION_PREFIX = 'orca-tab-' diff --git a/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts index c704ae8c9ad..2280dead092 100644 --- a/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts +++ b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts @@ -36,7 +36,7 @@ vi.mock('electron', () => ({ app: { getPath: appGetPathMock }, session: { fromPartition: sessionFromPartitionMock } })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcessSync: runProcessSyncMock })) vi.mock('./browser-session-registry', () => ({ diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 32bd17d4018..35f78dac13f 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -31,7 +31,7 @@ vi.mock('./browser-session-registry', () => ({ // Why mock the chokepoint: command timeouts and hidden-console handling belong to // runProcessSync, while this suite only needs to control the credential output. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcessSync: runProcessSyncMock })) vi.mock('node:fs', async (importOriginal) => { diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts index 76fc14cf9e6..182ec1cee76 100644 --- a/src/main/browser/browser-cookie-key.ts +++ b/src/main/browser/browser-cookie-key.ts @@ -1,8 +1,8 @@ import { pbkdf2Sync } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { runProcessSync } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { diag } from './browser-cookie-import-diagnostics' import { CHROMIUM_BROWSERS, diff --git a/src/main/browser/remote-browser-socks-server.ts b/src/main/browser/remote-browser-socks-server.ts index 1b72963cf97..1b49019396e 100644 --- a/src/main/browser/remote-browser-socks-server.ts +++ b/src/main/browser/remote-browser-socks-server.ts @@ -1,6 +1,6 @@ import { createServer, type Server, type Socket } from 'node:net' import type { Duplex } from 'node:stream' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' import { pipeUpstreamToClient } from './remote-browser-socks-upstream' const SOCKS_VERSION = 5 diff --git a/src/main/browser/wsl-browser-network-relay-launch.ts b/src/main/browser/wsl-browser-network-relay-launch.ts index 89e1c964c14..5d470c15c3c 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' diff --git a/src/main/claude-accounts/claude-command-process.test.ts b/src/main/claude-accounts/claude-command-process.test.ts index 1eb6c933d9c..9a03d2d36c8 100644 --- a/src/main/claude-accounts/claude-command-process.test.ts +++ b/src/main/claude-accounts/claude-command-process.test.ts @@ -8,7 +8,7 @@ const spawned = vi.hoisted(() => { return { specs } }) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ spawnProcess: (spec: SpawnSpec) => { spawned.specs.push(spec) const child = Object.assign(new EventEmitter(), { diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 326848a051e..21e75a3434b 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { buildWindowsHostInteractiveLoginSpawn, diff --git a/src/main/claude-accounts/claude-login-process-termination.ts b/src/main/claude-accounts/claude-login-process-termination.ts index 946c6b9627c..c80db9d30ab 100644 --- a/src/main/claude-accounts/claude-login-process-termination.ts +++ b/src/main/claude-accounts/claude-login-process-termination.ts @@ -1,4 +1,5 @@ -import { spawnProcess, type ChildProcessHandle } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard' diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index c34a827fcac..65e536d6bd7 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -11,7 +11,7 @@ import { type SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { afterEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 219faa221fa..45a8a1477b6 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -1,8 +1,9 @@ +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { execFileSync } from 'node:child_process' -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import type { DescendantSnapshot } from '../pty-descendant-termination' import { @@ -98,7 +99,7 @@ async function proveExitWithRetries( return proveClaudeChildExit(input) } -function spawnScript(script: string): ReturnType { +function spawnScript(script: string): PipedChildProcess { const child = spawnProcess({ program: process.execPath, args: ['-e', script], @@ -108,25 +109,20 @@ function spawnScript(script: string): ReturnType { return child } -function firstStdoutLine(child: ReturnType): Promise { +function firstStdoutLine(child: PipedChildProcess): Promise { return new Promise((resolve) => { child.stdout.setEncoding('utf8').once('data', (chunk: string) => resolve(chunk.trim())) }) } /** `null` models a spawn that failed before a pid existed. */ -function mockChild( - pid: number | null = 424242 -): EventEmitter & - Pick & { kill: ReturnType } { - const child = Object.assign(new EventEmitter(), { +function mockChild(pid: number | null = 424242) { + return Object.assign(createFakePipedChild(), { pid: pid ?? undefined, stdin: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The proof reads only pid, events, kill, stdin and stderr from this fixture. - return child as never } /** A tree whose verdict is scripted per reap, recording when it was armed. */ diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index ec31475c0a6..e8726295278 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -1,4 +1,4 @@ -import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { SpawnedProcess } from '@orca/process-host/process-spec' import { terminateDescendantSnapshotWithVerdict, type DescendantTreeVerdict diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts index 421e4a9c719..69f435d2cd3 100644 --- a/src/main/claude/claude-agent-sdk-import-boundary.test.ts +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' /** * Keep the agent SDK on the structured-Claude side of the toggle. diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index 43729e9fb9a..ceb77f6034f 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -1,9 +1,8 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { describe, expect, it, vi } from 'vitest' import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' -import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { PipedProcessSpawner, ProcessSpec } from '@orca/process-host/process-spec' + import type * as ProviderSupervisor from '../provider-process/provider-process-supervisor' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' @@ -14,26 +13,13 @@ vi.mock('../provider-process/provider-process-supervisor', async (importOriginal return { ...actual, createProviderSpawnSpec: vi.fn(actual.createProviderSpawnSpec) } }) -type FakeChild = EventEmitter & { - pid: number - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType boolean>> -} - function fakeSpawn() { - const child = new EventEmitter() as FakeChild - child.pid = 4321 - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true) + const child = createFakePipedChild() const specs: ProcessSpec[] = [] - const spawnImpl = ((spec: ProcessSpec) => { + const spawnImpl: PipedProcessSpawner = (spec) => { specs.push(spec) return child - }) as unknown as typeof spawnProcess + } return { child, spawnImpl, specs } } @@ -183,7 +169,7 @@ describe('claude agent SDK process spawn', () => { expect(spawn.managed?.stderrTail().length).toBe(8192) }) - it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { + it('preserves a Windows .cmd shim and its arguments for the host spawner', () => { const process = fakeSpawn() createClaudeCodeProcessSpawn(process.spawnImpl, 'win32').spawn( sdkOptions({ @@ -192,15 +178,11 @@ describe('claude agent SDK process spawn', () => { }) ) - // The spec the spawner builds is what Orca's Windows branch encodes; the SDK's - // own spawn would hand `.cmd` straight to Node and mangle the argument. - const resolved = resolveSpawn(process.specs[0] as ProcessSpec, 'win32') - expect(resolved.file.toLowerCase()).toContain('cmd.exe') - expect(resolved.options.windowsVerbatimArguments).toBe(true) - expect(resolved.args).toHaveLength(1) - // `/v:off` plus the quoted argument is what keeps `&` from splitting the line. - expect(resolved.args[0]).toContain('/v:off') - expect(resolved.args[0]).toContain('"a b&c"') - expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"') + expect(process.specs).toEqual([ + expect.objectContaining({ + program: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd', + args: ['--setting-sources=user,project,local', '--session-id', 'a b&c'] + }) + ]) }) }) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index c117ee09b66..a48b3e8a210 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,19 +1,17 @@ +import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess, type ManagedProviderProcess } from '../provider-process/managed-provider-process' import { claudeChildClosePolicy, claudeChildCloseProven } from './claude-child-exit-proof-ladder' -/** Derived rather than imported: only src/shared/child-process may name node:child_process. */ -type ClaudeCodeChild = ReturnType - export type ClaudeCodeProcessSpawn = { /** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */ - spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild + spawn: (options: ClaudeAgentSdkSpawnOptions) => PipedChildProcess /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ - readonly child: ClaudeCodeChild | null + readonly child: PipedChildProcess | null readonly managed: ManagedProviderProcess | null /** * Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn @@ -45,7 +43,7 @@ function definedEnv(env: Record): Record void ): ClaudeCodeProcessSpawn { diff --git a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts index bb1f84bf5cd..a06370f9de5 100644 --- a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts +++ b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts @@ -1,7 +1,8 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import type { SpawnedProcess } from '../../shared/child-process/run-process' + +import type { SpawnedProcess } from '@orca/process-host/process-spec' import type { DescendantSnapshot } from '../pty-descendant-termination' import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' diff --git a/src/main/claude/claude-child-exit-proof-fixture.ts b/src/main/claude/claude-child-exit-proof-fixture.ts index 7357730ae75..b5330be0577 100644 --- a/src/main/claude/claude-child-exit-proof-fixture.ts +++ b/src/main/claude/claude-child-exit-proof-fixture.ts @@ -1,5 +1,4 @@ -import type { EventEmitter } from 'node:events' -import type { spawnProcess, SpawnedProcess } from '../../shared/child-process/run-process' +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { spawnManagedProviderProcess, type ManagedProviderProcess @@ -10,7 +9,7 @@ const managedChildren = new WeakMap() /** `closePlatform` picks the close rules; the spawn itself always skips the POSIX supervisor. */ export function managedChild( - child: Pick & EventEmitter, + child: PipedChildProcess, closePlatform: NodeJS.Platform = 'linux' ): ManagedProviderProcess { const existing = managedChildren.get(child) @@ -20,8 +19,7 @@ export function managedChild( const managed = spawnManagedProviderProcess( { command: 'fixture-provider', args: [] }, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed process reads only the fixture's owned pid, events, kill, stdin and stderr. - spawnImpl: () => child as ReturnType, + spawnImpl: () => child, platform: 'win32', site: 'claude-proof-fixture', policy: (supervised) => claudeChildClosePolicy(supervised, closePlatform), diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts index 2e2bfa7f2be..0ed96b12d87 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.test.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -1,7 +1,6 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { createClaudeChildTreeReaper, @@ -22,7 +21,7 @@ function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } } function rootStoppedBySigterm(stopMs: number, platform: NodeJS.Platform) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: 4321, stdin: new PassThrough(), stdout: new PassThrough(), @@ -35,8 +34,7 @@ function rootStoppedBySigterm(stopMs: number, platform: NodeJS.Platform) { }) }) const spawner = createClaudeCodeProcessSpawn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every event, stream and process field used by the spawner and close. - return child as unknown as ReturnType + return child }, platform) spawner.spawn({ command: 'fixture-provider', @@ -58,7 +56,7 @@ function fixtureRoot( leavesOnStdinEnd: boolean, leavesOnKill = true ) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: 4321, stdin: new PassThrough(), stdout: new PassThrough(), diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts index 3b3f138cd54..610a55fcb91 100644 --- a/src/main/claude/claude-child-root-termination.ts +++ b/src/main/claude/claude-child-root-termination.ts @@ -1,4 +1,4 @@ -import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { SpawnedProcess } from '@orca/process-host/process-spec' type RootTerminationInput = { child: Pick diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts index 2f12556444f..fa8beb63df4 100644 --- a/src/main/claude/claude-hook-event-versions.ts +++ b/src/main/claude/claude-hook-event-versions.ts @@ -1,5 +1,5 @@ import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import path from 'node:path' // Why: Claude 1.0.23 through 2.1.100 validate `hooks` against a closed event enum and discard the diff --git a/src/main/claude/claude-real-cli-availability-test-support.ts b/src/main/claude/claude-real-cli-availability-test-support.ts index ecbce5c354f..9811aecba4f 100644 --- a/src/main/claude/claude-real-cli-availability-test-support.ts +++ b/src/main/claude/claude-real-cli-availability-test-support.ts @@ -3,7 +3,8 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CLAUDE_AUTH_ENV_VARS } from '../claude-accounts/environment' import { resolveClaudeCommand } from '../codex-cli/command' diff --git a/src/main/claude/claude-stop-ack-race.test.ts b/src/main/claude/claude-stop-ack-race.test.ts index 5f2f60d3a69..d6d3cc2eef6 100644 --- a/src/main/claude/claude-stop-ack-race.test.ts +++ b/src/main/claude/claude-stop-ack-race.test.ts @@ -7,7 +7,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 276328b009c..a275b75d93d 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -4,8 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { spawnProcess } from '@orca/process-host' +import type { SpawnedProcess, ProcessSpec } from '@orca/process-host/process-spec' + import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' import { openClaudeStreamJsonConnection, diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 4d7aeeb237a..d22c842ed7e 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,7 +1,8 @@ +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import { @@ -125,7 +126,7 @@ function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error) export async function openClaudeStreamJsonConnection( launch: ClaudeStreamJsonLaunch, handlers: ClaudeStreamJsonConnectionHandlers = {}, - spawnImpl: typeof spawnProcess = spawnProcess, + spawnImpl: PipedProcessSpawner = spawnProcess, queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index baeda27ae2f..93a607c13f3 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -15,7 +15,7 @@ import { import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { closeProcessRegistry } from '@orca/process-host/close-process-registry' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b1ccc81b9d5..000626f4637 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -18,7 +18,7 @@ import type { import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition' import { randomUUID } from 'node:crypto' import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' diff --git a/src/main/claude/claude-supervised-stop-real-cli.test.ts b/src/main/claude/claude-supervised-stop-real-cli.test.ts index 82432e3fd6e..48f8f1fb4e4 100644 --- a/src/main/claude/claude-supervised-stop-real-cli.test.ts +++ b/src/main/claude/claude-supervised-stop-real-cli.test.ts @@ -3,7 +3,7 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { SUPERVISED_GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' import { diff --git a/src/main/claude/claude-supervised-stop.integration.test.ts b/src/main/claude/claude-supervised-stop.integration.test.ts index c3dcb008749..3b4b6fe0046 100644 --- a/src/main/claude/claude-supervised-stop.integration.test.ts +++ b/src/main/claude/claude-supervised-stop.integration.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' -import { spawnProcess } from '../../shared/child-process/run-process' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { spawnProcess } from '@orca/process-host' +import type { ProcessSpec } from '@orca/process-host/process-spec' import { PROVIDER_SIGTERM_GRACE_MS, PROVIDER_STDIN_END_GRACE_MS, diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index 36d3a8b5493..fbd76cf09c6 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -3,7 +3,7 @@ import { lstatSync, readlinkSync, statSync } from 'node:fs' import { mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' import { removeExtractedAppImagePayload } from './appimage-payload-removal' import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' diff --git a/src/main/cli/appimage-stable-launcher.test.ts b/src/main/cli/appimage-stable-launcher.test.ts index b7830443b4b..5e1f5045fb0 100644 --- a/src/main/cli/appimage-stable-launcher.test.ts +++ b/src/main/cli/appimage-stable-launcher.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const { filePublicationFailures } = vi.hoisted(() => ({ filePublicationFailures: { copy: 0, link: 0, replaceBeforeRename: '' } diff --git a/src/main/cli/cli-command-privileged-transaction.test.ts b/src/main/cli/cli-command-privileged-transaction.test.ts index 2238ffc8f06..e2cf8e697a9 100644 --- a/src/main/cli/cli-command-privileged-transaction.test.ts +++ b/src/main/cli/cli-command-privileged-transaction.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('electron', () => ({ app: { diff --git a/src/main/cli/cli-privileged-processes.test.ts b/src/main/cli/cli-privileged-processes.test.ts index 16d59af2df9..ec92da1f047 100644 --- a/src/main/cli/cli-privileged-processes.test.ts +++ b/src/main/cli/cli-privileged-processes.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { runMacPrivilegedCommand, diff --git a/src/main/cli/cli-privileged-processes.ts b/src/main/cli/cli-privileged-processes.ts index 89acc2542e0..b6ffbaae715 100644 --- a/src/main/cli/cli-privileged-processes.ts +++ b/src/main/cli/cli-privileged-processes.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { WINDOWS_PATH_WRITE_TIMEOUT_MS } from './cli-install-constants' diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 905d4807368..f173af07405 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('electron', () => ({ app: { isPackaged: true } diff --git a/src/main/cli/native-cli-powershell-argv-boundary.test.ts b/src/main/cli/native-cli-powershell-argv-boundary.test.ts index 2c701f0ca47..fa2be8e7ca2 100644 --- a/src/main/cli/native-cli-powershell-argv-boundary.test.ts +++ b/src/main/cli/native-cli-powershell-argv-boundary.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { runProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { parseOrchestrationTaskDepsFlag } from '../runtime/orchestration/task-deps-flag' const PARENT_TASK_ID = 'task_b2a580db74d8' diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 64b9219aad9..48decf7539a 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -17,7 +17,8 @@ import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { createPackageWithOptions, statFile } from '@electron/asar' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import { stagePackagedProcessHost } from '../../../config/scripts/packaged-process-host-fixture.mjs' const require = createRequire(import.meta.url) const execFileAsync = promisify(execFile) @@ -96,6 +97,7 @@ describe('packaged CLI assets', () => { } ) } + await stagePackagedProcessHost(join(root, 'resources')) await createPackageWithOptions(source, archive, { unpack: `{${builderConfig.asarUnpack?.map((pattern) => join(source, pattern).split(sep).join('/')).join(',')}}` }) @@ -119,6 +121,8 @@ describe('packaged CLI assets', () => { env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', + NODE_PATH: '', + NODE_OPTIONS: '', ORCA_USER_DATA_PATH: join(root, 'user-data'), HOME: join(root, 'home') }, @@ -148,6 +152,7 @@ describe('packaged CLI assets', () => { join('node_modules', 'ws'), join('node_modules', 'tweetnacl'), join('node_modules', 'zod'), + join('node_modules', '@orca', 'process-host'), join('node_modules', 'yaml'), join('node_modules', 'jsonc-parser'), join('node_modules', 'node-pty'), diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts index 500d1a29f8f..ab097c8617e 100644 --- a/src/main/cli/wsl-cli-powershell-boundary.test.ts +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -4,7 +4,7 @@ import { removeTree } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { buildWslBridgeScript, buildWslLauncher } from './wsl-cli-scripts' const FORWARDED_ARGS = [ diff --git a/src/main/cli/wsl-managed-cli.ts b/src/main/cli/wsl-managed-cli.ts index 2e3c478b9a9..cf8c7f56e2d 100644 --- a/src/main/cli/wsl-managed-cli.ts +++ b/src/main/cli/wsl-managed-cli.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { DEV_COMMAND_NAME } from './cli-install-constants' diff --git a/src/main/cli/wsl-managed-cli.wsl.test.ts b/src/main/cli/wsl-managed-cli.wsl.test.ts index c7738397d77..e0c1c4b4309 100644 --- a/src/main/cli/wsl-managed-cli.wsl.test.ts +++ b/src/main/cli/wsl-managed-cli.wsl.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 79bbed83412..fcd37c6bc39 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,5 +1,5 @@ -import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' -import { spawnProcess } from '../../shared/child-process/run-process' +import type { ChildProcessHandle, ProcessSpec } from '@orca/process-host/process-spec' +import { spawnProcess } from '@orca/process-host' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' diff --git a/src/main/codex/codex-app-server-connection-exit-order.test.ts b/src/main/codex/codex-app-server-connection-exit-order.test.ts index 661d099df73..5f4595b3545 100644 --- a/src/main/codex/codex-app-server-connection-exit-order.test.ts +++ b/src/main/codex/codex-app-server-connection-exit-order.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { spawnProcess } from '@orca/process-host' import { openCodexAppServerConnection } from './codex-app-server-connection' function stubChild() { diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts index 0e0cca283e8..599c98641b1 100644 --- a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { spawnProcess } from '@orca/process-host' import type { ProviderProcessTeardownVerdict } from '../provider-process/provider-process-teardown' import { openCodexAppServerConnection } from './codex-app-server-connection' diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 68c2726a4f5..afe50c954bc 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,11 +1,12 @@ -import { EventEmitter } from 'node:events' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' +import type { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec } from '../../shared/child-process/process-spec' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { ProcessSpec, PipedProcessSpawner } from '@orca/process-host/process-spec' + import { isCodexAppServerRequestError, openCodexAppServerConnection, @@ -89,11 +90,11 @@ type StubChild = EventEmitter & { /** Full control over framing and death, which a real child cannot give. */ function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { - child: StubChild - spawnImpl: typeof spawnProcess + child: ReturnType + spawnImpl: PipedProcessSpawner written: Record[] } { - const child = new EventEmitter() as StubChild + const child = createFakePipedChild(9_999_999) child.stdout = new PassThrough() child.stderr = new PassThrough() child.stdin = new PassThrough() @@ -112,7 +113,7 @@ function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { if (options.exitOnStdinEnd !== false) { child.stdin.on('finish', () => child.emit('exit', 0, null)) } - return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written } + return { child, spawnImpl: () => child, written } } /** Answers the handshake so `openCodexAppServerConnection` can resolve. */ @@ -196,7 +197,7 @@ describe('openCodexAppServerConnection', () => { const connection = await openCodexAppServerConnection( { command: 'codex', args: ['app-server'] }, {}, - (spec: ProcessSpec) => { + (spec) => { specs.push(spec) return spawnImpl(spec) } diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 4efcbaf30ba..60ce06db743 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,4 +1,5 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' @@ -43,7 +44,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 export async function openCodexAppServerConnection( launch: CodexAppServerLaunch, handlers: CodexAppServerConnectionHandlers = {}, - spawnImpl: typeof spawnProcess = spawnProcess + spawnImpl: PipedProcessSpawner = spawnProcess ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, diff --git a/src/main/codex/codex-app-server-process-tree-kill.ts b/src/main/codex/codex-app-server-process-tree-kill.ts index 1d8d0f10bda..d0fd9ecd7ea 100644 --- a/src/main/codex/codex-app-server-process-tree-kill.ts +++ b/src/main/codex/codex-app-server-process-tree-kill.ts @@ -1,6 +1,6 @@ -import { spawnProcess } from '../../shared/child-process/run-process' -import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' -import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle, ProcessSpec } from '@orca/process-host/process-spec' +import { admitProcessTreeKill } from '@orca/process-host/process-tree-kill-gate' /** Spawn seam for tests; production always goes through the hardened spawnProcess wrapper. */ export type CodexAppServerSpawn = ( diff --git a/src/main/codex/codex-hook-command-form.test.ts b/src/main/codex/codex-hook-command-form.test.ts index 4c544179ccc..d81296e862f 100644 --- a/src/main/codex/codex-hook-command-form.test.ts +++ b/src/main/codex/codex-hook-command-form.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildWindowsHookPowerShellCommand, createManagedCommandMatcher, diff --git a/src/main/codex/codex-hook-trust-derivation.test.ts b/src/main/codex/codex-hook-trust-derivation.test.ts index 91a6320b883..b54e32b2511 100644 --- a/src/main/codex/codex-hook-trust-derivation.test.ts +++ b/src/main/codex/codex-hook-trust-derivation.test.ts @@ -13,7 +13,7 @@ vi.mock('./codex-app-server-session', async (importOriginal) => ({ ...(await importOriginal()), runCodexAppServerSession: mocks.runCodexAppServerSession })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.runProcess })) +vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess })) import { CodexAppServerTimeoutError, diff --git a/src/main/codex/codex-hook-trust-derivation.ts b/src/main/codex/codex-hook-trust-derivation.ts index 44b1389e48b..3e1d8f42d11 100644 --- a/src/main/codex/codex-hook-trust-derivation.ts +++ b/src/main/codex/codex-hook-trust-derivation.ts @@ -2,7 +2,7 @@ import { realpathSync, statSync } from 'node:fs' import { mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { withCliRuntimeOnPath } from '../codex-cli/command' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { collectListedHooks, type CodexListedHook } from './codex-app-server-client' diff --git a/src/main/codex/codex-hook-trust-failure-memo.test.ts b/src/main/codex/codex-hook-trust-failure-memo.test.ts index d663df86200..54b17ede8ec 100644 --- a/src/main/codex/codex-hook-trust-failure-memo.test.ts +++ b/src/main/codex/codex-hook-trust-failure-memo.test.ts @@ -13,7 +13,7 @@ vi.mock('./codex-app-server-session', async (importOriginal) => ({ ...(await importOriginal()), runCodexAppServerSession: mocks.runCodexAppServerSession })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.runProcess })) +vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess })) import { CodexAppServerRequestError } from './codex-app-server-request-error' import { CodexAppServerUnsupportedError } from './codex-app-server-session' diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index a28aba10a30..7223064d0e1 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -28,7 +28,7 @@ import { } from './config-toml-trust' // Why: the guest identity probe needs wsl.exe; a failed probe leaves the grant unstamped. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(async () => ({ code: 1, signal: null, diff --git a/src/main/codex/codex-shared-server-fix.test.ts b/src/main/codex/codex-shared-server-fix.test.ts index 479a1f1854a..7de9dd3a4b7 100644 --- a/src/main/codex/codex-shared-server-fix.test.ts +++ b/src/main/codex/codex-shared-server-fix.test.ts @@ -2,13 +2,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' const mocks = vi.hoisted(() => ({ runProcess: vi.fn<(spec: ProcessSpec) => Promise>(), probeCodexSharedServer: vi.fn<(home: string) => Promise<'live' | 'absent' | 'unknown'>>() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.runProcess })) +vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess })) vi.mock('./codex-shared-server-probe', () => ({ probeCodexSharedServer: mocks.probeCodexSharedServer })) diff --git a/src/main/codex/codex-shared-server-fix.ts b/src/main/codex/codex-shared-server-fix.ts index 46efd82d598..8d87387cd6b 100644 --- a/src/main/codex/codex-shared-server-fix.ts +++ b/src/main/codex/codex-shared-server-fix.ts @@ -1,6 +1,6 @@ import { stat } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { CODEX_DISABLE_SHARED_SERVER_ARGS, CODEX_SHARED_SERVER_FEATURE_KEY, diff --git a/src/main/codex/codex-shared-server-probe.test.ts b/src/main/codex/codex-shared-server-probe.test.ts index 8d3e5cdc3df..6e891622427 100644 --- a/src/main/codex/codex-shared-server-probe.test.ts +++ b/src/main/codex/codex-shared-server-probe.test.ts @@ -9,7 +9,7 @@ const { readWindowsProcessCreationTime } = vi.hoisted(() => ({ })) vi.mock('../windows/windows-process-table', () => ({ readWindowsProcessCreationTime })) -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { probeCodexSharedServer } from './codex-shared-server-probe' const originalPlatform = process.platform diff --git a/src/main/codex/codex-state-db-backfill-recovery-process.test.ts b/src/main/codex/codex-state-db-backfill-recovery-process.test.ts index 7f7659dc537..e139b8f15be 100644 --- a/src/main/codex/codex-state-db-backfill-recovery-process.test.ts +++ b/src/main/codex/codex-state-db-backfill-recovery-process.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' import { stopCodexBackfillRecoveryProcess } from './codex-state-db-backfill-recovery-process' diff --git a/src/main/codex/codex-state-db-backfill-recovery-process.ts b/src/main/codex/codex-state-db-backfill-recovery-process.ts index 77bd3e53732..e0d9651fe4e 100644 --- a/src/main/codex/codex-state-db-backfill-recovery-process.ts +++ b/src/main/codex/codex-state-db-backfill-recovery-process.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { parseWslUncPath } from '../../shared/wsl-paths' import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' diff --git a/src/main/codex/codex-state-db-backfill-recovery.ts b/src/main/codex/codex-state-db-backfill-recovery.ts index 85635bf0e6a..a946dc799ec 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.ts @@ -6,7 +6,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { withManagedHookInstallLock } from '../agent-hooks/managed-hook-install-lock' import { readManagedHookHostIdentity } from '../agent-hooks/managed-hook-owner-identity' import { resolveCodexCommand } from '../codex-cli/command' -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { spawnCodexAppServerProcess, type CodexAppServerSpawn diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 465d7801fec..be214a25d59 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,6 +1,6 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' -import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { closeProcessRegistry } from '@orca/process-host/close-process-registry' import { cancelCodexAcquisitionAttempt, type CodexAcquisitionRegistry, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 62347780f85..f319ff63633 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -7,7 +7,7 @@ import type { } from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition' import type { CodexAppServerConnection, openCodexAppServerConnection diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index f1227321831..2d827690bb2 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -10,7 +10,7 @@ import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' import type { CodexTrustEntry } from './config-toml-trust' // Why: the guest identity probe needs wsl.exe; a failed probe leaves the grant unstamped. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(async () => ({ code: 1, signal: null, diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts index f61514618c0..8d4a42d141c 100644 --- a/src/main/codex/codex-trust-grant-host.test.ts +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index 7483e7d5413..59ebe765837 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildWslCodexAppServerArgs, buildWslCodexIdentityProbe, diff --git a/src/main/codex/windows-hook-command.test.ts b/src/main/codex/windows-hook-command.test.ts index 1b24dd12c12..11fb094bff2 100644 --- a/src/main/codex/windows-hook-command.test.ts +++ b/src/main/codex/windows-hook-command.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { createServer } from 'node:http' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { getManagedCommand, CODEX_EVENTS } from './codex-hook-definition' import { getManagedScript } from './codex-hook-script' diff --git a/src/main/computer/desktop-script-provider-bridge.ts b/src/main/computer/desktop-script-provider-bridge.ts index fecc4036df1..3dc08eb9bc1 100644 --- a/src/main/computer/desktop-script-provider-bridge.ts +++ b/src/main/computer/desktop-script-provider-bridge.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { reportComputerDiagnostic } from './computer-sidecar-diagnostics' import { RuntimeClientError } from './runtime-client-error' import type { DesktopScriptPlatform } from './desktop-script-provider-paths' diff --git a/src/main/computer/desktop-script-runtime-host.test.ts b/src/main/computer/desktop-script-runtime-host.test.ts index 4d42a3c3e37..939fc9bd9ef 100644 --- a/src/main/computer/desktop-script-runtime-host.test.ts +++ b/src/main/computer/desktop-script-runtime-host.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { ProcessSpec } from '@orca/process-host/process-spec' import type { RuntimeChildProcess } from './desktop-script-serve-channel' import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host' diff --git a/src/main/computer/desktop-script-runtime-host.ts b/src/main/computer/desktop-script-runtime-host.ts index 09aff5ec479..45d90788c46 100644 --- a/src/main/computer/desktop-script-runtime-host.ts +++ b/src/main/computer/desktop-script-runtime-host.ts @@ -1,5 +1,5 @@ -import { spawnProcess } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { spawnProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { reportComputerDiagnostic } from './computer-sidecar-diagnostics' import { isReplayableTool } from './desktop-script-action' import type { BridgeRequest, BridgeResponse } from './desktop-script-provider-types' diff --git a/src/main/computer/desktop-script-runtime-host.win32.test.ts b/src/main/computer/desktop-script-runtime-host.win32.test.ts index 76927a7dd65..e29288eb6ed 100644 --- a/src/main/computer/desktop-script-runtime-host.win32.test.ts +++ b/src/main/computer/desktop-script-runtime-host.win32.test.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { spawnProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { DesktopScriptRuntimeHost } from './desktop-script-runtime-host' import { startServeChannel } from './desktop-script-serve-channel' import { diff --git a/src/main/computer/desktop-script-serve-channel.ts b/src/main/computer/desktop-script-serve-channel.ts index 03c224cef31..2fada79adf0 100644 --- a/src/main/computer/desktop-script-serve-channel.ts +++ b/src/main/computer/desktop-script-serve-channel.ts @@ -1,12 +1,15 @@ import { StringDecoder } from 'node:string_decoder' -import type { ProcessSpec } from '../../shared/child-process/process-spec' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { + PipedChildProcess, + PipedProcessSpawner, + PipedProcessSpec +} from '@orca/process-host/process-spec' import { ownRetainedString } from '../../shared/own-retained-string' -/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */ -export type RuntimeChildProcess = ReturnType +/** The child streams required by the serve protocol; avoids a node:child_process import. */ +export type RuntimeChildProcess = PipedChildProcess -export type RuntimeProcessSpawn = (spec: ProcessSpec) => RuntimeChildProcess +export type RuntimeProcessSpawn = PipedProcessSpawner /** UTF-16 units, not bytes — this bounds the buffer, it is not a payload contract. */ const MAX_RESPONSE_CHARS = 20 * 1024 * 1024 @@ -147,7 +150,7 @@ export class DesktopScriptServeChannel { } export function startServeChannel( - spec: ProcessSpec, + spec: PipedProcessSpec, spawn: RuntimeProcessSpawn, handlers: ServeChannelHandlers ): DesktopScriptServeChannel { diff --git a/src/main/computer/macos-native-provider-process-reaping.ts b/src/main/computer/macos-native-provider-process-reaping.ts index 7ce243e6100..e5ab5d4c24b 100644 --- a/src/main/computer/macos-native-provider-process-reaping.ts +++ b/src/main/computer/macos-native-provider-process-reaping.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle as ChildProcess } from '../../shared/child-process/run-process' +import type { ChildProcessHandle as ChildProcess } from '@orca/process-host/process-spec' export const PROVIDER_SIGKILL_GRACE_MS = 2_000 diff --git a/src/main/computer/macos-native-provider-reaping.integration.test.ts b/src/main/computer/macos-native-provider-reaping.integration.test.ts index b4180242e58..2ba3fe2c502 100644 --- a/src/main/computer/macos-native-provider-reaping.integration.test.ts +++ b/src/main/computer/macos-native-provider-reaping.integration.test.ts @@ -1,6 +1,7 @@ import { once } from 'node:events' import { afterEach, describe, expect, it } from 'vitest' -import { spawnProcess, type ChildProcessHandle } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { reapMacOSProviderProcess } from './macos-native-provider-process-reaping' describe.skipIf(process.platform === 'win32')('real macOS provider process reaping', () => { diff --git a/src/main/computer/sidecar-provider-reaping.integration.test.ts b/src/main/computer/sidecar-provider-reaping.integration.test.ts index 1e6e89d2bb0..3ed45c4de62 100644 --- a/src/main/computer/sidecar-provider-reaping.integration.test.ts +++ b/src/main/computer/sidecar-provider-reaping.integration.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { build } from 'esbuild' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' describe.skipIf(process.platform === 'win32')('real sidecar exit reaping', () => { let directory = '' @@ -30,7 +30,7 @@ describe.skipIf(process.platform === 'win32')('real sidecar exit reaping', () => contents: ` import { once } from 'node:events'; import { writeFileSync } from 'node:fs'; - import { spawnProcess } from '../../shared/child-process/run-process'; + import { spawnProcess } from '@orca/process-host'; import { reapMacOSProviderProcess } from './macos-native-provider-process-reaping'; let child; export function currentComputerProvider() { diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts index c9a2a5cdf03..339cacd60a2 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts @@ -25,7 +25,7 @@ import { import { admitProcessTreeKill, setProcessTreeKillGate -} from '../../shared/child-process/process-tree-kill-gate' +} from '@orca/process-host/process-tree-kill-gate' import { terminateWindowsProcessTree } from '../windows-process-tree-kill' import { installMainProcessTreeKillGate } from '../own-chromium-tree-kill-guard' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.ts index 30234a4b3f1..1c26da65e4e 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.ts @@ -1,5 +1,5 @@ import type { CrashReportDetailValue } from '../../shared/crash-reporting' -import type { ProcessTreeKillScope } from '../../shared/child-process/process-tree-kill-gate' +import type { ProcessTreeKillScope } from '@orca/process-host/process-tree-kill-gate' import { recordCoalescedDurableCrashBreadcrumb } from './durable-crash-breadcrumb' /** diff --git a/src/main/cursor/hook-command.live-shell.test.ts b/src/main/cursor/hook-command.live-shell.test.ts index a4a34d6a9ff..1c61dcc0ca8 100644 --- a/src/main/cursor/hook-command.live-shell.test.ts +++ b/src/main/cursor/hook-command.live-shell.test.ts @@ -11,7 +11,7 @@ import type * as OsModule from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { runProcess, runProcessSync, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, runProcessSync, spawnProcess } from '@orca/process-host' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { getCursorHookResponse } from './hook-events' import { CursorHookService } from './hook-service' diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index e1c335e7bb9..89b86411587 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { spawnSync } from 'node:child_process' import { createServer, type Server } from 'node:http' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ diff --git a/src/main/daemon/daemon-cgroup-scope.ts b/src/main/daemon/daemon-cgroup-scope.ts index 1ea52e2a677..dc0a5d0f392 100644 --- a/src/main/daemon/daemon-cgroup-scope.ts +++ b/src/main/daemon/daemon-cgroup-scope.ts @@ -19,7 +19,8 @@ */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { cgroupPathFromProcLine, userManagerOutlivesCaller } from './daemon-user-manager-lifetime' diff --git a/src/main/daemon/daemon-folder-access-probe.test.ts b/src/main/daemon/daemon-folder-access-probe.test.ts index b7f18f7c216..439d6f25278 100644 --- a/src/main/daemon/daemon-folder-access-probe.test.ts +++ b/src/main/daemon/daemon-folder-access-probe.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { probeFolderAccessForFreshDaemon } from './daemon-folder-access-probe' diff --git a/src/main/daemon/daemon-folder-access-probe.ts b/src/main/daemon/daemon-folder-access-probe.ts index a65b33a2932..90260623ecc 100644 --- a/src/main/daemon/daemon-folder-access-probe.ts +++ b/src/main/daemon/daemon-folder-access-probe.ts @@ -4,7 +4,7 @@ // restarting the terminal service is the remedy or whether they must re-allow Orca first. import { isAbsolute } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { DirectoryEnumerationOutcome } from './directory-enumeration-probe' /** `unknown` keeps "the probe could not answer" apart from every verdict it could have returned. */ diff --git a/src/main/daemon/daemon-host-build-inventory.ts b/src/main/daemon/daemon-host-build-inventory.ts new file mode 100644 index 00000000000..85e01cfc1cd --- /dev/null +++ b/src/main/daemon/daemon-host-build-inventory.ts @@ -0,0 +1,215 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { join, win32 as winPath } from 'node:path' +import { hashDaemonHostFile } from './daemon-host-file-hash-cache' +import { + buildDaemonHostManifest, + daemonHostExeName, + daemonHostProcessPackageDir, + destPath, + toPosixRelative, + type DaemonHostSources +} from './daemon-host-manifest' + +export const DAEMON_HOST_MARKER_NAME = '.materialized.json' + +type DaemonHostBuildFile = { path: string; sha256: string } + +export type DaemonHostBuildInventory = { + fingerprint: string + runtimeVersion: string + executableName: string + entryRelPath: string + files: DaemonHostBuildFile[] +} + +export type DaemonHostBuildMarker = DaemonHostBuildInventory & { + version: string + completedAt: string +} + +function runtimeVersion(): string { + return `${process.versions.electron ?? `node-${process.versions.node}`}:${process.arch}` +} + +function fingerprintInventory(inventory: Omit): string { + return createHash('sha256').update(JSON.stringify(inventory)).digest('hex') +} + +function collectCodeFiles(sourcePath: string, destRel: string): DaemonHostBuildFile[] { + if (statSync(sourcePath).isFile()) { + return [{ path: destRel, sha256: hashDaemonHostFile(sourcePath) }] + } + return readdirSync(sourcePath) + .sort() + .flatMap((name) => collectCodeFiles(join(sourcePath, name), `${destRel}/${name}`)) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isSafeRelativePath(value: unknown): value is string { + return ( + typeof value === 'string' && + !value.includes('\\') && + !value.includes(':') && + value.split('/').every((part) => part !== '' && part !== '.' && part !== '..') + ) +} + +function processHostExportsExist(packageDir: string): boolean { + const manifest: unknown = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) + if ( + !isRecord(manifest) || + manifest.name !== '@orca/process-host' || + !isRecord(manifest.exports) + ) { + return false + } + const exports = Object.values(manifest.exports) + return ( + exports.length > 0 && + exports.every((entry) => { + if (!isRecord(entry) || typeof entry.default !== 'string') { + return false + } + const relative = entry.default.startsWith('./') ? entry.default.slice(2) : entry.default + return ( + isSafeRelativePath(relative) && + relative.startsWith('dist/') && + existsSync(destPath(packageDir, relative)) + ) + }) + ) +} + +/** Hash code and its file list, without rereading the 260MB Electron image; unchanged files reuse cached hashes. */ +export function collectDaemonHostBuildInventory( + sources: DaemonHostSources +): DaemonHostBuildInventory | null { + try { + if (buildDaemonHostManifest(sources).some((op) => !op.optional && !existsSync(op.sourcePath))) { + return null + } + const processHostDir = daemonHostProcessPackageDir(sources) + if (!processHostExportsExist(processHostDir)) { + return null + } + const files = collectCodeFiles(sources.entrySourcePath, sources.entryRelPath) + const chunksDir = join(winPath.dirname(sources.entrySourcePath), 'chunks') + if (existsSync(chunksDir)) { + files.push(...collectCodeFiles(chunksDir, toPosixRelative(sources.appDir, chunksDir))) + } + const outManifest = join(sources.resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + if (existsSync(outManifest)) { + files.push(...collectCodeFiles(outManifest, toPosixRelative(sources.appDir, outManifest))) + } + const packageRel = toPosixRelative(sources.appDir, processHostDir) + files.push( + ...collectCodeFiles(join(processHostDir, 'package.json'), `${packageRel}/package.json`) + ) + files.push(...collectCodeFiles(join(processHostDir, 'dist'), `${packageRel}/dist`)) + files.sort((left, right) => left.path.localeCompare(right.path)) + const inventory = { + runtimeVersion: runtimeVersion(), + executableName: daemonHostExeName(sources.execPath), + entryRelPath: sources.entryRelPath, + files + } + return { fingerprint: fingerprintInventory(inventory), ...inventory } + } catch { + return null + } +} + +function isBuildFile(value: unknown): value is DaemonHostBuildFile { + return ( + isRecord(value) && + isSafeRelativePath(value.path) && + typeof value.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(value.sha256) + ) +} + +export function readDaemonHostBuildMarker(root: string): DaemonHostBuildMarker | null { + try { + const parsed: unknown = JSON.parse(readFileSync(join(root, DAEMON_HOST_MARKER_NAME), 'utf8')) + if ( + !isRecord(parsed) || + typeof parsed.version !== 'string' || + typeof parsed.completedAt !== 'string' || + typeof parsed.fingerprint !== 'string' || + typeof parsed.runtimeVersion !== 'string' || + !isSafeRelativePath(parsed.executableName) || + parsed.executableName.includes('/') || + !isSafeRelativePath(parsed.entryRelPath) || + !Array.isArray(parsed.files) || + !parsed.files.every(isBuildFile) + ) { + return null + } + const inventory = { + runtimeVersion: parsed.runtimeVersion, + executableName: parsed.executableName, + entryRelPath: parsed.entryRelPath, + files: parsed.files + } + if (parsed.fingerprint !== fingerprintInventory(inventory)) { + return null + } + return { + ...inventory, + fingerprint: parsed.fingerprint, + version: parsed.version, + completedAt: parsed.completedAt + } + } catch { + return null + } +} + +export function daemonHostBuildInventoryMatches( + root: string, + inventory: DaemonHostBuildInventory +): boolean { + try { + return inventory.files.every( + (file) => hashDaemonHostFile(destPath(root, file.path)) === file.sha256 + ) + } catch { + return false + } +} + +export function daemonHostBuildRuntimeMatches( + inventory: DaemonHostBuildInventory, + sources: DaemonHostSources +): boolean { + return ( + inventory.runtimeVersion === runtimeVersion() && + inventory.executableName === daemonHostExeName(sources.execPath) && + inventory.entryRelPath === sources.entryRelPath && + inventory.files.some( + (file) => + file.path === + `${toPosixRelative(sources.appDir, daemonHostProcessPackageDir(sources))}/package.json` + ) + ) +} + +export function writeDaemonHostBuildMarker( + root: string, + version: string, + inventory: DaemonHostBuildInventory +): void { + if (!daemonHostBuildInventoryMatches(root, inventory)) { + throw new Error('daemon-host relocation: code changed while copying') + } + const marker: DaemonHostBuildMarker = { + ...inventory, + version, + completedAt: new Date().toISOString() + } + writeFileSync(join(root, DAEMON_HOST_MARKER_NAME), JSON.stringify(marker)) +} diff --git a/src/main/daemon/daemon-host-current-version-reclaim.test.ts b/src/main/daemon/daemon-host-current-version-reclaim.test.ts new file mode 100644 index 00000000000..5076a230d00 --- /dev/null +++ b/src/main/daemon/daemon-host-current-version-reclaim.test.ts @@ -0,0 +1,244 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { daemonHostStagingName } from './daemon-host-reclaim' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { + collectPinnedDaemonVersions, + getDaemonHostRootDir, + materializeRelocatedDaemonHost, + pruneDaemonHostStaging, + pruneOldDaemonHosts +} from './daemon-host-relocation' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' + +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +let tempDir: string +let installDir: string +let versionRoot: string +const DEAD_WRITER_PID = 2_000_001 +const LIVE_WRITER_PID = 2_000_002 +const UNQUERYABLE_WRITER_PID = 2_000_003 + +function processError(code: string): Error { + return Object.assign(new Error(code), { code }) +} + +// Writer liveness by pid; any other pid (e.g. a daemon record) answers as alive. +function stubWriterLiveness(): void { + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === DEAD_WRITER_PID) { + throw processError('ESRCH') + } + if (pid === UNQUERYABLE_WRITER_PID) { + throw processError('ETIMEDOUT') + } + return true + }) +} + +function seedStaging(name: string): string { + const staging = join(versionRoot, name) + mkdirSync(join(staging, 'resources'), { recursive: true }) + writeFileSync(join(staging, 'Orca.exe'), 'partial-exe') + return staging +} + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +function useInstall(dir: string): void { + installDir = dir + setAppEnvironment({ + getPath: () => join(tempDir, 'userData'), + getAppPath: () => join(installDir, 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) +} + +function selectedBuild(): string { + const host = materializeRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') + } + return dirname(host.execPath) +} + +// Leftovers a crash mid-copy, a repaired mirror, and the pre-build-dir layout leave behind. +function seedLeftovers(): { staleBuild: string; staging: string; legacyExe: string } { + const staleBuild = join(versionRoot, 'build-stale-000000000000') + mkdirSync(join(staleBuild, 'resources'), { recursive: true }) + writeFileSync(join(staleBuild, 'Orca.exe'), 'stale-exe') + const staging = seedStaging(daemonHostStagingName(DEAD_WRITER_PID, '0123456789ab')) + const legacyExe = join(versionRoot, 'Orca.exe') + writeFileSync(legacyExe, 'legacy-exe') + return { staleBuild, staging, legacyExe } +} + +function prune(versionLiveness: [string, ProcessLivenessVerdict][]): void { + pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map(versionLiveness) }) +} + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-current-reclaim-')) + process.env.LOCALAPPDATA = join(tempDir, 'localAppData') + setProcessProp('platform', 'win32') + buildInstallFixture(join(tempDir, 'app')) + useInstall(join(tempDir, 'app')) + versionRoot = join(getDaemonHostRootDir(), '9.9.9') + stubWriterLiveness() +}) + +afterEach(() => { + vi.restoreAllMocks() + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('current-version daemon-host staging cleanup', () => { + it('reclaims abandoned staging while retaining all published current-version builds', () => { + const selected = selectedBuild() + const { staleBuild, staging, legacyExe } = seedLeftovers() + + prune([]) + + expect(existsSync(staging)).toBe(false) + expect(existsSync(staleBuild)).toBe(true) + expect(existsSync(join(selected, 'Orca.exe'))).toBe(true) + expect(existsSync(legacyExe)).toBe(true) + expect(materializeRelocatedDaemonHost()?.execPath).toBe(join(selected, 'Orca.exe')) + }) + + it.each<[string, ProcessLivenessVerdict]>([ + ['exited', { status: 'exited' }], + ['live', { status: 'live' }], + ['unverifiable', { status: 'unverifiable', reason: 'query failed' }] + ])('preserves every build while a current-version daemon is %s', (_label, verdict) => { + const selected = selectedBuild() + const { staleBuild, staging } = seedLeftovers() + + prune([['9.9.9', verdict]]) + + expect(existsSync(join(staleBuild, 'Orca.exe'))).toBe(true) + expect(existsSync(join(selected, 'Orca.exe'))).toBe(true) + expect(existsSync(staging)).toBe(false) + }) + + it('retains published builds after their previous pid record exits', () => { + selectedBuild() + const { staleBuild } = seedLeftovers() + const runtimeDir = join(tempDir, 'userData', 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v9.pid'), + JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '9.9.9' }) + ) + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + expect(existsSync(staleBuild)).toBe(true) + + vi.spyOn(process, 'kill').mockImplementation(() => { + throw processError('ESRCH') + }) + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + expect(existsSync(staleBuild)).toBe(true) + }) + + it('uses staging writer evidence independently of unverifiable daemon pid evidence', () => { + selectedBuild() + const { staleBuild, staging } = seedLeftovers() + + pruneOldDaemonHosts({ status: 'unverifiable', reason: 'runtime dir unreadable' }) + + expect(existsSync(staleBuild)).toBe(true) + expect(existsSync(staging)).toBe(false) + }) + + it('preserves every build when this process cannot identify its own build', () => { + selectedBuild() + const { staleBuild, staging } = seedLeftovers() + const unobserved = join(tempDir, 'unobserved-app') + buildInstallFixture(unobserved) + rmSync( + join( + unobserved, + 'resources', + 'node_modules', + '@orca', + 'process-host', + 'dist', + 'run-process.js' + ) + ) + useInstall(unobserved) + + prune([]) + + expect(existsSync(staleBuild)).toBe(true) + expect(existsSync(staging)).toBe(false) + }) + + it.each([ + ['an active writer', daemonHostStagingName(LIVE_WRITER_PID, 'aaaaaaaaaaaa')], + ['an unqueryable writer', daemonHostStagingName(UNQUERYABLE_WRITER_PID, 'bbbbbbbbbbbb')], + ['an unidentified pre-pid writer', '.staging-cccccccccccc'] + ])('preserves staging from %s with no daemon record', (_label, name) => { + selectedBuild() + const kept = seedStaging(name) + const abandoned = seedStaging(daemonHostStagingName(DEAD_WRITER_PID, 'dddddddddddd')) + + prune([]) + + expect(existsSync(join(kept, 'Orca.exe'))).toBe(true) + expect(existsSync(abandoned)).toBe(false) + }) + + it('retains every published build before a replacement writes its pid record', () => { + const selected = selectedBuild() + const { staleBuild } = seedLeftovers() + const runtimeDir = join(tempDir, 'userData', 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + const record = join(runtimeDir, 'daemon-v9.pid') + expect(existsSync(record)).toBe(false) + const secondStale = join(versionRoot, 'build-stale-111111111111') + + pruneDaemonHostStaging() + expect(existsSync(staleBuild)).toBe(true) + expect(existsSync(join(selected, 'Orca.exe'))).toBe(true) + + // Publication also preserves all current-version mirrors. + mkdirSync(secondStale, { recursive: true }) + writeFileSync(record, JSON.stringify({ pid: LIVE_WRITER_PID, appVersion: '9.9.9' })) + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + expect(existsSync(secondStale)).toBe(true) + }) + + it('keeps staging off packaged win32', () => { + selectedBuild() + const { staleBuild, staging } = seedLeftovers() + setProcessProp('platform', 'linux') + + pruneDaemonHostStaging() + + expect(existsSync(staleBuild)).toBe(true) + expect(existsSync(staging)).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-host-entry-layout.test.ts b/src/main/daemon/daemon-host-entry-layout.test.ts new file mode 100644 index 00000000000..ae4bc747c93 --- /dev/null +++ b/src/main/daemon/daemon-host-entry-layout.test.ts @@ -0,0 +1,116 @@ +import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { getRelocatedDaemonHost, materializeRelocatedDaemonHost } from './daemon-host-relocation' + +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +let tempDir: string +let installDir: string +let directEntry: string + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-entry-layout-')) + installDir = join(tempDir, 'app') + buildInstallFixture(installDir) + const unpacked = join(installDir, 'resources', 'app.asar.unpacked') + directEntry = join(unpacked, 'daemon-entry.js') + const nestedEntry = join(unpacked, 'out', 'main', 'daemon-entry.js') + cpSync(nestedEntry, directEntry) + process.env.LOCALAPPDATA = join(tempDir, 'localAppData') + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) + setAppEnvironment({ + getPath: () => join(tempDir, 'userData'), + getAppPath: () => join(installDir, 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('daemon-host entry layout after source removal', () => { + it('keeps the observed direct-entry mirror after the entire install disappears', () => { + const host = materializeRelocatedDaemonHost() + expect(host).not.toBeNull() + expect(host?.entryPath).toBe( + join(dirname(host?.execPath ?? ''), 'resources', 'app.asar.unpacked', 'daemon-entry.js') + ) + rmSync(installDir, { recursive: true, force: true }) + + expect(getRelocatedDaemonHost()).toEqual(host) + expect(materializeRelocatedDaemonHost()).toEqual(host) + expect(existsSync(host?.entryPath ?? '')).toBe(true) + }) + + it('validates a present alternate layout and preserves that newer observed build', () => { + const direct = materializeRelocatedDaemonHost() + expect(direct).not.toBeNull() + rmSync(directEntry) + + expect(getRelocatedDaemonHost()).toBeNull() + const nested = materializeRelocatedDaemonHost() + expect(nested).not.toBeNull() + expect(nested?.entryPath).toBe( + join( + dirname(nested?.execPath ?? ''), + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'daemon-entry.js' + ) + ) + expect(nested?.execPath).not.toBe(direct?.execPath) + rmSync(installDir, { recursive: true, force: true }) + + expect(getRelocatedDaemonHost()).toEqual(nested) + expect(materializeRelocatedDaemonHost()).toEqual(nested) + expect(existsSync(direct?.entryPath ?? '')).toBe(true) + }) + + it('refuses to reuse the observed fingerprint for an incomplete alternate layout', () => { + const direct = materializeRelocatedDaemonHost() + expect(direct).not.toBeNull() + rmSync(directEntry) + rmSync( + join( + installDir, + 'resources', + 'node_modules', + '@orca', + 'process-host', + 'dist', + 'run-process.js' + ) + ) + + expect(getRelocatedDaemonHost()).toBeNull() + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(direct?.entryPath ?? '')).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-host-file-hash-cache.test.ts b/src/main/daemon/daemon-host-file-hash-cache.test.ts new file mode 100644 index 00000000000..abdce401801 --- /dev/null +++ b/src/main/daemon/daemon-host-file-hash-cache.test.ts @@ -0,0 +1,157 @@ +import type * as NodeFs from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { + daemonHostFileStatSignature, + hashDaemonHostFile, + resetDaemonHostFileHashCacheForTests +} from './daemon-host-file-hash-cache' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { getRelocatedDaemonHost, materializeRelocatedDaemonHost } from './daemon-host-relocation' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFileSync: vi.fn(actual.readFileSync) } +}) + +function codeReads(): unknown[] { + return vi + .mocked(readFileSync) + .mock.calls.map(([path]) => path) + .filter((path) => typeof path === 'string' && path.endsWith('.js')) +} + +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +const chunkRel = join('resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js') +let tempDir: string +let installDir: string + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +function materializedRoot(): string { + const host = materializeRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') + } + return dirname(host.execPath) +} + +const PINNED_MTIME_SECONDS = 1_700_000_000 + +// Whole-second mtime so restoring it is exact; only identity/ctime can then reveal the edit. +function pinMtime(path: string): void { + utimesSync(path, PINNED_MTIME_SECONDS, PINNED_MTIME_SECONDS) +} + +function rewriteKeepingSizeAndMtime(path: string, contents: string): void { + const before = statSync(path) + expect(contents.length).toBe(before.size) + writeFileSync(path, contents) + pinMtime(path) + expect(statSync(path).mtimeMs).toBe(before.mtimeMs) +} + +beforeEach(() => { + resetDaemonHostFileHashCacheForTests() + vi.mocked(readFileSync).mockClear() + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-hash-cache-')) + installDir = join(tempDir, 'app') + buildInstallFixture(installDir) + process.env.LOCALAPPDATA = join(tempDir, 'localAppData') + setAppEnvironment({ + getPath: () => join(tempDir, 'userData'), + getAppPath: () => join(installDir, 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('daemon-host code hash cache', () => { + it('validates a warm mirror without rereading source or mirror code', () => { + const root = materializedRoot() + expect(getRelocatedDaemonHost()?.execPath).toBe(join(root, 'Orca.exe')) + vi.mocked(readFileSync).mockClear() + + expect(getRelocatedDaemonHost()?.execPath).toBe(join(root, 'Orca.exe')) + expect(materializeRelocatedDaemonHost()?.execPath).toBe(join(root, 'Orca.exe')) + expect(codeReads()).toEqual([]) + }) + + it('detects a same-size source edit that restores mtime', () => { + pinMtime(join(installDir, chunkRel)) + const root = materializedRoot() + rewriteKeepingSizeAndMtime(join(installDir, chunkRel), 'chunX') + + expect(getRelocatedDaemonHost()).toBeNull() + expect(codeReads()).toContain(join(installDir, chunkRel)) + const rebuilt = materializedRoot() + expect(rebuilt).not.toBe(root) + expect(readFileSync(join(rebuilt, chunkRel), 'utf8')).toBe('chunX') + }) + + it('detects a same-size mirror edit that restores mtime', () => { + const root = materializedRoot() + pinMtime(join(root, chunkRel)) + expect(getRelocatedDaemonHost()).not.toBeNull() + rewriteKeepingSizeAndMtime(join(root, chunkRel), 'chunX') + + expect(getRelocatedDaemonHost()).toBeNull() + }) + + it('detects a mirror file deleted after it was cached', () => { + const root = materializedRoot() + getRelocatedDaemonHost() + rmSync(join(root, chunkRel)) + + expect(getRelocatedDaemonHost()).toBeNull() + }) + + it('keeps the observed build when the source disappears after caching', () => { + const root = materializedRoot() + rmSync(installDir, { recursive: true, force: true }) + + expect(getRelocatedDaemonHost()?.execPath).toBe(join(root, 'Orca.exe')) + expect(() => hashDaemonHostFile(join(installDir, chunkRel))).toThrow() + }) + + it('keeps file ids and timestamps that a float stat would collapse distinct', () => { + const base = { + dev: 1n, + ino: 9_007_199_254_740_993n, + size: 5n, + mtimeNs: 1_700_000_000_000_000_001n, + ctimeNs: 1_700_000_000_000_000_001n + } + const signature = daemonHostFileStatSignature(base) + + expect(daemonHostFileStatSignature({ ...base, ino: base.ino + 1n })).not.toBe(signature) + expect(daemonHostFileStatSignature({ ...base, mtimeNs: base.mtimeNs + 1n })).not.toBe(signature) + expect(daemonHostFileStatSignature({ ...base, ctimeNs: base.ctimeNs + 1n })).not.toBe(signature) + }) +}) diff --git a/src/main/daemon/daemon-host-file-hash-cache.ts b/src/main/daemon/daemon-host-file-hash-cache.ts new file mode 100644 index 00000000000..8ce075bd1c8 --- /dev/null +++ b/src/main/daemon/daemon-host-file-hash-cache.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' +import { readFileSync, statSync, type BigIntStats } from 'node:fs' + +// Metadata validation avoids rereading unchanged daemon code on the main thread. +const cachedHashes = new Map() + +type StatIdentity = Pick + +// Bigint stats keep full NTFS file ids and ns timestamps; float ms can collapse distinct values. +export function daemonHostFileStatSignature(stats: StatIdentity): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].join(':') +} + +function statSignature(path: string): string { + return daemonHostFileStatSignature(statSync(path, { bigint: true })) +} + +// Missing files must invalidate the inventory rather than reuse a cached hash. +export function hashDaemonHostFile(path: string): string { + const before = statSignature(path) + const cached = cachedHashes.get(path) + if (cached?.signature === before) { + return cached.sha256 + } + const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex') + // Only cache bytes proven stable across the read; a concurrent write leaves the entry absent. + if (statSignature(path) === before) { + cachedHashes.set(path, { signature: before, sha256 }) + } else { + cachedHashes.delete(path) + } + return sha256 +} + +export function resetDaemonHostFileHashCacheForTests(): void { + cachedHashes.clear() +} diff --git a/src/main/daemon/daemon-host-manifest.ts b/src/main/daemon/daemon-host-manifest.ts index 83d6d4984da..89a50a117d9 100644 --- a/src/main/daemon/daemon-host-manifest.ts +++ b/src/main/daemon/daemon-host-manifest.ts @@ -54,6 +54,19 @@ export type DaemonHostSources = { windowsProcessTreeDir: string } +export function daemonHostProcessPackageDir(sources: DaemonHostSources): string { + return join(sources.resourcesPath, 'node_modules', '@orca', 'process-host') +} + +function isRuntimeProcessHostPath(packageRel: string): boolean { + return ( + packageRel === '' || + packageRel === 'package.json' || + packageRel === 'dist' || + packageRel.startsWith('dist/') + ) +} + /** A required file, a directory on the way to one, or anything in lib/ (index.js requires its siblings). */ function isRuntimeProcessTreePath(packageRel: string): boolean { return ( @@ -118,6 +131,15 @@ export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { optional: true }) + // Keep the external package complete so every public subpath shares the same process policy state. + const processHostDir = daemonHostProcessPackageDir(sources) + ops.push({ + sourcePath: processHostDir, + destRel: toPosixRelative(appDir, processHostDir), + kind: 'dir', + filter: (sourcePath) => isRuntimeProcessHostPath(toPosixRelative(processHostDir, sourcePath)) + }) + // @vscode/windows-process-tree, mirrored so the daemon's require() resolves it; without it every snapshot forks a powershell.exe (#16905). // Filtered to the runtime files: the installed package is ~25MB of gyp intermediates (.obj/.pdb) around a ~150KB addon. const { windowsProcessTreeDir } = sources diff --git a/src/main/daemon/daemon-host-process-package.test.ts b/src/main/daemon/daemon-host-process-package.test.ts new file mode 100644 index 00000000000..8640b88499c --- /dev/null +++ b/src/main/daemon/daemon-host-process-package.test.ts @@ -0,0 +1,282 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { + getDaemonHostRootDir, + getRelocatedDaemonHost, + materializeRelocatedDaemonHost +} from './daemon-host-relocation' + +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +const originalElectronVersion = Object.getOwnPropertyDescriptor(process.versions, 'electron') +const packageRel = join('resources', 'node_modules', '@orca', 'process-host') +let tempDir: string +let installDir: string +let hostRoot: string + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +function materializedRoot(): string { + const host = materializeRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') + } + return dirname(host.execPath) +} + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-process-package-')) + installDir = join(tempDir, 'app') + buildInstallFixture(installDir) + process.env.LOCALAPPDATA = join(tempDir, 'localAppData') + setAppEnvironment({ + getPath: () => join(tempDir, 'userData'), + getAppPath: () => join(installDir, 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) + hostRoot = join(getDaemonHostRootDir(), '9.9.9') +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + if (originalElectronVersion) { + Object.defineProperty(process.versions, 'electron', originalElectronVersion) + } else { + Reflect.deleteProperty(process.versions, 'electron') + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('relocated process-host package', () => { + it('copies the manifest and complete emitted tree without its source', () => { + const root = materializedRoot() + const copiedPackage = join(root, packageRel) + expect(existsSync(join(copiedPackage, 'package.json'))).toBe(true) + expect(existsSync(join(copiedPackage, 'dist', 'run-process.js'))).toBe(true) + expect(existsSync(join(copiedPackage, 'dist', 'spawn-observer.js'))).toBe(true) + expect(existsSync(join(copiedPackage, 'dist', 'run-process.d.ts'))).toBe(true) + expect(existsSync(join(copiedPackage, 'src'))).toBe(false) + const entry = join(root, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + expect(createRequire(entry).resolve('@orca/process-host')).toBe( + realpathSync(join(copiedPackage, 'dist', 'run-process.js')) + ) + expect(createRequire(entry).resolve('@orca/process-host/spawn-observer')).toBe( + realpathSync(join(copiedPackage, 'dist', 'spawn-observer.js')) + ) + }) + + it.each(['package.json', 'dist/run-process.js', 'dist/spawn-observer.js'])( + 'refuses a source missing %s before copying', + (relative) => { + rmSync(join(installDir, packageRel, ...relative.split('/'))) + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(hostRoot)).toBe(false) + } + ) + + it('rejects a mirror missing a private emitted module', () => { + writeFileSync( + join(installDir, packageRel, 'dist', 'private-internal.js'), + 'module.exports = {}' + ) + const root = materializedRoot() + rmSync(join(root, packageRel, 'dist', 'private-internal.js')) + expect(getRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(materializedRoot(), packageRel, 'dist', 'private-internal.js'))).toBe( + true + ) + }) + + it.each(['package.json', 'dist/run-process.js', 'dist/spawn-observer.js'])( + 'rejects a mirror missing %s and preserves it while publishing a repair', + (relative) => { + const root = materializedRoot() + const sentinel = join(root, 'live-host-sentinel') + writeFileSync(sentinel, 'keep') + rmSync(join(root, packageRel, ...relative.split('/'))) + expect(getRelocatedDaemonHost()).toBeNull() + const repaired = materializedRoot() + expect(repaired).not.toBe(root) + expect(existsSync(join(repaired, packageRel, ...relative.split('/')))).toBe(true) + expect(readFileSync(sentinel, 'utf8')).toBe('keep') + } + ) + + it.each(['bytes', 'file list'])( + 'distinguishes the same app version when package %s change', + (change) => { + const previous = materializedRoot() + const file = join( + installDir, + packageRel, + 'dist', + change === 'bytes' ? 'spawn-observer.js' : 'new-module.js' + ) + writeFileSync(file, 'module.exports = { changed: true }') + expect(getRelocatedDaemonHost()).toBeNull() + const current = materializedRoot() + expect(current).not.toBe(previous) + expect(dirname(current)).toBe(hostRoot) + expect(existsSync(join(previous, 'Orca.exe'))).toBe(true) + expect( + readFileSync( + join( + current, + packageRel, + 'dist', + change === 'bytes' ? 'spawn-observer.js' : 'new-module.js' + ), + 'utf8' + ) + ).toContain('changed: true') + } + ) + + it('distinguishes an Electron runtime patch with the same app version', () => { + Object.defineProperty(process.versions, 'electron', { value: '41.0.0', configurable: true }) + const previous = materializedRoot() + Object.defineProperty(process.versions, 'electron', { value: '41.0.1', configurable: true }) + expect(getRelocatedDaemonHost()).toBeNull() + expect(materializedRoot()).not.toBe(previous) + expect(existsSync(join(previous, 'Orca.exe'))).toBe(true) + }) + + it('preserves a legacy version directory while publishing the new dependency layout', () => { + mkdirSync(hostRoot, { recursive: true }) + writeFileSync(join(hostRoot, 'Orca.exe'), 'live-legacy-exe') + writeFileSync( + join(hostRoot, '.materialized.json'), + JSON.stringify({ + version: '9.9.9', + completedAt: '', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + ) + expect(getRelocatedDaemonHost()).toBeNull() + const current = materializedRoot() + expect(dirname(current)).toBe(hostRoot) + expect(readFileSync(join(hostRoot, 'Orca.exe'), 'utf8')).toBe('live-legacy-exe') + }) + + it('keeps the copied code ready after the entire install tree disappears', () => { + const root = materializedRoot() + rmSync(installDir, { recursive: true, force: true }) + expect(getRelocatedDaemonHost()?.execPath).toBe(join(root, 'Orca.exe')) + }) + + it('retains the build observed by this process when a newer same-version mirror exists', () => { + const initial = materializedRoot() + const modulePath = join(installDir, packageRel, 'dist', 'spawn-observer.js') + const initialBytes = readFileSync(modulePath) + writeFileSync(modulePath, 'module.exports = { changed: true }') + expect(materializedRoot()).not.toBe(initial) + writeFileSync(modulePath, initialBytes) + expect(getRelocatedDaemonHost()?.execPath).toBe(join(initial, 'Orca.exe')) + rmSync(installDir, { recursive: true, force: true }) + expect(getRelocatedDaemonHost()?.execPath).toBe(join(initial, 'Orca.exe')) + }) + + it('declines an existing mirror when this installation has never identified its unavailable build', () => { + const previous = materializedRoot() + installDir = join(tempDir, 'unobserved-app') + buildInstallFixture(installDir) + rmSync(join(installDir, packageRel, 'dist', 'run-process.js')) + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) + expect(getRelocatedDaemonHost()).toBeNull() + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(previous, 'Orca.exe'))).toBe(true) + }) + + it('runs emitted bare imports from the relocated entry after removing the install tree', () => { + const require = createRequire(import.meta.url) + const actualPackage = dirname(dirname(require.resolve('@orca/process-host'))) + const installedPackage = join(installDir, packageRel) + cpSync(join(actualPackage, 'package.json'), join(installedPackage, 'package.json')) + cpSync(join(actualPackage, 'dist'), join(installedPackage, 'dist'), { + recursive: true, + dereference: true, + force: true + }) + const mainDir = join(installDir, 'resources', 'app.asar.unpacked', 'out', 'main') + writeFileSync( + join(mainDir, 'chunks', 'a.js'), + `module.exports = { + runner: require('@orca/process-host'), + observer: require('@orca/process-host/spawn-observer') + }` + ) + writeFileSync( + join(mainDir, 'daemon-entry.js'), + ` + const assert = require('node:assert/strict') + const { runner, observer } = require('./chunks/a.js') + async function main() { + let observed = 0 + observer.setSpawnObserver(() => { observed += 1 }) + const result = await runner.runProcess({ program: process.execPath, args: ['-e', "process.stdout.write('daemon-closure')"] }) + assert.equal(result.code, 0) + assert.equal(result.stdout, 'daemon-closure') + assert.equal(observed, 1) + observer.setSpawnObserver(null) + process.stdout.write(result.stdout) + } + main().catch(error => { console.error(error); process.exitCode = 1 }) + ` + ) + const host = materializeRelocatedDaemonHost() + expect(host).not.toBeNull() + rmSync(installDir, { recursive: true, force: true }) + if (!host) { + throw new Error('fixture host was not materialized') + } + const result = runProcessSync({ + program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? originalExecPath, + args: [host.entryPath], + cwd: tempDir, + env: { + ...process.env, + NODE_PATH: undefined, + NODE_OPTIONS: undefined, + ORCA_BACKGROUND_LAUNCH: '1' + }, + timeoutMs: 15_000 + }) + expect(result.stderr).toBe('') + expect(result.code).toBe(0) + expect(result.stdout).toBe('daemon-closure') + }) +}) diff --git a/src/main/daemon/daemon-host-profile-isolation.test.ts b/src/main/daemon/daemon-host-profile-isolation.test.ts new file mode 100644 index 00000000000..337f343e3b0 --- /dev/null +++ b/src/main/daemon/daemon-host-profile-isolation.test.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { daemonHostStagingName } from './daemon-host-reclaim' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { + getDaemonHostRootDir, + materializeRelocatedDaemonHost, + pruneDaemonHostStaging, + pruneOldDaemonHosts +} from './daemon-host-relocation' + +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +const DEAD_WRITER_PID = 2_000_011 +const LIVE_WRITER_PID = 2_000_012 +let tempDir: string +let localAppData: string + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +function useProfile(userDataPath: string): void { + setAppEnvironment({ + getPath: () => userDataPath, + getAppPath: () => join(tempDir, 'app', 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +} + +function materializedBuild(): string { + const host = materializeRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') + } + return dirname(host.execPath) +} + +function seedDir(path: string, exe: string): string { + mkdirSync(join(path, 'resources'), { recursive: true }) + writeFileSync(join(path, 'Orca.exe'), exe) + return path +} + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-profiles-')) + localAppData = join(tempDir, 'localAppData') + process.env.LOCALAPPDATA = localAppData + setProcessProp('platform', 'win32') + buildInstallFixture(join(tempDir, 'app')) + setProcessProp('execPath', join(tempDir, 'app', 'Orca.exe')) + setProcessProp('resourcesPath', join(tempDir, 'app', 'resources')) + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === DEAD_WRITER_PID) { + throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) + } + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('daemon-host profile isolation under a shared LOCALAPPDATA', () => { + it('gives each userData profile its own same-version mirror', () => { + useProfile(join(tempDir, 'profile-a')) + const rootA = getDaemonHostRootDir() + const buildA = materializedBuild() + useProfile(join(tempDir, 'profile-b')) + const rootB = getDaemonHostRootDir() + const buildB = materializedBuild() + + expect(rootA).not.toBe(rootB) + expect(dirname(rootA)).toBe(join(localAppData, 'Orca', 'daemon-host-profiles')) + expect(dirname(dirname(buildA))).toBe(rootA) + expect(dirname(dirname(buildB))).toBe(rootB) + // The same profile spelled differently must not fork a second mirror. + useProfile(`${join(tempDir, 'PROFILE-A')}\\`) + expect(getDaemonHostRootDir()).toBe(rootA) + }) + + it('never touches a live profile when another profile reclaims', () => { + useProfile(join(tempDir, 'profile-a')) + const buildA = materializedBuild() + const versionRootA = dirname(buildA) + const staleA = seedDir(join(versionRootA, 'build-stale-aaaaaaaaaaaa'), 'a-stale') + const stagingA = seedDir( + join(versionRootA, daemonHostStagingName(DEAD_WRITER_PID, 'aaaaaaaaaaaa')), + 'a-staging' + ) + const oldVersionA = seedDir(join(dirname(versionRootA), '1.0.0'), 'a-old') + + useProfile(join(tempDir, 'profile-b')) + const buildB = materializedBuild() + const versionRootB = dirname(buildB) + const staleB = seedDir(join(versionRootB, 'build-stale-bbbbbbbbbbbb'), 'b-stale') + const deadStagingB = seedDir( + join(versionRootB, daemonHostStagingName(DEAD_WRITER_PID, 'bbbbbbbbbbbb')), + 'b-staging' + ) + const liveStagingB = seedDir( + join(versionRootB, daemonHostStagingName(LIVE_WRITER_PID, 'cccccccccccc')), + 'b-live-staging' + ) + const oldVersionB = seedDir(join(dirname(versionRootB), '1.0.0'), 'b-old') + + pruneDaemonHostStaging() + pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map() }) + + for (const [dir, exe] of [ + [buildA, null], + [staleA, 'a-stale'], + [stagingA, 'a-staging'], + [oldVersionA, 'a-old'] + ] as const) { + expect(existsSync(join(dir, 'Orca.exe'))).toBe(true) + if (exe) { + expect(readFileSync(join(dir, 'Orca.exe'), 'utf8')).toBe(exe) + } + } + expect(existsSync(join(buildB, 'Orca.exe'))).toBe(true) + expect(existsSync(join(liveStagingB, 'Orca.exe'))).toBe(true) + expect(existsSync(staleB)).toBe(true) + expect(existsSync(deadStagingB)).toBe(false) + expect(existsSync(oldVersionB)).toBe(false) + }) + + it('keeps legacy unscoped mirrors that older daemons may still run from', () => { + const legacyRoot = join(localAppData, 'Orca', 'daemon-host') + const legacyCurrent = seedDir(join(legacyRoot, '9.9.9', 'build-legacy-000000000000'), 'cur') + const legacyStaging = seedDir( + join(legacyRoot, '9.9.9', daemonHostStagingName(DEAD_WRITER_PID, '000000000000')), + 'staging' + ) + const legacyOld = seedDir(join(legacyRoot, '1.0.0'), 'old') + useProfile(join(tempDir, 'profile-a')) + materializedBuild() + + pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map() }) + + expect(existsSync(join(legacyCurrent, 'Orca.exe'))).toBe(true) + expect(existsSync(join(legacyStaging, 'Orca.exe'))).toBe(true) + expect(existsSync(join(legacyOld, 'Orca.exe'))).toBe(true) + }) + + it('stays under userData when LOCALAPPDATA is unset', () => { + delete process.env.LOCALAPPDATA + useProfile(join(tempDir, 'profile-a')) + expect(getDaemonHostRootDir()).toBe(join(tempDir, 'profile-a', 'daemon-host')) + }) +}) diff --git a/src/main/daemon/daemon-host-reclaim.ts b/src/main/daemon/daemon-host-reclaim.ts new file mode 100644 index 00000000000..e5ff4c12129 --- /dev/null +++ b/src/main/daemon/daemon-host-reclaim.ts @@ -0,0 +1,55 @@ +import { readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' +import { inspectProcessLiveness } from './daemon-process-inspection' + +const STAGING_PREFIX = '.staging-' + +/** Staging dir name carrying its writer's pid, so a later launch can prove the copy was abandoned. */ +export function daemonHostStagingName(writerPid: number, nonce: string): string { + return `${STAGING_PREFIX}${writerPid}-${nonce}` +} + +// Unparseable names (pre-pid layout) have no identifiable writer and are kept. +function stagingWriterVerdict(name: string): ProcessLivenessVerdict { + const match = /^\.staging-(\d+)-[0-9a-f]+$/.exec(name) + const pid = match ? Number(match[1]) : Number.NaN + if (!Number.isSafeInteger(pid) || pid <= 0) { + return { status: 'unverifiable', reason: 'the staging writer could not be identified' } + } + return inspectProcessLiveness(pid) +} + +// Only positive exit evidence permits deletion; unknown verdicts must preserve the directory. +export function reclaimUnownedDaemonHostDir( + verdict: ProcessLivenessVerdict, + hostDir: string +): void { + if (verdict.status !== 'exited') { + return + } + try { + rmSync(hostDir, { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } +} + +/** Daemons never run from staging; only its copying process can keep it live. */ +export function reclaimAbandonedDaemonHostStaging(versionRoot: string): void { + let entries + try { + entries = readdirSync(versionRoot, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const dir = join(versionRoot, entry.name) + if (entry.name.startsWith(STAGING_PREFIX)) { + reclaimUnownedDaemonHostDir(stagingWriterVerdict(entry.name), dir) + } + } +} diff --git a/src/main/daemon/daemon-host-relocation.test-fixture.ts b/src/main/daemon/daemon-host-relocation.test-fixture.ts new file mode 100644 index 00000000000..113d3efc70c --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test-fixture.ts @@ -0,0 +1,68 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and +// node-pty under resources, mirroring the packaged layout the copy expects. +export function buildInstallFixture(root: string): void { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') + const processHostDir = join(root, 'resources', 'node_modules', '@orca', 'process-host') + mkdirSync(join(processHostDir, 'dist'), { recursive: true }) + mkdirSync(join(processHostDir, 'src'), { recursive: true }) + writeFileSync( + join(processHostDir, 'package.json'), + JSON.stringify({ + name: '@orca/process-host', + type: 'commonjs', + exports: { + '.': { default: './dist/run-process.js' }, + './spawn-observer': { default: './dist/spawn-observer.js' } + } + }) + ) + writeFileSync( + join(processHostDir, 'dist', 'run-process.js'), + 'module.exports = require("./spawn-observer")' + ) + writeFileSync(join(processHostDir, 'dist', 'spawn-observer.js'), 'module.exports = {}') + writeFileSync(join(processHostDir, 'dist', 'run-process.d.ts'), 'export {}') + writeFileSync(join(processHostDir, 'src', 'run-process.ts'), 'export {}') + for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { + writeFileSync(join(root, name), name) + } + writeFileSync(join(root, 'ffmpeg.dll'), 'dll') + writeFileSync(join(root, 'libEGL.dll'), 'dll') + const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') + mkdirSync(join(mainDir, 'chunks'), { recursive: true }) + writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') + writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') + writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') + const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(nativeDir, { recursive: true }) + writeFileSync(join(nativeDir, 'conpty.node'), 'native') + writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') + mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) + writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') + // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the + // `win32-` prefix); the copy filter keeps the host arch's and drops the other. + const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') + for (const arch of ['win32-x64', 'win32-arm64']) { + mkdirSync(join(prebuildsRoot, arch), { recursive: true }) + writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) + } + const processTreeDir = join(root, 'resources', 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(join(processTreeDir, 'build', 'Release'), { recursive: true }) + mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) + mkdirSync(join(processTreeDir, 'src'), { recursive: true }) + writeFileSync(join(processTreeDir, 'package.json'), '{"main":"lib/index.js"}') + writeFileSync(join(processTreeDir, 'lib', 'index.js'), 'module.exports = {}') + writeFileSync( + join(processTreeDir, 'build', 'Release', 'windows_process_tree.node'), + 'process-tree-native' + ) + writeFileSync(join(processTreeDir, 'build', 'Release', 'windows_process_tree.pdb'), 'symbols') + writeFileSync(join(processTreeDir, 'src', 'process.cc'), 'source') + writeFileSync(join(processTreeDir, 'lib', 'promises.js'), 'require("./index")') + mkdirSync(join(processTreeDir, 'build', 'Release', 'obj'), { recursive: true }) + writeFileSync(join(processTreeDir, 'build', 'Release', 'obj', 'addon.obj'), 'intermediate') +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 7b81bdcfa1e..544260b791c 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -40,13 +40,15 @@ function installHostApp(): void { } import { buildDaemonHostManifest } from './daemon-host-manifest' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' import { collectPinnedDaemonVersions, + getDaemonHostRootDir, getRelocatedDaemonHost, materializeRelocatedDaemonHost, - pruneOldDaemonHosts, - reclaimUnownedDaemonHostDir + pruneOldDaemonHosts } from './daemon-host-relocation' +import { reclaimUnownedDaemonHostDir } from './daemon-host-reclaim' import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' let tempDir: string @@ -62,49 +64,12 @@ function setProcessProp(key: string, value: unknown): void { Object.defineProperty(process, key, { value, configurable: true, writable: true }) } -// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and -// node-pty under resources, mirroring the packaged layout the copy expects. -function buildInstallFixture(root: string): void { - mkdirSync(root, { recursive: true }) - writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') - for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { - writeFileSync(join(root, name), name) +function relocatedHostRoot(): string { + const host = getRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') } - writeFileSync(join(root, 'ffmpeg.dll'), 'dll') - writeFileSync(join(root, 'libEGL.dll'), 'dll') - const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') - mkdirSync(join(mainDir, 'chunks'), { recursive: true }) - writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') - writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') - writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') - const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(nativeDir, { recursive: true }) - writeFileSync(join(nativeDir, 'conpty.node'), 'native') - writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') - mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) - writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') - // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the - // `win32-` prefix); the copy filter keeps the host arch's and drops the other. - const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') - for (const arch of ['win32-x64', 'win32-arm64']) { - mkdirSync(join(prebuildsRoot, arch), { recursive: true }) - writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) - } - const processTreeDir = join(root, 'resources', 'node_modules', '@vscode', 'windows-process-tree') - mkdirSync(join(processTreeDir, 'build', 'Release'), { recursive: true }) - mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) - mkdirSync(join(processTreeDir, 'src'), { recursive: true }) - writeFileSync(join(processTreeDir, 'package.json'), '{"main":"lib/index.js"}') - writeFileSync(join(processTreeDir, 'lib', 'index.js'), 'module.exports = {}') - writeFileSync( - join(processTreeDir, 'build', 'Release', 'windows_process_tree.node'), - 'process-tree-native' - ) - writeFileSync(join(processTreeDir, 'build', 'Release', 'windows_process_tree.pdb'), 'symbols') - writeFileSync(join(processTreeDir, 'src', 'process.cc'), 'source') - writeFileSync(join(processTreeDir, 'lib', 'promises.js'), 'require("./index")') - mkdirSync(join(processTreeDir, 'build', 'Release', 'obj'), { recursive: true }) - writeFileSync(join(processTreeDir, 'build', 'Release', 'obj', 'addon.obj'), 'intermediate') + return dirname(host.execPath) } const PROCESS_TREE_DIR_REL = join('resources', 'node_modules', '@vscode', 'windows-process-tree') @@ -202,7 +167,7 @@ describe('materializeRelocatedDaemonHost', () => { it('copies the tree, writes the marker, and returns mirrored fork paths', () => { const result = materializeRelocatedDaemonHost() expect(result).not.toBeNull() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const dest = relocatedHostRoot() expect(result?.execPath).toBe(join(dest, 'Orca.exe')) expect(result?.entryPath).toBe( join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') @@ -279,7 +244,7 @@ describe('materializeRelocatedDaemonHost', () => { renameSync(join(installDir, 'Orca.exe'), join(installDir, 'Orca Nightly.exe')) setProcessProp('execPath', join(installDir, 'Orca Nightly.exe')) const result = materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const dest = relocatedHostRoot() expect(result?.execPath).toBe(join(dest, 'Orca Nightly.exe')) expect(existsSync(join(dest, 'orca-terminal-daemon.exe'))).toBe(false) // Re-resolution must agree with materialization or the fork would target a missing exe. @@ -294,7 +259,7 @@ describe('materializeRelocatedDaemonHost', () => { // Any one of them missing means require() cannot reach the addon, and a host // that cannot load it runs anyway -- forking a shell per snapshot (#16905). materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const dest = relocatedHostRoot() expect(getRelocatedDaemonHost()).not.toBeNull() rmSync(join(dest, relativePath)) @@ -304,26 +269,26 @@ describe('materializeRelocatedDaemonHost', () => { it('rematerializes a host whose copied addon went missing', () => { materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const dest = relocatedHostRoot() const relocatedAddon = join(dest, PROCESS_TREE_ADDON_REL) rmSync(relocatedAddon) - // A sentinel proves the host was rebuilt rather than reused. const sentinel = join(dest, 'sentinel.txt') - writeFileSync(sentinel, 'remove') + writeFileSync(sentinel, 'keep') - expect(materializeRelocatedDaemonHost()).not.toBeNull() - expect(readFileSync(relocatedAddon, 'utf8')).toBe('process-tree-native') - expect(existsSync(sentinel)).toBe(false) + const rebuilt = materializeRelocatedDaemonHost() + expect(rebuilt).not.toBeNull() + expect(dirname(rebuilt!.execPath)).not.toBe(dest) + expect(readFileSync(join(dirname(rebuilt!.execPath), PROCESS_TREE_ADDON_REL), 'utf8')).toBe( + 'process-tree-native' + ) + expect(existsSync(sentinel)).toBe(true) }) - it('does not read the install dir to decide an existing host is still good', () => { - // Relocation exists to outlive the install dir, so validity cannot depend on - // it: an updater mid-copy would otherwise condemn an intact host. A real - // upgrade changes the version keying this directory, which already forces a - // rebuild, and nothing else in the mirror is source-verified either. + it('keeps a complete host ready when the install dir disappears', () => { + // A disappearing install tree during an update cannot invalidate the copied runtime. materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const dest = relocatedHostRoot() const sentinel = join(dest, 'sentinel.txt') writeFileSync(sentinel, 'keep') @@ -348,13 +313,13 @@ describe('materializeRelocatedDaemonHost', () => { expect(materializeRelocatedDaemonHost()).toBeNull() // Not even the host root: the source is checked before any directory is made. - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + expect(existsSync(getDaemonHostRootDir())).toBe(false) }) it('is idempotent: a valid marker short-circuits without recopying', () => { materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - // A recopy would rm the dest; a sentinel inside it must survive the 2nd call. + const dest = relocatedHostRoot() + // The same build must be reused, including unrelated files already in its mirror. const sentinel = join(dest, 'sentinel.txt') writeFileSync(sentinel, 'keep') const result = materializeRelocatedDaemonHost() @@ -369,7 +334,7 @@ describe('materializeRelocatedDaemonHost', () => { }) const result = materializeRelocatedDaemonHost() expect(result).toBeNull() - const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host') + const hostRoot = getDaemonHostRootDir() // Neither the published dest nor any leftover staging dir remains. const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : [] expect(remaining).toEqual([]) @@ -378,7 +343,7 @@ describe('materializeRelocatedDaemonHost', () => { it('returns null off win32', () => { setProcessProp('platform', 'darwin') expect(materializeRelocatedDaemonHost()).toBeNull() - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + expect(existsSync(getDaemonHostRootDir())).toBe(false) }) it('does nothing for a packaged host with no asar root (orcad on win32)', () => { @@ -390,35 +355,17 @@ describe('materializeRelocatedDaemonHost', () => { installHostApp() expect(materializeRelocatedDaemonHost()).toBeNull() expect(getRelocatedDaemonHost()).toBeNull() - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + expect(existsSync(getDaemonHostRootDir())).toBe(false) }) }) describe('getRelocatedDaemonHost', () => { it('returns null when the marker version does not match the current version', () => { - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - mkdirSync(dirname(join(dest, 'x')), { recursive: true }) - writeFileSync(join(dest, 'Orca.exe'), 'exe') - mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true }) - writeFileSync( - join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), - 'e' - ) - // The process-table files too, or this passes because the mirror has no addon - // and never exercises the version comparison it is named for. - mkdirSync(join(dest, PROCESS_TREE_DIR_REL, 'lib'), { recursive: true }) - mkdirSync(dirname(join(dest, PROCESS_TREE_ADDON_REL)), { recursive: true }) - writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'package.json'), '{}') - writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'lib', 'index.js'), 'm') - writeFileSync(join(dest, PROCESS_TREE_ADDON_REL), 'n') - writeFileSync( - join(dest, '.materialized.json'), - JSON.stringify({ - version: '8.8.8', - completedAt: '', - entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' - }) - ) + materializeRelocatedDaemonHost() + const dest = relocatedHostRoot() + const markerPath = join(dest, '.materialized.json') + const marker = JSON.parse(readFileSync(markerPath, 'utf8')) + writeFileSync(markerPath, JSON.stringify({ ...marker, version: '8.8.8' })) expect(getRelocatedDaemonHost()).toBeNull() }) }) @@ -432,7 +379,7 @@ function ageRecordPastQuarantineFloor(recordPath: string): void { describe('pruneOldDaemonHosts', () => { it('removes unpinned non-current version dirs, keeping current and pinned', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() for (const v of ['9.9.9', '1.0.0', '2.0.0']) { mkdirSync(join(root, v), { recursive: true }) } @@ -446,7 +393,7 @@ describe('pruneOldDaemonHosts', () => { }) it('keeps a host when its pid liveness query is permission denied', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '8.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -470,7 +417,7 @@ describe('pruneOldDaemonHosts', () => { }) it('keeps a host when its pid liveness query is unavailable', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '7.0.0'), { recursive: true }) mkdirSync(join(root, '6.0.0'), { recursive: true }) @@ -498,7 +445,7 @@ describe('pruneOldDaemonHosts', () => { }) it('prunes nothing and never throws when the evidence is unverifiable', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() for (const v of ['1.0.0', '2.0.0']) { mkdirSync(join(root, v), { recursive: true }) } @@ -521,7 +468,7 @@ describe('pruneOldDaemonHosts', () => { }) it('skips pruning when the runtime directory cannot be read', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() mkdirSync(join(root, '1.0.0'), { recursive: true }) const evidence = collectPinnedDaemonVersions(join(userDataDir, 'daemon-never-created')) @@ -535,7 +482,7 @@ describe('pruneOldDaemonHosts', () => { }) it('keeps a version live when any of its pid records is live', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '7.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -568,7 +515,7 @@ describe('pruneOldDaemonHosts', () => { }) it('preserves a host dir for any verdict that is not positively exited', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() mkdirSync(join(root, '1.0.0'), { recursive: true }) // Why: deliberate out-of-contract cast — deletion must require a positive 'exited' match, // so a future verdict status the prune does not know preserves the host dir, not deletes it. @@ -591,7 +538,7 @@ describe('pruneOldDaemonHosts', () => { }) it('quarantines a record torn inside the pid digits without probing the truncated prefix', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -620,7 +567,7 @@ describe('pruneOldDaemonHosts', () => { }) it('never lets an immortal-pid prefix turn a torn record into a permanent prune veto', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -654,7 +601,7 @@ describe('pruneOldDaemonHosts', () => { // A live daemon's record is created before it is written (writeFileSync 'wx'), so a // concurrent launch can read it as empty. Quarantining it would strand the running daemon's // record and let the NEXT launch reclaim its host image. - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -681,7 +628,7 @@ describe('pruneOldDaemonHosts', () => { // pid 0 with appVersion null. Skipping it as "pins no host dir" would leave the version // unpinned and let the prune below reclaim a live daemon's host image. Aged past the // quarantine floor so this pins the pid guard rather than the freshness guard. - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -702,7 +649,7 @@ describe('pruneOldDaemonHosts', () => { }) it('vetoes pruning while a pid salvaged from a corrupt record still answers', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -728,7 +675,7 @@ describe('pruneOldDaemonHosts', () => { }) it('quarantines a corrupt record naming no live pid so pruning resumes next launch', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -764,7 +711,7 @@ describe('pruneOldDaemonHosts', () => { if (originalPlatform === 'win32') { return ctx.skip() } - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() const runtimeDir = join(userDataDir, 'daemon') mkdirSync(join(root, '1.0.0'), { recursive: true }) mkdirSync(runtimeDir, { recursive: true }) @@ -792,7 +739,7 @@ describe('pruneOldDaemonHosts', () => { }) it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { - const root = join(localAppDataDir, 'Orca', 'daemon-host') + const root = getDaemonHostRootDir() mkdirSync(join(root, '1.0.0'), { recursive: true }) hostApp.appPath = join(installDir, 'resources', 'app') installHostApp() diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 51fed83530a..6d0353668a1 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -1,25 +1,28 @@ -import { randomBytes } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' +import { createHash, randomBytes } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs' import { join, win32 as winPath } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { buildDaemonHostManifest, - daemonHostExeName, destPath, executeManifest, toPosixRelative, WINDOWS_PROCESS_TREE_REQUIRED, type DaemonHostSources } from './daemon-host-manifest' +import { + collectDaemonHostBuildInventory, + daemonHostBuildInventoryMatches, + daemonHostBuildRuntimeMatches, + readDaemonHostBuildMarker, + writeDaemonHostBuildMarker +} from './daemon-host-build-inventory' import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' +import { + daemonHostStagingName, + reclaimAbandonedDaemonHostStaging, + reclaimUnownedDaemonHostDir +} from './daemon-host-reclaim' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine' import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-process-inspection' @@ -44,25 +47,48 @@ export type RelocatedDaemonHost = { } const HOST_SUBDIR = 'daemon-host' -const MARKER_NAME = '.materialized.json' +const observedBuilds = new Map() + +function observedBuildKey(sources: Pick): string { + return JSON.stringify([ + getAppEnvironment().getVersion(), + sources.execPath, + sources.resourcesPath, + process.versions.electron ?? `node-${process.versions.node}`, + process.arch + ]) +} + +function currentBuildFingerprint( + sources: DaemonHostSources, + sourceFingerprint: string | null +): string | null { + const key = observedBuildKey(sources) + if (sourceFingerprint !== null) { + observedBuilds.set(key, { + fingerprint: sourceFingerprint, + entrySourcePath: sources.entrySourcePath + }) + return sourceFingerprint + } + // Once this process observed its build, an updater removing source files cannot select another same-version build. + const observed = observedBuilds.get(key) + return observed?.entrySourcePath === sources.entrySourcePath ? observed.fingerprint : null +} // LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' -type MaterializeMarker = { - version: string - completedAt: string - entryRelPath: string -} - // Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run. -function resolveEntrySourcePath(resourcesPath: string): string { +function resolveEntrySourcePath(resourcesPath: string, observedEntryPath?: string): string { const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') const direct = join(unpackedRoot, 'daemon-entry.js') if (existsSync(direct)) { return direct } - return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') + const nested = join(unpackedRoot, 'out', 'main', 'daemon-entry.js') + // Preserve the observed layout only when neither live entry survives the updater. + return existsSync(nested) ? nested : (observedEntryPath ?? nested) } /** @@ -94,7 +120,8 @@ function collectDaemonHostSources(): DaemonHostSources | null { } const execPath = process.execPath const appDir = winPath.dirname(execPath) - const entrySourcePath = resolveEntrySourcePath(resourcesPath) + const observed = observedBuilds.get(observedBuildKey({ execPath, resourcesPath })) + const entrySourcePath = resolveEntrySourcePath(resourcesPath, observed?.entrySourcePath) return { appDir, execPath, @@ -105,24 +132,6 @@ function collectDaemonHostSources(): DaemonHostSources | null { } } -function readMarker(dir: string): MaterializeMarker | null { - try { - const parsed = JSON.parse( - readFileSync(join(dir, MARKER_NAME), 'utf8') - ) as Partial - if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { - return { - version: parsed.version, - completedAt: typeof parsed.completedAt === 'string' ? parsed.completedAt : '', - entryRelPath: parsed.entryRelPath - } - } - } catch { - // Missing/corrupt marker — treat as not materialized. - } - return null -} - function processTreeRelDir(sources: DaemonHostSources): string { return toPosixRelative(sources.appDir, sources.windowsProcessTreeDir) } @@ -134,14 +143,33 @@ function missingProcessTreeFiles(packageDir: string): boolean { ) } -function hostRootDir(): string { +// Sibling of the legacy shared HOST_SUBDIR, whose older pruners delete every unpinned child dir. +// That legacy root is never reclaimed here: profile-scoped pid evidence can't prove another +// profile's daemon gone, and new code never writes there, so its cost is a finite one-time +// copy (~260MB) per pre-upgrade mirror until a genuine uninstall removes it. +const PROFILE_HOST_SUBDIR = 'daemon-host-profiles' + +// Profiles (--user-data-dir, E2E) share LOCALAPPDATA but not pid records, so one profile's +// listing cannot prove another's mirror unowned; each profile owns only its own root. +function userDataProfileKey(userDataPath: string): string { + const normalized = winPath.resolve(userDataPath).toLowerCase() + return createHash('sha256').update(normalized).digest('hex').slice(0, 16) +} + +/** This profile's mirror root; the only tree its prune may reclaim. */ +export function getDaemonHostRootDir(): string { + const userDataPath = getAppEnvironment().getPath('userData') // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset. const localAppData = process.env.LOCALAPPDATA - const base = - typeof localAppData === 'string' && localAppData.length > 0 - ? join(localAppData, LOCAL_HOST_ROOT_NAME) - : getAppEnvironment().getPath('userData') - return join(base, HOST_SUBDIR) + if (typeof localAppData === 'string' && localAppData.length > 0) { + return join( + localAppData, + LOCAL_HOST_ROOT_NAME, + PROFILE_HOST_SUBDIR, + userDataProfileKey(userDataPath) + ) + } + return join(userDataPath, HOST_SUBDIR) } /** @@ -153,25 +181,59 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } + const inventory = collectDaemonHostBuildInventory(sources) + return findRelocatedDaemonHost( + sources, + currentBuildFingerprint(sources, inventory?.fingerprint ?? null) + ) +} + +function findRelocatedDaemonHost( + sources: DaemonHostSources, + fingerprint: string | null +): RelocatedDaemonHost | null { + if (fingerprint === null) { + return null + } const version = getAppEnvironment().getVersion() - const dest = join(hostRootDir(), version) - const marker = readMarker(dest) - if (!marker || marker.version !== version) { + const versionRoot = join(getDaemonHostRootDir(), version) + let builds + try { + builds = readdirSync(versionRoot, { withFileTypes: true }) + } catch { return null } - const execPath = join(dest, daemonHostExeName(sources.execPath)) - const entryPath = destPath(dest, marker.entryRelPath) - if (!existsSync(execPath) || !existsSync(entryPath)) { - return null + const candidates = builds + .filter((entry) => entry.isDirectory() && entry.name.startsWith('build-')) + .map((entry) => ({ + dest: join(versionRoot, entry.name), + marker: readDaemonHostBuildMarker(join(versionRoot, entry.name)) + })) + .sort((left, right) => + (right.marker?.completedAt ?? '').localeCompare(left.marker?.completedAt ?? '') + ) + for (const { dest, marker } of candidates) { + if ( + !marker || + marker.version !== version || + !daemonHostBuildRuntimeMatches(marker, sources) || + marker.fingerprint !== fingerprint || + !daemonHostBuildInventoryMatches(dest, marker) + ) { + continue + } + const execPath = join(dest, marker.executableName) + const entryPath = destPath(dest, marker.entryRelPath) + if ( + !existsSync(execPath) || + !existsSync(entryPath) || + missingProcessTreeFiles(destPath(dest, processTreeRelDir(sources))) + ) { + continue + } + return { execPath, entryPath } } - // A mirror the daemon cannot load the addon from still runs -- it just forks a - // shell per snapshot (#16905) -- so treat it as unmaterialized and rebuild. Hosts - // from before this shipped have none of these files. Checked in the mirror, never - // in the install dir, which is the thing relocation exists to outlive. - if (missingProcessTreeFiles(destPath(dest, processTreeRelDir(sources)))) { - return null - } - return { execPath, entryPath } + return null } /** @@ -179,39 +241,35 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { * via marker; stages into a temp sibling and publishes by atomic rename, so a crash mid-copy never leaves a half-populated dest. */ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { - const existing = getRelocatedDaemonHost() - if (existing) { - return existing - } const sources = collectDaemonHostSources() if (!sources) { return null } + const inventory = collectDaemonHostBuildInventory(sources) + const existing = findRelocatedDaemonHost( + sources, + currentBuildFingerprint(sources, inventory?.fingerprint ?? null) + ) + if (existing) { + return existing + } // Checked against the source before copying: the mirror check below would refuse // the result anyway, and re-copying ~260MB on every launch to reach that verdict // is the loop this shares its list with the copy plan to prevent. - if (missingProcessTreeFiles(sources.windowsProcessTreeDir)) { + if (!inventory || missingProcessTreeFiles(sources.windowsProcessTreeDir)) { return null } const version = getAppEnvironment().getVersion() - const root = hostRootDir() - const dest = join(root, version) - const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) + const root = getDaemonHostRootDir() + const versionRoot = join(root, version) + const nonce = randomBytes(6).toString('hex') + const dest = join(versionRoot, `build-${inventory.fingerprint}-${nonce}`) + const staging = join(versionRoot, daemonHostStagingName(process.pid, nonce)) try { - mkdirSync(root, { recursive: true }) - rmSync(staging, { recursive: true, force: true }) + mkdirSync(versionRoot, { recursive: true }) executeManifest(buildDaemonHostManifest(sources), staging) - // Marker written LAST so an interrupted copy leaves a marker-less staging dir the next launch discards. - const marker: MaterializeMarker = { - version, - completedAt: new Date().toISOString(), - entryRelPath: sources.entryRelPath - } - writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) - // Replace any stale/partial dest, then publish atomically. Windows refuses to delete a running - // image, so a live daemon already hosted in THIS version's dir (same-version reinstall, or a dev - // channel reusing a version) throws here and materialization fails open to the install-dir host. - rmSync(dest, { recursive: true, force: true }) + writeDaemonHostBuildMarker(staging, version, inventory) + // Publish a new immutable build; a same-version daemon may still load files from an older mirror. renameSync(staging, dest) } catch { try { @@ -221,7 +279,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { } return null } - return getRelocatedDaemonHost() + return findRelocatedDaemonHost(sources, inventory.fingerprint) } export type PinnedDaemonVersionsEvidence = @@ -282,23 +340,6 @@ export function collectPinnedDaemonVersions(runtimeDir: string): PinnedDaemonVer return { status: 'complete', versionLiveness } } -// Why: deletion is the destructive direction and this is a statement position the compiler does -// not police for exhaustiveness — reclaim must be opted into by a positively matched 'exited', -// so any future unhandled verdict status preserves the host dir instead of deleting it. -export function reclaimUnownedDaemonHostDir( - verdict: ProcessLivenessVerdict, - hostDir: string -): void { - if (verdict.status !== 'exited') { - return - } - try { - rmSync(hostDir, { recursive: true, force: true }) - } catch { - // Still locked or already gone — retry on a future launch. - } -} - /** * Reclaim daemon-host/ dirs that are neither the current version nor pinned by a live daemon. * Best-effort — never throws; a locked/staging dir is retried on a future launch. @@ -307,12 +348,13 @@ export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): voi if (!isPackagedElectronWin32()) { return } + pruneDaemonHostStaging() if (evidence.status === 'unverifiable') { console.warn(`[daemon] Skipping daemon-host prune: ${evidence.reason}`) return } const version = getAppEnvironment().getVersion() - const root = hostRootDir() + const root = getDaemonHostRootDir() let entries try { entries = readdirSync(root, { withFileTypes: true }) @@ -320,6 +362,7 @@ export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): voi return } for (const entry of entries) { + // A published current-version build can belong to a daemon that has not written its pid yet. if (!entry.isDirectory() || entry.name === version) { continue } @@ -328,3 +371,12 @@ export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): voi reclaimUnownedDaemonHostDir(verdict, join(root, entry.name)) } } + +/** Reclaim only copies whose writer positively exited, independently of daemon pid publication. */ +export function pruneDaemonHostStaging(): void { + if (isPackagedElectronWin32()) { + reclaimAbandonedDaemonHostStaging( + join(getDaemonHostRootDir(), getAppEnvironment().getVersion()) + ) + } +} diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a28aa222237..31eab8fb6a9 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnDaemonChildProcess } from './daemon-launched-child-spawn' const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: spawn })) -vi.mock('../../shared/child-process/fork-process', () => ({ forkProcess: fork })) +vi.mock('@orca/process-host', () => ({ spawnProcess: spawn })) +vi.mock('@orca/process-host/fork-process', () => ({ forkProcess: fork })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getVersion: () => '1.0.0' }) })) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index bc39d6b3ee6..d4b173e02d5 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,5 +1,6 @@ -import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' -import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { forkProcess, type ForkSpec } from '@orca/process-host/fork-process' +import { spawnProcess } from '@orca/process-host' +import type { SpawnedProcess } from '@orca/process-host/process-spec' import { getAppEnvironment } from '../../shared/app-environment' import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' @@ -50,7 +51,7 @@ function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { * unchanged. Do not read the daemon's PID off the returned child; the daemon reports its own * (see daemon-ready-identity.ts). * - * Both arms go through the shared child-process chokepoint (`src/shared/child-process`), which + * Both arms go through the process-host chokepoint (`@orca/process-host`), which * is what every caller outside that directory must use — `forkProcess` for the Node-module arm, * `spawnProcess` for the program arm. */ diff --git a/src/main/daemon/daemon-launched-child.ts b/src/main/daemon/daemon-launched-child.ts index e0db8400db6..a474d242a01 100644 --- a/src/main/daemon/daemon-launched-child.ts +++ b/src/main/daemon/daemon-launched-child.ts @@ -8,7 +8,7 @@ import { } from './daemon-launched-child-spawn' import { parseDaemonReadyIdentity } from './daemon-ready-identity' import { unlinkOwnedDaemonPidFile } from './daemon-spawner' -import { childProcessHasExited } from '../../shared/child-process/process-tree-termination' +import { childProcessHasExited } from '@orca/process-host/process-tree-termination' const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 diff --git a/src/main/daemon/daemon-mac-code-identity.test.ts b/src/main/daemon/daemon-mac-code-identity.test.ts index 6c408c1ef89..8e7117680dc 100644 --- a/src/main/daemon/daemon-mac-code-identity.test.ts +++ b/src/main/daemon/daemon-mac-code-identity.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { classifyCodesignDisplayOutput, getDaemonMacCodeIdentity } from './daemon-mac-code-identity' diff --git a/src/main/daemon/daemon-mac-code-identity.ts b/src/main/daemon/daemon-mac-code-identity.ts index f89ddf7d2f6..a06c1da2f53 100644 --- a/src/main/daemon/daemon-mac-code-identity.ts +++ b/src/main/daemon/daemon-mac-code-identity.ts @@ -1,7 +1,7 @@ // Adapted from David Bebawy's PR #21826: `codesign --display +` is the probe that answers // where a running pid's executable lives now. Measurement only; nothing reads the verdict. -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { DaemonCodeIdentity } from '../../shared/daemon-adoption-telemetry' const CODESIGN_TIMEOUT_MS = 3_000 diff --git a/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts b/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts new file mode 100644 index 00000000000..6bd55cf57a8 --- /dev/null +++ b/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts @@ -0,0 +1,279 @@ +import { EventEmitter } from 'node:events' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { daemonHostStagingName } from './daemon-host-reclaim' +import { buildInstallFixture } from './daemon-host-relocation.test-fixture' +import { + collectPinnedDaemonVersions, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' +import { getDaemonPidPath } from './daemon-spawner' + +const harness = vi.hoisted(() => ({ + prepareDaemonReplacement: vi.fn(), + launchDaemonChild: vi.fn(), + holdDaemonAdoptionLease: vi.fn(), + entryPath: '' +})) + +vi.mock('./client', () => ({ + DaemonClient: class { + ensureConnectedWithin(): Promise { + return Promise.reject(new Error('no daemon answering')) + } + disconnect(): void {} + } +})) +vi.mock('./daemon-endpoint-adoption', () => ({ + DaemonEndpointOwnershipError: class extends Error {}, + holdDaemonAdoptionLease: harness.holdDaemonAdoptionLease, + reconcileDaemonPidOwnership: vi.fn() +})) +vi.mock('./daemon-launched-child', () => ({ + DaemonEndpointUnavailableError: class extends Error {}, + launchDaemonChild: harness.launchDaemonChild, + terminateLaunchedDaemonChild: vi.fn() +})) +vi.mock('./daemon-launch-paths', () => ({ + getDaemonEntryPath: () => harness.entryPath, + probeDaemonSocket: vi.fn(async () => false) +})) +vi.mock('./daemon-replacement-preflight', () => ({ + prepareDaemonReplacement: harness.prepareDaemonReplacement +})) +vi.mock('./daemon-socket-endpoint-path', () => ({ ensureDaemonSocketDir: vi.fn() })) +vi.mock('./daemon-protocol-cleanup', () => ({ cleanupDaemonForProtocol: vi.fn() })) + +const { createOutOfProcessLauncher } = await import('./daemon-out-of-process-launcher') + +const STALE_DAEMON_PID = 3_000_001 +const REPLACEMENT_PID = 3_000_002 +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA +let tempDir: string +let installDir: string +let runtimeDir: string +let staleDaemon: 'live' | 'exited' | 'unqueryable' + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +function processError(code: string): Error { + return Object.assign(new Error(code), { code }) +} + +function writePidRecord(pid: number): void { + writeFileSync(getDaemonPidPath(runtimeDir), JSON.stringify({ pid, appVersion: '9.9.9' })) +} + +function materializedBuild(): string { + const host = materializeRelocatedDaemonHost() + if (!host) { + throw new Error('fixture host was not materialized') + } + return dirname(host.execPath) +} + +// What the real launch sees at fork time, before the child publishes its live record. +function recordLaunch(oldBuild: string): { oldBuildPresent?: boolean; forkEntryPath?: string } { + const seen: { oldBuildPresent?: boolean; forkEntryPath?: string } = {} + harness.launchDaemonChild.mockImplementation(async (options: { forkEntryPath: string }) => { + seen.oldBuildPresent = existsSync(join(oldBuild, 'Orca.exe')) + seen.forkEntryPath = options.forkEntryPath + writePidRecord(REPLACEMENT_PID) + return { child: new EventEmitter(), identity: { pid: REPLACEMENT_PID } } + }) + return seen +} + +async function launch(): Promise { + await createOutOfProcessLauncher(runtimeDir)( + join(tempDir, 'daemon.sock'), + join(tempDir, 'daemon.token') + ) +} + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-launcher-host-prune-')) + installDir = join(tempDir, 'app') + runtimeDir = join(tempDir, 'userData', 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + process.env.LOCALAPPDATA = join(tempDir, 'localAppData') + setProcessProp('platform', 'win32') + buildInstallFixture(installDir) + setAppEnvironment({ + getPath: () => join(tempDir, 'userData'), + getAppPath: () => join(installDir, 'resources', 'app.asar'), + getVersion: () => '9.9.9', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) + harness.entryPath = join( + installDir, + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'daemon-entry.js' + ) + staleDaemon = 'live' + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === STALE_DAEMON_PID && staleDaemon === 'exited') { + throw processError('ESRCH') + } + if (pid === STALE_DAEMON_PID && staleDaemon === 'unqueryable') { + throw processError('ETIMEDOUT') + } + return true + }) + harness.holdDaemonAdoptionLease.mockResolvedValue({ shutdown: async () => {} }) +}) + +afterEach(() => { + vi.restoreAllMocks() + harness.prepareDaemonReplacement.mockReset() + harness.launchDaemonChild.mockReset() + harness.holdDaemonAdoptionLease.mockReset() + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe('out-of-process launcher: published daemon-host retention', () => { + // A changed fingerprint selects a new mirror without deleting a previous daemon's code. + function seedSupersededLaunch(): string { + const oldBuild = materializedBuild() + writePidRecord(STALE_DAEMON_PID) + writeFileSync(harness.entryPath, 'entry-rebuilt-same-version') + return oldBuild + } + + it('retains a superseded current-version build after its previous daemon exits', async () => { + const oldBuild = seedSupersededLaunch() + harness.prepareDaemonReplacement.mockImplementation(async () => { + staleDaemon = 'exited' + return null + }) + const seen = recordLaunch(oldBuild) + + await launch() + + expect(seen.oldBuildPresent).toBe(true) + expect(seen.forkEntryPath).toBeDefined() + expect(seen.forkEntryPath?.startsWith(oldBuild)).toBe(false) + expect(existsSync(seen.forkEntryPath ?? '')).toBe(true) + expect(existsSync(join(oldBuild, 'Orca.exe'))).toBe(true) + }) + + it('keeps a published build when another daemon can still be starting before pid publication', async () => { + const startingBuild = materializedBuild() + expect(existsSync(getDaemonPidPath(runtimeDir))).toBe(false) + const abandonedStage = join( + dirname(startingBuild), + daemonHostStagingName(STALE_DAEMON_PID, '0123456789ab') + ) + mkdirSync(abandonedStage, { recursive: true }) + writeFileSync(join(abandonedStage, 'Orca.exe'), 'abandoned-copy') + staleDaemon = 'exited' + writeFileSync(harness.entryPath, 'entry-rebuilt-same-version') + harness.prepareDaemonReplacement.mockResolvedValue(null) + const seen = recordLaunch(startingBuild) + const fork = harness.launchDaemonChild.getMockImplementation() + let stagingPresentAtFork: boolean | undefined + harness.launchDaemonChild.mockImplementation(async (options) => { + stagingPresentAtFork = existsSync(abandonedStage) + expect(existsSync(getDaemonPidPath(runtimeDir))).toBe(false) + return fork?.(options) + }) + + await launch() + + expect(seen.oldBuildPresent).toBe(true) + expect(stagingPresentAtFork).toBe(false) + expect( + existsSync( + join(startingBuild, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + ) + ).toBe(true) + expect(seen.forkEntryPath?.startsWith(startingBuild)).toBe(false) + expect(existsSync(seen.forkEntryPath ?? '')).toBe(true) + }) + + it.each([ + ['still live', 'live'], + ['unverifiable', 'unqueryable'] + ] as const)( + 'keeps the superseded build when the stale daemon is %s after preflight', + async (_label, verdict) => { + const oldBuild = seedSupersededLaunch() + harness.prepareDaemonReplacement.mockImplementation(async () => { + staleDaemon = verdict + return null + }) + const seen = recordLaunch(oldBuild) + + await launch() + + expect(seen.oldBuildPresent).toBe(true) + expect(seen.forkEntryPath?.startsWith(oldBuild)).toBe(false) + expect(existsSync(seen.forkEntryPath ?? '')).toBe(true) + } + ) + + it('leaves an exited superseded version to the post-launch prune so it never delays the fork', async () => { + const oldBuild = seedSupersededLaunch() + // No pid record names 1.0.0, so its mirror is exited and reclaimable at any point. + const supersededVersion = join(dirname(dirname(oldBuild)), '1.0.0') + mkdirSync(supersededVersion, { recursive: true }) + writeFileSync(join(supersededVersion, 'Orca.exe'), 'superseded') + harness.prepareDaemonReplacement.mockImplementation(async () => { + staleDaemon = 'exited' + return null + }) + const seen = recordLaunch(oldBuild) + let supersededPresentAtFork: boolean | undefined + const fork = harness.launchDaemonChild.getMockImplementation() + harness.launchDaemonChild.mockImplementation(async (options) => { + supersededPresentAtFork = existsSync(join(supersededVersion, 'Orca.exe')) + return fork?.(options) + }) + + await launch() + + expect(seen.oldBuildPresent).toBe(true) + expect(supersededPresentAtFork).toBe(true) + expect(existsSync(join(supersededVersion, 'Orca.exe'))).toBe(true) + + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + + expect(existsSync(supersededVersion)).toBe(false) + expect(existsSync(seen.forkEntryPath ?? '')).toBe(true) + }) + + it('reclaims nothing when preflight adopts the running daemon', async () => { + const oldBuild = seedSupersededLaunch() + harness.prepareDaemonReplacement.mockResolvedValue({ adopted: true, shutdown: async () => {} }) + + await launch() + + expect(harness.launchDaemonChild).not.toHaveBeenCalled() + expect(existsSync(join(oldBuild, 'Orca.exe'))).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index dacf1e87ce4..9ac31e4206b 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { getAppEnvironment } from '../../shared/app-environment' -import { childProcessHasExited } from '../../shared/child-process/process-tree-termination' +import { childProcessHasExited } from '@orca/process-host/process-tree-termination' import type { DaemonReplaceReason } from '../../shared/daemon-lifecycle-telemetry' import { DaemonClient } from './client' import { @@ -14,7 +14,7 @@ import { terminateLaunchedDaemonChild } from './daemon-launched-child' import { getDaemonEntryPath, probeDaemonSocket as probeSocket } from './daemon-launch-paths' -import { materializeRelocatedDaemonHost } from './daemon-host-relocation' +import { materializeRelocatedDaemonHost, pruneDaemonHostStaging } from './daemon-host-relocation' import { DAEMON_RECOVERY_BUDGET_MS, daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { cleanupDaemonForProtocol } from './daemon-protocol-cleanup' import { @@ -123,6 +123,7 @@ export function createOutOfProcessLauncher( const userDataPath = getAppEnvironment().getPath('userData') // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() + pruneDaemonHostStaging() // Fork the relocated entry when available; otherwise the install-dir entry. const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath let launched diff --git a/src/main/daemon/daemon-scope-death-watch.test.ts b/src/main/daemon/daemon-scope-death-watch.test.ts index 951fe9c165a..ca1c2919519 100644 --- a/src/main/daemon/daemon-scope-death-watch.test.ts +++ b/src/main/daemon/daemon-scope-death-watch.test.ts @@ -1,7 +1,8 @@ import { ChildProcess } from 'node:child_process' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec, SpawnedProcess } from '../../shared/child-process/run-process' + +import type { ProcessSpec, SpawnedProcess } from '@orca/process-host/process-spec' import { startDaemonScopeDeathWatch } from './daemon-scope-death-watch' const spawnProcess = vi.fn<(spec: ProcessSpec) => SpawnedProcess>() diff --git a/src/main/daemon/daemon-scope-death-watch.ts b/src/main/daemon/daemon-scope-death-watch.ts index c36cb1774ce..100bc325af6 100644 --- a/src/main/daemon/daemon-scope-death-watch.ts +++ b/src/main/daemon/daemon-scope-death-watch.ts @@ -1,8 +1,5 @@ -import { - spawnProcess, - type ProcessSpec, - type SpawnedProcess -} from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import type { ProcessSpec, SpawnedProcess } from '@orca/process-host/process-spec' import { daemonScopeUnitName, detectOwnCgroupScopeUnit } from './daemon-cgroup-scope' // The pipe closes even on SIGKILL; a live/reused owner PID always vetoes cleanup. diff --git a/src/main/daemon/daemon-user-manager-lifetime.ts b/src/main/daemon/daemon-user-manager-lifetime.ts index a2942cdf0e7..05a71bbd3aa 100644 --- a/src/main/daemon/daemon-user-manager-lifetime.ts +++ b/src/main/daemon/daemon-user-manager-lifetime.ts @@ -8,7 +8,8 @@ * dies with it anyway, so the scope costs it nothing. */ import { readFileSync } from 'node:fs' -import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' const LINGER_PROBE_TIMEOUT_MS = 2_000 diff --git a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts index b02eec33773..0633c811ba7 100644 --- a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts +++ b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts @@ -17,7 +17,7 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' diff --git a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts index abf086e6e46..dc91c8918a2 100644 --- a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts +++ b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts @@ -7,7 +7,7 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 99ceeb31044..a1f60191281 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { app } from 'electron' import { platform, tmpdir } from 'node:os' diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts index 1b8d3b6182f..6ead7fd9a1d 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.test.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' const DYLIB_CONTENT = Buffer.from('signed-simcam-dylib-mach-o-bytes') diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts index 267fc7e20a5..85597da2f2d 100644 --- a/src/main/git/command-runner/exec-file-capture.ts +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -1,7 +1,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_process' import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver' import type { WslProcessGroupTermination } from '../wsl-process-group-termination' import { createAbortError } from './abort-error' diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts index f7ebc3036b2..40bab1322c6 100644 --- a/src/main/git/command-runner/git-admission-storm-measurement.test.ts +++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts @@ -2,7 +2,7 @@ import { existsSync, watch } from 'node:fs' import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { gitExecFileAsync } from './git-exec-file' import { GENERAL_CAP, diff --git a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts index 86e1c6114f9..76a0caa3129 100644 --- a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts +++ b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts @@ -25,7 +25,7 @@ vi.mock('node:child_process', async (importOriginal) => ({ vi.mock('./spawned-command-tree-kill', () => ({ killSpawnedCommandTree: killSpawnedCommandTreeMock })) -vi.mock('../../../shared/child-process/process-tree-termination', () => ({ +vi.mock('@orca/process-host/process-tree-termination', () => ({ signalProcessTree: signalProcessTreeMock, forceTerminateProcessTree: forceTerminateProcessTreeMock })) diff --git a/src/main/git/command-runner/spawned-command-tree-kill.test.ts b/src/main/git/command-runner/spawned-command-tree-kill.test.ts index 758fb296227..4f8251ea5da 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.test.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.test.ts @@ -1,6 +1,6 @@ import { ChildProcess } from 'node:child_process' import { once } from 'node:events' -import { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import type * as NodeChildProcess from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts index 7fd7dbb26f0..d81c9bf087f 100644 --- a/src/main/git/git-network-safety-real-git.test.ts +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { createGitHandlerRelay } from '../../relay/git-handler-test-harness' import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner' diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index e450afd7939..3a763547a21 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -12,7 +12,7 @@ vi.mock('child_process', () => ({ spawn: spawnMock })) -import { fakeSpawnReturning } from '../../shared/child-process/__fixtures__/fake-spawned-child' +import { fakeSpawnReturning } from '../../shared/__fixtures__/fake-spawned-child' import { ghExecFileAsync } from './runner' import { _resetGhRateLimitBreaker, diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 64047e50c0f..e2ee30d2646 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -3,9 +3,9 @@ import { createFakeSpawnedChild, fakeSpawnDispatch, fakeSpawnReturning -} from '../../shared/child-process/__fixtures__/fake-spawned-child' +} from '../../shared/__fixtures__/fake-spawned-child' import type * as WslModule from '../wsl' -import { windowsSystem32Binary } from '../../shared/child-process/windows-system-binary' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), diff --git a/src/main/git/source-control/blob-absence-real-git.test.ts b/src/main/git/source-control/blob-absence-real-git.test.ts index 0bc27cc2083..5eb0a2bce67 100644 --- a/src/main/git/source-control/blob-absence-real-git.test.ts +++ b/src/main/git/source-control/blob-absence-real-git.test.ts @@ -1,7 +1,7 @@ import { readFile, unlink, utimes, writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' import { createGitHandlerRelay, diff --git a/src/main/git/source-control/bulk-pathspec-stdin.test.ts b/src/main/git/source-control/bulk-pathspec-stdin.test.ts index b08cc81d3a6..c45f82645f0 100644 --- a/src/main/git/source-control/bulk-pathspec-stdin.test.ts +++ b/src/main/git/source-control/bulk-pathspec-stdin.test.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' import { createGitHandlerRelay, diff --git a/src/main/git/source-control/staging-discard-index-safety.test.ts b/src/main/git/source-control/staging-discard-index-safety.test.ts index c5c4cbb9a4b..aaff540d231 100644 --- a/src/main/git/source-control/staging-discard-index-safety.test.ts +++ b/src/main/git/source-control/staging-discard-index-safety.test.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' import { createGitHandlerRelay, diff --git a/src/main/git/worktree-rebase-update-refs-real-git.test.ts b/src/main/git/worktree-rebase-update-refs-real-git.test.ts index 8625b77490a..2795f3f1a7e 100644 --- a/src/main/git/worktree-rebase-update-refs-real-git.test.ts +++ b/src/main/git/worktree-rebase-update-refs-real-git.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn(), gitExecFileSync: vi.fn() })) vi.mock('./local-repo-ref-maintenance', () => ({ diff --git a/src/main/git/wsl-process-group-termination.ts b/src/main/git/wsl-process-group-termination.ts index dbb7eecef19..e1b715657bc 100644 --- a/src/main/git/wsl-process-group-termination.ts +++ b/src/main/git/wsl-process-group-termination.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' -import type { ProcessTerminationBarrier } from '../../shared/child-process/run-process' + +import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec' import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { runWslProcess } from '../wsl/wsl-runner' diff --git a/src/main/github/work-item-details-config-change.test.ts b/src/main/github/work-item-details-config-change.test.ts index f10ecfe0046..0b8ffa19b8c 100644 --- a/src/main/github/work-item-details-config-change.test.ts +++ b/src/main/github/work-item-details-config-change.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import type * as GitExecError from '../git/exec-error' import type * as WorkItemLookup from './client/fetch/get-work-item' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const { ghMock, fixture } = vi.hoisted(() => { const fixture: { diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index f9f3c573629..e742281b579 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fakeSpawnDispatch } from '../../shared/child-process/__fixtures__/fake-spawned-child' +import { fakeSpawnDispatch } from '../../shared/__fixtures__/fake-spawned-child' import type * as WslModule from '../wsl' const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 00cf8ea7408..25ad08689cc 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -18,8 +18,8 @@ import { spawn } from 'node:child_process' import { forceTerminateProcessTree, signalProcessTree -} from '../shared/child-process/process-tree-termination' -import { createOutputSink } from '../shared/child-process/bounded-output-sink' +} from '@orca/process-host/process-tree-termination' +import { createOutputSink } from '@orca/process-host/bounded-output-sink' const HOOK_TIMEOUT = 120_000 // 2 minutes diff --git a/src/main/ipc/filesystem-content-search-cancellation.test.ts b/src/main/ipc/filesystem-content-search-cancellation.test.ts index 3587eaff380..99256e1ba7d 100644 --- a/src/main/ipc/filesystem-content-search-cancellation.test.ts +++ b/src/main/ipc/filesystem-content-search-cancellation.test.ts @@ -1,5 +1,5 @@ import { isAbsolute } from 'node:path' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as BundledRipgrepPath from '../ripgrep/bundled-ripgrep-path' diff --git a/src/main/ipc/parcel-watcher-child-slot.ts b/src/main/ipc/parcel-watcher-child-slot.ts index c26e4ba32e6..52c35a829b4 100644 --- a/src/main/ipc/parcel-watcher-child-slot.ts +++ b/src/main/ipc/parcel-watcher-child-slot.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' /** The supervisor's one watcher child: the live one, the one being terminated, and its canary. */ diff --git a/src/main/ipc/parcel-watcher-owned-children.ts b/src/main/ipc/parcel-watcher-owned-children.ts index 7ac044ac906..1d1c33e7a66 100644 --- a/src/main/ipc/parcel-watcher-owned-children.ts +++ b/src/main/ipc/parcel-watcher-owned-children.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { watcherChildPhysicalExit, requireWatcherChildTermination diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts index dbae7eccde1..53331e8059c 100644 --- a/src/main/ipc/preflight-command-exec.test.ts +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import path from 'node:path' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { ProcessSpec } from '@orca/process-host/process-spec' import type * as LocalCommandResolver from './command-path-resolver' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' @@ -16,7 +16,7 @@ const { runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined })) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index 203572fa0d0..2197f8e9c4f 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -4,7 +4,7 @@ import { homedir } from 'node:os' import { promisify } from 'node:util' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { beginLocalCommandSelection, isCommandOnLocalPath, diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 4cf38177c9c..5ae2d7e493f 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as LocalCommandResolver from './command-path-resolver' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { ProcessSpec } from '@orca/process-host/process-spec' const { handleMock, @@ -52,7 +52,7 @@ vi.mock('child_process', () => { } }) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: async (spec: ProcessSpec) => ({ ...(await execFileAsyncMock(spec.program, spec.args, { encoding: 'utf-8', diff --git a/src/main/jcode/daemon-prewarm.test.ts b/src/main/jcode/daemon-prewarm.test.ts index 61cae8ff1a0..ceac26b38fc 100644 --- a/src/main/jcode/daemon-prewarm.test.ts +++ b/src/main/jcode/daemon-prewarm.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: spawnProcessMock })) +vi.mock('@orca/process-host', () => ({ spawnProcess: spawnProcessMock })) import { prewarmJcodeDaemon, resetJcodeDaemonPrewarmForTests } from './daemon-prewarm' diff --git a/src/main/jcode/daemon-prewarm.ts b/src/main/jcode/daemon-prewarm.ts index fd40454cd59..51d89d8de78 100644 --- a/src/main/jcode/daemon-prewarm.ts +++ b/src/main/jcode/daemon-prewarm.ts @@ -6,7 +6,7 @@ // died with "Timed out waiting for responsive server socket" before its prompt was // ever delivered. Starting the daemon as the PTY spawns gives it the shell's own // startup time as head start, so the client finds a live socket instead of racing. -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export type JcodeDaemonPrewarm = { diff --git a/src/main/jcode/hook-gate-script.test.ts b/src/main/jcode/hook-gate-script.test.ts index d924e9f220d..98b3fa81b1f 100644 --- a/src/main/jcode/hook-gate-script.test.ts +++ b/src/main/jcode/hook-gate-script.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { createServer } from 'node:http' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) vi.mock('os', async () => { diff --git a/src/main/jsonl-rpc/agent-connection.test.ts b/src/main/jsonl-rpc/agent-connection.test.ts index d8cd3e009ea..e9eb5537109 100644 --- a/src/main/jsonl-rpc/agent-connection.test.ts +++ b/src/main/jsonl-rpc/agent-connection.test.ts @@ -1,7 +1,7 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' @@ -21,16 +21,15 @@ function fixture( options: JsonlRpcAgentConnectionOptions = {}, behavior: { exitOnEnd?: boolean; processless?: boolean } = {} ) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: behavior.processless ? undefined : 9_999_999, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) - const spawn = vi.fn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fixture supplies piped stdio, pid, kill and process lifecycle events read by the shared supervisor. - return child as unknown as ReturnType + const spawn = vi.fn(() => { + return child }) if (behavior.exitOnEnd !== false) { child.stdin.once('finish', () => child.emit('exit', 0, null)) @@ -210,7 +209,7 @@ describe('JSON-lines RPC process ownership', () => { }) it('validates peer limits before spawning', () => { - const spawn = vi.fn() + const spawn = vi.fn() expect( () => new JsonlRpcAgentConnection( diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 89be90dc6e4..551beb40dec 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -1,4 +1,5 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess, type ManagedProviderProcess, @@ -32,7 +33,7 @@ export class JsonlRpcAgentConnection { constructor( launch: ProviderProcessLaunch, private readonly options: JsonlRpcAgentConnectionOptions = {}, - spawnImpl: typeof spawnProcess = spawnProcess + spawnImpl: PipedProcessSpawner = spawnProcess ) { const peerOptions = resolveJsonlRpcPeerOptions(options.peer) this.managed = spawnManagedProviderProcess(launch, { diff --git a/src/main/linux-lid-sleep-assertion.ts b/src/main/linux-lid-sleep-assertion.ts index 22171c489ae..e01a3268ae5 100644 --- a/src/main/linux-lid-sleep-assertion.ts +++ b/src/main/linux-lid-sleep-assertion.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from '../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' export const LINUX_LID_SLEEP_ASSERTION_RETRY_MS = 30_000 diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index d69d82b914f..8cc1584c380 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -9,7 +9,7 @@ const { runProcessMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({ // Why mock the chokepoint: encoding, timeout and the hidden console are its // contract now, so this suite asserts what the distro is asked to run. -vi.mock('../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index d6820dd5b14..346f9e057fd 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { lstat, readFile } from 'node:fs/promises' import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command' import { removeHostTree } from './host-tree-removal' diff --git a/src/main/macos-press-and-hold-default.defaults-domain.test.ts b/src/main/macos-press-and-hold-default.defaults-domain.test.ts index 18dab052450..f05db4bcd27 100644 --- a/src/main/macos-press-and-hold-default.defaults-domain.test.ts +++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts @@ -4,7 +4,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcessSync } from '../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { PRESS_AND_HOLD_KEY, ensureMacPressAndHoldDefault, diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts index b1535b50408..29cc0657ec9 100644 --- a/src/main/macos-press-and-hold-default.test.ts +++ b/src/main/macos-press-and-hold-default.test.ts @@ -2,7 +2,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult } from '../shared/child-process/run-process' + +import type { ProcessResult } from '@orca/process-host/process-spec' import { ensureMacPressAndHoldDefault, interpretDefaultsRead, diff --git a/src/main/macos-press-and-hold-default.ts b/src/main/macos-press-and-hold-default.ts index 41b7819f2b2..63123ae5f7b 100644 --- a/src/main/macos-press-and-hold-default.ts +++ b/src/main/macos-press-and-hold-default.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { writeFileAtomically } from './codex-accounts/fs-utils' /** diff --git a/src/main/macos-tcc-reset.test.ts b/src/main/macos-tcc-reset.test.ts index 76cc5ff5144..953ad05fe42 100644 --- a/src/main/macos-tcc-reset.test.ts +++ b/src/main/macos-tcc-reset.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult } from '../shared/child-process/run-process' + +import type { ProcessResult } from '@orca/process-host/process-spec' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { readMacosBundleId, resetMacosTccPermission } from './macos-tcc-reset' diff --git a/src/main/macos-tcc-reset.ts b/src/main/macos-tcc-reset.ts index 34c627f5a5e..65b1ea34e77 100644 --- a/src/main/macos-tcc-reset.ts +++ b/src/main/macos-tcc-reset.ts @@ -3,7 +3,8 @@ // daemon folder-access fix (STA-7948), where clearing the row is what makes macOS ask again. import { join } from 'node:path' -import { runProcess, type ProcessResult } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' /** Bounded so a wedged helper cannot hold a caller: neither binary prompts, so neither lingers. */ const TCC_COMMAND_TIMEOUT_MS = 10_000 diff --git a/src/main/macos-update-running-instances.integration.test.ts b/src/main/macos-update-running-instances.integration.test.ts index afc5c3d7e66..ff4968eebc9 100644 --- a/src/main/macos-update-running-instances.integration.test.ts +++ b/src/main/macos-update-running-instances.integration.test.ts @@ -3,7 +3,7 @@ import { once } from 'node:events' import { tmpdir } from 'node:os' import path from 'node:path' import { expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { getMacUpdateRunningInstances } from './macos-update-running-instances' const APPLICATION_SOURCE = ` diff --git a/src/main/macos-update-running-instances.test.ts b/src/main/macos-update-running-instances.test.ts index f6af6f04f53..e88b3829623 100644 --- a/src/main/macos-update-running-instances.test.ts +++ b/src/main/macos-update-running-instances.test.ts @@ -5,7 +5,7 @@ import { } from './macos-update-running-instances' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test' diff --git a/src/main/macos-update-running-instances.ts b/src/main/macos-update-running-instances.ts index 1ce757d30f5..f2399d22052 100644 --- a/src/main/macos-update-running-instances.ts +++ b/src/main/macos-update-running-instances.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const RUNNING_INSTANCES_SCRIPT = `function run(argv) { ObjC.import('AppKit'); diff --git a/src/main/memory/collector-windows-sweep.test.ts b/src/main/memory/collector-windows-sweep.test.ts index d5fd62218c3..015bcf759ce 100644 --- a/src/main/memory/collector-windows-sweep.test.ts +++ b/src/main/memory/collector-windows-sweep.test.ts @@ -25,7 +25,7 @@ vi.mock('child_process', () => ({ // Why mock the chokepoint for the Windows sweep: maxBuffer, timeout and the // hidden console are its contract now, so the assertions below are about which // query runs, not how a process is started. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec) })) diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index b5345d0990b..af4c365446b 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -25,7 +25,7 @@ vi.mock('child_process', () => ({ // Why mock the chokepoint for the Windows sweep: maxBuffer, timeout and the // hidden console are its contract now, so the assertions below are about which // query runs, not how a process is started. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec) })) diff --git a/src/main/memory/host-memory.test.ts b/src/main/memory/host-memory.test.ts index a7e14667d3b..fd5bec82459 100644 --- a/src/main/memory/host-memory.test.ts +++ b/src/main/memory/host-memory.test.ts @@ -6,7 +6,7 @@ const { runProcessMock, readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) diff --git a/src/main/memory/host-memory.ts b/src/main/memory/host-memory.ts index 7cf6d17eaa5..835f96f07a3 100644 --- a/src/main/memory/host-memory.ts +++ b/src/main/memory/host-memory.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { readFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts index bc7e85a79f8..c868ab0f393 100644 --- a/src/main/memory/windows-process-resource-collector.ts +++ b/src/main/memory/windows-process-resource-collector.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import os from 'node:os' import { performance } from 'node:perf_hooks' import { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts index 8adf15a1365..643586ca8dd 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts @@ -1,9 +1,9 @@ +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' // The one way a catalog probe runs an agent CLI: a disposable child under the provider supervisor, // one budget for the whole listing, bounded output, and a teardown awaited on every path. A probe // only names what to run and how to read its answer. -import { createOutputSink } from '../../../shared/child-process/bounded-output-sink' -import type { spawnProcess } from '../../../shared/child-process/run-process' +import { createOutputSink } from '@orca/process-host/bounded-output-sink' import { spawnManagedProviderProcess } from '../../provider-process/managed-provider-process' import type { ProviderProcessClosePolicy } from '../../provider-process/provider-process-close' import type { ProviderProcessLaunch } from '../../provider-process/provider-process-launch' @@ -60,7 +60,7 @@ export type AgentModelCatalogRunnerOptions = { /** Stops the listing and its child, as the deadline would. */ signal?: AbortSignal /** Test seams. */ - spawnImpl?: typeof spawnProcess + spawnImpl?: PipedProcessSpawner platform?: NodeJS.Platform inheritedEnv?: NodeJS.ProcessEnv } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index dc2980d4833..e4bc88384b6 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -10,7 +10,7 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { tearDownRuntime } from '../../runtime/structured-agent-session-runtime-teardown' import { journalDatabasePath } from './journal-host-database' import { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 772746008e4..c657daa574f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' diff --git a/src/main/notebook/notebook-kernel.ts b/src/main/notebook/notebook-kernel.ts index 413b3bfeb65..370d14fff90 100644 --- a/src/main/notebook/notebook-kernel.ts +++ b/src/main/notebook/notebook-kernel.ts @@ -1,6 +1,6 @@ import bridgePath from '../../../resources/notebook/kernel-bridge.py?asset&asarUnpack' -import { spawnProcess } from '../../shared/child-process/run-process' -import { forceTerminateProcessTree } from '../../shared/child-process/process-tree-termination' +import { spawnProcess } from '@orca/process-host' +import { forceTerminateProcessTree } from '@orca/process-host/process-tree-termination' import { createNdjsonParser } from '../../shared/main-process-ndjson-framer' import { KERNEL_OUTPUT_TYPES, diff --git a/src/main/notebook/python-environments.test.ts b/src/main/notebook/python-environments.test.ts index eb69d981493..b16489140dc 100644 --- a/src/main/notebook/python-environments.test.ts +++ b/src/main/notebook/python-environments.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { createNotebookVenv, diff --git a/src/main/notebook/python-environments.ts b/src/main/notebook/python-environments.ts index 116e20e9e1b..733e5a6383d 100644 --- a/src/main/notebook/python-environments.ts +++ b/src/main/notebook/python-environments.ts @@ -1,7 +1,7 @@ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' -import type { ProcessResult } from '../../shared/child-process/process-spec' +import { runProcess } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import type { CreateVenvResult, PythonEnvironment, diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index 23f6b9eea3f..f56a28172dd 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -1,8 +1,8 @@ import { beforeEach, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { probeOpenCodeCliVersion } from './opencode-cli-version' -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) beforeEach(() => vi.mocked(runProcess).mockReset()) it('shares concurrent probes and isolates executable, host and environment identities', async () => { diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 06804bad2ea..1a0be7341ca 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -4,7 +4,7 @@ import { ORCA_SCRUB_SAFE_LAUNCH_ENV, ORCA_SCRUB_SAFE_PANE_ENV } from '../../shared/agent-hook-scrub-safe-env' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getOpenCodeCliCapabilities, type OpenCodeCliCapabilities diff --git a/src/main/opencode/opencode-client-sweep.test.ts b/src/main/opencode/opencode-client-sweep.test.ts index 72f5106c8f6..b95abfcb774 100644 --- a/src/main/opencode/opencode-client-sweep.test.ts +++ b/src/main/opencode/opencode-client-sweep.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/process-spec' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { isOpenCodeClientArgv, isOpenCodeClientProcess, diff --git a/src/main/opencode/opencode-client-sweep.ts b/src/main/opencode/opencode-client-sweep.ts index bb37e504caa..8207279446b 100644 --- a/src/main/opencode/opencode-client-sweep.ts +++ b/src/main/opencode/opencode-client-sweep.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { readWindowsProcessTable, type WindowsProcessRow as NativeWindowsProcessRow diff --git a/src/main/opencode/opencode-credential-backend.test.ts b/src/main/opencode/opencode-credential-backend.test.ts index 3bf00336eb1..fe3e21c499c 100644 --- a/src/main/opencode/opencode-credential-backend.test.ts +++ b/src/main/opencode/opencode-credential-backend.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { detectOpenCodeCredentialBackend, @@ -8,7 +8,7 @@ import { const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) vi.mock('node:fs/promises', () => files) diff --git a/src/main/opencode/opencode-credential-backend.ts b/src/main/opencode/opencode-credential-backend.ts index 69c860dbe48..725c82388c8 100644 --- a/src/main/opencode/opencode-credential-backend.ts +++ b/src/main/opencode/opencode-credential-backend.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { createHash } from 'node:crypto' import { realpath, stat } from 'node:fs/promises' diff --git a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts index cf5df3a7d7f..f88f8e2c153 100644 --- a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts +++ b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts @@ -2,18 +2,18 @@ import { z } from 'zod' import { ChildProcess } from 'node:child_process' import { PassThrough } from 'node:stream' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { forceTerminateProcessTree, signalProcessTree -} from '../../shared/child-process/process-tree-termination' +} from '@orca/process-host/process-tree-termination' import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' -vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: vi.fn() })) -vi.mock('../../shared/child-process/process-tree-termination', () => ({ +vi.mock('@orca/process-host', () => ({ spawnProcess: vi.fn() })) +vi.mock('@orca/process-host/process-tree-termination', () => ({ signalProcessTree: vi.fn(), forceTerminateProcessTree: vi.fn() })) diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts index 134b7b4ab92..76395efc45b 100644 --- a/src/main/opencode/opencode-launch-model-context.ts +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -1,16 +1,16 @@ import { randomBytes } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' import { z } from 'zod' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { forceTerminateProcessTree, signalProcessTree -} from '../../shared/child-process/process-tree-termination' +} from '@orca/process-host/process-tree-termination' import { withTimeout } from '../../shared/promise-timeout-fallback' -import { createOutputSink } from '../../shared/child-process/bounded-output-sink' +import { createOutputSink } from '@orca/process-host/bounded-output-sink' import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop' diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts index f8b3fdf552c..5452b75f811 100644 --- a/src/main/opencode/opencode-model-availability.test.ts +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { probeOpenCodeModelAvailability, resolveOpenCodeDirectModelExecutable } from './opencode-model-availability' -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) const options = { command: '/private/opencode', diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts index ce507de9969..b732a7806bd 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-availability.ts @@ -1,5 +1,5 @@ import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' export async function resolveOpenCodeDirectModelExecutable(options: { diff --git a/src/main/opencode/opencode-wsl-probe-environment.test.ts b/src/main/opencode/opencode-wsl-probe-environment.test.ts index 864321c6001..1bb6fa03eb3 100644 --- a/src/main/opencode/opencode-wsl-probe-environment.test.ts +++ b/src/main/opencode/opencode-wsl-probe-environment.test.ts @@ -3,7 +3,7 @@ import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' import { seedWslGuestEnvironmentForTests } from '../wsl/wsl-guest-environment' const run = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: run })) +vi.mock('@orca/process-host', () => ({ runProcess: run })) vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) vi.mock('../agent-hooks/server', () => ({ agentHookServer: { endpointFilePath: null } })) diff --git a/src/main/orca-profiles/profile-project-domain-transfer.test.ts b/src/main/orca-profiles/profile-project-domain-transfer.test.ts index 00ceb64455e..34d0fd40e7d 100644 --- a/src/main/orca-profiles/profile-project-domain-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { buildSync } from 'esbuild' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getDefaultPersistedState } from '../../shared/constants' import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' import type { Repo } from '../../shared/repo-types' diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index b92d6150dfd..10f0f413236 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -1,17 +1,20 @@ +import type { ProcessSpec } from '@orca/process-host/process-spec' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcessModule from '../../shared/child-process/run-process' +import type * as RunProcessModule from '@orca/process-host' import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' -const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn() })) +const { spawnProcessMock } = vi.hoisted(() => ({ + spawnProcessMock: vi.fn<(spec: ProcessSpec) => ReturnType>() +})) // Why wrap rather than fake: the provider must still go through the real spawn // path (argv/env handling, a real pid to signal); only the program is swapped // for a stub that speaks the sidecar's serve protocol. -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal()), spawnProcess: spawnProcessMock })) @@ -91,8 +94,8 @@ async function startProvider(): Promise { return processHandle } -function spawnSpec(): RunProcessModule.ProcessSpec { - return spawnProcessMock.mock.calls[0][0] as RunProcessModule.ProcessSpec +function spawnSpec(): ProcessSpec { + return spawnProcessMock.mock.calls[0][0] } beforeEach(async () => { @@ -103,11 +106,9 @@ beforeEach(async () => { started = [] await writeFile(logPath, '') await setControl({}) - const actual = await vi.importActual( - '../../shared/child-process/run-process' - ) + const actual = await vi.importActual('@orca/process-host') spawnProcessMock.mockReset() - spawnProcessMock.mockImplementation((spec: RunProcessModule.ProcessSpec) => + spawnProcessMock.mockImplementation((spec: ProcessSpec) => actual.spawnProcess({ ...spec, program: process.execPath, diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index 10cbf5bfff3..59b7ac813f7 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -12,7 +12,7 @@ import type { import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' import { readRuntimeMetadata } from '../runtime/runtime-metadata' -import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client' import { ElectronSidecarTabRegistry, @@ -97,7 +97,7 @@ function processIsLive(pid: number): boolean { } export class ElectronServeBrowserProcess { - private child: SpawnedProcess | null = null + private child: ReturnType | null = null private metadata: RuntimeMetadata | null = null private readonly tabs = new ElectronSidecarTabRegistry() private sidecarDataPath: string | null = null @@ -237,10 +237,7 @@ export class ElectronServeBrowserProcess { this.tabs.require(requestedPageId, worktreeId) return { browserPageId: requestedPageId } } - // Why drop `page`: the runtime advertises browser.tabCreate.known-id.v1, so web - // clients send a provisional id for a page that does not exist yet. The sidecar - // mints its own id and the caller's is adopted as the public one below; passing - // the unknown id through would make the generic branch require() a missing page. + // The sidecar mints its own tab id; adopt the caller's provisional id after creating it. delete params.page } diff --git a/src/main/orcad/electron-serve-provider-selection.test.ts b/src/main/orcad/electron-serve-provider-selection.test.ts index 663802a45c5..0f43da3dbdc 100644 --- a/src/main/orcad/electron-serve-provider-selection.test.ts +++ b/src/main/orcad/electron-serve-provider-selection.test.ts @@ -1,15 +1,16 @@ +import type { ProcessSpec } from '@orca/process-host/process-spec' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcessModule from '../../shared/child-process/run-process' +import type * as RunProcessModule from '@orca/process-host' const { runProcessMock, spawnProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn(), spawnProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal()), runProcess: runProcessMock, spawnProcess: spawnProcessMock @@ -57,11 +58,9 @@ beforeEach(async () => { await chmod(chromiumExecutable, 0o755) } - const actual = await vi.importActual( - '../../shared/child-process/run-process' - ) + const actual = await vi.importActual('@orca/process-host') spawnProcessMock.mockReset() - spawnProcessMock.mockImplementation((spec: RunProcessModule.ProcessSpec) => + spawnProcessMock.mockImplementation((spec: ProcessSpec) => actual.spawnProcess({ ...spec, program: process.execPath, @@ -74,7 +73,7 @@ beforeEach(async () => { }) ) runProcessMock.mockReset() - runProcessMock.mockImplementation(async (spec: RunProcessModule.ProcessSpec) => { + runProcessMock.mockImplementation(async (spec: ProcessSpec) => { const command = commandFromArgs(spec.args ?? []) if (command[0] === 'open') { return agentBrowserSuccess({ url: 'about:blank', title: '' }) diff --git a/src/main/orcad/external-chromium-browser-session.test.ts b/src/main/orcad/external-chromium-browser-session.test.ts index 989d3f6f217..c8a0f4412da 100644 --- a/src/main/orcad/external-chromium-browser-session.test.ts +++ b/src/main/orcad/external-chromium-browser-session.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir } from 'node:fs/promises' const runProcessMock = vi.fn<(spec: Spec) => Promise>() -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (spec: Spec) => runProcessMock(spec) })) vi.mock('node:fs/promises', () => ({ diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts index 56c7bd47e78..962c33cb60a 100644 --- a/src/main/orcad/external-chromium-browser-session.ts +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { z } from 'zod' import { BrowserError } from '../browser/browser-error' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const COMMAND_TIMEOUT_MS = 90_000 const MAX_OUTPUT_BYTES = 50 * 1024 * 1024 diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index e6c9f19fb68..47e25b6deb3 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -18,7 +18,8 @@ import { existsSync, accessSync, constants } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' -import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' import { diff --git a/src/main/orcad/orcad-browser-provider.test.ts b/src/main/orcad/orcad-browser-provider.test.ts index c1828ad0dd2..47a75e871a6 100644 --- a/src/main/orcad/orcad-browser-provider.test.ts +++ b/src/main/orcad/orcad-browser-provider.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' import { installedElectronCandidates, resolveOrcadBrowserProvider } from './orcad-browser-provider' import { orcadAgentBrowserNativeName } from './orcad-agent-browser-binary' @@ -11,7 +11,7 @@ import { setRuntimeBrowserUnavailableCause } from '../runtime/runtime-browser-commands-factory' -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), spawnProcess: vi.fn() })) diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts index 5ed9085fa36..f0ba342c9c5 100644 --- a/src/main/orcad/orcad-bundle-native-load-order.test.ts +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const REPO_ROOT = join(__dirname, '..', '..', '..') const directories: string[] = [] diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts index 07c13f0f3e7..fa794db736d 100644 --- a/src/main/orcad/orcad-bundled-runtime.integration.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.integration.test.ts @@ -3,7 +3,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' import { shellEscape } from '../ssh/ssh-connection-utils' @@ -99,6 +99,9 @@ function launch( detached: true }) children.add(child) + if (!child.stdout || !child.stderr) { + throw new Error('The runtime fixture requires stdout and stderr pipes') + } let output = '' child.stdout.on('data', (data: Buffer) => { output += data.toString() diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index 3cbfa8546c3..357d0efd1c6 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -30,7 +30,7 @@ vi.mock('node:fs', () => ({ readFileSync: fixture.read, realpathSync: fixture.realpath })) -vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn })) +vi.mock('@orca/process-host', () => ({ spawnProcess: fixture.spawn })) class RuntimeChild extends EventEmitter { kill = vi.fn() diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 927018ac37b..c6d1f58df75 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { constants } from 'node:os' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { resolveOrcadInstallRoot } from './orcad-app-paths' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 00c4c9e5b35..3df5044d458 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' diff --git a/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts b/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts index 8e3e2d3be9a..2c68365bb07 100644 --- a/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts +++ b/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts @@ -18,7 +18,7 @@ import { } from 'node:fs' import { join, resolve } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { ORCAD_BUILD_TARGET_FILENAME, diff --git a/src/main/orcad/orcad-daemon-teardown-fixture.ts b/src/main/orcad/orcad-daemon-teardown-fixture.ts index 41798ac2ac9..ac524676de0 100644 --- a/src/main/orcad/orcad-daemon-teardown-fixture.ts +++ b/src/main/orcad/orcad-daemon-teardown-fixture.ts @@ -9,7 +9,7 @@ import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { readDaemonPidRecord } from '../daemon/daemon-endpoint-incarnation' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { spawnProcess } from '@orca/process-host' const EXIT_WAIT_MS = 10_000 const POLL_MS = 50 diff --git a/src/main/orcad/orcad-help.integration.test.ts b/src/main/orcad/orcad-help.integration.test.ts index 8c7330e7e07..f0f98a96f08 100644 --- a/src/main/orcad/orcad-help.integration.test.ts +++ b/src/main/orcad/orcad-help.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' let directory = '' diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts index f52fe00b36a..361a3de7a16 100644 --- a/src/main/orcad/orcad-launcher-isolation.integration.test.ts +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { hashOrcadLauncher } from '../../shared/orcad-build-identity' diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index 49e5f2d2bc8..bbc0bb54f3f 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -11,7 +11,7 @@ */ import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' import { ORCAD_SERVER_ENTRY_FILENAME, diff --git a/src/main/orcad/orcad-node-launcher.integration.test.ts b/src/main/orcad/orcad-node-launcher.integration.test.ts index c91bad85ca7..0306229bf77 100644 --- a/src/main/orcad/orcad-node-launcher.integration.test.ts +++ b/src/main/orcad/orcad-node-launcher.integration.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { @@ -150,6 +150,9 @@ describe.skipIf(skip)('real pinned-Node launcher lifecycle', () => { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }) children.add(child) + if (!child.stdout || !child.stderr) { + throw new Error('The launcher fixture requires stdout and stderr pipes') + } child.once('spawn', () => record('spawn')) let closed = false child.once('close', () => { diff --git a/src/main/orcad/orcad-node-slot-fixture.ts b/src/main/orcad/orcad-node-slot-fixture.ts index 73fa3c9c944..0b02950ff58 100644 --- a/src/main/orcad/orcad-node-slot-fixture.ts +++ b/src/main/orcad/orcad-node-slot-fixture.ts @@ -5,7 +5,7 @@ import { createRequire } from 'node:module' import type * as NodePty from 'node-pty' import { join, resolve } from 'node:path' import { it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' import { ORCAD_NODE_PTY_DIR, diff --git a/src/main/orcad/orcad-packaged-node-pty.integration.test.ts b/src/main/orcad/orcad-packaged-node-pty.integration.test.ts index b10bf68b9b6..e0cddbddd08 100644 --- a/src/main/orcad/orcad-packaged-node-pty.integration.test.ts +++ b/src/main/orcad/orcad-packaged-node-pty.integration.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, symlink, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { ORCAD_NODE_PTY_DIR } from '../../shared/orcad-artifacts' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 6d6d1187ed5..d895822898c 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' @@ -29,7 +30,7 @@ vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ vi.mock('./orcad-runtime-native-preflight', () => ({ preflightOrcadNativeRuntime: fixture.native })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: fixture.run })) +vi.mock('@orca/process-host', () => ({ runProcess: fixture.run })) const identity = '0.1.0+aaaaaaaaaaaa' const nonce = '743bf9c8-2e58-4c79-a0ac-52c8d3e8e103' diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index cd66fda5a00..f4871f125d6 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -13,7 +13,7 @@ import { import { readOrcadArtifactIdentity } from './orcad-artifact-identity' import { ORCAD_SERVER_ENTRY_FILENAME, ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' import { isRunningAsBundledOrcadRuntime, diff --git a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts index b9421483294..bd7322040f7 100644 --- a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts +++ b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { orcadArtifactFilenames } from '../../shared/orcad-artifacts' const REPO_ROOT = join(__dirname, '..', '..', '..') diff --git a/src/main/orcad/orcad-serve-parity.integration.test.ts b/src/main/orcad/orcad-serve-parity.integration.test.ts index 99ac6746dfa..e59beb8d123 100644 --- a/src/main/orcad/orcad-serve-parity.integration.test.ts +++ b/src/main/orcad/orcad-serve-parity.integration.test.ts @@ -7,7 +7,7 @@ import { existsSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir, userInfo } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_NATIVE_PREFLIGHT_FLAG, diff --git a/src/main/orcad/orcad-stop-request-shutdown.integration.test.ts b/src/main/orcad/orcad-stop-request-shutdown.integration.test.ts index bf0aed4f78f..98857ff3c7f 100644 --- a/src/main/orcad/orcad-stop-request-shutdown.integration.test.ts +++ b/src/main/orcad/orcad-stop-request-shutdown.integration.test.ts @@ -10,7 +10,7 @@ import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' import { diff --git a/src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts b/src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts index 77164eb2cac..0822a8be897 100644 --- a/src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts +++ b/src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts @@ -10,7 +10,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { quoteWindowsArgument } from '../../shared/child-process/windows-command-line' +import { quoteWindowsArgument } from '@orca/process-host/windows-command-line' import { ORCAD_NODE_PTY_DIR, ORCAD_WINDOWS_PROCESS_TREE_FILENAME diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index 5bfad815631..1b8a2c3b96d 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -18,7 +18,7 @@ import { installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard' import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' -import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' +import { setProcessTreeKillGate } from '@orca/process-host/process-tree-kill-gate' import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log' import { clearCrashBreadcrumbsForTest, diff --git a/src/main/own-chromium-tree-kill-guard.ts b/src/main/own-chromium-tree-kill-guard.ts index b594912e317..6f1791d1e3d 100644 --- a/src/main/own-chromium-tree-kill-guard.ts +++ b/src/main/own-chromium-tree-kill-guard.ts @@ -4,7 +4,7 @@ import { type SelfInitiatedTreeKillScope } from './crash-reporting/self-initiated-tree-kill-log' import { readOrcaChromiumProcessPids } from './orca-chromium-process-pids' -import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' +import { setProcessTreeKillGate } from '@orca/process-host/process-tree-kill-gate' /** * Gate every main-process tree-kill through one decision: refuse a pid-addressed diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts index a36f220923b..427c4f291ed 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { runProcessSync } from '../../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { parseLinuxProcStartTicks } from '../../daemon/daemon-process-start-time' import { getPsProcessIdentity } from '../../daemon/daemon-process-identity-query' import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../../windows-native-registry' diff --git a/src/main/persistence/profile-state/profile-state-access-process.test.ts b/src/main/persistence/profile-state/profile-state-access-process.test.ts index eef48007bf1..4188cabc625 100644 --- a/src/main/persistence/profile-state/profile-state-access-process.test.ts +++ b/src/main/persistence/profile-state/profile-state-access-process.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { buildSync } from 'esbuild' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' -import { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import { stagePackagedProcessHost } from '../../../../config/scripts/packaged-process-host-fixture.mjs' import { acquireProfileStateMaintenance, acquireProfileStateRuntimeAdmission @@ -15,7 +16,7 @@ const fixture = mkdtempSync(join(tmpdir(), 'orca-state-access-process-')) const bundle = join(fixture, 'access.cjs') const children = new Set>() -beforeAll(() => { +beforeAll(async () => { buildSync({ entryPoints: [resolve(__dirname, 'profile-state-access.ts')], outfile: bundle, @@ -24,6 +25,7 @@ beforeAll(() => { format: 'cjs', packages: 'external' }) + await stagePackagedProcessHost(fixture) }) afterEach(async () => { @@ -71,7 +73,7 @@ async function startChild(root: string, mode: string): Promise { - bundle = buildRecoveryCrashProcess(suite) + bundle = buildRecoveryCrashProcess(suite).bundle }) afterEach(() => { for (const root of roots.splice(0)) { diff --git a/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts index 82e4ccf708e..7a192915968 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts @@ -17,7 +17,7 @@ import type * as FileSystem from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as processes from '../../../shared/child-process/run-process' +import * as processes from '@orca/process-host' import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts index d7bc8796be2..243dd182741 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts @@ -17,7 +17,7 @@ import type * as FileSystem from 'node:fs' import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as processes from '../../../shared/child-process/run-process' +import * as processes from '@orca/process-host' import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' const cloneLink = vi.hoisted(() => ({ unsupported: false })) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts index cb7a1f9315b..e5b09754862 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-copy.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -1,6 +1,6 @@ import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' -import { runProcessSync } from '../../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' // Keep process startup overhead off small recovery copies. const MINIMUM_CLONE_BYTES = 8 * 1024 * 1024 diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts index 0beae73fdb1..2e89c0279d2 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -90,7 +90,7 @@ const selectedJson = JSON.stringify(selectedState) const oldJson = JSON.stringify(oldState) beforeAll(() => { - bundle = buildRecoveryCrashProcess(suiteRoot) + bundle = buildRecoveryCrashProcess(suiteRoot).bundle }) beforeEach(() => { setSecretStore({ diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.test.ts new file mode 100644 index 00000000000..93ecec15a29 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.test.ts @@ -0,0 +1,25 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { buildRecoveryCrashProcess } from './profile-state-recovery-crash-process' + +it('bundles one process-host source graph into the isolated recovery fixture', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-recovery-bundle-')) + try { + const { metafile } = buildRecoveryCrashProcess(root) + const packageInputs = Object.keys(metafile.inputs).filter((file) => + file.includes('/process-host/') + ) + expect(packageInputs).toContain('src/packages/process-host/src/run-process.ts') + expect(packageInputs.every((file) => file.startsWith('src/packages/process-host/src/'))).toBe( + true + ) + const packageExternals = Object.values(metafile.outputs) + .flatMap((output) => output.imports) + .filter((entry) => entry.external && entry.path.startsWith('@orca/process-host')) + expect(packageExternals).toEqual([]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts index 19c5f9be730..02f05ac348f 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -1,8 +1,9 @@ import { once } from 'node:events' import { writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { buildSync } from 'esbuild' -import { spawnProcess } from '../../../shared/child-process/run-process' +import { buildSync, type Metafile } from 'esbuild' +import { spawnProcess } from '@orca/process-host' +import { createWorkspaceSourceResolver } from '../../../../config/scripts/workspace-source-exports.mjs' export type RecoveryCrashOptions = { root: string @@ -16,9 +17,12 @@ export type RecoveryCrashOptions = { } /** Build only the recovery graph into an isolated test directory, never shared out/. */ -export function buildRecoveryCrashProcess(directory: string): string { +export function buildRecoveryCrashProcess(directory: string): { + bundle: string + metafile: Metafile +} { const bundle = join(directory, 'recovery-crash-api.cjs') - buildSync({ + const built = buildSync({ stdin: { contents: ` export { acquireProfileStateMaintenance } from './src/main/persistence/profile-state/profile-state-access' @@ -35,9 +39,11 @@ export function buildRecoveryCrashProcess(directory: string): string { bundle: true, platform: 'node', format: 'cjs', - packages: 'external' + packages: 'external', + alias: createWorkspaceSourceResolver().esbuildAliases, + metafile: true }) - return bundle + return { bundle, metafile: built.metafile } } const CHILD_SOURCE = ` diff --git a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts index 51f2399c674..779b90069fa 100644 --- a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts @@ -4,7 +4,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeAll, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveElectronProbeLaunch } from '../../browser/electron-probe-display-launch' const root = mkdtempSync(join(tmpdir(), 'orca-writer-electron-')) diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts index b993c63db6a..0fdd15aaa4a 100644 --- a/src/main/pi/rpc-runtime-registration.test.ts +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -2,10 +2,10 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) type LaunchInput = Parameters>[0] function supportsLaunch(launch: LaunchInput): Promise { diff --git a/src/main/provider-process/managed-provider-process-fallback-tree.test.ts b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts index 27369311656..f438d887c08 100644 --- a/src/main/provider-process/managed-provider-process-fallback-tree.test.ts +++ b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts @@ -1,7 +1,7 @@ -import { EventEmitter } from 'node:events' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import type { DescendantSnapshot } from '../pty-descendant-termination' import { spawnManagedProviderProcess } from './managed-provider-process' @@ -26,7 +26,7 @@ afterEach(() => { }) function rootOnly(platform: NodeJS.Platform) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: 4242, stdin: new PassThrough(), stdout: new PassThrough(), @@ -39,8 +39,7 @@ function rootOnly(platform: NodeJS.Platform) { return true }) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. - const spawnImpl = (() => child) as unknown as typeof spawnProcess + const spawnImpl: PipedProcessSpawner = () => child return spawnManagedProviderProcess( { command: 'fixture-provider', args: [] }, { spawnImpl, platform, site: 'fixture-provider-teardown' } diff --git a/src/main/provider-process/managed-provider-process-grace.test.ts b/src/main/provider-process/managed-provider-process-grace.test.ts index de61b9505cd..147ab7fdcf5 100644 --- a/src/main/provider-process/managed-provider-process-grace.test.ts +++ b/src/main/provider-process/managed-provider-process-grace.test.ts @@ -1,21 +1,20 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { spawnManagedProviderProcess } from './managed-provider-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' function fixture() { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: 9_999_999, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) - const spawn = vi.fn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies all process fields the managed lifecycle consumes. - return child as unknown as ReturnType + const spawn = vi.fn(() => { + return child }) return { child, spawn } } diff --git a/src/main/provider-process/managed-provider-process-output.test.ts b/src/main/provider-process/managed-provider-process-output.test.ts index 1973d83918d..8a140eec29e 100644 --- a/src/main/provider-process/managed-provider-process-output.test.ts +++ b/src/main/provider-process/managed-provider-process-output.test.ts @@ -1,11 +1,10 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { spawnManagedProviderProcess } from './managed-provider-process' function launchWithOutput(onOutput: () => void) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: 9_999_999, stdin: new PassThrough(), stdout: new PassThrough(), @@ -15,8 +14,7 @@ function launchWithOutput(onOutput: () => void) { const managed = spawnManagedProviderProcess( { command: 'fixture-provider', args: [] }, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. - spawnImpl: () => child as unknown as ReturnType, + spawnImpl: () => child, platform: 'win32', site: 'fixture-provider-teardown', onOutput diff --git a/src/main/provider-process/managed-provider-process-root-only.test.ts b/src/main/provider-process/managed-provider-process-root-only.test.ts index bb3f407c7dc..650cd22f809 100644 --- a/src/main/provider-process/managed-provider-process-root-only.test.ts +++ b/src/main/provider-process/managed-provider-process-root-only.test.ts @@ -1,7 +1,7 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { spawnManagedProviderProcess } from './managed-provider-process' import { ROOT_ONLY_GRACEFUL_EXIT_MS } from './provider-process-close' import type { ProviderProcessTeardownVerdict } from './provider-process-teardown' @@ -19,16 +19,15 @@ afterEach(() => { }) function fakeChild(pid: number | null = 9_999_999) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: pid ?? undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) - const spawn = vi.fn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. - return child as unknown as ReturnType + const spawn = vi.fn(() => { + return child }) return { child, spawn } } diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index 483d493a2bc..9ef1c7e800f 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -1,7 +1,7 @@ -import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { PipedProcessSpawner } from '@orca/process-host/process-spec' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' import { PROVIDER_SPAWN_FAILURE_MARKER } from './provider-spawn-failure-report' import { spawnManagedProviderProcess } from './managed-provider-process' @@ -21,16 +21,15 @@ afterEach(() => { }) function fakeChild(pid: number | null = 9_999_999) { - const child = Object.assign(new EventEmitter(), { + const child = Object.assign(createFakePipedChild(), { pid: pid ?? undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) - const spawn = vi.fn(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. - return child as unknown as ReturnType + const spawn = vi.fn(() => { + return child }) return { child, spawn } } diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index 31a869202a0..c37783f1bb3 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -1,5 +1,6 @@ -import { spawnProcess } from '../../shared/child-process/run-process' -import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' +import { spawnProcess } from '@orca/process-host' +import { RetryableProcessExitProof } from '@orca/process-host/retryable-process-exit-proof' import type { ProviderProcessLaunch } from './provider-process-launch' import { PROVIDER_SUPERVISOR_MAX_STOP_MS, @@ -34,7 +35,7 @@ type ManagedProviderProcessOptions = { site: string /** Defaults to the root-only policy; only a provider with its own reaper overrides it. */ policy?: (supervised: boolean) => ProviderProcessClosePolicy - spawnImpl?: typeof spawnProcess + spawnImpl?: PipedProcessSpawner platform?: NodeJS.Platform inheritedEnv?: NodeJS.ProcessEnv /** Defaults to "the root is gone". */ @@ -46,7 +47,7 @@ type ManagedProviderProcessOptions = { } export type ManagedProviderProcess = { - child: ReturnType + child: PipedChildProcess supervised: boolean /** The spawn failed before a process existed: absence is proven, but no exit was observed. */ readonly processless: boolean diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index 24347bea933..4d3f356e42d 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -1,4 +1,4 @@ -import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { SpawnedProcess } from '@orca/process-host/process-spec' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { waitForProcessExitUntil } from './provider-process-exit-deadline' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index ef414d2b4c3..32820726fee 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/run-process' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' import { terminateDescendantSnapshotWithVerdict, diff --git a/src/main/provider-process/supervised-child-process-stop.ts b/src/main/provider-process/supervised-child-process-stop.ts index d87c808a1e9..1954edc45a2 100644 --- a/src/main/provider-process/supervised-child-process-stop.ts +++ b/src/main/provider-process/supervised-child-process-stop.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { closeProviderProcess, rootOnlyProviderClosePolicy, diff --git a/src/main/providers/local-filesystem-provider.ts b/src/main/providers/local-filesystem-provider.ts index 89c88673046..18f0e15f02e 100644 --- a/src/main/providers/local-filesystem-provider.ts +++ b/src/main/providers/local-filesystem-provider.ts @@ -1,7 +1,7 @@ import { constants, copyFile, lstat, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { readAuthorizedDocPreviewFile } from '../../shared/doc-preview-file-access' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' import type { resolveAuthorizedPath as ResolveAuthorizedPath } from '../ipc/filesystem-auth' diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 585c9eb9d38..06b2322e7b4 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -13,7 +13,7 @@ vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) vi.mock('node:os', () => ({ userInfo: userInfoMock })) // Why mock the chokepoint: encoding, kill signal, output cap and closing stdin // are its contract now, so this suite asserts the PAM probe's argv and verdict. -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (...args: unknown[]) => Promise.resolve(runProcessMock(...args)) })) vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({ diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 51c42e23a0a..a0e26200ec7 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { existsSync } from 'node:fs' import { userInfo } from 'node:os' import { basename } from 'node:path' diff --git a/src/main/providers/ssh-review-draft-context.test.ts b/src/main/providers/ssh-review-draft-context.test.ts index 9703d5fc71f..9fb0d7359a1 100644 --- a/src/main/providers/ssh-review-draft-context.test.ts +++ b/src/main/providers/ssh-review-draft-context.test.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ReviewDraftContextError } from '../../shared/review-draft-context-error' import { RelayContext } from '../../relay/context' import { RelayDispatcher } from '../../relay/dispatcher' diff --git a/src/main/pty/codex-no-daemon-launch-command.ts b/src/main/pty/codex-no-daemon-launch-command.ts index eec86055568..55e1c162f8f 100644 --- a/src/main/pty/codex-no-daemon-launch-command.ts +++ b/src/main/pty/codex-no-daemon-launch-command.ts @@ -1,5 +1,5 @@ import { isAbsolute, win32 as pathWin32 } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' diff --git a/src/main/pty/posix-pty-foreground-group.ts b/src/main/pty/posix-pty-foreground-group.ts index e558f9ce156..cbee3204bff 100644 --- a/src/main/pty/posix-pty-foreground-group.ts +++ b/src/main/pty/posix-pty-foreground-group.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { notifySpawnObserver } from '../../shared/child-process/spawn-observer' +import { notifySpawnObserver } from '@orca/process-host/spawn-observer' const PROCESS_TABLE_LOOKUP_TIMEOUT_MS = 250 const PROCESS_TABLE_QUERY_TIMEOUT_MS = PROCESS_TABLE_LOOKUP_TIMEOUT_MS / 2 diff --git a/src/main/pty/posix-pty-process-groups.test.ts b/src/main/pty/posix-pty-process-groups.test.ts index f0b3727c6ee..edddd0aebd0 100644 --- a/src/main/pty/posix-pty-process-groups.test.ts +++ b/src/main/pty/posix-pty-process-groups.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult } from '../../shared/child-process/run-process' + +import type { ProcessResult } from '@orca/process-host/process-spec' const { recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ recordSelfInitiatedTreeKillMock: vi.fn(), @@ -9,7 +10,7 @@ const { recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ recordSelfInitiatedTreeKill: recordSelfInitiatedTreeKillMock })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock, runProcessSync: runProcessSyncMock })) diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index 2607875b269..cfe7485fdd4 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -1,11 +1,8 @@ import { hasControllingTty } from '../../shared/posix-controlling-terminal' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { - runProcess, - runProcessSync, - type ProcessResult -} from '../../shared/child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' const PROCESS_TABLE_TIMEOUT_MS = 1_000 const PROCESS_TABLE_MAX_BYTES = 1024 * 1024 diff --git a/src/main/pwsh.test.ts b/src/main/pwsh.test.ts index 150229894b7..79e21efb987 100644 --- a/src/main/pwsh.test.ts +++ b/src/main/pwsh.test.ts @@ -9,7 +9,7 @@ const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ // by hand -- execFileSync's ETIMEDOUT versus execFile's SIGTERM-with-no-code -- // are now one `timedOut` flag, so the suite states the outcome rather than the // spawn mechanism that produced it. -vi.mock('../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock, runProcessSync: runProcessSyncMock })) diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index 14f77f4d826..e74d6ea14b9 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -1,4 +1,4 @@ -import { runProcess, runProcessSync } from '../shared/child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000 const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000 diff --git a/src/main/qoder/real-cli.test.ts b/src/main/qoder/real-cli.test.ts index 5048c14bef0..97ebf7dd3b3 100644 --- a/src/main/qoder/real-cli.test.ts +++ b/src/main/qoder/real-cli.test.ts @@ -14,7 +14,7 @@ import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { makePaneKey } from '../../shared/stable-pane-id' import { parseQoderSessionFile } from '../ai-vault/session-scanner-qoder-parser' diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 911861a9265..ca7ef170d5b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -4,7 +4,7 @@ import { resetAntigravityUsageSupportForTests } from './antigravity-usage-fetcher' import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command' -import type { ProcessResult } from '../../shared/child-process/process-spec' +import type { ProcessResult } from '@orca/process-host/process-spec' const USAGE_ENVELOPE = JSON.stringify({ conversation_id: '', diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 2097989e062..2980d9b9603 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { hasReachedAppVersion, parseCliVersion } from '../../shared/app-version' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' diff --git a/src/main/rate-limits/antigravity-usage-real-cli.test.ts b/src/main/rate-limits/antigravity-usage-real-cli.test.ts index 6ea1c4e621d..38bdae3b624 100644 --- a/src/main/rate-limits/antigravity-usage-real-cli.test.ts +++ b/src/main/rate-limits/antigravity-usage-real-cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { stdoutShowsModelTurn } from './antigravity-usage-response' import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts index a87ddf4c929..e718eb5a453 100644 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -24,7 +24,7 @@ const runningDistros = vi.hoisted(() => ({ vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: runningDistros.filter })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ spawnProcess: () => { throw new Error('Usage must not launch a process') }, diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index 0fd1f5f2c1e..e1449ac09bd 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '../../shared/child-process/run-process' +import type * as RunProcess from '@orca/process-host' const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ stubScript: { path: '' }, @@ -11,7 +11,7 @@ const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ( })) // Runs the node stub in place of the resolved CLI, through the real chokepoint. -vi.mock('../../shared/child-process/run-process', async (importOriginal) => { +vi.mock('@orca/process-host', async (importOriginal) => { const actual = await importOriginal() return { ...actual, diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts index 3231d71876d..12d74f5a5bb 100644 --- a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '../../shared/child-process/run-process' +import type * as RunProcess from '@orca/process-host' import { isCodexAuthError } from '../../shared/codex-auth-errors' const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ @@ -12,7 +12,7 @@ const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ( })) // Runs the node stub in place of the resolved CLI, through the real chokepoint. -vi.mock('../../shared/child-process/run-process', async (importOriginal) => { +vi.mock('@orca/process-host', async (importOriginal) => { const actual = await importOriginal() return { ...actual, diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts index ee23ead1966..3414d2b609d 100644 --- a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '../../shared/child-process/run-process' +import type * as RunProcess from '@orca/process-host' const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -14,7 +14,7 @@ const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) // The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal()), spawnProcess: (spec: { program: string; args?: readonly string[] }) => childSpawnMock(spec.program, spec.args ?? [], spec) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index ec22103901b..d700b8c36d4 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '../../shared/child-process/run-process' +import type * as RunProcess from '@orca/process-host' const { childSpawnMock, @@ -24,7 +24,7 @@ vi.mock('node:child_process', () => ({ })) // The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal()), spawnProcess: (spec: { program: string; args?: readonly string[] }) => childSpawnMock(spec.program, spec.args ?? [], spec) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index e10202fd430..3fca3d394a1 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -13,7 +13,7 @@ import { } from '../codex-cli/codex-home-process-lock' import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { probeCodexAuthPresence } from './codex-auth-presence' import { fetchCodexRateLimitsViaBackend, diff --git a/src/main/refused-tree-kill-root-termination.test.ts b/src/main/refused-tree-kill-root-termination.test.ts index f6bb531165a..fa2233e18d3 100644 --- a/src/main/refused-tree-kill-root-termination.test.ts +++ b/src/main/refused-tree-kill-root-termination.test.ts @@ -19,7 +19,7 @@ import { type AppEnvironment } from '../shared/app-environment' import { installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard' -import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' +import { setProcessTreeKillGate } from '@orca/process-host/process-tree-kill-gate' import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log' import { clearCrashBreadcrumbsForTest, @@ -30,7 +30,7 @@ import { killLocalPrecheckProcessTree } from './automations/precheck-runner' import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process' import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill' import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' -import { signalProcessTree } from '../shared/child-process/process-tree-termination' +import { signalProcessTree } from '@orca/process-host/process-tree-termination' import { killSourceControlAgentProcess } from './text-generation/source-control-local-process' /** A pid Electron reports as one of ours: every gate below must refuse it. */ diff --git a/src/main/ripgrep/bundled-ripgrep-path.test.ts b/src/main/ripgrep/bundled-ripgrep-path.test.ts index 6146d715749..6e0b95be8c5 100644 --- a/src/main/ripgrep/bundled-ripgrep-path.test.ts +++ b/src/main/ripgrep/bundled-ripgrep-path.test.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { toBundledRipgrepPlatform } from '../../shared/bundled-ripgrep' import { bundledRipgrepCommand, diff --git a/src/main/ripgrep/bundled-ripgrep-stop.test.ts b/src/main/ripgrep/bundled-ripgrep-stop.test.ts index cd194632731..e2d6628956c 100644 --- a/src/main/ripgrep/bundled-ripgrep-stop.test.ts +++ b/src/main/ripgrep/bundled-ripgrep-stop.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { stopBundledRipgrep } from './bundled-ripgrep-stop' const { signalTree, killChild } = vi.hoisted(() => ({ signalTree: vi.fn(), killChild: vi.fn() })) -vi.mock('../../shared/child-process/process-tree-termination', () => ({ +vi.mock('@orca/process-host/process-tree-termination', () => ({ signalProcessTree: signalTree })) vi.mock('../../shared/ripgrep-process-availability', () => ({ diff --git a/src/main/ripgrep/bundled-ripgrep-stop.ts b/src/main/ripgrep/bundled-ripgrep-stop.ts index 071691fb820..f3cdb6df963 100644 --- a/src/main/ripgrep/bundled-ripgrep-stop.ts +++ b/src/main/ripgrep/bundled-ripgrep-stop.ts @@ -1,6 +1,6 @@ -import { signalProcessTree } from '../../shared/child-process/process-tree-termination' +import { signalProcessTree } from '@orca/process-host/process-tree-termination' import { killSpawnedRipgrepProcess } from '../../shared/ripgrep-process-availability' -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' const stoppingChildren = new WeakSet() diff --git a/src/main/ripgrep/bundled-ripgrep-text-search.ts b/src/main/ripgrep/bundled-ripgrep-text-search.ts index 46804d1d2d1..56fd4393b2c 100644 --- a/src/main/ripgrep/bundled-ripgrep-text-search.ts +++ b/src/main/ripgrep/bundled-ripgrep-text-search.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import { RipgrepSearchDiagnostics } from '../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 2c7992af6f5..1caebf25e29 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -14,7 +14,7 @@ import type { AgentSessionProcessIdentityField } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh diff --git a/src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts b/src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts index 60b32545acd..268c928c0c8 100644 --- a/src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts +++ b/src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts @@ -10,7 +10,7 @@ import { STATUS_METHODS } from './status' // case needs a process whose first lookup is still running when the publish budget runs out. // Why mocked: the friendly-name lookup is the one path that spawns `scutil`; the test decides when it lands. const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, diff --git a/src/main/runtime/rpc/methods/status.test.ts b/src/main/runtime/rpc/methods/status.test.ts index b363bd6e628..7b15975c3bc 100644 --- a/src/main/runtime/rpc/methods/status.test.ts +++ b/src/main/runtime/rpc/methods/status.test.ts @@ -7,7 +7,7 @@ import { STATUS_METHODS } from './status' // Why mocked: the friendly-name lookup is the one path that spawns `scutil`; the test decides when it lands. const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, diff --git a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts index b5589aa7724..edce8592523 100644 --- a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts +++ b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts @@ -6,8 +6,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebSocket } from 'ws' -import type { ProcessResult, ProcessSpec } from '../../../shared/child-process/run-process' -import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +import { runProcess, runProcessSync } from '@orca/process-host' import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files' import { DeviceRegistry, type DeviceEntry } from '../device-registry' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './e2ee-crypto' @@ -16,7 +17,7 @@ import { MobileSocketWiring, type MobileSocketTransport } from './mobile-socket- // Why this module and not `node:child_process`: hardening reaches the OS only through // runProcess/runProcessSync, and a hand-written child_process factory silently omitted the one // function they call — so every spawn threw, hardening no-opped, and the test double hid it. -vi.mock('../../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), runProcessSync: vi.fn() })) diff --git a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts index e4fbfa0b6dd..99312a0b059 100644 --- a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts +++ b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split class members. -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' import { MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, diff --git a/src/main/runtime/runtime-machine-name.test.ts b/src/main/runtime/runtime-machine-name.test.ts index cad46279c9c..b5865002914 100644 --- a/src/main/runtime/runtime-machine-name.test.ts +++ b/src/main/runtime/runtime-machine-name.test.ts @@ -7,7 +7,7 @@ import { detectRuntimeMachineName, type RuntimeMachineName } from './runtime-mac // loaded macOS runner can hit the lookup timeout and answer with the hostname while a second live // spawn does not, which is a flake and not a verdict. const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! diff --git a/src/main/runtime/runtime-machine-name.ts b/src/main/runtime/runtime-machine-name.ts index 53768e3e502..9fbf2812ac9 100644 --- a/src/main/runtime/runtime-machine-name.ts +++ b/src/main/runtime/runtime-machine-name.ts @@ -1,5 +1,5 @@ import os from 'node:os' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { normalizeMachineName } from '../../shared/machine-name' import { withTimeout } from '../../shared/promise-timeout-fallback' diff --git a/src/main/runtime/structured-session-login-shell-test-harness.ts b/src/main/runtime/structured-session-login-shell-test-harness.ts index 943dfe0f4d3..b81f6f966c1 100644 --- a/src/main/runtime/structured-session-login-shell-test-harness.ts +++ b/src/main/runtime/structured-session-login-shell-test-harness.ts @@ -8,7 +8,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f import { tmpdir } from 'node:os' import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' diff --git a/src/main/runtime/terminal-foreground-group.ts b/src/main/runtime/terminal-foreground-group.ts index 35b237d2362..ba641dd534f 100644 --- a/src/main/runtime/terminal-foreground-group.ts +++ b/src/main/runtime/terminal-foreground-group.ts @@ -5,7 +5,7 @@ */ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { parseShellForegroundRows, type ProcessTableRow } from '../../shared/process-table-snapshot' import { isShellProcess } from '../../shared/shell-process-detection' import type { TuiAgent } from '../../shared/tui-agent' diff --git a/src/main/runtime/terminal-wait-tail-window.test.ts b/src/main/runtime/terminal-wait-tail-window.test.ts index 22a71140ee8..480e5e5cd34 100644 --- a/src/main/runtime/terminal-wait-tail-window.test.ts +++ b/src/main/runtime/terminal-wait-tail-window.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { build } from 'esbuild' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { startOfLastNonBlankLines } from './terminal-wait-tail-window' let scratch = '' diff --git a/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts index d92deccfa9a..8e6026a8349 100644 --- a/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts +++ b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts @@ -2,8 +2,8 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsSystem32Binary } from '../../shared/child-process/windows-system-binary' +import { runProcessSync } from '@orca/process-host' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' /** diff --git a/src/main/skills/skill-wsl-delete.integration.test.ts b/src/main/skills/skill-wsl-delete.integration.test.ts index 143216a46b5..464a7376315 100644 --- a/src/main/skills/skill-wsl-delete.integration.test.ts +++ b/src/main/skills/skill-wsl-delete.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem' diff --git a/src/main/ssh/orcad-activation-fence-generation.test.ts b/src/main/ssh/orcad-activation-fence-generation.test.ts index 11ba1a7c76c..36a04f9bd4d 100644 --- a/src/main/ssh/orcad-activation-fence-generation.test.ts +++ b/src/main/ssh/orcad-activation-fence-generation.test.ts @@ -16,7 +16,7 @@ import type * as DeployHelpers from './ssh-relay-deploy-helpers' // Every remote command runs in a real local shell, so the host-side checks are the real ones. const shell = vi.hoisted((): { env: NodeJS.ProcessEnv | undefined } => ({ env: undefined })) vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => { - const { runProcess } = await import('../../shared/child-process/run-process') + const { runProcess } = await import('@orca/process-host') return { ...(await importOriginal()), execCommand: async (_conn: unknown, command: string) => { diff --git a/src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts b/src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts index 4da20635802..0a2f7ecad01 100644 --- a/src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts +++ b/src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts @@ -10,12 +10,12 @@ import { import { hostname, tmpdir, uptime } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import type * as DeployHelpers from './ssh-relay-deploy-helpers' // The proof's remote commands run in a real local shell. vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => { - const { runProcess: run } = await import('../../shared/child-process/run-process') + const { runProcess: run } = await import('@orca/process-host') return { ...(await importOriginal()), execCommand: async (_conn: unknown, command: string) => diff --git a/src/main/ssh/orcad-exited-own-fence.test.ts b/src/main/ssh/orcad-exited-own-fence.test.ts index 261b34dd906..65a4b1c8d19 100644 --- a/src/main/ssh/orcad-exited-own-fence.test.ts +++ b/src/main/ssh/orcad-exited-own-fence.test.ts @@ -16,7 +16,7 @@ import type * as DeployHelpers from './ssh-relay-deploy-helpers' // Every remote command runs in a real local shell, so the host-side checks are the real ones. const shell = vi.hoisted((): { env: NodeJS.ProcessEnv | undefined } => ({ env: undefined })) vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => { - const { runProcess } = await import('../../shared/child-process/run-process') + const { runProcess } = await import('@orca/process-host') const { sshCommandExitError } = await import('./ssh-relay-exec-command') return { ...(await importOriginal()), diff --git a/src/main/ssh/orcad-exited-own-install-lock.test.ts b/src/main/ssh/orcad-exited-own-install-lock.test.ts index 6df375af814..8c4f9815c38 100644 --- a/src/main/ssh/orcad-exited-own-install-lock.test.ts +++ b/src/main/ssh/orcad-exited-own-install-lock.test.ts @@ -15,7 +15,7 @@ import type * as DeployHelpers from './ssh-relay-deploy-helpers' // Every remote command runs in a real local shell, so the host-side checks are the real ones. vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => { - const { runProcess } = await import('../../shared/child-process/run-process') + const { runProcess } = await import('@orca/process-host') const { sshCommandExitError } = await import('./ssh-relay-exec-command') return { ...(await importOriginal()), diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index a9c3afb7d1d..d852b965c57 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -3,7 +3,7 @@ import type * as RecordFile from './orcad-remote-record-file' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), diff --git a/src/main/ssh/orcad-remote-launch-file-modes.test.ts b/src/main/ssh/orcad-remote-launch-file-modes.test.ts index 836f4f661ac..bb1577280b7 100644 --- a/src/main/ssh/orcad-remote-launch-file-modes.test.ts +++ b/src/main/ssh/orcad-remote-launch-file-modes.test.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ORCAD_LOG_FILENAME, ORCAD_PID_FILENAME, diff --git a/src/main/ssh/orcad-remote-log-tail.test.ts b/src/main/ssh/orcad-remote-log-tail.test.ts index 61b8fa3f4ac..4c0fcc2770d 100644 --- a/src/main/ssh/orcad-remote-log-tail.test.ts +++ b/src/main/ssh/orcad-remote-log-tail.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { ORCAD_LOG_TAIL_LINES, ORCAD_LOG_TAIL_MAX_BYTES, diff --git a/src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts b/src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts index 42b2c9d2ebd..9944a561b48 100644 --- a/src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts +++ b/src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { pinnedNodeRuntimeAsset } from '../../shared/node-runtime-pin' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { SshConnection } from './ssh-connection' import { ensureRemoteOrcadNodeRuntime, diff --git a/src/main/ssh/orcad-remote-node-runtime.test.ts b/src/main/ssh/orcad-remote-node-runtime.test.ts index 9ce30dd4eaf..08b9566dea0 100644 --- a/src/main/ssh/orcad-remote-node-runtime.test.ts +++ b/src/main/ssh/orcad-remote-node-runtime.test.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' import { diff --git a/src/main/ssh/orcad-remote-readiness-wait.test.ts b/src/main/ssh/orcad-remote-readiness-wait.test.ts index 8e1043a59dc..16c6979b831 100644 --- a/src/main/ssh/orcad-remote-readiness-wait.test.ts +++ b/src/main/ssh/orcad-remote-readiness-wait.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { orcadReadinessWaitCommand } from './orcad-remote-readiness-wait' import { getRemoteHostPlatform } from './ssh-remote-platform' diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts index 2b774d8d2c5..076a5e527ba 100644 --- a/src/main/ssh/orcad-remote-runtime.test.ts +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { getRemoteHostPlatform } from './ssh-remote-platform' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { stopOrcadCommand } from './orcad-remote-process-control' diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index d1699ac18d3..29943aca14a 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -10,7 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ORCAD_WINDOWS_PROCESS_TREE_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' import { ORCAD_WINDOWS_PROCESS_FILENAME } from './orcad-remote-host-support' diff --git a/src/main/ssh/orcad-retained-fence-takeover.test.ts b/src/main/ssh/orcad-retained-fence-takeover.test.ts index b70b3e4f3c6..d3fe57670dd 100644 --- a/src/main/ssh/orcad-retained-fence-takeover.test.ts +++ b/src/main/ssh/orcad-retained-fence-takeover.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { orphanInstallLockCommand, tryCreateInstallLockCommand, diff --git a/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts b/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts index 11c19a1456e..5896eda7ec0 100644 --- a/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts +++ b/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { RELAY_INSTALL_LOCK_NAME } from '../../shared/relay-install-lock-name' import { ORCAD_ACTIVATION_TRANSACTION_DIRNAME } from './orcad-activation-transaction' import { serializedStateMutationCommand } from './orcad-state-snapshot' diff --git a/src/main/ssh/orcad-state-snapshot-shell.test.ts b/src/main/ssh/orcad-state-snapshot-shell.test.ts index 9cf0192a63f..9ac45cc600e 100644 --- a/src/main/ssh/orcad-state-snapshot-shell.test.ts +++ b/src/main/ssh/orcad-state-snapshot-shell.test.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { captureOrcadStateSnapshotCommand, clearOrcadStateSnapshotMembersCommand, diff --git a/src/main/ssh/orcad-stdio-bridge-test-channel.ts b/src/main/ssh/orcad-stdio-bridge-test-channel.ts index 4fc4b5fe87b..668e0072875 100644 --- a/src/main/ssh/orcad-stdio-bridge-test-channel.ts +++ b/src/main/ssh/orcad-stdio-bridge-test-channel.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:net' import { Duplex } from 'node:stream' import type { ClientChannel } from 'ssh2' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' export function spawnLocalBridgeChannel( program: string, diff --git a/src/main/ssh/orcad-windows-host-fence-ops.test.ts b/src/main/ssh/orcad-windows-host-fence-ops.test.ts index 7993ac76ca7..493af30a7bf 100644 --- a/src/main/ssh/orcad-windows-host-fence-ops.test.ts +++ b/src/main/ssh/orcad-windows-host-fence-ops.test.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ORCAD_FENCE_LOST_EXIT, ORCAD_FENCE_LOST_MARKER } from './orcad-activation-fence-scope' import { ORCAD_WINDOWS_HOST_SCRIPT } from './orcad-windows-host-script' diff --git a/src/main/ssh/orcad-windows-host-state-ops.test.ts b/src/main/ssh/orcad-windows-host-state-ops.test.ts index da63d07727b..a19414dc730 100644 --- a/src/main/ssh/orcad-windows-host-state-ops.test.ts +++ b/src/main/ssh/orcad-windows-host-state-ops.test.ts @@ -16,7 +16,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { ORCAD_WINDOWS_HOST_SCRIPT, type OrcadWindowsHostOp } from './orcad-windows-host-script' let dir = '' diff --git a/src/main/ssh/pinned-runtime-materializer-node.test.ts b/src/main/ssh/pinned-runtime-materializer-node.test.ts index 1d479fe44fb..230890c930a 100644 --- a/src/main/ssh/pinned-runtime-materializer-node.test.ts +++ b/src/main/ssh/pinned-runtime-materializer-node.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN, diff --git a/src/main/ssh/pinned-runtime-materializer.test.ts b/src/main/ssh/pinned-runtime-materializer.test.ts index 3b587e703e7..086e709e69a 100644 --- a/src/main/ssh/pinned-runtime-materializer.test.ts +++ b/src/main/ssh/pinned-runtime-materializer.test.ts @@ -11,13 +11,13 @@ import { type ServerTarget } from '../../shared/node-runtime-pin' import { setMainHttpClient } from '../network/http-client' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { materializeCachedNodeRuntime } from './pinned-runtime-materializer' import { RUNTIME_ARCHIVE_RETRY_DELAYS_MS } from './runtime-archive-download' const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), member: '' })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(async (spec: { args: string[] }) => { const flag = spec.args.includes('-C') ? '-C' : '-d' const extracted = join(spec.args[spec.args.indexOf(flag) + 1]!, ...extraction.member.split('/')) @@ -165,7 +165,7 @@ describe('materializeCachedNodeRuntime', () => { await writeFile(join(cacheRoot, 'plain-file'), 'not a directory') const { runProcess: spawnForReal } = await vi.importActual<{ runProcess: typeof runProcess - }>('../../shared/child-process/run-process') + }>('@orca/process-host') vi.mocked(runProcess).mockImplementationOnce(spawnForReal) vi.stubEnv('ORCA_UNZIP_BIN', path()) diff --git a/src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts b/src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts index 8563c040f0a..69f3e5b3997 100644 --- a/src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts +++ b/src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import type { SshConnection } from './ssh-connection' import { RUNTIME_STORE_LOCK_NAME, diff --git a/src/main/ssh/runtime-archive-download.ts b/src/main/ssh/runtime-archive-download.ts index a37fcca9f2c..d12b99fade2 100644 --- a/src/main/ssh/runtime-archive-download.ts +++ b/src/main/ssh/runtime-archive-download.ts @@ -1,7 +1,8 @@ /** Streams and hash-checks a pinned runtime archive, and extracts it with the host's own tools. */ import { createHash } from 'node:crypto' import { open, rm } from 'node:fs/promises' -import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { getZipExtractorCommand } from '../../shared/zip-extractor-command' diff --git a/src/main/ssh/ssh-config-resolver.test.ts b/src/main/ssh/ssh-config-resolver.test.ts index 671ee086151..fe2d61bf06e 100644 --- a/src/main/ssh/ssh-config-resolver.test.ts +++ b/src/main/ssh/ssh-config-resolver.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveWithSshG } from './ssh-config-parser' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('os', () => ({ homedir: () => '/home/testuser' @@ -9,8 +9,8 @@ vi.mock('os', () => ({ // Why mock the chokepoint and not child_process: the point of routing this // probe through runProcess is that the spawn shape (windowsHide, timeout, // argv quoting) is no longer this module's decision, so the test must not -// re-assert it here. src/shared/child-process owns that contract. -vi.mock('../../shared/child-process/run-process', () => ({ +// re-assert it here. @orca/process-host owns that contract. +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) diff --git a/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts b/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts index d6be7bbbf94..5cc5a362060 100644 --- a/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts +++ b/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { wrapRemoteCommandForPosixShell, type SshExecOptions } from './ssh-connection-utils' import { execCommand, isSshCommandExitError } from './ssh-relay-exec-command' import { readRemoteHomeCommand } from './ssh-remote-commands' diff --git a/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts b/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts index 271a56cb674..efd6d01d9cb 100644 --- a/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts +++ b/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Duplex } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import type { SshConnection } from './ssh-connection' import { EXEC_STDIN_WRITE_DONE, diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index e22f6b46e05..2758ef9c5a2 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { existsSync } from 'node:fs' import { readdir, readFile } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' diff --git a/src/main/ssh/ssh-hostile-host-local-sshd.ts b/src/main/ssh/ssh-hostile-host-local-sshd.ts index a39b324a671..d2fc3dbd466 100644 --- a/src/main/ssh/ssh-hostile-host-local-sshd.ts +++ b/src/main/ssh/ssh-hostile-host-local-sshd.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node import { createServer } from 'node:net' import { userInfo } from 'node:os' import { join, posix } from 'node:path' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import { forbiddenToolShimScript, forbiddenToolsFor, diff --git a/src/main/ssh/ssh-hostile-host-test-fixture.ts b/src/main/ssh/ssh-hostile-host-test-fixture.ts index be52dae1395..3336e79ae90 100644 --- a/src/main/ssh/ssh-hostile-host-test-fixture.ts +++ b/src/main/ssh/ssh-hostile-host-test-fixture.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { SshTarget } from '../../shared/ssh-types' import { shellEscape } from './ssh-connection-utils' import { diff --git a/src/main/ssh/ssh-proxy-command.ts b/src/main/ssh/ssh-proxy-command.ts index 3df2c961f79..d4e8c33a1f9 100644 --- a/src/main/ssh/ssh-proxy-command.ts +++ b/src/main/ssh/ssh-proxy-command.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { Duplex } from 'node:stream' import type { Socket as NetSocket } from 'node:net' import type { SshTarget } from '../../shared/ssh-types' diff --git a/src/main/ssh/ssh-relay-answered-failure-producers.test.ts b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts index 1daee192722..665dbdad86e 100644 --- a/src/main/ssh/ssh-relay-answered-failure-producers.test.ts +++ b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import type { SshTarget } from '../../shared/ssh-types' -import { runProcess, runProcessSync } from '../../shared/child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' import { REMOTE_NODE_RUNTIME_READY, REMOTE_NODE_RUNTIME_VERIFIED_MARKER, @@ -24,7 +24,7 @@ import { findSystemSsh } from './system-ssh-binary' import { uploadDirectoryViaSystemSsh } from './system-ssh-file-transfer' import { runSftpBatch } from './system-ssh-sftp-transfer' -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal>()), runProcess: vi.fn() })) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts index 40d2c49f5d9..2d602f98b32 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { RELAY_LSOF_PROBE_JS } from '../../shared/child-process/posix-lsof-probe' +import { RELAY_LSOF_PROBE_JS } from '@orca/process-host/posix-lsof-probe' const execCommand = vi.fn() vi.mock('./ssh-relay-deploy-helpers', () => ({ diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index ee4d7d59eea..668ed4e6a20 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -19,7 +19,7 @@ * reason is `unverifiable`. It cannot authorize client cleanup; guarded launch still * delegates socket takeover checks to the daemon. */ -import { RELAY_LSOF_PROBE_JS } from '../../shared/child-process/posix-lsof-probe' +import { RELAY_LSOF_PROBE_JS } from '@orca/process-host/posix-lsof-probe' import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' import { diff --git a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts index d2e5da79387..9ec49d96667 100644 --- a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts +++ b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts @@ -1,9 +1,10 @@ +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { ClientChannel } from 'ssh2' -import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { runProcess, spawnProcess } from '@orca/process-host' import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' import { execCommand } from './ssh-relay-exec-command' @@ -13,7 +14,7 @@ export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 class RelayGcShellChannel extends PassThrough implements ClientChannel { stdin: this = this stdout: this = this - stderr: ReturnType['stderr'] + stderr: PipedChildProcess['stderr'] server = false as const type = 'session' as const subtype = 'exec' as const @@ -21,7 +22,7 @@ class RelayGcShellChannel extends PassThrough implements ClientChannel { outgoing: unknown = null private readonly exited = Promise.withResolvers() - constructor(private readonly child: ReturnType) { + constructor(private readonly child: PipedChildProcess) { super({ autoDestroy: false, emitClose: false }) this.stderr = child.stderr child.stdout.pipe(this) diff --git a/src/main/ssh/ssh-relay-incumbent-process.test.ts b/src/main/ssh/ssh-relay-incumbent-process.test.ts index f77cd335174..8758d06fa3a 100644 --- a/src/main/ssh/ssh-relay-incumbent-process.test.ts +++ b/src/main/ssh/ssh-relay-incumbent-process.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { createServer } from 'node:net' import { describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), isUnconfirmedSshCommandTermination: () => false diff --git a/src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts b/src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts index 50471173369..21e04314e69 100644 --- a/src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts +++ b/src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' diff --git a/src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts b/src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts index 530826337ef..b0d93ee0e9a 100644 --- a/src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts +++ b/src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Client, Server as Ssh2Server, utils, type Connection } from 'ssh2' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { SshConnection } from './ssh-connection' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { getRemoteHostPlatform } from './ssh-remote-platform' diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts index e04d4bcf6bd..21a21968642 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts @@ -15,7 +15,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts index 52938d6f239..11832aae7d8 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/p import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getRemoteHostPlatform } from './ssh-remote-platform' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { diff --git a/src/main/ssh/ssh-relay-pinned-node.integration.test.ts b/src/main/ssh/ssh-relay-pinned-node.integration.test.ts index d1581f0fc78..dac3d757cee 100644 --- a/src/main/ssh/ssh-relay-pinned-node.integration.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node.integration.test.ts @@ -19,7 +19,7 @@ import { import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN, diff --git a/src/main/ssh/ssh-relay-runtime-self-test.test.ts b/src/main/ssh/ssh-relay-runtime-self-test.test.ts index aa59dfbb825..20f573b45b4 100644 --- a/src/main/ssh/ssh-relay-runtime-self-test.test.ts +++ b/src/main/ssh/ssh-relay-runtime-self-test.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { RELAY_RUNTIME_SELF_TEST_PREFIX } from '../../shared/relay-runtime-self-test-report' import type { SshConnection } from './ssh-connection' diff --git a/src/main/ssh/ssh-response-payload.ts b/src/main/ssh/ssh-response-payload.ts index e6a9bff2a6d..00fa8252a22 100644 --- a/src/main/ssh/ssh-response-payload.ts +++ b/src/main/ssh/ssh-response-payload.ts @@ -1,4 +1,4 @@ -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' export class SshResponsePayload { private readonly bytes = new GrowingByteBuffer() diff --git a/src/main/ssh/system-ssh-sftp-transfer.ts b/src/main/ssh/system-ssh-sftp-transfer.ts index 0c319a6e28c..e0a0a1bc424 100644 --- a/src/main/ssh/system-ssh-sftp-transfer.ts +++ b/src/main/ssh/system-ssh-sftp-transfer.ts @@ -14,7 +14,7 @@ import { UnsupportedSftpPathError } from './system-ssh-sftp-path' import { SystemSshCommandExitError, throwIfAborted } from './system-ssh-operation-lifecycle' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' /** The host answered, but not with an sftp subsystem. The caller must fall back, not fail. */ export class SftpSubsystemUnavailableError extends Error { diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index bc339f24785..f28dc6075da 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -31,7 +31,7 @@ vi.mock('./system-ssh-command', () => ({ spawnSystemSshCommand: spawnSystemSshCommandMock })) -vi.mock('../../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) diff --git a/src/main/startup/cli-launch-redirect.ts b/src/main/startup/cli-launch-redirect.ts index 88c0e8062cb..09fd57cb640 100644 --- a/src/main/startup/cli-launch-redirect.ts +++ b/src/main/startup/cli-launch-redirect.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { posix, win32 } from 'node:path' -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { CLI_BOOLEAN_FLAGS, findCliCommandIndex } from '../../shared/cli-argument-boundary' import { CLI_COMMAND_NAMES } from './cli-command-names' import { VALUE_TAKING_FLAGS } from './serve-mode-argv' diff --git a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts index 7c6e7dcaaec..cbcea64045d 100644 --- a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts +++ b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts @@ -32,7 +32,7 @@ vi.mock('@electron-toolkit/utils', () => ({ electronApp: { setAppUserModelId: vi.fn() } })) -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, diff --git a/src/main/startup/linux-secret-service-probe.test.ts b/src/main/startup/linux-secret-service-probe.test.ts index d20b08df98f..ad8c91e8dfd 100644 --- a/src/main/startup/linux-secret-service-probe.test.ts +++ b/src/main/startup/linux-secret-service-probe.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const runProcessSync = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcessSync })) +vi.mock('@orca/process-host', () => ({ runProcessSync })) const { probeSecretServiceCollection } = await import('./linux-secret-service-probe') diff --git a/src/main/startup/linux-secret-service-probe.ts b/src/main/startup/linux-secret-service-probe.ts index 3c1de23318b..1be79cc6a4e 100644 --- a/src/main/startup/linux-secret-service-probe.ts +++ b/src/main/startup/linux-secret-service-probe.ts @@ -1,4 +1,4 @@ -import { runProcessSync } from '../../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import type { SecretServiceCollectionState } from './linux-keyring-backend-selection' /** diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index bc4730be7c0..ce49deb4576 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { win32 as pathWin32 } from 'node:path' -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' import { resolveProfileLoadingFallbackShell, diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 9ac5fa66661..5443a2f5d4e 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -43,7 +43,7 @@ import { recordSubprocessSpawn, startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' -import { setSpawnObserver } from '../../shared/child-process/spawn-observer' +import { setSpawnObserver } from '@orca/process-host/spawn-observer' import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' import { createServeDesktopActivationGate } from './serve-desktop-activation' diff --git a/src/main/startup/windows-install-dir-acl-recovery.test.ts b/src/main/startup/windows-install-dir-acl-recovery.test.ts index 23ca9de5062..bc358d92c34 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.test.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.test.ts @@ -2,7 +2,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { readActiveGpuFallbackMarker, writeGpuFallbackMarker } from './gpu-fallback-marker' import { diff --git a/src/main/startup/windows-install-dir-acl-repair.win32.test.ts b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts index 9a04aa1edef..0c15195792f 100644 --- a/src/main/startup/windows-install-dir-acl-repair.win32.test.ts +++ b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getIcaclsExePath } from '../win32-utils' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts index e7222b016c0..c0d88c24ad1 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -2,7 +2,8 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { buildInstallDirAclRepairCommands, diff --git a/src/main/startup/windows-install-dir-package-acl-repair.ts b/src/main/startup/windows-install-dir-package-acl-repair.ts index 878f7c47411..18f635de8b3 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { sanitizeCrashReportString, type CrashReportBreadcrumbData diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 548fabf5a73..e278c0392fa 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../shared/child-process/run-process' + +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn<(spec: ProcessSpec) => Promise>() @@ -8,7 +9,7 @@ const { runProcessMock } = vi.hoisted(() => ({ // Why mock the chokepoint rather than child_process: the timeout, the output // cap and the hidden console are runProcess's contract now, so this suite // asserts what font discovery asks for, not how a process gets started. -vi.mock('../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index 73d5ef9f993..35a6223ee96 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -1,5 +1,5 @@ -import { runProcess } from '../shared/child-process/run-process' -import { windowsPowerShellPath } from '../shared/child-process/windows-system-binary' +import { runProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null diff --git a/src/main/text-generation/source-control-agent-launch.test.ts b/src/main/text-generation/source-control-agent-launch.test.ts index 7c1670361c7..b71c0c9fd97 100644 --- a/src/main/text-generation/source-control-agent-launch.test.ts +++ b/src/main/text-generation/source-control-agent-launch.test.ts @@ -3,8 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '../../shared/child-process/run-process' -import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type * as RunProcess from '@orca/process-host' +import type { ProcessSpec } from '@orca/process-host/process-spec' import { POSIX_PROVIDER_SUPERVISOR_SCRIPT } from '../provider-process/provider-process-supervisor' import { generateCommitMessageFromContext } from './commit-message-text-generation' import { withPlatform } from './commit-message-text-generation-test-harness' @@ -12,7 +12,7 @@ import { spawnSourceControlAgent } from './source-control-agent-launch' const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', async (importOriginal) => { +vi.mock('@orca/process-host', async (importOriginal) => { const actual = await importOriginal() spawnProcessMock.mockImplementation(actual.spawnProcess) return { ...actual, spawnProcess: spawnProcessMock } diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 056a3eb4b47..7ab86e67778 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { resolveCliCommand } from '../codex-cli/command' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' diff --git a/src/main/text-generation/source-control-local-process.test.ts b/src/main/text-generation/source-control-local-process.test.ts index 34c34009048..76a8fac5cc9 100644 --- a/src/main/text-generation/source-control-local-process.test.ts +++ b/src/main/text-generation/source-control-local-process.test.ts @@ -216,8 +216,13 @@ describe.runIf(process.platform !== 'win32')( stdinMode: 'ignore', useCwdForNative: false }) + const stdout = child.stdout + if (!stdout) { + child.kill('SIGKILL') + throw new Error('The source-control fixture requires a stdout pipe') + } const agentPid = await new Promise((resolve) => - child.stdout.once('data', (chunk: Buffer) => resolve(Number(chunk.toString().trim()))) + stdout.once('data', (chunk: Buffer) => resolve(Number(chunk.toString().trim()))) ) try { diff --git a/src/main/text-generation/source-control-text-generation-types.ts b/src/main/text-generation/source-control-text-generation-types.ts index 123785f10d6..42dc3b0d34a 100644 --- a/src/main/text-generation/source-control-text-generation-types.ts +++ b/src/main/text-generation/source-control-text-generation-types.ts @@ -1,5 +1,5 @@ import type { AgentSessionUnavailable } from '../../shared/agent-session-availability' -import type { spawnProcess } from '../../shared/child-process/run-process' +import type { spawnProcess } from '@orca/process-host' import type { AgentGenerationFailureOutput } from './agent-failure-output' import type { CommitMessageAgentCapability, diff --git a/src/main/window/darwin-user-temp-dir.test.ts b/src/main/window/darwin-user-temp-dir.test.ts index 3d97591d351..a7ba6903e33 100644 --- a/src/main/window/darwin-user-temp-dir.test.ts +++ b/src/main/window/darwin-user-temp-dir.test.ts @@ -4,7 +4,7 @@ import type * as DarwinUserTempDir from './darwin-user-temp-dir' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) async function loadResolver(): Promise { vi.resetModules() diff --git a/src/main/window/darwin-user-temp-dir.ts b/src/main/window/darwin-user-temp-dir.ts index 30408e66dac..dda7fc7b75a 100644 --- a/src/main/window/darwin-user-temp-dir.ts +++ b/src/main/window/darwin-user-temp-dir.ts @@ -1,6 +1,6 @@ import { tmpdir } from 'node:os' import { isAbsolute } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const GETCONF_TIMEOUT_MS = 5_000 diff --git a/src/main/window/dragged-temp-file-copy.test.ts b/src/main/window/dragged-temp-file-copy.test.ts index 3b3252b7ee6..c93732b6336 100644 --- a/src/main/window/dragged-temp-file-copy.test.ts +++ b/src/main/window/dragged-temp-file-copy.test.ts @@ -16,7 +16,7 @@ import { import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type * as RuntimeImportLimits from '../ipc/runtime-import-limits' const fsFaults: { diff --git a/src/main/window/renderer-launch-failure-probe.test.ts b/src/main/window/renderer-launch-failure-probe.test.ts index 29714f84dad..84fec4ff3cc 100644 --- a/src/main/window/renderer-launch-failure-probe.test.ts +++ b/src/main/window/renderer-launch-failure-probe.test.ts @@ -4,7 +4,7 @@ const { runProcessMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ runProcessMock: vi.fn(), recordDurableCrashBreadcrumbMock: vi.fn() })) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ recordDurableCrashBreadcrumb: recordDurableCrashBreadcrumbMock })) diff --git a/src/main/window/renderer-launch-failure-probe.ts b/src/main/window/renderer-launch-failure-probe.ts index fd81b419c6f..405bf19472d 100644 --- a/src/main/window/renderer-launch-failure-probe.ts +++ b/src/main/window/renderer-launch-failure-probe.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' /** 'ok' when a throwaway child could start, the spawn errno (EAGAIN = per-user process limit), or 'skipped' on Windows. */ diff --git a/src/main/windows-live-tree-kill.win32.test.ts b/src/main/windows-live-tree-kill.win32.test.ts index 7af6e221c07..2080d6cf4ed 100644 --- a/src/main/windows-live-tree-kill.win32.test.ts +++ b/src/main/windows-live-tree-kill.win32.test.ts @@ -5,9 +5,9 @@ import { spawn, ChildProcess } from 'node:child_process' import { subscribe, unsubscribe } from 'node:diagnostics_channel' import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../shared/app-environment' -import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' -import { signalProcessTree } from '../shared/child-process/process-tree-termination' -import { windowsSystem32Binary } from '../shared/child-process/windows-system-binary' +import { setProcessTreeKillGate } from '@orca/process-host/process-tree-kill-gate' +import { signalProcessTree } from '@orca/process-host/process-tree-termination' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' import { removeTreeSync } from '../shared/windows-transient-lock-removal' import { findSelfInitiatedTreeKills, diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index b8d654ce238..8b16f07e976 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -1,5 +1,5 @@ -import { runProcess } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { runProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import type { WindowsProcessRow } from './windows-process-table' /** diff --git a/src/main/windows/windows-pty-job.win32.test.ts b/src/main/windows/windows-pty-job.win32.test.ts index 3af01c41796..26892390fb6 100644 --- a/src/main/windows/windows-pty-job.win32.test.ts +++ b/src/main/windows/windows-pty-job.win32.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { IPty } from 'node-pty' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { isPtyJobOwnershipAvailable, listPtyJobProcessIds, diff --git a/src/main/wsl-availability-missing-kernel.test.ts b/src/main/wsl-availability-missing-kernel.test.ts index 3ab7b6d4a26..95df76e7de5 100644 --- a/src/main/wsl-availability-missing-kernel.test.ts +++ b/src/main/wsl-availability-missing-kernel.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { execFile, execFileSync } from 'node:child_process' -import { runProcess, runProcessSync, type ProcessResult } from '../shared/child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { _resetWslAvailabilityCacheForTests, isWslAvailable, @@ -8,7 +9,7 @@ import { } from './wsl-availability' vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), runProcessSync: vi.fn() })) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index ad5e5645c30..1e133361e20 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -1,5 +1,6 @@ import { execFile, execFileSync } from 'node:child_process' -import { runProcess, runProcessSync, type ProcessSpec } from '../shared/child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' +import type { ProcessSpec } from '@orca/process-host/process-spec' import { buildWslExecArgs } from '../shared/wsl-login-shell-command' import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' diff --git a/src/main/wsl-managed-cli-path-restore.test.ts b/src/main/wsl-managed-cli-path-restore.test.ts index 01d80390099..e2dcab26027 100644 --- a/src/main/wsl-managed-cli-path-restore.test.ts +++ b/src/main/wsl-managed-cli-path-restore.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { runProcessSync } from '../shared/child-process/run-process' +import { runProcessSync } from '@orca/process-host' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' const posix = process.platform !== 'win32' diff --git a/src/main/wsl/wsl-guest-environment.test.ts b/src/main/wsl/wsl-guest-environment.test.ts index 1103f29bcf3..2ec5734999c 100644 --- a/src/main/wsl/wsl-guest-environment.test.ts +++ b/src/main/wsl/wsl-guest-environment.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('./wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) import { diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index 4edfb862236..aad903b91a9 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildWslCapturedLoginShellCommand, buildWslExecArgs diff --git a/src/main/wsl/wsl-runner.test.ts b/src/main/wsl/wsl-runner.test.ts index 0a516253ac0..4786742258b 100644 --- a/src/main/wsl/wsl-runner.test.ts +++ b/src/main/wsl/wsl-runner.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('./wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' })) diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index ac7676c25db..b063c76c36f 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -1,6 +1,6 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { getWslGuestEnvironment, type WslGuestEnvironment } from './wsl-guest-environment' diff --git a/src/main/wsl/wsl-runner.wsl.test.ts b/src/main/wsl/wsl-runner.wsl.test.ts index c31ac669140..1fa79cc258c 100644 --- a/src/main/wsl/wsl-runner.wsl.test.ts +++ b/src/main/wsl/wsl-runner.wsl.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { runWslProcess } from './wsl-runner' import { invalidateWslGuestEnvironment } from './wsl-guest-environment' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveWslExecutablePath } from './wsl-executable-path' /** diff --git a/src/main/wsl/wsl-w1-w3-contract.test.ts b/src/main/wsl/wsl-w1-w3-contract.test.ts index 0da8d0f900b..52cdf509879 100644 --- a/src/main/wsl/wsl-w1-w3-contract.test.ts +++ b/src/main/wsl/wsl-w1-w3-contract.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' const runProcessMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) vi.mock('./wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' })) diff --git a/src/main/zcode/interactive-capability.ts b/src/main/zcode/interactive-capability.ts index d5b65851dae..3bf550a5dc6 100644 --- a/src/main/zcode/interactive-capability.ts +++ b/src/main/zcode/interactive-capability.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { isZCodeMissingTuiOutput, type ZCodeInteractiveCapability diff --git a/src/packages/process-host/.gitignore b/src/packages/process-host/.gitignore new file mode 100644 index 00000000000..491b684810e --- /dev/null +++ b/src/packages/process-host/.gitignore @@ -0,0 +1,2 @@ +.dist-staging-*/ +.dist-build.lock/ diff --git a/src/packages/process-host/README.md b/src/packages/process-host/README.md new file mode 100644 index 00000000000..5b6919ae7c6 --- /dev/null +++ b/src/packages/process-host/README.md @@ -0,0 +1,29 @@ +# Orca process host + +This Node package owns subprocess spawning, forked children, bounded output, cancellation, Windows argument handling, and process-tree termination. Desktop, the headless server, CLI, SSH relay, and daemon use the same implementation. It has no runtime npm dependencies, Electron imports, or native addons. + +Import `runProcess`, `runProcessSync`, and `spawnProcess` from `@orca/process-host`. Import their contracts and default limits from `@orca/process-host/process-spec`. Other explicit subpaths expose forking, termination, Windows arguments, and bounded output. Application code must use public exports. + +`spawnProcess` guarantees three stream pipes when `stdio` is omitted, is `'pipe'`, or starts with `['pipe', 'pipe', 'pipe']`. Other specifications return a child with nullable streams. Use `PipedProcessSpec` and `PipedChildProcess` when a stream-owning interface requires pipes. + +The application supplies policy through `process-tree-kill-gate` and diagnostics through `spawn-observer`. Register these before starting managed children. Each process must load one package instance so registration and execution share state. The production exports use one CommonJS output for both import and require callers. The `orca-source` condition supports tests, dependency analysis, and Node tooling that runs TypeScript through `tsx`. Source tooling runs in a separate process and must use that condition for every package import; mixing source and compiled output would split the registration state. + +From the repository root: + +```sh +pnpm build:packages +pnpm --filter @orca/process-host typecheck +ORCA_BACKGROUND_LAUNCH=1 pnpm --filter @orca/process-host test +``` + +Which copy a caller loads: + +- Root app build, test, typecheck, and launch scripts build `dist` first. Editor types and root typechecks read its declarations. +- `pnpm dev` refreshes `dist` once at launch. Restart it after package edits, or run `pnpm dev --watch` to rebuild the package and restart the app after package or main-process source changes. +- Run `pnpm build:packages` after package edits before invoking config scripts or Vitest directly; their bootstrap code can read `dist`. +- Vitest and `tsx --conditions=orca-source` load `src` through the `orca-source` condition. +- Packaging ships `dist`; supported build scripts and CI rebuild it first. The packaging guard checks missing exports/modules and removed-source leftovers, but does not certify source freshness. Build the package before invoking electron-builder directly. + +The build needs no prior `dist`: `tsx --conditions=orca-source` starts its compiler through source. It serializes builds with a lock, compiles into staging, adds new files before replacing existing files, then removes obsolete output. Failed compilation preserves prior output; unchanged files are not rewritten. Publication is atomic per file, so a concurrent reader can still observe mixed build versions. Detected lock loss stops subsequent writes and preserves the new owner's lock; already published files remain. `tsx` and `proper-lockfile` are build-only dependencies. + +The package tests cover execution, output bounds, cancellation, termination, and Windows command construction. Windows shim integration stays under `src/shared/__tests__/process-host` because those tests use the host's filesystem cleanup policy. Root CI runs both sets, and root `pnpm typecheck` type-checks the package tests through `tsconfig.test.json`; Windows integration still requires a Windows runner. Package artifact tests verify public exports and shared registration state from staged output. The `engines` floor is Node 18 because the relay bundles this package for remote hosts. Its compiler uses Node 18 module semantics and Node 18 types to reject newer builtin APIs; CI's Node 18 job runs `config/scripts/smoke-process-host-node18.mjs` against the staged public output. Repository build and test tools use the root manifest's newer Node requirement. diff --git a/src/packages/process-host/package.json b/src/packages/process-host/package.json new file mode 100644 index 00000000000..6b6b7f92f2d --- /dev/null +++ b/src/packages/process-host/package.json @@ -0,0 +1,109 @@ +{ + "name": "@orca/process-host", + "version": "0.0.0", + "private": true, + "description": "Orca host subprocess execution, cancellation, and termination", + "files": [ + "dist" + ], + "type": "commonjs", + "main": "./dist/run-process.js", + "types": "./dist/run-process.d.ts", + "exports": { + ".": { + "orca-source": "./src/run-process.ts", + "types": "./dist/run-process.d.ts", + "default": "./dist/run-process.js" + }, + "./process-spec": { + "orca-source": "./src/process-spec.ts", + "types": "./dist/process-spec.d.ts", + "default": "./dist/process-spec.js" + }, + "./fork-process": { + "orca-source": "./src/fork-process.ts", + "types": "./dist/fork-process.d.ts", + "default": "./dist/fork-process.js" + }, + "./process-tree-termination": { + "orca-source": "./src/process-tree-termination.ts", + "types": "./dist/process-tree-termination.d.ts", + "default": "./dist/process-tree-termination.js" + }, + "./process-tree-kill-gate": { + "orca-source": "./src/process-tree-kill-gate.ts", + "types": "./dist/process-tree-kill-gate.d.ts", + "default": "./dist/process-tree-kill-gate.js" + }, + "./spawn-observer": { + "orca-source": "./src/spawn-observer.ts", + "types": "./dist/spawn-observer.d.ts", + "default": "./dist/spawn-observer.js" + }, + "./bounded-output-sink": { + "orca-source": "./src/bounded-output-sink.ts", + "types": "./dist/bounded-output-sink.d.ts", + "default": "./dist/bounded-output-sink.js" + }, + "./growing-byte-buffer": { + "orca-source": "./src/growing-byte-buffer.ts", + "types": "./dist/growing-byte-buffer.d.ts", + "default": "./dist/growing-byte-buffer.js" + }, + "./windows-command-line": { + "orca-source": "./src/windows-command-line.ts", + "types": "./dist/windows-command-line.d.ts", + "default": "./dist/windows-command-line.js" + }, + "./windows-system-binary": { + "orca-source": "./src/windows-system-binary.ts", + "types": "./dist/windows-system-binary.d.ts", + "default": "./dist/windows-system-binary.js" + }, + "./windows-cmd-shim-resolution": { + "orca-source": "./src/windows-cmd-shim-resolution.ts", + "types": "./dist/windows-cmd-shim-resolution.d.ts", + "default": "./dist/windows-cmd-shim-resolution.js" + }, + "./posix-lsof-probe": { + "orca-source": "./src/posix-lsof-probe.ts", + "types": "./dist/posix-lsof-probe.d.ts", + "default": "./dist/posix-lsof-probe.js" + }, + "./retryable-process-exit-proof": { + "orca-source": "./src/retryable-process-exit-proof.ts", + "types": "./dist/retryable-process-exit-proof.d.ts", + "default": "./dist/retryable-process-exit-proof.js" + }, + "./cancel-process-acquisition": { + "orca-source": "./src/cancel-process-acquisition.ts", + "types": "./dist/cancel-process-acquisition.d.ts", + "default": "./dist/cancel-process-acquisition.js" + }, + "./close-process-registry": { + "orca-source": "./src/close-process-registry.ts", + "types": "./dist/close-process-registry.d.ts", + "default": "./dist/close-process-registry.js" + }, + "./child-termination-reporter": { + "orca-source": "./src/child-termination-reporter.ts", + "types": "./dist/child-termination-reporter.d.ts", + "default": "./dist/child-termination-reporter.js" + } + }, + "scripts": { + "build": "tsx --conditions=orca-source scripts/build-dist.mjs", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run --config vitest.config.ts" + }, + "devDependencies": { + "@types/node": "^18.19.130", + "proper-lockfile": "4.1.2", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=18" + } +} diff --git a/src/packages/process-host/scripts/build-dist.mjs b/src/packages/process-host/scripts/build-dist.mjs new file mode 100644 index 00000000000..a0574559e2c --- /dev/null +++ b/src/packages/process-host/scripts/build-dist.mjs @@ -0,0 +1,189 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + rmdirSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' +import { runProcess } from '@orca/process-host' +import lockfile from 'proper-lockfile' + +// Source resolution bootstraps compilation without requiring an existing dist. + +const STAGING_PREFIX = '.dist-staging-' +const LOCK_PATH = '.dist-build.lock' +const LOCK_STALE_MS = 30_000 +const COMPILE_TIMEOUT_MS = 120_000 +const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']) + +function resolvePackageCompiler(packageDir) { + const manifestPath = createRequire(join(packageDir, 'package.json')).resolve( + 'typescript/package.json' + ) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.tsc + if (!bin) { + throw new Error(`${manifestPath} does not declare a tsc binary`) + } + return join(dirname(manifestPath), bin) +} + +function listFiles(directory, base = directory) { + if (!existsSync(directory)) { + return [] + } + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? listFiles(path, base) : [relative(base, path)] + }) +} + +function removeEmptyDirectories(directory, root) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + removeEmptyDirectories(join(directory, entry.name), root) + } + } + if (directory !== root && readdirSync(directory).length === 0) { + rmdirSync(directory) + } +} + +async function renameReplacing(source, target, assertLockHeld) { + // Windows reports a transient sharing violation while a scanner or loader has the target open. + for (let attempt = 0; ; attempt += 1) { + // Rechecked per attempt because the retry delay lets the lock be lost. + assertLockHeld() + try { + renameSync(source, target) + return + } catch (error) { + if ( + process.platform !== 'win32' || + !RETRYABLE_RENAME_CODES.has(error.code) || + attempt >= 20 + ) { + throw error + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)) + } + } +} + +async function syncCompiledOutput(stagingDir, distDir, assertLockHeld) { + assertLockHeld() + const staged = listFiles(stagingDir) + const stagedSet = new Set(staged) + const result = { written: 0, removed: 0, unchanged: 0 } + mkdirSync(distDir, { recursive: true }) + const existing = new Set(listFiles(distDir)) + // New dependencies must exist before any replacement can import them. + const publicationOrder = [ + ...staged.filter((file) => !existing.has(file)), + ...staged.filter((file) => existing.has(file)) + ] + for (const file of publicationOrder) { + const source = join(stagingDir, file) + const target = join(distDir, file) + if (existsSync(target) && readFileSync(source).equals(readFileSync(target))) { + result.unchanged += 1 + continue + } + mkdirSync(dirname(target), { recursive: true }) + await renameReplacing(source, target, assertLockHeld) + result.written += 1 + } + // Outputs of renamed or deleted sources would otherwise ship and stay importable. + for (const file of existing) { + if (!stagedSet.has(file)) { + assertLockHeld() + rmSync(join(distDir, file), { force: true }) + result.removed += 1 + } + } + assertLockHeld() + removeEmptyDirectories(distDir, distDir) + return result +} + +async function compileAndSync(packageDir, assertLockHeld) { + // Only the lock holder stages, so any staging directory left here is from a killed build. + for (const name of readdirSync(packageDir)) { + if (name.startsWith(STAGING_PREFIX)) { + rmSync(join(packageDir, name), { recursive: true, force: true }) + } + } + // Same depth as dist so emitted source-map paths are identical once moved. + const stagingDir = join(packageDir, `${STAGING_PREFIX}${randomUUID().slice(0, 8)}`) + try { + const compile = await runProcess({ + program: process.execPath, + args: [resolvePackageCompiler(packageDir), '-p', 'tsconfig.json', '--outDir', stagingDir], + cwd: packageDir, + timeoutMs: COMPILE_TIMEOUT_MS + }) + if (compile.code !== 0) { + const detail = [compile.stdout, compile.stderr].filter(Boolean).join('\n').trim() + const reason = compile.timedOut ? 'timed out' : (compile.signal ?? compile.code) + throw new Error(`process-host compilation failed (${reason})${detail ? `:\n${detail}` : ''}`) + } + return await syncCompiledOutput(stagingDir, join(packageDir, 'dist'), assertLockHeld) + } finally { + rmSync(stagingDir, { recursive: true, force: true }) + } +} + +export async function buildPackageDist(packageDir) { + const lockfilePath = join(packageDir, LOCK_PATH) + let compromised + // Held across compile and sync so an older source snapshot cannot publish after a newer one. + const release = await lockfile.lock(packageDir, { + lockfilePath, + realpath: false, + stale: LOCK_STALE_MS, + retries: { retries: 1_000, minTimeout: 50, maxTimeout: 250 }, + onCompromised: (error) => { + compromised = error + } + }) + // Reject before publishing after proper-lockfile observes a lost lock. + const assertLockHeld = () => { + if (compromised) { + throw new Error( + `process-host build lock was compromised; stopped publishing dist: ${compromised.message}` + ) + } + } + try { + const result = await compileAndSync(packageDir, assertLockHeld) + assertLockHeld() + return result + } finally { + // proper-lockfile already untracks compromised locks; releasing one could affect its new owner. + if (!compromised) { + await release() + } + } +} + +// realpath: the entry path may reach this file through a symlinked directory. +if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) { + try { + const packageDir = resolve(process.argv[2] ?? dirname(import.meta.dirname)) + const result = await buildPackageDist(packageDir) + if (result.written > 0 || result.removed > 0) { + console.log( + `process-host dist: ${result.written} written, ${result.removed} removed, ${result.unchanged} unchanged` + ) + } + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/src/shared/child-process/__fixtures__/windows-argument-corpus.ts b/src/packages/process-host/src/__fixtures__/windows-argument-corpus.ts similarity index 100% rename from src/shared/child-process/__fixtures__/windows-argument-corpus.ts rename to src/packages/process-host/src/__fixtures__/windows-argument-corpus.ts diff --git a/src/shared/child-process/__fixtures__/windows-cmd-shim-bodies.ts b/src/packages/process-host/src/__fixtures__/windows-cmd-shim-bodies.ts similarity index 100% rename from src/shared/child-process/__fixtures__/windows-cmd-shim-bodies.ts rename to src/packages/process-host/src/__fixtures__/windows-cmd-shim-bodies.ts diff --git a/src/shared/child-process/bounded-output-sink.test.ts b/src/packages/process-host/src/bounded-output-sink.test.ts similarity index 100% rename from src/shared/child-process/bounded-output-sink.test.ts rename to src/packages/process-host/src/bounded-output-sink.test.ts diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/packages/process-host/src/bounded-output-sink.ts similarity index 96% rename from src/shared/child-process/bounded-output-sink.ts rename to src/packages/process-host/src/bounded-output-sink.ts index 4c02b4651cc..c6b225106ab 100644 --- a/src/shared/child-process/bounded-output-sink.ts +++ b/src/packages/process-host/src/bounded-output-sink.ts @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer' -import { GrowingByteBuffer } from '../growing-byte-buffer' +import { GrowingByteBuffer } from './growing-byte-buffer' /** * Collects output up to a cap, so a chatty child cannot grow the heap. diff --git a/src/shared/child-process/cancel-process-acquisition.ts b/src/packages/process-host/src/cancel-process-acquisition.ts similarity index 100% rename from src/shared/child-process/cancel-process-acquisition.ts rename to src/packages/process-host/src/cancel-process-acquisition.ts diff --git a/src/shared/child-process/child-termination-reporter.ts b/src/packages/process-host/src/child-termination-reporter.ts similarity index 100% rename from src/shared/child-process/child-termination-reporter.ts rename to src/packages/process-host/src/child-termination-reporter.ts diff --git a/src/shared/child-process/close-process-registry.test.ts b/src/packages/process-host/src/close-process-registry.test.ts similarity index 100% rename from src/shared/child-process/close-process-registry.test.ts rename to src/packages/process-host/src/close-process-registry.test.ts diff --git a/src/shared/child-process/close-process-registry.ts b/src/packages/process-host/src/close-process-registry.ts similarity index 100% rename from src/shared/child-process/close-process-registry.ts rename to src/packages/process-host/src/close-process-registry.ts diff --git a/src/shared/child-process/fork-process.ts b/src/packages/process-host/src/fork-process.ts similarity index 100% rename from src/shared/child-process/fork-process.ts rename to src/packages/process-host/src/fork-process.ts diff --git a/src/shared/growing-byte-buffer.test.ts b/src/packages/process-host/src/growing-byte-buffer.test.ts similarity index 100% rename from src/shared/growing-byte-buffer.test.ts rename to src/packages/process-host/src/growing-byte-buffer.test.ts diff --git a/src/shared/growing-byte-buffer.ts b/src/packages/process-host/src/growing-byte-buffer.ts similarity index 100% rename from src/shared/growing-byte-buffer.ts rename to src/packages/process-host/src/growing-byte-buffer.ts diff --git a/src/shared/child-process/posix-lsof-probe.test.ts b/src/packages/process-host/src/posix-lsof-probe.test.ts similarity index 100% rename from src/shared/child-process/posix-lsof-probe.test.ts rename to src/packages/process-host/src/posix-lsof-probe.test.ts diff --git a/src/shared/child-process/posix-lsof-probe.ts b/src/packages/process-host/src/posix-lsof-probe.ts similarity index 100% rename from src/shared/child-process/posix-lsof-probe.ts rename to src/packages/process-host/src/posix-lsof-probe.ts diff --git a/src/shared/child-process/process-spec.ts b/src/packages/process-host/src/process-spec.ts similarity index 68% rename from src/shared/child-process/process-spec.ts rename to src/packages/process-host/src/process-spec.ts index 2f42b4c4e15..8e0202e43ea 100644 --- a/src/shared/child-process/process-spec.ts +++ b/src/packages/process-host/src/process-spec.ts @@ -1,27 +1,15 @@ -// The public contract for Orca's single child-process entry point. Split from -// run-process.ts so the runner stays under its line cap; import the runtime -// functions from run-process, which re-exports everything here. -import type { ChildProcess, SpawnOptions as NodeSpawnOptions } from 'node:child_process' +import type { + ChildProcess, + ChildProcessWithoutNullStreams, + SpawnOptions as NodeSpawnOptions, + StdioOptions +} from 'node:child_process' export type ChildProcessHandle = ChildProcess export type SpawnedProcess = ChildProcess -/** - * The single place Orca starts a child process. - * - * Why one place: six decisions have to be made every time a child is spawned, - * POSIX forgives all six, and Windows punishes each of them differently — - * console visibility, argument quoting, `.cmd` interpretation, binary - * resolution, timeout policy, and how the tree is later terminated. Made - * per-call-site, they were right in some files and wrong in others, and the - * wrong ones reached users as stolen keyboard focus, mangled agent prompts and - * orphaned process trees. - * - * Callers outside this directory must not import `node:child_process`; a guard - * test enforces that against a shrinking allowlist. - */ - +/** Centralizes host execution settings so argument encoding and tree ownership stay consistent. */ export type ProcessSpec = { /** * Program to run. On Windows this should already be an absolute path — @@ -52,12 +40,20 @@ export type ProcessSpec = { stdio?: NodeSpawnOptions['stdio'] /** Bun and Node require JSON for an IPC channel shared between the two runtimes. */ serialization?: NodeSpawnOptions['serialization'] - /** Kill the whole process tree and do not settle until termination is verified. */ + /** Await tree termination or observed root exit, then settle at the final deadline if unverified. */ terminationBarrier?: boolean | ProcessTerminationBarrier /** Called once when the child exits or tree termination is verified. */ onChildTerminated?: () => void } +export type PipedChildProcess = ChildProcessWithoutNullStreams + +export type PipedProcessSpec = Omit & { + stdio?: 'pipe' | ['pipe', 'pipe', 'pipe', ...Exclude] +} + +export type PipedProcessSpawner = (spec: PipedProcessSpec) => PipedChildProcess + export type ProcessTerminationBarrier = { observeStderr?: (chunk: Buffer | string) => void signal: (child: ChildProcess, signal?: NodeJS.Signals) => Promise diff --git a/src/shared/child-process/process-tree-kill-gate.ts b/src/packages/process-host/src/process-tree-kill-gate.ts similarity index 100% rename from src/shared/child-process/process-tree-kill-gate.ts rename to src/packages/process-host/src/process-tree-kill-gate.ts diff --git a/src/shared/child-process/process-tree-termination.test.ts b/src/packages/process-host/src/process-tree-termination.test.ts similarity index 89% rename from src/shared/child-process/process-tree-termination.test.ts rename to src/packages/process-host/src/process-tree-termination.test.ts index 26d95576b9f..55933a8b3be 100644 --- a/src/shared/child-process/process-tree-termination.test.ts +++ b/src/packages/process-host/src/process-tree-termination.test.ts @@ -1,23 +1,32 @@ import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' +import { ChildProcess } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal() + return { ...original, spawn: spawnMock } +}) import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination' import { setProcessTreeKillGate, type ProcessTreeKill } from './process-tree-kill-gate' import { windowsSystem32Binary } from './windows-system-binary' -function mockProcess(pid: number): ChildProcess { - const child = new EventEmitter() as EventEmitter & { - pid: number - kill: ReturnType +class StubbedChildProcess extends ChildProcess { + override pid: number + override exitCode: number | null = null + override kill = vi.fn((_signal?: NodeJS.Signals | number) => true) + + constructor(pid: number) { + super() + this.pid = pid } - child.pid = pid - child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true) - return child as unknown as ChildProcess +} + +function mockProcess(pid: number): StubbedChildProcess { + return new StubbedChildProcess(pid) } async function withWindows(run: () => Promise): Promise { @@ -170,7 +179,7 @@ describe('process-tree-kill breadcrumb seam', () => { await withWindows(async () => { // A reaped pid is Windows' to reissue, and this host may be the daemon or // relay, where the main-process own-Chromium guard cannot run. - const child = mockProcess(1234) as ChildProcess & { exitCode: number } + const child = mockProcess(1234) child.exitCode = 0 // `false`, not `true`: a taskkill against a reaped pid already resolved to diff --git a/src/shared/child-process/process-tree-termination.ts b/src/packages/process-host/src/process-tree-termination.ts similarity index 98% rename from src/shared/child-process/process-tree-termination.ts rename to src/packages/process-host/src/process-tree-termination.ts index 2e699e80a8f..6be61924ad1 100644 --- a/src/shared/child-process/process-tree-termination.ts +++ b/src/packages/process-host/src/process-tree-termination.ts @@ -210,7 +210,12 @@ function processGroupExists(processGroupId: number): boolean { process.kill(-processGroupId, 0) return true } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' + return !( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ESRCH' + ) } } diff --git a/src/shared/child-process/retryable-process-exit-proof.test.ts b/src/packages/process-host/src/retryable-process-exit-proof.test.ts similarity index 100% rename from src/shared/child-process/retryable-process-exit-proof.test.ts rename to src/packages/process-host/src/retryable-process-exit-proof.test.ts diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/packages/process-host/src/retryable-process-exit-proof.ts similarity index 100% rename from src/shared/child-process/retryable-process-exit-proof.ts rename to src/packages/process-host/src/retryable-process-exit-proof.ts diff --git a/src/shared/child-process/run-process-bytes.test.ts b/src/packages/process-host/src/run-process-bytes.test.ts similarity index 100% rename from src/shared/child-process/run-process-bytes.test.ts rename to src/packages/process-host/src/run-process-bytes.test.ts diff --git a/src/shared/child-process/run-process-termination-failure.test.ts b/src/packages/process-host/src/run-process-termination-failure.test.ts similarity index 100% rename from src/shared/child-process/run-process-termination-failure.test.ts rename to src/packages/process-host/src/run-process-termination-failure.test.ts diff --git a/src/shared/child-process/run-process.test.ts b/src/packages/process-host/src/run-process.test.ts similarity index 97% rename from src/shared/child-process/run-process.test.ts rename to src/packages/process-host/src/run-process.test.ts index 0e83e643d91..2b333cf89fe 100644 --- a/src/shared/child-process/run-process.test.ts +++ b/src/packages/process-host/src/run-process.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' -import { resolveSpawn, runProcess, runProcessSync } from './run-process' +import { runProcess, runProcessSync } from './run-process' +import { resolveSpawn } from './spawn-resolution' import { WINDOWS_ARGUMENT_CORPUS } from './__fixtures__/windows-argument-corpus' const SPEC = { program: 'C:\\bin\\agent.cmd', args: ['--prompt', 'hi'] } @@ -175,7 +176,8 @@ describe('abort', () => { const root = await mkdtemp(path.join(tmpdir(), 'run-process-barrier-')) const marker = path.join(root, 'descendant-state') const descendantScript = - `printf ready > "$1";trap 'printf signaled > "$1"' TERM;` + `while :;do sleep 1;done` + // Trap before ready: an abort right after ready must not hit sh's default TERM action. + `trap 'printf signaled > "$1"' TERM;printf ready > "$1";` + `while :;do sleep 1;done` const controller = new AbortController() const pending = runProcess({ program: process.execPath, diff --git a/src/shared/child-process/run-process.ts b/src/packages/process-host/src/run-process.ts similarity index 93% rename from src/shared/child-process/run-process.ts rename to src/packages/process-host/src/run-process.ts index 63265af47e5..7cf50c12c0d 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/packages/process-host/src/run-process.ts @@ -1,8 +1,7 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, - type ChildProcess, - type ChildProcessWithoutNullStreams + type ChildProcess } from 'node:child_process' import { resolveSpawn } from './spawn-resolution' import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination' @@ -11,16 +10,12 @@ import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer' import { createOutputSink } from './bounded-output-sink' import { createChildTerminationReporter } from './child-termination-reporter' -export type { - ChildProcessHandle, - SpawnedProcess, +import type { + PipedChildProcess, + PipedProcessSpec, ProcessSpec, - ProcessTerminationBarrier, ProcessResult } from './process-spec' -export { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec' -export { resolveSpawn, type ResolvedSpawn } from './spawn-resolution' -import type { ProcessSpec, ProcessResult } from './process-spec' import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec' /** * Grace between the timeout kill and giving up on the child's exit. @@ -49,17 +44,14 @@ const BARRIER_UNVERIFIED_EXIT_GRACE_MS = 10_000 * `runProcess` handles that for you; here it cannot, because a blanket handler * would also defeat callers that track and remove their own listeners. */ -export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams { +export function spawnProcess(spec: PipedProcessSpec): PipedChildProcess +export function spawnProcess(spec: ProcessSpec): ChildProcess +export function spawnProcess(spec: ProcessSpec): ChildProcess { const resolved = resolveSpawn(spec, process.platform) // Diagnostics only: uv_spawn runs synchronously on the calling thread, so this // brackets the main-thread block for every child started through the wrapper. const spawnStartedAt = hasSpawnObserver() ? performance.now() : null - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveSpawn never sets stdio to 'ignore'/'inherit' for a stream slot, so stdin/stdout/stderr are always pipes. - const child = nodeSpawn( - resolved.file, - [...resolved.args], - resolved.options - ) as ChildProcessWithoutNullStreams + const child = nodeSpawn(resolved.file, [...resolved.args], resolved.options) if (spawnStartedAt !== null) { notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt) } @@ -354,7 +346,10 @@ export function runProcessSync(spec: ProcessSpec): ProcessResult { maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, encoding: 'buffer' }) - if (result.error && (result.error as NodeJS.ErrnoException).code !== 'ETIMEDOUT') { + const timedOut = result.error + ? 'code' in result.error && result.error.code === 'ETIMEDOUT' + : false + if (result.error && !timedOut) { throw result.error } return { @@ -370,6 +365,6 @@ export function runProcessSync(spec: ProcessSpec): ProcessResult { // does anything else that terminates the child, and only a timeout also // sets this error. Reading the signal alone reports a deliberately // stopped process as having timed out, which callers retry. - timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' + timedOut } } diff --git a/src/shared/child-process/spawn-observer.test.ts b/src/packages/process-host/src/spawn-observer.test.ts similarity index 100% rename from src/shared/child-process/spawn-observer.test.ts rename to src/packages/process-host/src/spawn-observer.test.ts diff --git a/src/shared/child-process/spawn-observer.ts b/src/packages/process-host/src/spawn-observer.ts similarity index 100% rename from src/shared/child-process/spawn-observer.ts rename to src/packages/process-host/src/spawn-observer.ts diff --git a/src/packages/process-host/src/spawn-process-stdio.test.ts b/src/packages/process-host/src/spawn-process-stdio.test.ts new file mode 100644 index 00000000000..cfa9613e4aa --- /dev/null +++ b/src/packages/process-host/src/spawn-process-stdio.test.ts @@ -0,0 +1,91 @@ +import { once } from 'node:events' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle, PipedChildProcess, ProcessSpec } from './process-spec' + +async function waitForClose(child: ChildProcessHandle): Promise { + try { + await once(child, 'close', { signal: AbortSignal.timeout(5_000) }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const closing = once(child, 'close', { signal: AbortSignal.timeout(1_000) }) + child.kill('SIGKILL') + await closing + } + } +} + +describe('public spawn stream contracts', () => { + it('returns three pipes by default', async () => { + const child = spawnProcess({ program: process.execPath, args: ['-e', ''] }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).not.toBeNull() + expect(child.stdout).not.toBeNull() + expect(child.stderr).not.toBeNull() + }) + + it('returns three pipes for an explicit pipe mode', async () => { + const child = spawnProcess({ program: process.execPath, args: ['-e', ''], stdio: 'pipe' }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).not.toBeNull() + expect(child.stdout).not.toBeNull() + expect(child.stderr).not.toBeNull() + }) + + it('returns three pipes for an explicit pipe tuple', async () => { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', ''], + stdio: ['pipe', 'pipe', 'pipe'] + }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).not.toBeNull() + expect(child.stdout).not.toBeNull() + expect(child.stderr).not.toBeNull() + }) + + it('keeps three pipes when the tuple adds an IPC channel', async () => { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', 'process.disconnect()'], + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] + }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).not.toBeNull() + expect(child.stdout).not.toBeNull() + expect(child.stderr).not.toBeNull() + }) + + it.each(['ignore', 'inherit'] as const)('returns nullable streams for %s', async (stdio) => { + const child = spawnProcess({ program: process.execPath, args: ['-e', ''], stdio }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).toBeNull() + expect(child.stdout).toBeNull() + expect(child.stderr).toBeNull() + }) + + it('does not promise a stdin pipe when only the output slots are piped', async () => { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', ''], + stdio: ['ignore', 'pipe', 'pipe'] + }) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + expect(child.stdin).toBeNull() + expect(child.stdout).not.toBeNull() + expect(child.stderr).not.toBeNull() + }) + + it('keeps the general contract when the specification does not guarantee pipes', async () => { + const spec: ProcessSpec = { program: process.execPath, args: ['-e', ''] } + const child = spawnProcess(spec) + expectTypeOf(child).toEqualTypeOf() + await waitForClose(child) + }) +}) diff --git a/src/shared/child-process/spawn-resolution.ts b/src/packages/process-host/src/spawn-resolution.ts similarity index 100% rename from src/shared/child-process/spawn-resolution.ts rename to src/packages/process-host/src/spawn-resolution.ts diff --git a/src/shared/child-process/windows-cmd-shim-resolution.ts b/src/packages/process-host/src/windows-cmd-shim-resolution.ts similarity index 100% rename from src/shared/child-process/windows-cmd-shim-resolution.ts rename to src/packages/process-host/src/windows-cmd-shim-resolution.ts diff --git a/src/shared/child-process/windows-command-line.test.ts b/src/packages/process-host/src/windows-command-line.test.ts similarity index 100% rename from src/shared/child-process/windows-command-line.test.ts rename to src/packages/process-host/src/windows-command-line.test.ts diff --git a/src/shared/child-process/windows-command-line.ts b/src/packages/process-host/src/windows-command-line.ts similarity index 100% rename from src/shared/child-process/windows-command-line.ts rename to src/packages/process-host/src/windows-command-line.ts diff --git a/src/shared/child-process/windows-system-binary.ts b/src/packages/process-host/src/windows-system-binary.ts similarity index 100% rename from src/shared/child-process/windows-system-binary.ts rename to src/packages/process-host/src/windows-system-binary.ts diff --git a/src/packages/process-host/tsconfig.json b/src/packages/process-host/tsconfig.json new file mode 100644 index 00000000000..1f2045c8409 --- /dev/null +++ b/src/packages/process-host/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node18", + "moduleResolution": "Node16", + "strict": true, + "types": ["node"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmitOnError": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/__fixtures__/**"] +} diff --git a/src/packages/process-host/tsconfig.test.json b/src/packages/process-host/tsconfig.test.json new file mode 100644 index 00000000000..495c6f921b3 --- /dev/null +++ b/src/packages/process-host/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/src/packages/process-host/vitest.config.ts b/src/packages/process-host/vitest.config.ts new file mode 100644 index 00000000000..5a7d0dda05c --- /dev/null +++ b/src/packages/process-host/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { conditions: ['orca-source'] }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + pool: 'forks', + maxWorkers: 2, + testTimeout: 30_000 + } +}) diff --git a/src/relay/fs-handler-git-search-capacity.test.ts b/src/relay/fs-handler-git-search-capacity.test.ts index 34a22a98264..6ac532ccfb4 100644 --- a/src/relay/fs-handler-git-search-capacity.test.ts +++ b/src/relay/fs-handler-git-search-capacity.test.ts @@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ spawnProcess: spawnMock })) +vi.mock('@orca/process-host', () => ({ spawnProcess: spawnMock })) import { searchWithGitGrep } from './fs-handler-git-search' import { GitGrepRecordCapacityError } from '../shared/git-grep-record-limit' diff --git a/src/relay/fs-handler-git-search-real-capacity.test.ts b/src/relay/fs-handler-git-search-real-capacity.test.ts index de980e6cf79..48292adccb4 100644 --- a/src/relay/fs-handler-git-search-real-capacity.test.ts +++ b/src/relay/fs-handler-git-search-real-capacity.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { searchWithGitGrep } from './fs-handler-git-search' import { GitGrepRecordCapacityError } from '../shared/git-grep-record-limit' diff --git a/src/relay/fs-handler-git-search.ts b/src/relay/fs-handler-git-search.ts index 87252e2f215..27703465b33 100644 --- a/src/relay/fs-handler-git-search.ts +++ b/src/relay/fs-handler-git-search.ts @@ -3,7 +3,7 @@ import { GIT_GREP_MAX_RECORD_BYTES } from '../shared/git-grep-record-limit' import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { spawnProcess } from '../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { abortSignalReason } from '../shared/abort-signal-reason' import type { SearchOptions, SearchResult } from './fs-handler-utils' import { diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts index 64cdcf601d8..a4d1a1fbaec 100644 --- a/src/relay/fs-handler-ripgrep-fallback.test.ts +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -1,5 +1,6 @@ +import type { ProcessResult } from '@orca/process-host/process-spec' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcessModule from '../shared/child-process/run-process' +import type * as RunProcessModule from '@orca/process-host' import type * as FsHandlerGitFallback from './fs-handler-git-fallback' import type * as FsHandlerUtils from './fs-handler-utils' @@ -19,7 +20,7 @@ const { searchWithRgMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', async (importOriginal) => ({ +vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal()), runProcess: runProcessMock })) @@ -155,7 +156,7 @@ describe('relay direct ripgrep admission', () => { it.each([0, 128])('lets cancellation during the Git probe win its exit (%s)', async (code) => { const controller = new AbortController() const cancellation = new FileListingCancelledError('superseded') - const probe = Promise.withResolvers() + const probe = Promise.withResolvers() listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) runProcessMock.mockReturnValueOnce(probe.promise) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 2af88784b9a..a7d47f63862 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -7,7 +7,7 @@ import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' * so they are straightforward to test independently. */ import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { spawnProcess } from '../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { abortSignalReason } from '../shared/abort-signal-reason' import { open } from 'node:fs/promises' import { diff --git a/src/relay/fs-list-files-fallback-chain.ts b/src/relay/fs-list-files-fallback-chain.ts index d1d377f132d..1d705591d3a 100644 --- a/src/relay/fs-list-files-fallback-chain.ts +++ b/src/relay/fs-list-files-fallback-chain.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { listFilesWithRg } from './fs-handler-utils' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' diff --git a/src/relay/fs-markdown-document-launch.test.ts b/src/relay/fs-markdown-document-launch.test.ts index fe39911228a..b79171badce 100644 --- a/src/relay/fs-markdown-document-launch.test.ts +++ b/src/relay/fs-markdown-document-launch.test.ts @@ -7,7 +7,7 @@ const { spawnMock, cwdUsableMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), cwdUsableMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ spawnProcess: spawnMock })) +vi.mock('@orca/process-host', () => ({ spawnProcess: spawnMock })) vi.mock('./relay-bundled-ripgrep', () => ({ resolveRelayRipgrepCommand: () => '/tools/rg' })) vi.mock('../shared/ripgrep-process-availability', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/relay/fs-markdown-document-listing.ts b/src/relay/fs-markdown-document-listing.ts index 487e672789c..2aaee521b88 100644 --- a/src/relay/fs-markdown-document-listing.ts +++ b/src/relay/fs-markdown-document-listing.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from '../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { collectMarkdownDocuments, MARKDOWN_DOCUMENT_GLOB diff --git a/src/relay/git-command-admission.test.ts b/src/relay/git-command-admission.test.ts index 96530e004e3..91b6a712d8f 100644 --- a/src/relay/git-command-admission.test.ts +++ b/src/relay/git-command-admission.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec } from '../shared/child-process/process-spec' +import type { ProcessSpec } from '@orca/process-host/process-spec' import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' const { capture } = vi.hoisted(() => ({ capture: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess: capture })) +vi.mock('@orca/process-host', () => ({ runProcess: capture })) import { _resetRelayGitAdmissionForTests, diff --git a/src/relay/git-handler-clone-progress-capture.test.ts b/src/relay/git-handler-clone-progress-capture.test.ts index 2c560d6d3d0..4e2bda7da31 100644 --- a/src/relay/git-handler-clone-progress-capture.test.ts +++ b/src/relay/git-handler-clone-progress-capture.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ChildProcess } from 'node:child_process' import type * as ChildProcessModule from 'node:child_process' -import { createFakeSpawnedChild } from '../shared/child-process/__fixtures__/fake-spawned-child' +import { createFakeSpawnedChild } from '../shared/__fixtures__/fake-spawned-child' import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' import { createGitHandlerRelay } from './git-handler-test-harness' import { _resetRelayGitAdmissionForTests } from './git-handler-command-termination' @@ -15,7 +15,7 @@ vi.mock('node:child_process', async (importOriginal) => ({ ...(await importOriginal()), spawn })) -vi.mock('../shared/child-process/process-tree-termination', () => ({ +vi.mock('@orca/process-host/process-tree-termination', () => ({ signalProcessTree: signalTree, forceTerminateProcessTree: forceTree })) diff --git a/src/relay/git-handler-command-termination.ts b/src/relay/git-handler-command-termination.ts index f028292b03d..f8cb07382f5 100644 --- a/src/relay/git-handler-command-termination.ts +++ b/src/relay/git-handler-command-termination.ts @@ -1,9 +1,9 @@ import { signalProcessTree, forceTerminateProcessTree -} from '../shared/child-process/process-tree-termination' -import type { ProcessTerminationBarrier } from '../shared/child-process/process-spec' -import { runProcess } from '../shared/child-process/run-process' +} from '@orca/process-host/process-tree-termination' +import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec' +import { runProcess } from '@orca/process-host' import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' import type { GitAdmissionRequest } from '../shared/git-admission-state' import { gitCommandTimeoutMs } from '../shared/git-command-timeout' diff --git a/src/relay/git-handler-commit-metadata.test.ts b/src/relay/git-handler-commit-metadata.test.ts index 3c94ce92bc3..dd81d834cf1 100644 --- a/src/relay/git-handler-commit-metadata.test.ts +++ b/src/relay/git-handler-commit-metadata.test.ts @@ -1,7 +1,7 @@ import { unlink, writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { commitCompare } from './git-handler-commit-diff-ops' import type { GitExec } from './git-handler-ops' import { gitCommit, gitInit } from './git-handler-test-setup' diff --git a/src/relay/git-handler-diff-blobs.test.ts b/src/relay/git-handler-diff-blobs.test.ts index 88d1d167f2e..37a6962de9c 100644 --- a/src/relay/git-handler-diff-blobs.test.ts +++ b/src/relay/git-handler-diff-blobs.test.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { commitDiffEntry } from './git-handler-commit-diff-ops' import { computeDiff, type GitBufferExec } from './git-handler-ops' import { gitCommit, gitInit, type MockDispatcher } from './git-handler-test-setup' diff --git a/src/relay/git-handler-review-draft.test.ts b/src/relay/git-handler-review-draft.test.ts index c28bd63e872..bf20de570d9 100644 --- a/src/relay/git-handler-review-draft.test.ts +++ b/src/relay/git-handler-review-draft.test.ts @@ -1,7 +1,7 @@ import { readFile, unlink, writeFile } from 'node:fs/promises' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as processRunner from '../shared/child-process/run-process' +import * as processRunner from '@orca/process-host' import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' import { GIT_READ_TIMEOUT_MS } from '../shared/git-command-timeout' import type { GitHandler } from './git-handler' diff --git a/src/relay/git-handler-termination.test.ts b/src/relay/git-handler-termination.test.ts index 86189374c53..5b3550e0ef4 100644 --- a/src/relay/git-handler-termination.test.ts +++ b/src/relay/git-handler-termination.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { createGitHandlerRelay } from './git-handler-test-harness' diff --git a/src/relay/git-review-draft-binary-compatibility.test.ts b/src/relay/git-review-draft-binary-compatibility.test.ts index bbc9fca7c59..451da5d9106 100644 --- a/src/relay/git-review-draft-binary-compatibility.test.ts +++ b/src/relay/git-review-draft-binary-compatibility.test.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { createGitHandlerRelay, createGitTempDir, diff --git a/src/relay/git-stdout-stream.test.ts b/src/relay/git-stdout-stream.test.ts index 65145d7e204..5018f15ecb3 100644 --- a/src/relay/git-stdout-stream.test.ts +++ b/src/relay/git-stdout-stream.test.ts @@ -1,5 +1,5 @@ -import type * as ProcessRunner from '../shared/child-process/run-process' -import type * as ProcessTreeTermination from '../shared/child-process/process-tree-termination' +import type * as ProcessRunner from '@orca/process-host' +import type * as ProcessTreeTermination from '@orca/process-host/process-tree-termination' import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,11 +8,11 @@ const { spawnMock, terminateMock } = vi.hoisted(() => ({ terminateMock: vi.fn() })) -vi.mock('../shared/child-process/run-process', async (importActual) => ({ +vi.mock('@orca/process-host', async (importActual) => ({ ...(await importActual()), spawnProcess: spawnMock })) -vi.mock('../shared/child-process/process-tree-termination', async (importActual) => ({ +vi.mock('@orca/process-host/process-tree-termination', async (importActual) => ({ ...(await importActual()), forceTerminateProcessTree: terminateMock })) diff --git a/src/relay/git-stdout-stream.ts b/src/relay/git-stdout-stream.ts index 914cccfd114..7354544a809 100644 --- a/src/relay/git-stdout-stream.ts +++ b/src/relay/git-stdout-stream.ts @@ -1,7 +1,7 @@ import { StringDecoder } from 'node:string_decoder' -import { spawnProcess } from '../shared/child-process/run-process' -import { forceTerminateProcessTree } from '../shared/child-process/process-tree-termination' -import { createChildTerminationReporter } from '../shared/child-process/child-termination-reporter' +import { spawnProcess } from '@orca/process-host' +import { forceTerminateProcessTree } from '@orca/process-host/process-tree-termination' +import { createChildTerminationReporter } from '@orca/process-host/child-termination-reporter' import { GitCommandTimeoutError, gitCommandTimeoutMs } from '../shared/git-command-timeout' import { expandTilde } from './context' import { buildRelayGitEnv } from './relay-command-env' diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts index 16a051c2302..5e8b0edb935 100644 --- a/src/relay/node-pty-binding-survey.ts +++ b/src/relay/node-pty-binding-survey.ts @@ -24,7 +24,7 @@ import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { release } from 'node:os' import process from 'node:process' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildToolchainProbeCommand, parseBuildToolchainProbe, diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts index 8697368567f..4765aecbdbd 100644 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b5d3cc73936..0a415f72015 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -31,7 +31,7 @@ vi.mock('../main/wsl', () => ({ listWslDistrosAsync: listWslDistrosAsyncMock })) vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) -vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { buildCommandLookupSpecs, diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a98a32af3a8..28324b95a7e 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -8,7 +8,7 @@ import { isPwshAvailableAsync } from '../main/pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' const execFileAsync = promisify(execFile) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index c28e2693854..39ab83503a1 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path' import * as ptyChildProcessInspection from './pty-child-process-inspection' import * as ptyShellUtils from './pty-shell-utils' import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' -import * as runProcessModule from '../shared/child-process/run-process' +import * as runProcessModule from '@orca/process-host' import * as nodePtyBindingSurvey from './node-pty-binding-survey' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ diff --git a/src/relay/relay-agent-process-lifetime.test.ts b/src/relay/relay-agent-process-lifetime.test.ts index 1b462eff64e..a94d124a892 100644 --- a/src/relay/relay-agent-process-lifetime.test.ts +++ b/src/relay/relay-agent-process-lifetime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { spawnProcess } from '../shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' describe.skipIf(process.platform === 'win32')('RelayAgentProcessLifetime', () => { diff --git a/src/relay/relay-endpoint-credential-publication.ts b/src/relay/relay-endpoint-credential-publication.ts index 7c9b675507e..914c9f4e998 100644 --- a/src/relay/relay-endpoint-credential-publication.ts +++ b/src/relay/relay-endpoint-credential-publication.ts @@ -10,7 +10,7 @@ */ import { randomBytes } from 'node:crypto' import { chmodSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { relayLogLine } from './relay-diagnostic-log' const ENDPOINT_CREDENTIAL_PATTERN = /^[A-Za-z0-9_-]{32,256}$/ diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts index 9fb074180b9..b8ba9e27d4d 100644 --- a/src/relay/windows-port-scan.test.ts +++ b/src/relay/windows-port-scan.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.fn() -vi.mock('../shared/child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: (spec: unknown) => runProcessMock(spec) })) diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts index f99fce4a087..9fcb5a61b4d 100644 --- a/src/relay/windows-port-scan.ts +++ b/src/relay/windows-port-scan.ts @@ -1,9 +1,9 @@ import { readWindowsProcessTable } from '../main/windows/windows-process-table' -import { runProcess } from '../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { windowsPowerShellPath, windowsSystem32Binary -} from '../shared/child-process/windows-system-binary' +} from '@orca/process-host/windows-system-binary' import { getProcessOutputFields } from '../shared/process-output-field-scanner' import type { DetectedPort } from './port-scan-handler' import { buildRelayCommandEnv } from './relay-command-env' diff --git a/src/relay/windows-port-scan.win32.test.ts b/src/relay/windows-port-scan.win32.test.ts index 976f477cd9c..07650bc2d01 100644 --- a/src/relay/windows-port-scan.win32.test.ts +++ b/src/relay/windows-port-scan.win32.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' -import { windowsPowerShellPath } from '../shared/child-process/windows-system-binary' +import { runProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' import { WINDOWS_PORT_SCAN_SCRIPT, parseWindowsPowerShellPortRows, diff --git a/src/renderer/src/components/editor/markdown-preview-search-memory.test.ts b/src/renderer/src/components/editor/markdown-preview-search-memory.test.ts index f60b462b05b..e506fd64614 100644 --- a/src/renderer/src/components/editor/markdown-preview-search-memory.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-search-memory.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { build } from 'esbuild' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '../../../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' let scratch = '' let childPath = '' diff --git a/src/shared/child-process/__fixtures__/fake-spawned-child.ts b/src/shared/__fixtures__/fake-spawned-child.ts similarity index 83% rename from src/shared/child-process/__fixtures__/fake-spawned-child.ts rename to src/shared/__fixtures__/fake-spawned-child.ts index 7183bc12e07..8e0e852ce1c 100644 --- a/src/shared/child-process/__fixtures__/fake-spawned-child.ts +++ b/src/shared/__fixtures__/fake-spawned-child.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'node:events' import type { ChildProcess } from 'node:child_process' +import { PassThrough } from 'node:stream' +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { vi } from 'vitest' /** @@ -19,6 +21,20 @@ export function createFakeSpawnedChild(pid = 4321): ChildProcess { return child as unknown as ChildProcess } +export function createFakePipedChild(pid = 4321) { + const stdin = new PassThrough() + const stdout = new PassThrough() + const stderr = new PassThrough() + return Object.assign(createFakeSpawnedChild(pid), { + pid, + stdin, + stdout, + stderr, + stdio: [stdin, stdout, stderr, null, null] satisfies PipedChildProcess['stdio'], + kill: vi.fn((_signal?: NodeJS.Signals | number) => true) + }) +} + /** Emit output and a clean exit, the way a CLI that answered would. */ export function completeFakeSpawn( child: ChildProcess, diff --git a/src/shared/child-process/windows-cmd-shim-resolution.test.ts b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts similarity index 87% rename from src/shared/child-process/windows-cmd-shim-resolution.test.ts rename to src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts index 2005d6e8bf2..07a3a2f3e29 100644 --- a/src/shared/child-process/windows-cmd-shim-resolution.test.ts +++ b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts @@ -1,10 +1,14 @@ import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { removeTreeSync } from '../windows-transient-lock-removal' -import { parseWindowsCmdShim, resolveWindowsCmdShim } from './windows-cmd-shim-resolution' -import { resolveSpawn } from './run-process' +import type * as ChildProcessModule from 'node:child_process' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { removeTreeSync } from '../../windows-transient-lock-removal' +import { + parseWindowsCmdShim, + resolveWindowsCmdShim +} from '@orca/process-host/windows-cmd-shim-resolution' +import { spawnProcess } from '@orca/process-host' import { REAL_AGENT_BROWSER_CMD, REAL_CODEX_CMD, @@ -16,7 +20,17 @@ import { npmDirectShim, npmProgNodeShim, pnpmBranchedNodeShim -} from './__fixtures__/windows-cmd-shim-bodies' +} from '../../../packages/process-host/src/__fixtures__/windows-cmd-shim-bodies' + +const { nodeSpawn } = vi.hoisted(() => ({ nodeSpawn: vi.fn() })) + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + spawn: nodeSpawn.mockImplementation(() => new original.ChildProcess()) + } +}) describe('parseWindowsCmdShim', () => { it('reads the npm node shim MDE flagged (codex.cmd)', () => { @@ -102,10 +116,10 @@ describe('parseWindowsCmdShim', () => { // Only the captured path is case-sensitive, so the case cases compare // against the rewritten spelling. const parsed = parseWindowsCmdShim(rewrite(REAL_CODEX_CMD)) - expect(parsed?.kind).toBe('node') - expect((parsed as { script: string }).script.toLowerCase()).toBe( - 'node_modules\\@openai\\codex\\bin\\codex.js' - ) + if (parsed?.kind !== 'node') { + throw new Error('Expected a parsed Node shim') + } + expect(parsed.script.toLowerCase()).toBe('node_modules\\@openai\\codex\\bin\\codex.js') }) it('refuses lone-CR line endings, which leave the body as one unsplit line', () => { @@ -330,47 +344,47 @@ describeOnWindows('resolveWindowsCmdShim', () => { }) it('keeps cmd.exe out of the spawn for a recognised shim', () => { - const resolved = resolveSpawn( - { - program: write('spawned.cmd', npmProgNodeShim('cli.js')), - args: ['a b', 'c"d'], - env - }, - 'win32' - ) - expect(resolved.file.toLowerCase()).not.toContain('cmd.exe') - expect(resolved.args).toEqual([join(dir, 'cli.js'), 'a b', 'c"d']) + spawnProcess({ + program: write('spawned.cmd', npmProgNodeShim('cli.js')), + args: ['a b', 'c"d'], + env + }) // Node's own quoting is CommandLineToArgvW-correct; the verbatim line is // only needed for the cmd hop we just removed. - expect(resolved.options.windowsVerbatimArguments).toBeUndefined() + expect(nodeSpawn).toHaveBeenLastCalledWith( + expect.stringMatching(/node\.exe$/i), + [join(dir, 'cli.js'), 'a b', 'c"d'], + expect.objectContaining({ windowsVerbatimArguments: undefined }) + ) }) it('clears a caller-set windowsVerbatimArguments on the resolved path', () => { // The flag means "I built the whole command line, hand it through". There // is no such line here, so honouring it would make Node join // `[script, ...args]` unquoted and shred every argument with a space. - const resolved = resolveSpawn( - { - program: write('verbatim.cmd', npmProgNodeShim('cli.js')), - args: ['a b'], - env, - windowsVerbatimArguments: true - }, - 'win32' + spawnProcess({ + program: write('verbatim.cmd', npmProgNodeShim('cli.js')), + args: ['a b'], + env, + windowsVerbatimArguments: true + }) + expect(nodeSpawn).toHaveBeenLastCalledWith( + expect.stringMatching(/node\.exe$/i), + [join(dir, 'cli.js'), 'a b'], + expect.objectContaining({ windowsVerbatimArguments: undefined }) ) - expect(resolved.options.windowsVerbatimArguments).toBeUndefined() }) it('still routes an unrecognised .cmd through cmd.exe', () => { - const resolved = resolveSpawn( - { - program: write('plain.cmd', REAL_PN_CMD), - args: ['x'], - env: { ComSpec: 'C:\\W\\cmd.exe' } - }, - 'win32' + spawnProcess({ + program: write('plain.cmd', REAL_PN_CMD), + args: ['x'], + env: { ComSpec: 'C:\\W\\cmd.exe' } + }) + expect(nodeSpawn).toHaveBeenLastCalledWith( + 'C:\\W\\cmd.exe', + [expect.stringContaining('/d /v:off /s /c')], + expect.objectContaining({ windowsVerbatimArguments: true }) ) - expect(resolved.file).toBe('C:\\W\\cmd.exe') - expect(resolved.args[0]).toContain('/d /v:off /s /c') }) }) diff --git a/src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts similarity index 87% rename from src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts rename to src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts index 8e5fee32d41..fada9e8da20 100644 --- a/src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts +++ b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts @@ -2,10 +2,10 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { removeTreeSync } from '../windows-transient-lock-removal' -import { runProcess } from './run-process' -import { WINDOWS_ARGUMENT_CORPUS } from './__fixtures__/windows-argument-corpus' -import { npmProgNodeShim } from './__fixtures__/windows-cmd-shim-bodies' +import { removeTreeSync } from '../../windows-transient-lock-removal' +import { runProcess } from '@orca/process-host' +import { WINDOWS_ARGUMENT_CORPUS } from '../../../packages/process-host/src/__fixtures__/windows-argument-corpus' +import { npmProgNodeShim } from '../../../packages/process-host/src/__fixtures__/windows-cmd-shim-bodies' /** * The resolved path has to deliver exactly what the cmd.exe path delivers, for diff --git a/src/shared/child-process/windows-command-line.win32.test.ts b/src/shared/__tests__/process-host/windows-command-line.win32.test.ts similarity index 94% rename from src/shared/child-process/windows-command-line.win32.test.ts rename to src/shared/__tests__/process-host/windows-command-line.win32.test.ts index 8f5aa14660e..5d5bd18bc5c 100644 --- a/src/shared/child-process/windows-command-line.win32.test.ts +++ b/src/shared/__tests__/process-host/windows-command-line.win32.test.ts @@ -1,13 +1,13 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { removeTreeSync } from '../windows-transient-lock-removal' +import { removeTreeSync } from '../../windows-transient-lock-removal' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from './run-process' +import { runProcess } from '@orca/process-host' import { WINDOWS_ARGUMENT_CORPUS, WINDOWS_ARGUMENT_CORPUS_ENV -} from './__fixtures__/windows-argument-corpus' +} from '../../../packages/process-host/src/__fixtures__/windows-argument-corpus' /** * The other half of the encoding proof: the unit test checks the bytes against diff --git a/src/shared/agent-process-presence-platform.test.ts b/src/shared/agent-process-presence-platform.test.ts index ce29f18f094..3bcfad9f4bf 100644 --- a/src/shared/agent-process-presence-platform.test.ts +++ b/src/shared/agent-process-presence-platform.test.ts @@ -3,7 +3,7 @@ import { readAgentProcess } from './agent-process-presence-probe' const run = vi.hoisted(() => vi.fn()) const read = vi.hoisted(() => vi.fn()) -vi.mock('./child-process/run-process', () => ({ runProcess: run })) +vi.mock('@orca/process-host', () => ({ runProcess: run })) vi.mock('node:fs/promises', () => ({ readFile: read })) const hostProcess = process function platform(name: string): void { diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts index 22fac38b7db..5896e2f50ad 100644 --- a/src/shared/agent-process-presence-probe.ts +++ b/src/shared/agent-process-presence-probe.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' export type AgentProcessObservation = diff --git a/src/shared/cheap-process-table-snapshot-reader.ts b/src/shared/cheap-process-table-snapshot-reader.ts index 1d2401f2045..6de0a70c4ac 100644 --- a/src/shared/cheap-process-table-snapshot-reader.ts +++ b/src/shared/cheap-process-table-snapshot-reader.ts @@ -1,4 +1,4 @@ -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { CHEAP_PS_ARGS, PS_MAX_BUFFER_BYTES, diff --git a/src/shared/cheap-process-table-snapshot.test.ts b/src/shared/cheap-process-table-snapshot.test.ts index 74bbba2b69b..f0afe5763c0 100644 --- a/src/shared/cheap-process-table-snapshot.test.ts +++ b/src/shared/cheap-process-table-snapshot.test.ts @@ -4,7 +4,7 @@ const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) // The cheap reader goes through Orca's single child-process entry point (windowsHide, argv // encoding, tree termination); mock at that seam rather than node:child_process. -vi.mock('./child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { getCheapProcessTableSnapshot, diff --git a/src/shared/dsh-real-cli.test.ts b/src/shared/dsh-real-cli.test.ts index 6422b9884fa..3a7cc857ffd 100644 --- a/src/shared/dsh-real-cli.test.ts +++ b/src/shared/dsh-real-cli.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' const binary = process.env.ORCA_REAL_DSH_CLI diff --git a/src/shared/ephemeral-vm-recipe-output-capture.test.ts b/src/shared/ephemeral-vm-recipe-output-capture.test.ts index 27fe3c13466..ac59f41be86 100644 --- a/src/shared/ephemeral-vm-recipe-output-capture.test.ts +++ b/src/shared/ephemeral-vm-recipe-output-capture.test.ts @@ -7,7 +7,7 @@ import { DEFAULT_MAX_CAPTURE_BYTES, runRecipeCommand } from './ephemeral-vm-recipe-process' -import { GrowingByteBuffer } from './growing-byte-buffer' +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' afterEach(() => { vi.restoreAllMocks() diff --git a/src/shared/ephemeral-vm-recipe-process.ts b/src/shared/ephemeral-vm-recipe-process.ts index e943567095c..1557de74ca4 100644 --- a/src/shared/ephemeral-vm-recipe-process.ts +++ b/src/shared/ephemeral-vm-recipe-process.ts @@ -1,8 +1,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner' -import { admitProcessTreeKill } from './child-process/process-tree-kill-gate' -import { GrowingByteBuffer } from './growing-byte-buffer' +import { admitProcessTreeKill } from '@orca/process-host/process-tree-kill-gate' +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' export const DEFAULT_MAX_CAPTURE_BYTES = 1024 * 1024 const CANCEL_FORCE_KILL_DELAY_MS = 5_000 diff --git a/src/shared/git-ssh-policy-env.test.ts b/src/shared/git-ssh-policy-env.test.ts index 1e0cc53eece..f6218c1db6c 100644 --- a/src/shared/git-ssh-policy-env.test.ts +++ b/src/shared/git-ssh-policy-env.test.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { quotePosixShell } from './wsl-login-shell-command' import { describe, expect, it } from 'vitest' import { buildGitSshPolicyEnv, parseGitSshConfig } from './git-ssh-policy-env' diff --git a/src/shared/node-markdown-document-listing.ts b/src/shared/node-markdown-document-listing.ts index 954486458ec..3004b1ccc54 100644 --- a/src/shared/node-markdown-document-listing.ts +++ b/src/shared/node-markdown-document-listing.ts @@ -1,4 +1,4 @@ -import type { ChildProcessHandle } from './child-process/process-spec' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import type { MarkdownDocument } from './filesystem-entry-types' import { RipgrepFilenameDecoder } from './ripgrep-filename-decoder' import { isRipgrepMissingCwdExit, ripgrepMissingCwdError } from './ripgrep-process-availability' diff --git a/src/shared/omp-draft-launch-shell.test.ts b/src/shared/omp-draft-launch-shell.test.ts index 889b9020c52..85fcbb091a9 100644 --- a/src/shared/omp-draft-launch-shell.test.ts +++ b/src/shared/omp-draft-launch-shell.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildAgentDraftLaunchPlan } from './tui-agent-startup' const shells = ['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'].filter( diff --git a/src/shared/omp-draft-launch-windows.test.ts b/src/shared/omp-draft-launch-windows.test.ts index 5a09769f2e9..eb74b54723e 100644 --- a/src/shared/omp-draft-launch-windows.test.ts +++ b/src/shared/omp-draft-launch-windows.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildAgentDraftLaunchPlan } from './tui-agent-startup' it.skipIf(process.platform !== 'win32').each(['cmd', 'powershell'] as const)( diff --git a/src/shared/omp-fresh-launch-shell.test.ts b/src/shared/omp-fresh-launch-shell.test.ts index ebdc7581a9a..f3c2eb04198 100644 --- a/src/shared/omp-fresh-launch-shell.test.ts +++ b/src/shared/omp-fresh-launch-shell.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildAgentStartupPlan } from './tui-agent-startup' const roots: string[] = [] diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts index 3bbf5f4f7e8..661efdb4eca 100644 --- a/src/shared/opencode-plugin-permissions.test.ts +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -3,7 +3,7 @@ import { expect, it } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' it.skipIf(process.platform === 'win32')( 'retains existing permissions when the process umask is stricter', async () => { diff --git a/src/shared/powershell-native-argument.windows.test.ts b/src/shared/powershell-native-argument.windows.test.ts index 1bd81a27721..7c8acead625 100644 --- a/src/shared/powershell-native-argument.windows.test.ts +++ b/src/shared/powershell-native-argument.windows.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { quotePowerShellLiteral } from './powershell-native-argument' async function captureNativeArgv(root: string, quotedArg: string): Promise { diff --git a/src/shared/quick-open-listing-limits.ts b/src/shared/quick-open-listing-limits.ts index 09c4f113c92..acd485c4209 100644 --- a/src/shared/quick-open-listing-limits.ts +++ b/src/shared/quick-open-listing-limits.ts @@ -1,4 +1,4 @@ -import { GrowingByteBuffer } from './growing-byte-buffer' +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' export const QUICK_OPEN_LISTING_MAX_RESULTS = 20_001 const QUICK_OPEN_LISTING_MAX_PATH_BYTES = 64 * 1024 diff --git a/src/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts index 6fa761cc02e..a187388b902 100644 --- a/src/shared/ripgrep-process-availability.ts +++ b/src/shared/ripgrep-process-availability.ts @@ -1,6 +1,7 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' -import { spawnProcess, type ChildProcessHandle } from './child-process/run-process' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { abortSignalReason, throwIfSignalAborted } from './abort-signal-reason' const RIPGREP_CWD_CHECK_TIMEOUT_MS = 1000 diff --git a/src/shared/secure-file.test.ts b/src/shared/secure-file.test.ts index 2439c6d2137..7ac742e1ffd 100644 --- a/src/shared/secure-file.test.ts +++ b/src/shared/secure-file.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, runProcessSync } from './child-process/run-process' +import { runProcess, runProcessSync } from '@orca/process-host' import { mayAttemptHardening } from './secure-path-hardening-retry-budget' import { __getSecureFileHardeningCacheStateForTests, @@ -16,7 +16,7 @@ import { const posixModeIt = process.platform === 'win32' ? it.skip : it -vi.mock('./child-process/run-process', () => ({ +vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), runProcessSync: vi.fn() })) diff --git a/src/shared/secure-path-windows-acl.ts b/src/shared/secure-path-windows-acl.ts index ac0aac0d1ce..d5f49adf0c7 100644 --- a/src/shared/secure-path-windows-acl.ts +++ b/src/shared/secure-path-windows-acl.ts @@ -2,8 +2,8 @@ import { randomBytes } from 'node:crypto' import { readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, win32 as pathWin32 } from 'node:path' -import { runProcess, runProcessSync } from './child-process/run-process' -import { windowsSystem32Binary } from './child-process/windows-system-binary' +import { runProcess, runProcessSync } from '@orca/process-host' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' import { reportSecurePathHardening, type SecurePathHardeningReport diff --git a/src/shared/secure-path-windows-acl.win32.test.ts b/src/shared/secure-path-windows-acl.win32.test.ts index 7ae193d70c5..eea6bb914b2 100644 --- a/src/shared/secure-path-windows-acl.win32.test.ts +++ b/src/shared/secure-path-windows-acl.win32.test.ts @@ -2,8 +2,8 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from './child-process/run-process' -import { windowsSystem32Binary } from './child-process/windows-system-binary' +import { runProcessSync } from '@orca/process-host' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' import { setSecurePathHardeningReporter, type SecurePathHardeningReport diff --git a/src/shared/text-search-dense-matches.test.ts b/src/shared/text-search-dense-matches.test.ts index 548b04d70e4..c49b0ac77b6 100644 --- a/src/shared/text-search-dense-matches.test.ts +++ b/src/shared/text-search-dense-matches.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { buildRgArgs, createAccumulator, ingestRgJsonLine } from './text-search' describe('text search match budgets', () => { diff --git a/src/shared/text-search-glob-patterns.test.ts b/src/shared/text-search-glob-patterns.test.ts index 0bd96723a9d..5fbb9ebec6b 100644 --- a/src/shared/text-search-glob-patterns.test.ts +++ b/src/shared/text-search-glob-patterns.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { splitSearchGlobPatterns } from './text-search-glob-patterns' import { buildGitGrepArgs, diff --git a/src/shared/tmux-host-attachment-probe.test.ts b/src/shared/tmux-host-attachment-probe.test.ts index f78c214a66c..edaa3404818 100644 --- a/src/shared/tmux-host-attachment-probe.test.ts +++ b/src/shared/tmux-host-attachment-probe.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { probeTmuxHostAttachments } from './tmux-host-attachment-probe' const run = vi.hoisted(() => vi.fn()) -vi.mock('./child-process/run-process', () => ({ runProcess: run })) +vi.mock('@orca/process-host', () => ({ runProcess: run })) vi.mock('node:fs/promises', () => ({ stat: async () => ({ isSocket: () => true, uid: process.getuid?.() }) })) diff --git a/src/shared/tmux-host-attachment-probe.ts b/src/shared/tmux-host-attachment-probe.ts index 3adcfe303cb..877b62ac5ef 100644 --- a/src/shared/tmux-host-attachment-probe.ts +++ b/src/shared/tmux-host-attachment-probe.ts @@ -1,6 +1,6 @@ import { stat } from 'node:fs/promises' import { readAgentProcess } from './agent-process-presence-probe' -import { runProcess } from './child-process/run-process' +import { runProcess } from '@orca/process-host' import { PS_ARGS, parseStrictProcessTableRows, diff --git a/src/shared/windows-breakaway-launcher.test.ts b/src/shared/windows-breakaway-launcher.test.ts index 9da748ad7a6..86a8c590a83 100644 --- a/src/shared/windows-breakaway-launcher.test.ts +++ b/src/shared/windows-breakaway-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { quoteWindowsArgument } from './child-process/windows-command-line' +import { quoteWindowsArgument } from '@orca/process-host/windows-command-line' import { ORCAD_WINDOWS_BREAKAWAY_CONTRACT, parseWindowsBreakawayLaunchReport, diff --git a/src/shared/windows-breakaway-launcher.ts b/src/shared/windows-breakaway-launcher.ts index 56de69cb65d..7add2860d36 100644 --- a/src/shared/windows-breakaway-launcher.ts +++ b/src/shared/windows-breakaway-launcher.ts @@ -9,7 +9,7 @@ */ import { closeSync, openSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { quoteWindowsArgument } from './child-process/windows-command-line' +import { quoteWindowsArgument } from '@orca/process-host/windows-command-line' import { formatWindowsBreakawayLaunchReport, WINDOWS_BREAKAWAY_ENV_FLAG, diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index d79ceafc327..bfda5598cdd 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -1,5 +1,7 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { dirname, join, relative } from 'node:path' +import { parse } from 'yaml' +import { createWorkspaceSourceResolver } from '../../../config/scripts/workspace-source-exports.mjs' import { collectPackageImports, installMissingPackageStandIns, @@ -13,6 +15,7 @@ const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 // the shared codec, plus the relay an app update leaves running. Skipping cli keeps a cold CI // extraction a few seconds. // The phone's `worktree ps` row reader is one self-contained file, so it rides along alone. +// Workspace packages the release declares are added per release (releaseWorkspaceArchivePaths). const ARCHIVE_PATHS = [ 'src/main', 'src/shared', @@ -23,15 +26,24 @@ const ARCHIVE_PATHS = [ 'mobile/src/worktree/agent-row-display.ts' ] +const WORKSPACE_POLICY = 'pnpm-workspace.yaml' + const ALIAS_SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g +// Static, side-effect, lazy and CommonJS loads of a bare specifier. +const BARE_SPECIFIER = + /(\bfrom\s*|^\s*import\s*|(? + +// Every module extension an orca-source export may target, so package JS is rewritten too. function isRewritableSource(name: string): boolean { - return name.endsWith('.ts') || name.endsWith('.tsx') + return /\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(name) } function isTestSource(name: string): boolean { - return /\.(test|bench|spec)\.(ts|tsx)$/.test(name) + return /\.(test|bench|spec)\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(name) } /** Keep renderer aliases inside the extracted release rather than the working tree. */ @@ -45,13 +57,40 @@ async function rewriteRendererAliases( } const rewritten = source.replace( ALIAS_SPECIFIER, - (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => { - const absolute = join(rendererRoot, target) - let relativePath = relative(dirname(file), absolute).split('\\').join('/') - if (!relativePath.startsWith('.')) { - relativePath = `./${relativePath}` + (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => + `${keyword}${quote}${relativeSpecifier(file, join(rendererRoot, target))}${quote}` + ) + if (rewritten !== source) { + await writeFile(file, rewritten) + } + return rewritten +} + +function relativeSpecifier(file: string, absolute: string): string { + const relativePath = relative(dirname(file), absolute).split('\\').join('/') + return relativePath.startsWith('.') ? relativePath : `./${relativePath}` +} + +/** Why: the install link and vitest's workspace aliases would otherwise load current packages. */ +async function rewriteWorkspaceImports( + root: string, + file: string, + source: string, + resolver: WorkspaceSourceResolver +): Promise { + const rewritten = source.replace( + BARE_SPECIFIER, + (match, keyword: string, quote: string, specifier: string) => { + let target: ReturnType + try { + target = resolver.resolve(specifier) + } catch (error) { + throw new Error(`${relative(root, file)}: ${String(error)}`) } - return `${keyword}${quote}${relativePath}${quote}` + // Native packages can't be built from the release, so they keep the current install. + return target + ? `${keyword}${quote}${relativeSpecifier(file, join(root, target.file))}${quote}` + : match } ) if (rewritten !== source) { @@ -60,7 +99,12 @@ async function rewriteRendererAliases( return rewritten } -async function prepareExtractedTree(root: string, packageImports: PackageImports): Promise { +async function prepareExtractedTree( + root: string, + workspacePaths: string[], + packageImports: PackageImports +): Promise { + const resolver = workspacePaths.length > 0 ? createWorkspaceSourceResolver(root) : null const rendererRoot = join(root, 'src', 'renderer', 'src') const walk = async (directory: string): Promise => { for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -78,15 +122,21 @@ async function prepareExtractedTree(root: string, packageImports: PackageImports continue } if (isRewritableSource(entry.name)) { - const source = await readFile(full, 'utf8') - collectPackageImports( - await rewriteRendererAliases(full, source, rendererRoot), - packageImports - ) + let source = await rewriteRendererAliases(full, await readFile(full, 'utf8'), rendererRoot) + if (resolver) { + source = await rewriteWorkspaceImports(root, full, source, resolver) + } + collectPackageImports(source, packageImports) } } } await walk(join(root, 'src')) + // Source packages outside src/ load from here too; native ones keep the current install. + for (const path of workspacePaths) { + if (path !== WORKSPACE_POLICY && !path.startsWith('src/') && !path.startsWith('native/')) { + await walk(join(root, path)) + } + } } function checkoutTarProgram(): string { @@ -102,9 +152,9 @@ async function runCheckoutProcess( program: string, args: string[], deadline: number -): Promise { +): Promise { // Kept lazy so plain Node 24 contention children never load Vite's TS graph. - const { runProcess } = await import('../../../src/shared/child-process/run-process') + const { runProcess } = await import('@orca/process-host') const result = await runProcess({ program, args, @@ -114,7 +164,7 @@ async function runCheckoutProcess( terminationBarrier: true }) if (result.code === 0 && !result.timedOut) { - return + return result.stdout } const detail = result.timedOut ? `timed out after ${CHECKOUT_PROCESS_TIMEOUT_MS}ms` @@ -122,6 +172,61 @@ async function runCheckoutProcess( throw new Error(`${program} ${args[0] ?? ''} ${detail}`) } +/** Static directory prefix of a workspace glob, e.g. `src/packages` for `src/packages/*`. */ +function workspacePatternRoot(pattern: string): string { + const segments: string[] = [] + for (const segment of pattern.replace(/\/$/, '').split('/')) { + if (/[*?[\]{}!]/.test(segment)) { + break + } + segments.push(segment) + } + if (segments.length === 0 || segments.some((segment) => segment === '..' || segment === '.')) { + throw new Error(`Unsupported release workspace package pattern: ${pattern}`) + } + return segments.join('/') +} + +/** The release's workspace policy and declared package directories; none before workspaces. */ +async function releaseWorkspaceArchivePaths( + repoRoot: string, + commit: string, + deadline: number +): Promise { + const listed = async (paths: string[]): Promise => + ( + await runCheckoutProcess( + repoRoot, + 'git', + ['ls-tree', '-z', '--name-only', commit, '--', ...paths], + deadline + ) + ) + .split('\0') + .filter(Boolean) + if ((await listed([WORKSPACE_POLICY])).length === 0) { + return [] + } + const policy = await runCheckoutProcess( + repoRoot, + 'git', + ['cat-file', 'blob', `${commit}:${WORKSPACE_POLICY}`], + deadline + ) + const { packages = [] } = parse(policy) ?? {} + if (!Array.isArray(packages) || packages.some((pattern) => typeof pattern !== 'string')) { + throw new Error(`Release ${commit} has unreadable workspace package patterns`) + } + const roots = [ + ...new Set( + packages + .filter((pattern: string) => !pattern.startsWith('!')) + .map((pattern: string) => workspacePatternRoot(pattern)) + ) + ] + return [WORKSPACE_POLICY, ...(roots.length > 0 ? await listed(roots) : [])] +} + export async function extractReleaseCheckoutTree( repoRoot: string, staging: string, @@ -129,11 +234,20 @@ export async function extractReleaseCheckoutTree( ): Promise { const archive = join(staging, '.release-checkout.tar') const deadline = Date.now() + CHECKOUT_PROCESS_TIMEOUT_MS + const workspacePaths = await releaseWorkspaceArchivePaths(repoRoot, commit, deadline) try { await runCheckoutProcess( repoRoot, 'git', - ['archive', '--format=tar', `--output=${archive}`, commit, '--', ...ARCHIVE_PATHS], + [ + 'archive', + '--format=tar', + `--output=${archive}`, + commit, + '--', + ...ARCHIVE_PATHS, + ...workspacePaths + ], deadline ) await runCheckoutProcess( @@ -146,7 +260,7 @@ export async function extractReleaseCheckoutTree( await rm(archive, { force: true }) } const packageImports: PackageImports = new Map() - await prepareExtractedTree(staging, packageImports) + await prepareExtractedTree(staging, workspacePaths, packageImports) await installMissingPackageStandIns( staging, commit, diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts new file mode 100644 index 00000000000..0d24c093978 --- /dev/null +++ b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts @@ -0,0 +1,206 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import * as currentProcessHost from '@orca/process-host' +import { importReleaseCheckoutModule } from './release-checkout' +import { extractReleaseCheckoutTree } from './release-checkout-tree' + +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function temporaryDirectory(prefix: string): string { + // Why realpath: vite reports module urls through macOS's /var -> /private/var symlink. + const root = realpathSync(mkdtempSync(join(tmpdir(), prefix))) + temporaryRoots.push(root) + return root +} + +// Every fixed archive path must exist in a release, or `git archive` refuses the pathspec. +const RELEASE_SKELETON: Record = Object.fromEntries( + [ + 'src/main/placeholder.ts', + 'src/shared/placeholder.ts', + 'src/preload/placeholder.ts', + 'src/relay/placeholder.ts', + 'src/renderer/src/placeholder.ts', + 'src/types/placeholder.ts', + 'mobile/src/worktree/agent-row-display.ts' + ].map((path) => [path, 'export {}\n']) +) + +/** A throwaway repository whose single commit plays the release. */ +function commitRelease(files: Record): { repo: string; commit: string } { + const repo = temporaryDirectory('orca-cross-version-release-repo-') + for (const [path, source] of Object.entries({ ...RELEASE_SKELETON, ...files })) { + mkdirSync(join(repo, path, '..'), { recursive: true }) + writeFileSync(join(repo, path), source) + } + const git = (args: string[]): string => + execFileSync('git', ['-c', 'core.autocrlf=false', ...args], { + cwd: repo, + encoding: 'utf8' + }).trim() + git(['init', '--quiet']) + git(['add', '--all']) + git([ + '-c', + 'user.name=release', + '-c', + 'user.email=release@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '--message', + 'release' + ]) + // Working-tree root manifest the stand-in logic compares against. + writeFileSync(join(repo, 'package.json'), '{ "dependencies": {} }\n') + return { repo, commit: git(['rev-parse', 'HEAD']) } +} + +const RELEASE_PROCESS_HOST = { + 'src/packages/process-host/package.json': JSON.stringify({ + name: '@orca/process-host', + type: 'commonjs', + exports: { + '.': { 'orca-source': './src/run-process.ts', default: './dist/run-process.js' }, + './process-spec': { + 'orca-source': './src/process-spec.ts', + default: './dist/process-spec.js' + } + } + }), + 'src/packages/process-host/src/run-process.ts': [ + "import { describeSpec } from './process-spec'", + "export const runProcess = () => 'release runProcess'", + 'export const releaseOnlyExport = describeSpec', + '' + ].join('\n'), + 'src/packages/process-host/src/process-spec.ts': + "export const describeSpec = () => 'release process-spec'\n", + 'src/packages/process-host/src/run-process.test.ts': 'throw new Error("stale spec")\n', + // A JavaScript source package that itself depends on another release workspace package. + 'src/packages/release-tool/package.json': JSON.stringify({ + name: '@orca/release-tool', + type: 'module', + exports: { + '.': { 'orca-source': './src/index.mjs', default: './dist/index.js' }, + './private': { default: './dist/private.js' } + } + }), + 'src/packages/release-tool/src/index.mjs': + "import { runProcess } from '@orca/process-host'\nexport const tool = () => `tool:${runProcess()}`\n", + 'src/packages/release-tool/src/index.test.mjs': 'throw new Error("stale spec")\n' +} + +describe('release checkout workspace packages', () => { + it('loads the release workspace package rather than the current one', async () => { + expect('releaseOnlyExport' in currentProcessHost).toBe(false) + const { repo, commit } = commitRelease({ + 'pnpm-workspace.yaml': "packages:\n - src/packages/*\n - '!src/packages/ignored'\n", + ...RELEASE_PROCESS_HOST, + 'src/main/consumer.ts': [ + "import { runProcess, releaseOnlyExport } from '@orca/process-host'", + "import * as spec from '@orca/process-host/process-spec'", + 'export { describeSpec as reexported } from "@orca/process-host/process-spec"', + 'export const staticRun = runProcess()', + 'export const releaseOnly = releaseOnlyExport()', + 'export const specValue = spec.describeSpec()', + "export const lazyRun = async () => (await import('@orca/process-host')).runProcess()", + '' + ].join('\n'), + 'src/main/esm-consumer.mjs': [ + "import { describeSpec } from '@orca/process-host/process-spec'", + "import { tool } from '@orca/release-tool'", + 'export const esm = `${describeSpec()}|${tool()}`', + '' + ].join('\n'), + 'src/main/commonjs-consumer.cjs': + "module.exports = () => require('@orca/process-host/process-spec')\n", + 'src/renderer/src/lib/view.ts': [ + "import { label } from '@/lib/label'", + "export const view = async () => `${label}:${(await import('@orca/process-host')).runProcess()}`", + '' + ].join('\n'), + 'src/renderer/src/lib/label.ts': "export const label = 'renderer'\n" + }) + const staging = temporaryDirectory('orca-cross-version-release-staging-') + + await extractReleaseCheckoutTree(repo, staging, commit) + const checkout = { ref: 'v0.0.0-synthetic', commit, label: 'v0.0.0-synthetic', root: staging } + const consumer = await importReleaseCheckoutModule(checkout, '/src/main/consumer.ts') + const view = await importReleaseCheckoutModule(checkout, '/src/renderer/src/lib/view.ts') + const esm = await importReleaseCheckoutModule(checkout, '/src/main/esm-consumer.mjs') + const call = async (module: Record, name: string): Promise => { + const exported = module[name] + if (typeof exported !== 'function') { + throw new Error(`synthetic release has no ${name} export`) + } + return exported() + } + + expect(consumer.staticRun).toBe('release runProcess') + expect(consumer.releaseOnly).toBe('release process-spec') + expect(consumer.specValue).toBe('release process-spec') + expect(await call(consumer, 'reexported')).toBe('release process-spec') + expect(await call(consumer, 'lazyRun')).toBe('release runProcess') + expect(await call(view, 'view')).toBe('renderer:release runProcess') + expect(esm.esm).toBe('release process-spec|tool:release runProcess') + expect(readFileSync(join(staging, 'src/main/commonjs-consumer.cjs'), 'utf8')).toContain( + "require('../packages/process-host/src/process-spec.ts')" + ) + expect(existsSync(join(staging, 'src/packages/process-host/src/run-process.test.ts'))).toBe( + false + ) + expect(existsSync(join(staging, 'src/packages/release-tool/src/index.test.mjs'))).toBe(false) + // A rewritten workspace import never becomes a stand-in package. + expect(existsSync(join(staging, 'node_modules'))).toBe(false) + }) + + it.each(['@orca/process-host/internal', '@orca/release-tool/private', '@orca/not-in-release'])( + 'fails extraction rather than loading the current tree for %s', + async (specifier) => { + const { repo, commit } = commitRelease({ + 'pnpm-workspace.yaml': 'packages:\n - src/packages/*\n', + ...RELEASE_PROCESS_HOST, + 'src/main/private.ts': `import { x } from '${specifier}'\nexport { x }\n` + }) + const staging = temporaryDirectory('orca-cross-version-release-staging-') + + const failure = await extractReleaseCheckoutTree(repo, staging, commit).then( + () => null, + (error: unknown) => String(error) + ) + + expect(failure).toMatch(/src[/\\]main[/\\]private\.ts: /) + expect(failure).toContain(`No public orca-source export for ${specifier}`) + } + ) + + it('extracts a release that predates workspace packages unchanged', async () => { + const source = "import { lock } from 'proper-lockfile'\nexport const locker = typeof lock\n" + const { repo, commit } = commitRelease({ 'src/main/old.ts': source }) + const staging = temporaryDirectory('orca-cross-version-release-staging-') + + await extractReleaseCheckoutTree(repo, staging, commit) + + expect(readFileSync(join(staging, 'src/main/old.ts'), 'utf8')).toBe(source) + expect(existsSync(join(staging, 'pnpm-workspace.yaml'))).toBe(false) + }) +}) diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index b1b2f093243..2267b74817c 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -16,7 +16,7 @@ const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-check // Released sources still import @streamparser/json; retain its pinned test-only dependency. // Bump when extraction or the alias rewrite changes so cached trees are rebuilt. -const CHECKOUT_FORMAT = 5 +const CHECKOUT_FORMAT = 6 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts index 52c750b0f77..e40e2def30d 100644 --- a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -15,8 +15,8 @@ import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { lock } from 'proper-lockfile' import { afterEach, describe, expect, it } from 'vitest' -import { forceTerminateProcessTree } from '../../../src/shared/child-process/process-tree-termination' -import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { forceTerminateProcessTree } from '@orca/process-host/process-tree-termination' +import { spawnProcess } from '@orca/process-host' import { importReleaseCheckoutModule, materializeReleaseCheckout, diff --git a/tests/e2e/cursor-quota-transport.unit.test.ts b/tests/e2e/cursor-quota-transport.unit.test.ts index a984ef6ce2c..8770a22f8b8 100644 --- a/tests/e2e/cursor-quota-transport.unit.test.ts +++ b/tests/e2e/cursor-quota-transport.unit.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { build } from 'vite' import { expect, it } from 'vitest' import { z } from 'zod' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resolveElectronProbeLaunch } from '../../src/main/browser/electron-probe-display-launch' import { createElectronHomeIsolation } from './helpers/electron-home-isolation' diff --git a/tests/e2e/helpers/compiled-orca-cli.ts b/tests/e2e/helpers/compiled-orca-cli.ts index 157ee72ea05..9fcd7727c59 100644 --- a/tests/e2e/helpers/compiled-orca-cli.ts +++ b/tests/e2e/helpers/compiled-orca-cli.ts @@ -3,7 +3,7 @@ * would run it: a separate process that finds the app through its userData directory. */ import path from 'node:path' -import { runProcess } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' export type OrcaCliResult = { args: string[] diff --git a/tests/e2e/helpers/headless-layout-oracle-hosts.ts b/tests/e2e/helpers/headless-layout-oracle-hosts.ts index 010eb4e372d..f3984512e7c 100644 --- a/tests/e2e/helpers/headless-layout-oracle-hosts.ts +++ b/tests/e2e/helpers/headless-layout-oracle-hosts.ts @@ -10,7 +10,7 @@ import os from 'node:os' import path from 'node:path' import { RuntimeClient } from '../../../src/cli/runtime/client' import { resolveBundledOrcadRuntime } from '../../../src/main/orcad/orcad-bundled-runtime' -import { runProcess } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { NODE_RUNTIME_PIN } from '../../../src/shared/node-runtime-pin' import type { WorkspaceSessionState } from '../../../src/shared/workspace-session-state-types' import type { ExecutionHostId } from '../../../src/shared/execution-host' diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 9efe016b38b..380b403abf1 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -20,7 +20,8 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' -import { runProcess, type ProcessResult } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import type { ProcessResult } from '@orca/process-host/process-spec' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' diff --git a/tests/e2e/helpers/orca-serve-cli-host.ts b/tests/e2e/helpers/orca-serve-cli-host.ts index 49cca54c8aa..0398abcfefd 100644 --- a/tests/e2e/helpers/orca-serve-cli-host.ts +++ b/tests/e2e/helpers/orca-serve-cli-host.ts @@ -4,7 +4,7 @@ */ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' -import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' import { createElectronHomeIsolation } from './electron-home-isolation' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' import { resolveElectronExecutable } from './daemon-generation-runtime-fixture' diff --git a/tests/e2e/helpers/relay-execution-process.ts b/tests/e2e/helpers/relay-execution-process.ts index c9247bffca8..afcf38384e9 100644 --- a/tests/e2e/helpers/relay-execution-process.ts +++ b/tests/e2e/helpers/relay-execution-process.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import path from 'node:path' import { createInterface } from 'node:readline' -import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { spawnProcess } from '@orca/process-host' const executionProgram = ` const fs = require('node:fs'); diff --git a/tests/e2e/helpers/windows-host-orcad-processes.ts b/tests/e2e/helpers/windows-host-orcad-processes.ts index b1314a8f80d..d1db3164808 100644 --- a/tests/e2e/helpers/windows-host-orcad-processes.ts +++ b/tests/e2e/helpers/windows-host-orcad-processes.ts @@ -3,7 +3,7 @@ * provisioned it: the lane's sshd listens on 127.0.0.1, so the account's processes are local here. */ import path from 'node:path' -import { runProcess } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' export type HostProcess = { pid: number; commandLine: string } diff --git a/tests/e2e/orcad-serve-mode-switch.spec.ts b/tests/e2e/orcad-serve-mode-switch.spec.ts index 5110241ef0e..04691643906 100644 --- a/tests/e2e/orcad-serve-mode-switch.spec.ts +++ b/tests/e2e/orcad-serve-mode-switch.spec.ts @@ -14,7 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getDaemonPidPath } from '../../src/main/daemon/daemon-spawner' import { readDaemonPidRecord } from '../../src/main/daemon/daemon-endpoint-incarnation' import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' diff --git a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts index 2c81b76f077..bf4aeb2a0ac 100644 --- a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts +++ b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts @@ -1,4 +1,4 @@ -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts index d3eb3a815e9..2bfd561aaed 100644 --- a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { openProfileStateDatabaseReadOnly } from '../../src/main/persistence/profile-state/profile-state-database' import { readProfileStateSnapshot } from '../../src/main/persistence/profile-state/profile-state-documents' import { profileStateDatabaseBackups } from '../../src/main/persistence/profile-state/profile-state-backup-path' diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index 90deffecb1f..8524beb58c6 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -3,7 +3,7 @@ import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' test.use({ seedTestRepo: false }) diff --git a/tests/e2e/repo-registration-concurrency.spec.ts b/tests/e2e/repo-registration-concurrency.spec.ts index e3a814f3f44..70e9577c87c 100644 --- a/tests/e2e/repo-registration-concurrency.spec.ts +++ b/tests/e2e/repo-registration-concurrency.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' test.use({ seedTestRepo: false }) diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts index ebea82a1241..3b0cc1f9e2d 100644 --- a/tests/e2e/settings-agent-awake.spec.ts +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index 84c21cd92d1..3d9d412af99 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -4,7 +4,7 @@ import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' test.use({ seedTestRepo: false }) diff --git a/tests/e2e/sparse-explorer-proof.spec.ts b/tests/e2e/sparse-explorer-proof.spec.ts index 06d7e183e8c..f1b943581e3 100644 --- a/tests/e2e/sparse-explorer-proof.spec.ts +++ b/tests/e2e/sparse-explorer-proof.spec.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import os from 'node:os' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { test as base, expect } from './helpers/orca-app' import { openFileExplorer } from './helpers/file-explorer' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' diff --git a/tests/e2e/spinner-workspace-fixture.ts b/tests/e2e/spinner-workspace-fixture.ts index 22bcffc76c5..ea7fce086a2 100644 --- a/tests/e2e/spinner-workspace-fixture.ts +++ b/tests/e2e/spinner-workspace-fixture.ts @@ -1,7 +1,7 @@ import type { Page } from '@stablyai/playwright-test' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import path from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { attachRepoAndOpenTerminal } from './helpers/orca-restart' import { configureRendererScaleFixture } from '../../config/scripts/idle-cpu-renderer-scale-fixture.mjs' diff --git a/tests/e2e/structured-attention-headless-delivery.unit.test.ts b/tests/e2e/structured-attention-headless-delivery.unit.test.ts index a45634998aa..2bc80a3e6c2 100644 --- a/tests/e2e/structured-attention-headless-delivery.unit.test.ts +++ b/tests/e2e/structured-attention-headless-delivery.unit.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' it('loads and delivers actual host notifications in plain Node without Electron installed', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-headless-attention-')) diff --git a/tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts b/tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts index 886f6e91e60..fb8e3e904ff 100644 --- a/tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts +++ b/tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { expect, it } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { writeSustainedAgentLoadScript } from './sustained-agent-typing-load-scripts' it('paces the generated Unicode stream in bytes without splitting UTF-8 characters', async () => { diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index cdb39420ef0..b09995aafdb 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,5 +1,5 @@ import { mkdirSync } from 'node:fs' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' diff --git a/tests/tools/benchmarks/analyze-main-cpuprofile.mjs b/tests/tools/benchmarks/analyze-main-cpuprofile.mjs index a688957a0d5..c5354aec612 100644 --- a/tests/tools/benchmarks/analyze-main-cpuprofile.mjs +++ b/tests/tools/benchmarks/analyze-main-cpuprofile.mjs @@ -124,7 +124,7 @@ const NAME_RULES = [ ] const URL_RULES = [ - [/child_process|spawn-resolution|run-process\.ts/, 'spawn-init'], + [/child_process|spawn-resolution|run-process\.(?:ts|js)/, 'spawn-init'], [ /string_decoder|node:internal\/streams|node:stream|node:net|bounded-output-sink/, 'stdout-drain' diff --git a/tests/tools/omp-close-lifecycle.test.mjs b/tests/tools/omp-close-lifecycle.test.mjs index 47df8325c56..2e97df9aefa 100644 --- a/tests/tools/omp-close-lifecycle.test.mjs +++ b/tests/tools/omp-close-lifecycle.test.mjs @@ -6,7 +6,7 @@ import { createDaemonPtySubprocessHandle } from '../../src/main/daemon/pty-subpr import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { runProcess } from '@orca/process-host' import { captureDescendantSnapshot, readProcessTable diff --git a/tests/tools/omp-config-root-local-shell.test.mjs b/tests/tools/omp-config-root-local-shell.test.mjs index c86bb0bd542..c8a48cd0575 100644 --- a/tests/tools/omp-config-root-local-shell.test.mjs +++ b/tests/tools/omp-config-root-local-shell.test.mjs @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node: import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { inheritOmpLaunchEnvironment } from '../../src/main/ipc/pty/host-env/omp-launch-environment' import { resetLoginShellEnvironmentCacheForTests } from '../../src/main/startup/login-shell-environment' import { PiTitlebarExtensionService } from '../../src/main/pi/titlebar-extension-service' diff --git a/tests/tools/omp-config-root-relay-shell.test.mjs b/tests/tools/omp-config-root-relay-shell.test.mjs index 5b9222406e3..423443e98ca 100644 --- a/tests/tools/omp-config-root-relay-shell.test.mjs +++ b/tests/tools/omp-config-root-relay-shell.test.mjs @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node: import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { resetLoginShellEnvironmentCacheForTests } from '../../src/main/startup/login-shell-environment' import { PluginOverlayManager } from '../../src/relay/plugin-overlay' import { resolveOmpConfigDirName } from '../../src/relay/plugin-overlay-env' diff --git a/tests/tools/omp-fresh-launch-windows-smoke.ts b/tests/tools/omp-fresh-launch-windows-smoke.ts index b3e071dcc91..5e6fdb0a1fc 100644 --- a/tests/tools/omp-fresh-launch-windows-smoke.ts +++ b/tests/tools/omp-fresh-launch-windows-smoke.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { withFreshOmpLaunch } from '../../src/shared/omp-fresh-launch' if (process.platform !== 'win32') { throw new Error('Run this smoke on a Windows host') diff --git a/tests/tools/omp-relay-close-lifecycle.test.mjs b/tests/tools/omp-relay-close-lifecycle.test.mjs index 2730356ebd1..7adf6c191e6 100644 --- a/tests/tools/omp-relay-close-lifecycle.test.mjs +++ b/tests/tools/omp-relay-close-lifecycle.test.mjs @@ -10,7 +10,7 @@ import { captureDescendantSnapshot, readProcessTable } from '../../src/main/pty-descendant-termination.ts' -import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { runProcess } from '@orca/process-host' const binary = process.env.ORCA_OMP_PROBE_BINARY const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/tests/tools/pi-owner-runtime-smoke.mjs b/tests/tools/pi-owner-runtime-smoke.mjs index 204627340ac..1de6eb848c8 100644 --- a/tests/tools/pi-owner-runtime-smoke.mjs +++ b/tests/tools/pi-owner-runtime-smoke.mjs @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' +import { runProcess } from '@orca/process-host' const piRoot = resolve(process.argv[2] || '') assert.ok(process.argv[2], 'Pass an installed pi-coding-agent package directory') @@ -25,10 +26,8 @@ try { const bundle = join(scratch, 'orca.cjs') await build({ stdin: { - contents: [ + contents: "export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';", - "export { runProcess } from './src/shared/child-process/run-process';" - ].join('\n'), resolveDir: process.cwd() }, bundle: true, @@ -37,7 +36,7 @@ try { outfile: bundle, packages: 'external' }) - const { getPiAgentStatusExtensionSource, runProcess } = createRequire(import.meta.url)(bundle) + const { getPiAgentStatusExtensionSource } = createRequire(import.meta.url)(bundle) server.listen(0, '127.0.0.1') await once(server, 'listening') const dead = await runProcess({ diff --git a/tests/tools/pi-provider-runtime-smoke.mjs b/tests/tools/pi-provider-runtime-smoke.mjs index bda4f6a0f89..d095cb72446 100644 --- a/tests/tools/pi-provider-runtime-smoke.mjs +++ b/tests/tools/pi-provider-runtime-smoke.mjs @@ -6,6 +6,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { build } from 'esbuild' +import { runProcess } from '@orca/process-host' const piCli = process.argv[2] && resolve(process.argv[2]) assert.ok(piCli, 'Pass the installed Pi CLI entrypoint') const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-provider-')) @@ -44,8 +45,7 @@ try { const bundle = join(scratch, 'orca.cjs') await build({ stdin: { - contents: - "export {planCommitMessageGeneration} from './src/shared/commit-message-plan'; export {runProcess} from './src/shared/child-process/run-process';", + contents: "export {planCommitMessageGeneration} from './src/shared/commit-message-plan';", resolveDir: process.cwd() }, bundle: true, @@ -54,7 +54,7 @@ try { outfile: bundle, packages: 'external' }) - const { planCommitMessageGeneration, runProcess } = createRequire(import.meta.url)(bundle) + const { planCommitMessageGeneration } = createRequire(import.meta.url)(bundle) server.listen(0, '127.0.0.1') await once(server, 'listening') const dir = join(scratch, 'agent') diff --git a/tests/tools/win-update-e2e/serve-switch.mjs b/tests/tools/win-update-e2e/serve-switch.mjs index 6cef9723826..bea38abf9f2 100644 --- a/tests/tools/win-update-e2e/serve-switch.mjs +++ b/tests/tools/win-update-e2e/serve-switch.mjs @@ -147,7 +147,7 @@ async function run(ctx) { ctx.daemonPid = before.pid check( 'desktop daemon runs from the relocated daemon-host', - /\\Orca\\daemon-host\\/i.test(before.commandLine), + /\\Orca\\daemon-host(?:-profiles)?\\/i.test(before.commandLine), before.commandLine.slice(0, 160) ) await closeApp(ctx.session.app, 45_000, { allowForceKill: false }) diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs index 580a2852a19..73fdd772e87 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs @@ -1,5 +1,5 @@ const { randomBytes } = require('node:crypto') -const { writeSync } = require('node:fs') +const { existsSync, writeSync } = require('node:fs') const net = require('node:net') const path = require('node:path') @@ -73,10 +73,28 @@ function buildGrandchildLaunch(channel, fixtureToken) { } } -function startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) { - const { spawnProcess } = require( - path.join(resourcesDir, 'app.asar.unpacked', 'out', 'shared', 'child-process', 'run-process.js') +// Prefer the public package extraResources stages; only packages that predate it ship the private out/ copy. +function packagedProcessHostPath(resourcesDir) { + const packageDir = path.join(resourcesDir, 'node_modules', '@orca', 'process-host') + if (existsSync(path.join(packageDir, 'package.json'))) { + return packageDir + } + // A present package without its manifest is broken output, not an older layout. + if (existsSync(packageDir)) { + throw new Error(`packaged @orca/process-host has no package.json: ${packageDir}`) + } + return path.join( + resourcesDir, + 'app.asar.unpacked', + 'out', + 'shared', + 'child-process', + 'run-process.js' ) +} + +function startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) { + const { spawnProcess } = require(packagedProcessHostPath(resourcesDir)) const launch = buildGrandchildLaunch(channel, fixtureToken) const child = spawnProcess({ ...launch, @@ -429,6 +447,7 @@ module.exports = { buildGrandchildLaunch, createFixtureServer, isOneShotMode, + packagedProcessHostPath, reportFixtureObservation } diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs index f26b5ae481e..5d3f1a2cbfd 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs @@ -1,9 +1,11 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' +import { stagePackagedProcessHost } from '../../../config/scripts/packaged-process-host-fixture.mjs' const require = createRequire(import.meta.url) const probePath = require.resolve('./packaged-node-pty-capability-probe.cjs') @@ -11,10 +13,20 @@ const { buildGrandchildLaunch, createFixtureServer, isOneShotMode, + packagedProcessHostPath, reportFixtureObservation } = require(probePath) const originalSystemRoot = process.env.SystemRoot +// The private copy packaged main emitted before the public package existed. +async function stageLegacyRunProcess(resourcesDir) { + const legacyDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'shared', 'child-process') + await mkdir(legacyDir, { recursive: true }) + const legacy = join(legacyDir, 'run-process.js') + await writeFile(legacy, "exports.spawnProcess = () => 'legacy spawnProcess'\n") + return legacy +} + afterEach(() => { if (originalSystemRoot === undefined) { delete process.env.SystemRoot @@ -59,6 +71,54 @@ describe('packaged node-pty launcher-surviving grandchild', () => { expect(JSON.stringify(launch)).not.toMatch(/cmd\.exe|start "" \/b/i) }) + it('loads spawnProcess from the packaged public process-host output', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-pty-capability-resources-')) + try { + const packageDir = await stagePackagedProcessHost(resourcesDir) + const entry = packagedProcessHostPath(resourcesDir) + + expect(realpathSync(require.resolve(entry))).toBe( + realpathSync(join(packageDir, 'dist', 'run-process.js')) + ) + expect(typeof require(entry).spawnProcess).toBe('function') + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('loads spawnProcess from the private out/ copy of a package that predates the public one', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-pty-capability-legacy-')) + try { + const legacy = await stageLegacyRunProcess(resourcesDir) + const entry = packagedProcessHostPath(resourcesDir) + + expect(realpathSync(require.resolve(entry))).toBe(realpathSync(legacy)) + expect(require(entry).spawnProcess()).toBe('legacy spawnProcess') + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('fails on a broken public package rather than loading the legacy copy', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-pty-capability-broken-')) + try { + const legacy = await stageLegacyRunProcess(resourcesDir) + const packageDir = await stagePackagedProcessHost(resourcesDir) + await rm(join(packageDir, 'dist', 'run-process.js')) + + expect(packagedProcessHostPath(resourcesDir)).toBe(packageDir) + expect(require(legacy).spawnProcess()).toBe('legacy spawnProcess') + expect(() => require(packagedProcessHostPath(resourcesDir))).toThrow() + + await rm(join(packageDir, 'package.json')) + expect(() => packagedProcessHostPath(resourcesDir)).toThrow( + /packaged @orca\/process-host has no package\.json/ + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + it('closes one-shot fixture observations before server teardown', async () => { const fixtureDir = await mkdtemp(join(tmpdir(), 'orca-pty-capability-')) const channel = join(fixtureDir, 'fixture.sock') diff --git a/tests/tools/windows-pty-native-capability-smoke/run.mjs b/tests/tools/windows-pty-native-capability-smoke/run.mjs index 491fba2642d..bfd3a7ce096 100644 --- a/tests/tools/windows-pty-native-capability-smoke/run.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/run.mjs @@ -16,7 +16,7 @@ function executableArgument(argv) { } export function checkoutRunProcessPath() { - return path.resolve(import.meta.dirname, '../../../out/shared/child-process/run-process.js') + return createRequire(import.meta.url).resolve('@orca/process-host') } export function packagedProbeInvocation(executable, environment = process.env) { diff --git a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs index 27439cb498e..bf34b0970bf 100644 --- a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs @@ -1,4 +1,4 @@ -import path from 'node:path' +import { createRequire } from 'node:module' import { describe, expect, it } from 'vitest' import { checkoutRunProcessPath, formatProbeFailure, packagedProbeInvocation } from './run.mjs' @@ -7,7 +7,9 @@ describe('packaged Windows native smoke runner boundary', () => { const current = packagedProbeInvocation('/ci/current/dist/win-unpacked/Orca.exe') const affected = packagedProbeInvocation('/ci/1.4.158/dist/win-unpacked/Orca.exe') - expect(checkoutRunProcessPath()).toBe(path.resolve('out/shared/child-process/run-process.js')) + expect(checkoutRunProcessPath()).toBe( + createRequire(import.meta.url).resolve('@orca/process-host') + ) expect(current.program).not.toBe(affected.program) expect(current.args[0]).toBe(affected.args[0]) expect(current.args[1]).toBe('--exercise')