import { spawnSync } from 'node:child_process' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' type NormalizedOutputOptions = Rollup.NormalizedOutputOptions type OutputBundle = Rollup.OutputBundle type OutputChunk = Rollup.OutputChunk // Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main // chunk gained `require("electron")` (an import edge added in #7642), and the // daemon is forked as a plain-Node process where electron cannot be required. // Nothing in CI executes the built daemon-entry under plain Node, so the leak // stayed invisible until an adopted old daemon died. This guard fails the // build when any chunk reachable from a plain-Node fork entry requires // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. // Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): // forked daemon, parcel-watcher and computer sidecars, and the CLI-run // agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'agent-hooks/managed-agent-hook-controls', 'codex/codex-app-server-grant-entry' ] as const // Entries executed as worker threads of the main process. Electron's module is // not registered on worker threads, so require("electron") throws // "Cannot find module 'electron'" there too (verified on Electron 43) and kills // the worker at startup. These carry hand-written "must stay electron-free" // comments, which is convention, not enforcement — and the port-scan worker in // particular sits one import away from a client module that deliberately does // require electron. const WORKER_THREAD_ENTRY_NAMES = [ 'stt-worker', 'warp-theme-parser-worker', 'session-scanner-opencode-sqlite-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry' ] as const type EntryRuntime = 'plain-Node process' | 'worker thread' const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ function collectReachableChunks( entry: OutputChunk, byFileName: Map ): OutputChunk[] { const seen = new Set() const reachable: OutputChunk[] = [] const stack = [entry.fileName] while (stack.length > 0) { const fileName = stack.pop() as string if (seen.has(fileName)) { continue } seen.add(fileName) const chunk = byFileName.get(fileName) if (!chunk) { continue } reachable.push(chunk) for (const imported of [...chunk.imports, ...chunk.dynamicImports]) { stack.push(imported) } } return reachable } function assertNoElectronRequire( entryName: string, entry: OutputChunk, byFileName: Map, runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { if (ELECTRON_REQUIRE_RE.test(chunk.code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + `requires electron. "${entryName}" runs as a ${runtime}, where ` + `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` + `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) } } } // Why: proves the whole daemon-entry graph resolves under plain Node (no // unresolved requires). require("electron") does not throw in a dev tree with // node_modules present, so the static scan above — not this smoke — is the // electron regression guard; this only catches gross load failures. function smokeLoadDaemonEntry(outputDir: string): void { const entryPath = join(outputDir, 'daemon-entry.js') const result = spawnSync(process.execPath, [entryPath], { encoding: 'utf8', timeout: 15_000 }) if (result.error) { throw new Error( `[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` + `${result.error.message}` ) } const stderr = result.stderr ?? '' if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { throw new Error( `[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}` ) } if (!stderr.includes('Usage: daemon-entry')) { throw new Error( `[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` + `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` ) } } export function createPlainNodeEntryGuardPlugin(): Plugin { let daemonOutputDir: string | undefined return { name: 'orca-plain-node-entry-guard', writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on // every rebuild, and the guard only needs to gate produced builds. if (this.meta.watchMode) { return } const chunks = Object.values(bundle).filter( (item): item is OutputChunk => item.type === 'chunk' ) const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])) const entryByName = new Map() for (const chunk of chunks) { if (chunk.isEntry && chunk.name) { entryByName.set(chunk.name, chunk) } } for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') } } for (const entryName of WORKER_THREAD_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') } } if (entryByName.has('daemon-entry') && options.dir) { daemonOutputDir = options.dir } }, closeBundle() { if (daemonOutputDir) { const outputDir = daemonOutputDir daemonOutputDir = undefined smokeLoadDaemonEntry(outputDir) } } } }