diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3d8ff25f922..8687830d062 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' -import type { spawn } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' import { isCodexAppServerRequestError, openCodexAppServerConnection, @@ -76,7 +76,7 @@ type StubChild = EventEmitter & { /** Full control over framing and death, which a real child cannot give. */ function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { child: StubChild - spawnImpl: typeof spawn + spawnImpl: typeof spawnProcess written: Record[] } { const child = new EventEmitter() as StubChild @@ -96,7 +96,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 spawn, written } + return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written } } /** Answers the handshake so `openCodexAppServerConnection` can resolve. */ diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index f9cd1f662b7..b5c6980921c 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawnProcess } from '../../shared/child-process/run-process' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { isAppServerRecord, parseCodexAppServerJsonLine } from './codex-app-server-jsonl' import { waitForProcessExitUntil } from './codex-process-exit-deadline' @@ -70,17 +70,18 @@ type PendingRequest = { export async function openCodexAppServerConnection( launch: CodexAppServerLaunch, handlers: CodexAppServerConnectionHandlers = {}, - spawnImpl: typeof spawn = spawn + spawnImpl: typeof spawnProcess = spawnProcess ): Promise { const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env } for (const key of launch.envToDelete ?? []) { delete childEnv[key] } - const child = spawnImpl(launch.command, launch.args, { + const child = spawnImpl({ + program: launch.command, + args: launch.args, env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) as ChildProcessWithoutNullStreams + cwd: process.cwd() + }) const pending = new Map() let stderrTail = '' diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts index 7019b960ecc..87616b25b96 100644 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -6,12 +6,13 @@ // workspace that is not a repository at all. Keying by id rather than by path // makes a worktree, a folder workspace, a WSL distro, and an SSH host identical. // -// `app` is imported lazily so the store stays usable from tests and from the -// headless/SSH runtime, which have no Electron app object. +// The host environment port supplies the root so desktop Electron and the +// headless/SSH runtime resolve their own durable state directories. import { createHash } from 'node:crypto' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { getAppEnvironment } from '../../../shared/app-environment' const JOURNAL_DIR_NAME = 'agent-session-journal' @@ -35,9 +36,7 @@ export function journalDirectoryFor( ) } -/** Default host state root. Electron is required lazily so importing this - * module from a non-Electron runtime does not pull the whole app in. */ -export async function defaultJournalRoot(): Promise { - const { app } = await import('electron') - return app.getPath('userData') +/** Default host state root. */ +export function defaultJournalRoot(): Promise { + return Promise.resolve(getAppEnvironment().getPath('userData')) } diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 93d5427f3aa..0ee9411dc66 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -8,16 +8,13 @@ * closed to manual recovery rather than minting a second writer. */ -import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' -import { promisify } from 'node:util' import type { AgentSessionIdentityMatchField, AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' - -const execFileAsync = promisify(execFile) +import { runProcess } from '../../shared/child-process/run-process' /** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */ export const PROCESS_START_TIME_TOLERANCE_MS = 2_000 @@ -64,10 +61,15 @@ async function readLinuxProcessStartTimeMs(pid: number): Promise async function readDarwinProcessStartTimeMs(pid: number): Promise { try { - const { stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)], { - timeout: PROCESS_START_TIME_TIMEOUT_MS + const result = await runProcess({ + program: 'ps', + args: ['-o', 'lstart=', '-p', String(pid)], + timeoutMs: PROCESS_START_TIME_TIMEOUT_MS }) - const parsed = Date.parse(stdout.trim()) + if (result.timedOut || result.code !== 0) { + return null + } + const parsed = Date.parse(result.stdout.trim()) return Number.isFinite(parsed) ? parsed : null } catch { return null @@ -79,12 +81,15 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise { let settled = false const chunks: Buffer[] = [] - const child = spawn(shell, args, { - env: process.env, - stdio: ['ignore', 'pipe', 'ignore'], - detached: false, - windowsHide: true - }) + const child = spawnProcess({ program: shell, args, env: process.env }) const finish = (value: NodeJS.ProcessEnv | null): void => { if (settled) { return @@ -108,6 +103,11 @@ function spawnShellAndReadEnvironment(shell: string): Promise {}) + child.stdout.on('error', () => finish(null)) + child.stderr.on('error', () => {}) + child.stdin.end() + child.stderr.resume() child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) child.on('error', () => finish(null)) child.on('close', () => { diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index af9a75ba01b..28e4f51b677 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -2,6 +2,7 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, + type ChildProcessWithoutNullStreams, type SpawnOptions as NodeSpawnOptions } from 'node:child_process' import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line' @@ -116,9 +117,13 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso * `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): ChildProcess { +export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams { const resolved = resolveSpawn(spec, process.platform) - return nodeSpawn(resolved.file, [...resolved.args], resolved.options) + return nodeSpawn( + resolved.file, + [...resolved.args], + resolved.options + ) as ChildProcessWithoutNullStreams } /**