diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4cad7a7f794..c5e2831a17d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -17,7 +17,6 @@ inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/providers/local-pty-provider.ts inline src/main/rate-limits/service.ts inline src/main/runtime/orca-runtime-browser.ts inline src/main/runtime/orca-runtime-files.ts diff --git a/src/main/providers/local-pty-finalize-environment.ts b/src/main/providers/local-pty-finalize-environment.ts new file mode 100644 index 00000000000..f77959ce671 --- /dev/null +++ b/src/main/providers/local-pty-finalize-environment.ts @@ -0,0 +1,123 @@ +import { win32 as pathWin32 } from 'node:path' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' +import { dropInheritedOrcaFishHistory } from '../fish-history-session' +import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' +import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' +import { + POWERLEVEL10K_WIZARD_DISABLE_ENV, + seedPowerlevel10kWizardEnv +} from '../pty/powerlevel10k-wizard-env' +import { resolvePathEnvKey } from '../pty/windows-environment-path' +import { selectShellStartupFeatures } from '../shell-startup-features' +import { + injectHistoryEnv, + injectWslFishHistoryEnv, + logHistoryInjection, + type HistoryInjectionResult +} from '../terminal-history' +import { addWslEnvKeys } from '../wsl-env' +import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' +import { promoteAgentTeamsShimPath } from './local-pty-launch-helpers' +import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import { getShellLaunchConfig } from './local-pty-shell-ready' +import { finalizeWindowsLocalPtySpawnEnvironment } from './local-pty-windows-spawn-environment' +import type { PtySpawnOptions } from './types' + +export function finalizeLocalPtySpawnEnvironment(args: { + spawn: PtySpawnOptions + getOptions: () => LocalPtyProviderOptions + plan: LocalPtyLaunchPlan + env: Record +}): HistoryInjectionResult | null { + const { spawn, getOptions, plan, env } = args + if (process.platform === 'win32') { + finalizeWindowsLocalPtySpawnEnvironment({ spawn, plan, env }) + } + seedPowerlevel10kWizardEnv(env, { envToDelete: spawn.envToDelete }) + if ( + env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && + process.platform === 'win32' && + pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' + ) { + addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) + } + const requestedEnv = spawn.env + expandWindowsPathEnvironmentVariables(env) + promoteAgentTeamsShimPath( + env, + requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined + ) + // Why: raw requested PATH promotion runs after the host-env scrub. + stripLegacyTerminalShimEnv(env, process.platform) + // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. + dropIncoherentCondaActivationEnv(env, process.platform) + + // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). + const worktreeId = spawn.worktreeId + const historyEnabled = worktreeId && (getOptions().isHistoryEnabled?.() ?? true) + // Effective shell for history injection: WSL's outer exe is wsl.exe but the inner login shell is bash. + const isWslTerminal = + Boolean(plan.wslInfo || plan.worktreeWslContext || plan.preferredWslContext) || + pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' + const effectiveShellPath = isWslTerminal ? 'bash' : plan.shellPath + let historyResult: ReturnType | null = null + if (historyEnabled) { + historyResult = injectHistoryEnv(env, worktreeId, effectiveShellPath, plan.cwd, { + wslDistro: plan.launchWslDistro + }) + if (isWslTerminal && plan.launchWslDistro) { + injectWslFishHistoryEnv(env, worktreeId, plan.launchWslDistro) + addWslEnvKeys(env, ['HISTFILE', 'fish_history']) + } + logHistoryInjection(worktreeId, historyResult) + } else { + // Why: injectHistoryEnv is what normally clears it, so when history is off + // an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146. + delete env.ORCA_HISTFILE + // Same for an exported `fish_history` from the fish pane that launched this + // Orca: history off means fish's own default, not another worktree's file. + dropInheritedOrcaFishHistory(env) + // And for an exported HISTFILE: history off means the shell's own default, + // not the history file of the worktree this Orca was launched from. + dropInheritedOrcaHistFile(env) + } + + if (!plan.wslInfo && process.platform !== 'win32') { + // Why after history injection: the wrapper is what repairs a worktree + // HISTFILE that the system zshrc clobbers, so the decision to wrap has to + // see whether this spawn actually injected one. + const isCodexStartupCommand = plan.startupAgentRecognition?.agent === 'codex' + // Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed. + const waitsForShellReady = + Boolean(spawn.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: spawn.command as string, + startupCommandDelivery: spawn.startupCommandDelivery + })) + // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch + // config below may name features for this shell. + delete env.ORCA_SHELL_FEATURES + plan.getFallbackShellReadyConfig = (shell) => + getShellLaunchConfig( + shell, + selectShellStartupFeatures({ + shellPath: shell, + env, + hasStartupCommand: Boolean(spawn.command), + waitsForShellReady, + // Why identical: the identity marker exists so the readiness + // handshake can bind output to the right shell PID. + emitsStartupIdentity: waitsForShellReady + }) + ) + const shellLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) + Object.assign(env, shellLaunch.env) + plan.shellArgs = shellLaunch.args ?? plan.shellArgs + plan.shellReadyLaunch = spawn.command ? shellLaunch : null + plan.primaryLaunchEnvKeys = Object.keys(shellLaunch.env) + } + return historyResult +} diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts new file mode 100644 index 00000000000..c42c06d9121 --- /dev/null +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -0,0 +1,198 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + confirmShellForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' +import { resolveForegroundFallbackProcess } from './local-pty-launch-helpers' +import { + ptyAgentForegroundContextPaths, + ptyLastRecognizedForeground, + ptyProcesses, + ptyShellName +} from './local-pty-provider-state' +import { resolveStableForegroundProcess } from './stable-foreground-process' +import { + canRevalidateCachedAgentWithoutScan, + judgeCachedAgentJobEvidence +} from './windows-cached-agent-revalidation' +import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' +import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' + +export async function hasLocalPtyChildProcesses(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + try { + const foreground = proc.process + const shell = ptyShellName.get(id) + if (!shell) { + return true + } + return foreground !== shell + } catch { + return false + } +} + +export async function getLocalPtyForegroundProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + ptyLastRecognizedForeground.delete(id) + return null + } + const fallbackProcess = resolveForegroundFallbackProcess( + proc.process || null, + ptyShellName.get(id) + ) + const cachedEntry = ptyLastRecognizedForeground.get(id) + const cachedAgent = cachedEntry?.name ?? null + let paneMembershipUnavailable = false + let cachedAgentAliveInJob = false + // Why: job membership preserves a live cached agent without the whole-table + // scan (incomplete under Windows load). Job, not console: this asks "is + // anything besides the shell alive?", which needs no console attachment and + // so needs no forked helper (#10857). + if ( + process.platform === 'win32' && + canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess) + ) { + try { + const paneProcessIds = readWindowsPtyJobProcessIds(proc) + if (ptyProcesses.get(id) !== proc) { + return null + } + const verdict = judgeCachedAgentJobEvidence({ + jobProcessIds: paneProcessIds, + jobSupported: isWindowsPtyJobReadable(), + shellPid: proc.pid, + anchorProcessId: cachedEntry?.pid ?? null, + identityAgeMs: Date.now() - (cachedEntry?.at ?? 0) + }) + if (verdict === 'confirmed' || verdict === 'unproven') { + return cachedAgent + } + if (verdict === 'exited') { + // The shell stands alone in a complete, inescapable job list: no + // successor is possible, so the identity retires before the scan. + ptyLastRecognizedForeground.delete(id) + } else if (verdict === 'anchor-exited' && cachedEntry) { + // The recognized process died but another member remains -- a + // leftover, or a restarted successor. Keep the name as unanchored, + // age-bounded evidence and let this cycle's scan decide: deleting + // here made a degraded scan read a mid-restart agent as an exit. + ptyLastRecognizedForeground.set(id, { ...cachedEntry, pid: null }) + } + cachedAgentAliveInJob = verdict === 'recheck' + paneMembershipUnavailable = verdict === 'unavailable' + } catch { + paneMembershipUnavailable = true + } + } + try { + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths: ptyAgentForegroundContextPaths.get(id), + ...(cachedEntry?.pid != null + ? { anchorProcessId: cachedEntry.pid, anchorProcessName: cachedEntry.name } + : {}) + } + ) + // Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id. + if (ptyProcesses.get(id) !== proc) { + return null + } + // Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead. + const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null + const resolvedAgent = resolution.processName + ? recognizeAgentProcessFromCommandLine(resolution.processName) + : null + // A recycled anchor pid keeps job membership truthful but the identity + // dead; the scan proving the pid now runs a non-agent settles it. + const anchorContradicted = resolution.anchorPidForeign === true + // Why: incomplete snapshot + unavailable job read isn't exit proof; and an + // anchor pid still alive in the job outranks a snapshot that lost its row. + const stableResolution = + (paneMembershipUnavailable || cachedAgentAliveInJob) && + !anchorContradicted && + resolvedAgent === null + ? { ...resolution, available: false } + : resolution + const stable = resolveStableForegroundProcess(stableResolution, lastRecognizedAgent) + if (stable.lastRecognizedAgent && stableResolution.available) { + // Only a positive recognition restarts the age bound. + ptyLastRecognizedForeground.set(id, { + name: stable.lastRecognizedAgent, + pid: + stable.lastRecognizedAgent === resolution.processName + ? (resolution.processId ?? null) + : null, + at: Date.now() + }) + } else if (stable.lastRecognizedAgent && cachedAgentAliveInJob && !anchorContradicted) { + // The anchor pid in the job is proof of life; restamp so the + // short-circuit resumes instead of scanning on every call. + const entry = ptyLastRecognizedForeground.get(id) + if (entry) { + ptyLastRecognizedForeground.set(id, { ...entry, at: Date.now() }) + } + } else if (!stable.lastRecognizedAgent) { + ptyLastRecognizedForeground.delete(id) + } + return stable.processName + } catch { + if (ptyProcesses.get(id) !== proc) { + return null + } + // Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit). + return ptyLastRecognizedForeground.get(id)?.name ?? null + } +} + +export async function confirmLocalPtyForegroundProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return null + } + try { + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), + { + contextPaths: ptyAgentForegroundContextPaths.get(id), + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConsoleAttachedProcessIds: () => + readWindowsConsoleAttachedProcessIds(proc.pid) + } + : {}) + } + ) + // Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session. + if (ptyProcesses.get(id) !== proc) { + return null + } + return resolution.available ? resolution.processName : null + } catch { + return null + } +} + +export async function confirmLocalPtyShellForeground(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + const confirmed = await confirmShellForegroundProcess( + proc.pid, + ptyShellName.get(id), + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } + : {} + ) + return ptyProcesses.get(id) === proc && confirmed +} diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts new file mode 100644 index 00000000000..199459dc0b8 --- /dev/null +++ b/src/main/providers/local-pty-launch-plan.ts @@ -0,0 +1,247 @@ +import { win32 as pathWin32 } from 'node:path' +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' +import { resolveWindowsGitBashShellPath } from '../git-bash' +import { getDefaultWslDistro, parseWslPath } from '../wsl' +import { + getDefaultCwd, + getWslContextFromPreferredDistro, + getWslContextFromWorktreeId +} from './local-pty-launch-helpers' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import type { getShellLaunchConfig } from './local-pty-shell-ready' +import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory } from './local-pty-utils' +import { + resolveEffectiveWindowsPowerShell, + shouldProbeWindowsPowerShellAvailability, + type WindowsPowerShellShellFamily +} from './windows-powershell' +import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain' +import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +import { assertSafeAgentStartupCwd } from './pty-default-cwd' +import type { PtySpawnOptions } from './types' + +type WslLaunchContext = { distro: string; treatPosixCwdAsWsl: true } +type LocalPtyLaunchSeed = { + args: PtySpawnOptions + startupAgentRecognition: ReturnType + defaultCwd: string + cwd: string + wslInfo: ReturnType + worktreeWslContext: WslLaunchContext | undefined + preferredWslContext: WslLaunchContext | undefined + launchWslContext: WslLaunchContext | undefined +} + +export type LocalPtyLaunchPlan = { + startupAgentRecognition: ReturnType + defaultCwd: string + cwd: string + wslInfo: ReturnType + worktreeWslContext: WslLaunchContext | undefined + preferredWslContext: WslLaunchContext | undefined + launchWslContext: WslLaunchContext | undefined + shellPath: string + shellArgs: string[] + effectiveCwd: string + validationCwd: string + startupCommandDeliveredInShellArgs: boolean + windowsFallbackAttempts: ReturnType + shellReadyLaunch: ReturnType | null + getFallbackShellReadyConfig: + | ((shell: string) => ReturnType) + | undefined + // Why hoisted: a fallback shell must drop the primary's launch env, and + // re-deriving the key names would re-run wrapper generation. + primaryLaunchEnvKeys: string[] + isWslShell: boolean + launchWslDistro: string | null +} + +export class DeferredLocalPtyLaunchPlan { + constructor( + readonly availability: boolean | Promise, + readonly finish: (available: boolean) => LocalPtyLaunchPlan + ) {} +} + +function finalizeLocalPtyLaunchPlan( + seed: LocalPtyLaunchSeed, + shell: { + shellPath: string + shellArgs: string[] + effectiveCwd: string + validationCwd: string + startupCommandDeliveredInShellArgs?: boolean + windowsFallbackAttempts?: ReturnType + } +): LocalPtyLaunchPlan { + ensureNodePtySpawnHelperExecutable() + if (seed.args.prevalidatedCwd !== shell.validationCwd) { + validateWorkingDirectory(shell.validationCwd) + } + const isWslShell = + Boolean(seed.wslInfo) || pathWin32.basename(shell.shellPath).toLowerCase() === 'wsl.exe' + return { + startupAgentRecognition: seed.startupAgentRecognition, + defaultCwd: seed.defaultCwd, + cwd: seed.cwd, + wslInfo: seed.wslInfo, + worktreeWslContext: seed.worktreeWslContext, + preferredWslContext: seed.preferredWslContext, + launchWslContext: seed.launchWslContext, + shellPath: shell.shellPath, + shellArgs: shell.shellArgs, + effectiveCwd: shell.effectiveCwd, + validationCwd: shell.validationCwd, + startupCommandDeliveredInShellArgs: shell.startupCommandDeliveredInShellArgs ?? false, + windowsFallbackAttempts: shell.windowsFallbackAttempts ?? [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryLaunchEnvKeys: [], + isWslShell, + launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null + } +} + +function createWindowsLocalPtyLaunchPlan( + seed: LocalPtyLaunchSeed, + getOptions: () => LocalPtyProviderOptions +): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan { + const { args, cwd, defaultCwd, worktreeWslContext } = seed + // Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting. + const requestedShellFamily = + args.shellOverride || + getOptions().getWindowsShell?.() || + process.env.COMSPEC || + 'powershell.exe' + const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily + if (!seed.launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') { + seed.launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro()) + } + const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) + // Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe. + const powerShellImplementation = getOptions().getWindowsPowerShellImplementation?.() + const resolvedShellFamily: WindowsPowerShellShellFamily = + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? normalizedShellFamily + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined + const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ + shellFamily: resolvedShellFamily, + implementation: powerShellImplementation + }) + const shouldResolvePowerShellFamily = + powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily + const finish = (pwshAvailable: boolean): LocalPtyLaunchPlan => { + let shellPath: string + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: resolvedShellFamily, + implementation: powerShellImplementation, + pwshAvailable + }) ?? shellFamily) + : shellFamily + } + // Why: bare `pwsh.exe` resolves to the Store App Execution Alias stub whose spawn fails (code 5); use an absolute exe + cmd.exe fallback. + const windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + shellPath, + cwd, + defaultCwd, + wslContext: seed.launchWslContext, + startupCommand: args.command + }) + const primaryAttempt = windowsFallbackAttempts[0] + if (primaryAttempt) { + return finalizeLocalPtyLaunchPlan(seed, { + shellPath: primaryAttempt.shellPath, + shellArgs: primaryAttempt.shellArgs, + effectiveCwd: primaryAttempt.effectiveCwd, + validationCwd: primaryAttempt.validationCwd, + startupCommandDeliveredInShellArgs: primaryAttempt.startupCommandDeliveredInShellArgs, + windowsFallbackAttempts + }) + } + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + cwd, + defaultCwd, + seed.launchWslContext, + args.command + ) + return finalizeLocalPtyLaunchPlan(seed, { + shellPath, + shellArgs: resolved.shellArgs, + effectiveCwd: resolved.effectiveCwd, + validationCwd: resolved.validationCwd, + startupCommandDeliveredInShellArgs: resolved.startupCommandDeliveredInShellArgs === true, + windowsFallbackAttempts + }) + } + return shouldProbePwsh + ? new DeferredLocalPtyLaunchPlan(getOptions().pwshAvailable?.() ?? false, finish) + : finish(false) +} + +export function createLocalPtyLaunchPlan( + args: PtySpawnOptions, + getOptions: () => LocalPtyProviderOptions +): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan { + const startupAgentRecognition = args.command + ? recognizeAgentProcessFromCommandLine(args.command) + : null + + const defaultCwd = getDefaultCwd() + const cwd = args.cwd || defaultCwd + // Why: gate on the effective cwd, not raw args.cwd — an omitted cwd becomes a safe default and must not be rejected as root-like. + if (args.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(cwd, args.command) + } + const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null + const worktreeWslContext = + process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined + const seed: LocalPtyLaunchSeed = { + args, + startupAgentRecognition, + defaultCwd, + cwd, + wslInfo, + worktreeWslContext, + preferredWslContext, + launchWslContext: + wslInfo !== null + ? getWslContextFromPreferredDistro(wslInfo.distro) + : (worktreeWslContext ?? preferredWslContext) + } + if (wslInfo) { + const shellPath = 'wsl.exe' + const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd) + return finalizeLocalPtyLaunchPlan(seed, { + shellPath, + shellArgs: resolved.shellArgs, + effectiveCwd: resolved.effectiveCwd, + validationCwd: resolved.validationCwd + }) + } + if (process.platform === 'win32') { + return createWindowsLocalPtyLaunchPlan(seed, getOptions) + } + const shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' + return finalizeLocalPtyLaunchPlan(seed, { + shellPath, + shellArgs: ['-l'], + effectiveCwd: cwd, + validationCwd: cwd + }) +} diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index 97a85fcce10..8dc45d40aa8 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -191,6 +191,22 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('re-reads buildSpawnEnv after a reentrant configuration change', async () => { + const initialBuildSpawnEnv = vi.fn((_id: string, env: Record) => env) + const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record) => env) + provider.configure({ + get buildSpawnEnv() { + provider.configure({ buildSpawnEnv: configuredBuildSpawnEnv }) + return initialBuildSpawnEnv + } + }) + + await provider.spawn({ cols: 80, rows: 24 }) + + expect(initialBuildSpawnEnv).not.toHaveBeenCalled() + expect(configuredBuildSpawnEnv).toHaveBeenCalledOnce() + }) + it.each([ // fish EXPORTS fish_history, so an Orca launched from a fish pane hands every // pane the LAUNCHING worktree's session — even with isolation off (STA-4682). diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index c4ef90816ed..bb224009117 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -317,6 +317,20 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledOnce() }) + it('cancels an immediate stable-session shutdown before any PTY is spawned', async () => { + spawnMock.mockClear() + + const spawn = provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'immediate-shutdown-session' + }) + await provider.shutdown('immediate-shutdown-session', { immediate: true }) + + await expect(spawn).rejects.toThrow('PTY spawn canceled: immediate-shutdown-session') + expect(spawnMock).not.toHaveBeenCalled() + }) + // Why (#16441): the Codex hook install and trust grant moved into // buildSpawnEnv, so the env build is now the long await before node-pty // exists — shutdown must be able to cancel the session id during it. @@ -341,6 +355,41 @@ describe('LocalPtyProvider', () => { expect(spawnMock).not.toHaveBeenCalled() }) + it('registers post-build preflight before a nested-microtask shutdown', async () => { + spawnMock.mockClear() + let finishEnvBuild!: () => void + const envProvider = new LocalPtyProvider({ + buildSpawnEnv: (_id, baseEnv) => + new Promise>((resolve) => { + finishEnvBuild = () => resolve(baseEnv) + }) + }) + const spawn = envProvider.spawn({ + cols: 80, + rows: 24, + sessionId: 'resolved-env-build-session' + }) + const canceledSpawn = expect(spawn).rejects.toThrow( + 'PTY spawn canceled: resolved-env-build-session' + ) + await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) + + finishEnvBuild() + const shutdown = new Promise((resolve, reject) => { + queueMicrotask(() => { + queueMicrotask(() => { + envProvider + .shutdown('resolved-env-build-session', { immediate: true }) + .then(resolve, reject) + }) + }) + }) + + await shutdown + await canceledSpawn + expect(spawnMock).not.toHaveBeenCalled() + }) + it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => { spawnMock.mockClear() const procA = { ...mockProc, pid: 1001 } diff --git a/src/main/providers/local-pty-provider-state.ts b/src/main/providers/local-pty-provider-state.ts new file mode 100644 index 00000000000..5e87502b1f6 --- /dev/null +++ b/src/main/providers/local-pty-provider-state.ts @@ -0,0 +1,157 @@ +import type * as pty from 'node-pty' +import type { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' + +export type PtyShutdownOperation = { + promise: Promise + immediate: boolean + rootSignalled: boolean + proc: pty.IPty +} + +export type PendingLocalPtySpawn = { + canceled: boolean +} + +export type DataCallback = (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +}) => void + +export type ExitCallback = (payload: { + id: string + code: number + incarnationId?: string + cause?: TerminalExitCause +}) => void + +let ptyCounter = 0 +export const ptyProcesses = new Map() +export const ptyIncarnations = new Map() +// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. +export const ptyAgentSessionIds = new Set() +// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. +export const ptyShutdownOperations = new Map() +export const pendingLocalPtySpawns = new Map>() +export const ptyShellName = new Map() +export const ptyAgentForegroundContextPaths = new Map() +// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit. +// `pid` anchors the identity to the row that proved it (null when ambiguous); +// `at` is the last confirmation, so unanchored job evidence -- only a superset -- cannot hold it forever. +export const ptyLastRecognizedForeground = new Map< + string, + { name: string; pid: number | null; at: number } +>() +export const ptyTerminalHandle = new Map() +export const ptyWorktreeId = new Map() +export const ptyInitialCwd = new Map() +// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity. +export const ptyWslDistroById = new Map() +// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination. +export const ptyDisposables = new Map void }[]>() +export const ptyExitDisposables = new Map void }>() +export const ptyCleanupCallbacks = new Map void>() +export const ptyTerminationMode = new Map() +export const ptyPhysicalExits = new Map() +// Why: a wrapper spawn (macOS TCC login) reports its own status, never the +// shell's, so its exit numbers must not be read as the agent's (STA-4536). +export const ptyReportsChildExitStatus = new Map() +export const ptyForceKillTimers = new Map>() + +let loadGeneration = 0 +export const ptyLoadGeneration = new Map() + +export const dataListeners = new Set() +export const exitListeners = new Set() +export const startupIngressByPty = new Map() + +/** + * Disposes native node-pty listeners registered for a PTY id. + */ +export function disposePtyListeners(id: string): void { + const disposables = ptyDisposables.get(id) + if (disposables) { + for (const d of disposables) { + d.dispose() + } + ptyDisposables.delete(id) + } +} + +export function disposePtyExitListener(id: string): void { + ptyExitDisposables.get(id)?.dispose() + ptyExitDisposables.delete(id) +} + +export function clearLocalPtyForceKillTimer(id: string): void { + const timer = ptyForceKillTimers.get(id) + if (timer) { + clearTimeout(timer) + ptyForceKillTimers.delete(id) + } +} + +export function runPtyCleanup(id: string): void { + const cleanup = ptyCleanupCallbacks.get(id) + if (!cleanup) { + return + } + ptyCleanupCallbacks.delete(id) + cleanup() +} + +/** + * Removes all local tracking state for a PTY id after teardown. + */ +export function clearPtyState(id: string): void { + clearLocalPtyForceKillTimer(id) + runPtyCleanup(id) + disposePtyListeners(id) + disposePtyExitListener(id) + ptyProcesses.delete(id) + ptyIncarnations.delete(id) + ptyAgentSessionIds.delete(id) + ptyShellName.delete(id) + ptyAgentForegroundContextPaths.delete(id) + ptyLastRecognizedForeground.delete(id) + ptyTerminalHandle.delete(id) + ptyWorktreeId.delete(id) + ptyInitialCwd.delete(id) + ptyWslDistroById.delete(id) + ptyLoadGeneration.delete(id) + ptyTerminationMode.delete(id) + ptyReportsChildExitStatus.delete(id) + ptyPhysicalExits.delete(id) +} + +/** + * Allocates either a stable caller-provided PTY id or a new numeric id. + */ +export function allocatePtyId(sessionId: string | undefined): string { + const requested = normalizeLocalCallerSessionId(sessionId) + if (requested) { + return requested + } + let id: string + do { + id = String(++ptyCounter) + } while (ptyProcesses.has(id)) + return id +} + +export function getLoadGeneration(): number { + return loadGeneration +} + +export function advanceLoadGeneration(): number { + return ++loadGeneration +} + +export function resetLoadGeneration(): void { + loadGeneration = 0 +} diff --git a/src/main/providers/local-pty-provider-types.ts b/src/main/providers/local-pty-provider-types.ts new file mode 100644 index 00000000000..c0eaaaa5ed0 --- /dev/null +++ b/src/main/providers/local-pty-provider-types.ts @@ -0,0 +1,37 @@ +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { PtySpawnOptions } from './types' + +export type LocalPtyProviderOptions = { + /** Why: `ctx.command` (pi/omp/claude) must drive overlay source-dir selection — a disk-presence fallback shadows the other agent's extensions. */ + buildSpawnEnv?: ( + id: string, + baseEnv: Record, + ctx?: { + command?: string + launchAgent?: PtySpawnOptions['launchAgent'] + codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] + cwd?: string + shellPath?: string + isWsl?: boolean + wslDistro?: string | null + } + // Why (#16441): Codex launch prep grants hook trust through a codex + // app-server session. `spawn` already awaits, so returning a promise keeps + // the Electron main thread responsive instead of blocking on spawnSync. + ) => Record | Promise> + /** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */ + isHistoryEnabled?: () => boolean + /** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */ + getWindowsShell?: () => string | undefined + getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined + pwshAvailable?: () => boolean | Promise + onSpawned?: (id: string, incarnationId: string) => void + onExit?: (id: string, code: number, incarnationId: string, cause?: TerminalExitCause) => void + onData?: ( + id: string, + data: string, + timestamp: number, + sequenceChars?: number, + transformed?: boolean + ) => void +} diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 4d8d1b95430..bc96cdb1d6a 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -436,6 +436,56 @@ describe('LocalPtyProvider', () => { expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS) }) + it('re-reads Windows shell options after a reentrant configuration change', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const initialPowerShellImplementation = vi.fn(() => 'powershell.exe' as const) + const configuredPwshAvailable = vi.fn(() => true) + provider.configure({ + getWindowsShell: () => { + provider.configure({ + getWindowsPowerShellImplementation: () => 'auto', + pwshAvailable: configuredPwshAvailable + }) + return 'powershell.exe' + }, + getWindowsPowerShellImplementation: initialPowerShellImplementation + }) + + await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) + + expect(initialPowerShellImplementation).not.toHaveBeenCalled() + expect(configuredPwshAvailable).toHaveBeenCalledOnce() + expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS) + }) + + it('registers a probe-complete spawn before a later microtask shutdown', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + getWindowsShell: () => 'powershell.exe', + getWindowsPowerShellImplementation: () => 'auto', + pwshAvailable: () => false + }) + const callsBeforeSpawn = spawnMock.mock.calls.length + const spawn = provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + sessionId: 'probe-shutdown-session' + }) + const canceledSpawn = expect(spawn).rejects.toThrow( + 'PTY spawn canceled: probe-shutdown-session' + ) + const shutdown = new Promise((resolve, reject) => { + queueMicrotask(() => { + provider.shutdown('probe-shutdown-session', { immediate: true }).then(resolve, reject) + }) + }) + + await shutdown + await canceledSpawn + expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn) + }) + it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const savedCodexHome = process.env.CODEX_HOME diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 1ad88aaa2ba..d08e437add3 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,450 +1,48 @@ -/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ -import { basename, win32 as pathWin32 } from 'node:path' -import { randomUUID } from 'node:crypto' -import { - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, - resolveWindowsShellLaunchArgs -} from './windows-shell-args' -import { - resolveEffectiveWindowsPowerShell, - shouldProbeWindowsPowerShellAvailability, - type WindowsPowerShellShellFamily -} from './windows-powershell' -import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain' -import { resolveProcessCwd } from './process-cwd' -import { existsSync } from 'node:fs' -import * as pty from 'node-pty' -import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl' -import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' -import { - injectHistoryEnv, - injectWslFishHistoryEnv, - updateHistoryEnvForFallback, - logHistoryInjection, - type HistoryInjectionResult -} from '../terminal-history' -import { dropInheritedOrcaFishHistory } from '../fish-history-session' -import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' +import type * as pty from 'node-pty' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { - ensureNodePtySpawnHelperExecutable, - validateWorkingDirectory, - spawnShellWithFallback -} from './local-pty-utils' -import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' -import { getShellLaunchConfig } from './local-pty-shell-ready' -import { selectShellStartupFeatures } from '../shell-startup-features' + confirmLocalPtyForegroundProcess, + confirmLocalPtyShellForeground, + getLocalPtyForegroundProcess, + hasLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' import { - writeStartupCommandWhenShellReady, - STARTUP_COMMAND_READY_MAX_WAIT_MS -} from './local-pty-shell-ready-startup-command' -import type { ShellReadySignal } from './local-pty-shell-ready-startup-command' -import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' -import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' -import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' -import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' -import { SessionNotFoundError } from '../daemon/daemon-errors' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { addWslEnvKeys } from '../wsl-env' + advanceLoadGeneration, + clearPtyState, + pendingLocalPtySpawns, + ptyProcesses, + resetLoadGeneration, + type DataCallback, + type ExitCallback +} from './local-pty-provider-state' import { - POWERLEVEL10K_WIZARD_DISABLE_ENV, - seedPowerlevel10kWizardEnv -} from '../pty/powerlevel10k-wizard-env' -import { - isWindowsGitBashShellPath, - resolveGitBashPath, - resolveWindowsGitBashShellPath -} from '../git-bash' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { - confirmShellForegroundProcess, - resolveAgentForegroundProcessWithAvailability -} from './agent-foreground-process' -import { resolveStableForegroundProcess } from './stable-foreground-process' -import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { killWithDescendantSweep } from '../pty-descendant-termination' -import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' -import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' -import { terminatePtyJob } from '../windows/windows-pty-job' -import { - canRevalidateCachedAgentWithoutScan, - judgeCachedAgentJobEvidence -} from './windows-cached-agent-revalidation' -import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import { assertSafeAgentStartupCwd } from './pty-default-cwd' -import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' -import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' -import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' -import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' -import { readPtsName } from '../pty/node-pty-pts-name' -import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' -import { - createShellStartupOutputScanState, - drainShellStartupOutputScanState, - scanShellStartupOutput -} from '../shell-startup-output-scanner' -import { - createShellPromptReadinessProbe, - type ShellPromptReadinessProbe -} from '../shell-prompt-readiness-probe' -import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' -import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' + clearLocalPtyBuffer, + closeLocalPtyStartupQueryAuthority, + getDefaultLocalPtyShell, + getLocalPtyAppliedSize, + getLocalPtyCwd, + getLocalPtyProcess, + getLocalPtyProfiles, + listLocalPtyProcesses, + onLocalPtyData, + onLocalPtyExit, + pauseLocalPtyProducer, + resizeLocalPty, + resumeLocalPtyProducer, + sendLocalPtySignal, + writeLocalPty +} from './local-pty-session-operations' +import { spawnLocalPty } from './local-pty-spawn' +import { cancelAllPendingLocalPtySpawns } from './local-pty-spawn-state' +import { killAllLocalPtys, killOrphanedLocalPtys, shutdownLocalPty } from './local-pty-termination' -import { - getDefaultCwd, - getSpawnedShellName, - getWslContextFromPreferredDistro, - getWslContextFromWorktreeId, - normalizeLocalCallerSessionId, - promoteAgentTeamsShimPath, - removeUnspecifiedPaneIdentityEnv, - resolveForegroundFallbackProcess -} from './local-pty-launch-helpers' - -let ptyCounter = 0 -const ptyProcesses = new Map() -const ptyIncarnations = new Map() -// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. -const ptyAgentSessionIds = new Set() -// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. -type PtyShutdownOperation = { - promise: Promise - immediate: boolean - rootSignalled: boolean - proc: pty.IPty -} -const ptyShutdownOperations = new Map() -type PendingLocalPtySpawn = { - canceled: boolean -} -const pendingLocalPtySpawns = new Map>() -const ptyShellName = new Map() -const ptyAgentForegroundContextPaths = new Map() -// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit. -// `pid` anchors the identity to the row that proved it (null when ambiguous); -// `at` is the last confirmation, so unanchored job evidence -- only a superset -- cannot hold it forever. -const ptyLastRecognizedForeground = new Map< - string, - { name: string; pid: number | null; at: number } ->() -const ptyTerminalHandle = new Map() -const ptyWorktreeId = new Map() -const ptyInitialCwd = new Map() -// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity. -const ptyWslDistroById = new Map() -// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination. -const ptyDisposables = new Map void }[]>() -const ptyExitDisposables = new Map void }>() -const ptyCleanupCallbacks = new Map void>() -const ptyTerminationMode = new Map() -const ptyPhysicalExits = new Map() -// Why: a wrapper spawn (macOS TCC login) reports its own status, never the -// shell's, so its exit numbers must not be read as the agent's (STA-4536). -const ptyReportsChildExitStatus = new Map() -const ptyForceKillTimers = new Map>() - -export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 -export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000 -export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 - -let loadGeneration = 0 -const ptyLoadGeneration = new Map() - -type DataCallback = (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number -}) => void -type ExitCallback = (payload: { - id: string - code: number - incarnationId?: string - cause?: TerminalExitCause -}) => void - -const dataListeners = new Set() -const exitListeners = new Set() -const startupIngressByPty = new Map() - -/** - * Disposes native node-pty listeners registered for a PTY id. - */ -function disposePtyListeners(id: string): void { - const disposables = ptyDisposables.get(id) - if (disposables) { - for (const d of disposables) { - d.dispose() - } - ptyDisposables.delete(id) - } -} - -function disposePtyExitListener(id: string): void { - ptyExitDisposables.get(id)?.dispose() - ptyExitDisposables.delete(id) -} - -function clearLocalPtyForceKillTimer(id: string): void { - const timer = ptyForceKillTimers.get(id) - if (timer) { - clearTimeout(timer) - ptyForceKillTimers.delete(id) - } -} - -function runPtyCleanup(id: string): void { - const cleanup = ptyCleanupCallbacks.get(id) - if (!cleanup) { - return - } - ptyCleanupCallbacks.delete(id) - cleanup() -} - -/** - * Removes all local tracking state for a PTY id after teardown. - */ -function clearPtyState(id: string): void { - clearLocalPtyForceKillTimer(id) - runPtyCleanup(id) - disposePtyListeners(id) - disposePtyExitListener(id) - ptyProcesses.delete(id) - ptyIncarnations.delete(id) - ptyAgentSessionIds.delete(id) - ptyShellName.delete(id) - ptyAgentForegroundContextPaths.delete(id) - ptyLastRecognizedForeground.delete(id) - ptyTerminalHandle.delete(id) - ptyWorktreeId.delete(id) - ptyInitialCwd.delete(id) - ptyWslDistroById.delete(id) - ptyLoadGeneration.delete(id) - ptyTerminationMode.delete(id) - ptyReportsChildExitStatus.delete(id) - ptyPhysicalExits.delete(id) -} - -function createPtyPhysicalExit(id: string): void { - ptyPhysicalExits.set(id, new PhysicalExitTracker()) -} - -function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { - if (!physicalExit) { - return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) - } - return physicalExit.waitForExit( - LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, - () => new Error(`Timed out waiting for PTY process exit: ${id}`) - ) -} - -function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void { - if (process.platform === 'win32') { - proc.kill() - return - } - if (!immediate) { - proc.kill('SIGTERM') - return - } - forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) -} - -function armLocalPtyForceKill( - id: string, - proc: pty.IPty, - options: { delayMs?: number; attemptsRemaining?: number } = {} -): void { - if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { - return - } - const attemptsRemaining = options.attemptsRemaining ?? 2 - const timer = setTimeout(() => { - ptyForceKillTimers.delete(id) - if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { - return - } - ptyTerminationMode.set(id, 'force') - try { - killLocalPtyProcess(proc, true) - } catch (error) { - ptyTerminationMode.set(id, 'graceful') - console.error('[pty] failed to force-kill PTY after graceful deadline', { id, error }) - // Why: a transient native rejection must not consume the only SIGKILL owner while shutdown still awaits physical exit. - if (attemptsRemaining > 1) { - armLocalPtyForceKill(id, proc, { - delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, - attemptsRemaining: attemptsRemaining - 1 - }) - } - } - }, options.delayMs ?? LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - timer.unref?.() - ptyForceKillTimers.set(id, timer) -} - -/** - * Allocates either a stable caller-provided PTY id or a new numeric id. - */ -function allocatePtyId(sessionId: string | undefined): string { - const requested = normalizeLocalCallerSessionId(sessionId) - if (requested) { - return requested - } - let id: string - do { - id = String(++ptyCounter) - } while (ptyProcesses.has(id)) - return id -} - -/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty - * process exists yet, so cancellation can only be observed after the await. */ -async function awaitCancelableLocalPtySpawn(id: string, operation: T | Promise): Promise { - const pendingSpawn: PendingLocalPtySpawn = { canceled: false } - const pending = pendingLocalPtySpawns.get(id) ?? new Set() - pending.add(pendingSpawn) - pendingLocalPtySpawns.set(id, pending) - try { - const result = await operation - if (pendingSpawn.canceled) { - throw new Error(`PTY spawn canceled: ${id}`) - } - return result - } finally { - pending.delete(pendingSpawn) - if (pending.size === 0) { - pendingLocalPtySpawns.delete(id) - } - } -} - -function cancelPendingLocalPtySpawns(id: string): void { - const pending = pendingLocalPtySpawns.get(id) - if (!pending) { - return - } - for (const pendingSpawn of pending) { - pendingSpawn.canceled = true - } -} - -function cancelAllPendingLocalPtySpawns(): void { - for (const id of pendingLocalPtySpawns.keys()) { - cancelPendingLocalPtySpawns(id) - } -} - -function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null { - const existing = ptyProcesses.get(id) - if (!existing) { - return null - } - try { - existing.resize(cols, rows) - } catch { - /* Existing PTY may reject resize during teardown; still return the live handle. */ - } - return { - id, - pid: existing.pid, - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), - isReattach: true - } -} - -/** - * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. - */ -function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { - // Why: neutralize proc.kill before destroy(), whose close-listener SIGHUPs a possibly-recycled POSIX pid; destroy() frees the ptmx fd (docs/fix-pty-fd-leak.md); on Windows destroy() is itself kill(). - if (process.platform === 'win32' && options.alreadyKilled) { - return - } - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - try { - ;(proc as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow — already torn down */ - } -} - -/** - * Requests local PTY termination while retaining physical-exit ownership. - */ -function requestPtyTermination(id: string, proc: pty.IPty): void { - runPtyCleanup(id) - disposePtyListeners(id) - const previousMode = ptyTerminationMode.get(id) - // Why: cleanup neutralizes proc.kill below, so escalate an outstanding graceful request before its deadline is disabled. - if (previousMode !== 'force') { - clearLocalPtyForceKillTimer(id) - ptyTerminationMode.set(id, 'force') - try { - killLocalPtyProcess(proc, true) - } catch { - if (previousMode === 'graceful') { - ptyTerminationMode.set(id, previousMode) - armLocalPtyForceKill(id, proc, { - delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, - attemptsRemaining: 1 - }) - } else { - ptyTerminationMode.delete(id) - } - /* Process may already be dead. */ - return - } - } - // Why: shutdown and orphan cleanup can race; keep onExit + tracker installed until the OS proves the child was reaped. - destroyPtyProcess(proc, { alreadyKilled: true }) -} - -export type LocalPtyProviderOptions = { - /** Why: `ctx.command` (pi/omp/claude) must drive overlay source-dir selection — a disk-presence fallback shadows the other agent's extensions. */ - buildSpawnEnv?: ( - id: string, - baseEnv: Record, - ctx?: { - command?: string - launchAgent?: PtySpawnOptions['launchAgent'] - codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] - cwd?: string - shellPath?: string - isWsl?: boolean - wslDistro?: string | null - } - // Why (#16441): Codex launch prep grants hook trust through a codex - // app-server session. `spawn` already awaits, so returning a promise keeps - // the Electron main thread responsive instead of blocking on spawnSync. - ) => Record | Promise> - /** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */ - isHistoryEnabled?: () => boolean - /** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */ - getWindowsShell?: () => string | undefined - getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined - pwshAvailable?: () => boolean | Promise - onSpawned?: (id: string, incarnationId: string) => void - onExit?: (id: string, code: number, incarnationId: string, cause?: TerminalExitCause) => void - onData?: ( - id: string, - data: string, - timestamp: number, - sequenceChars?: number, - transformed?: boolean - ) => void -} +export type { LocalPtyProviderOptions } from './local-pty-provider-types' +export { + LOCAL_PTY_FORCE_KILL_RETRY_MS, + LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS, + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS +} from './local-pty-termination' export class LocalPtyProvider implements IPtyProvider { private opts: LocalPtyProviderOptions @@ -463,634 +61,8 @@ export class LocalPtyProvider implements IPtyProvider { * * Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed. */ - async spawn(args: PtySpawnOptions): Promise { - const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true) - if (reattachId) { - const pendingShutdown = ptyShutdownOperations.get(reattachId) - if (pendingShutdown) { - await pendingShutdown.promise - } - const existing = reattachLocalPty(reattachId, args.cols, args.rows) - if (existing) { - return existing - } - } - if (args.attachOnly) { - throw new SessionNotFoundError(args.sessionId ?? '') - } - const id = allocatePtyId(reattachId ?? undefined) - const incarnationId = randomUUID() - - const startupAgentRecognition = args.command - ? recognizeAgentProcessFromCommandLine(args.command) - : null - - const defaultCwd = getDefaultCwd() - const cwd = args.cwd || defaultCwd - // Why: gate on the effective cwd, not raw args.cwd — an omitted cwd becomes a safe default and must not be rejected as root-like. - if (args.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(cwd, args.command) - } - const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null - const worktreeWslContext = - process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined - const preferredWslContext = - process.platform === 'win32' - ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) - : undefined - let launchWslContext = - wslInfo !== null - ? getWslContextFromPreferredDistro(wslInfo.distro) - : (worktreeWslContext ?? preferredWslContext) - - let shellPath: string - let shellArgs: string[] - let effectiveCwd: string - let validationCwd: string - let startupCommandDeliveredInShellArgs = false - let windowsFallbackAttempts: ReturnType = [] - let shellReadyLaunch: ReturnType | null = null - let getFallbackShellReadyConfig: - | ((shell: string) => ReturnType) - | undefined - // Why hoisted: a fallback shell must drop the primary's launch env, and - // re-deriving the key names would re-run wrapper generation. - let primaryLaunchEnvKeys: string[] = [] - if (wslInfo) { - shellPath = 'wsl.exe' - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd) - shellArgs = resolved.shellArgs - effectiveCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - } else if (process.platform === 'win32') { - // Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting. - const requestedShellFamily = - args.shellOverride || - this.opts.getWindowsShell?.() || - process.env.COMSPEC || - 'powershell.exe' - const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily - if (!launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') { - launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro()) - } - const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() - const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) - // Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe. - const powerShellImplementation = this.opts.getWindowsPowerShellImplementation?.() - const resolvedShellFamily: WindowsPowerShellShellFamily = - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? normalizedShellFamily - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined - const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ - shellFamily: resolvedShellFamily, - implementation: powerShellImplementation - }) - const shouldResolvePowerShellFamily = - powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily - const pwshAvailable = shouldProbePwsh ? await (this.opts.pwshAvailable?.() ?? false) : false - if (resolvedGitBashPath) { - shellPath = resolvedGitBashPath - } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { - shellPath = 'powershell.exe' - } else { - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: resolvedShellFamily, - implementation: powerShellImplementation, - pwshAvailable - }) ?? shellFamily) - : shellFamily - } - // Why: bare `pwsh.exe` resolves to the Store App Execution Alias stub whose spawn fails (code 5); use an absolute exe + cmd.exe fallback. - windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ - shellPath, - cwd, - defaultCwd, - wslContext: launchWslContext, - startupCommand: args.command - }) - const primaryAttempt = windowsFallbackAttempts[0] - if (primaryAttempt) { - shellPath = primaryAttempt.shellPath - shellArgs = primaryAttempt.shellArgs - effectiveCwd = primaryAttempt.effectiveCwd - validationCwd = primaryAttempt.validationCwd - startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs - } else { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - cwd, - defaultCwd, - launchWslContext, - args.command - ) - shellArgs = resolved.shellArgs - effectiveCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } - } else { - shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' - shellArgs = ['-l'] - effectiveCwd = cwd - validationCwd = cwd - } - - ensureNodePtySpawnHelperExecutable() - if (args.prevalidatedCwd !== validationCwd) { - validateWorkingDirectory(validationCwd) - } - - const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), args.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them. - FORCE_HYPERLINK: '1' - } as Record - // Why: Orca can be launched from an Orca terminal; pane identity belongs to the child PTY, not the parent shell. - removeUnspecifiedPaneIdentityEnv(spawnEnv, args.env) - removeAppImageRuntimeEnv(spawnEnv) - removeInheritedNoColor(spawnEnv) - for (const key of args.envToDelete ?? []) { - delete spawnEnv[key] - } - if (args.env?.TERM) { - spawnEnv.TERM = args.env.TERM - } - - spawnEnv.LANG ??= 'en_US.UTF-8' - - // Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK. - if (process.platform === 'win32') { - spawnEnv.PYTHONUTF8 ??= '1' - if (isWindowsGitBashShellPath(shellPath)) { - // Why: Git for Windows login files otherwise cd to $HOME, ignoring node-pty's cwd for repo-scoped terminals. - spawnEnv.CHERE_INVOKING ??= '1' - } - } - - const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - const launchWslDistro = isWslShell ? (launchWslContext?.distro ?? null) : null - // Why (#16441): building the env now awaits Codex hook installs and trust - // grants, so shutdown must be able to cancel this session id here too. - const finalEnv = this.opts.buildSpawnEnv - ? await awaitCancelableLocalPtySpawn( - id, - this.opts.buildSpawnEnv(id, spawnEnv, { - command: args.command, - launchAgent: args.launchAgent, - codexHomePathOverride: args.codexHomePathOverride, - cwd, - shellPath, - isWsl: isWslShell, - wslDistro: launchWslDistro - }) - ) - : spawnEnv - // Why: app-level env hooks can re-add scrubbed vars; delete last so shims like Claude Agent Teams keep their PATH. - for (const key of args.envToDelete ?? []) { - delete finalEnv[key] - } - if (args.env?.TERM) { - finalEnv.TERM = args.env.TERM - } - if (process.platform === 'win32') { - const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - if (codexHomeWslInfo) { - if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { - delete finalEnv.CODEX_HOME - delete finalEnv.ORCA_CODEX_HOME - } else { - finalEnv.CODEX_HOME = codexHomeWslInfo.linuxPath - finalEnv.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath - // Why: wsl.exe only imports non-default env vars named in WSLENV. - addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - if (!launchWslDistro) { - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, { - distro: codexHomeWslInfo.distro - }) - shellArgs = resolved.shellArgs - effectiveCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = - resolved.startupCommandDeliveredInShellArgs === true - } - } - } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { - // Why: Orca's Codex home is host-local; WSL Codex must use its Linux-side ~/.codex, not a Windows path. - delete finalEnv.CODEX_HOME - delete finalEnv.ORCA_CODEX_HOME - } else if (finalEnv.CODEX_HOME) { - addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - } - if (finalEnv.CLAUDE_CONFIG_DIR) { - // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. - addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) - } - if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { - // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. - addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]) - } - } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { - // Why: WSL Codex homes are Linux paths Windows can't use; also drop ORCA_CODEX_HOME (shell-ready restores CODEX_HOME from it). - delete finalEnv.CODEX_HOME - delete finalEnv.ORCA_CODEX_HOME - } - - const shellBasename = pathWin32.basename(shellPath).toLowerCase() - const codexLaunchPreflightCommand = finalEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - if ( - codexLaunchPreflightCommand && - (shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(shellPath)) - ) { - if (shellBasename === 'cmd.exe') { - // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. - finalEnv[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' - } - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - cwd, - defaultCwd, - launchWslContext, - args.command, - codexLaunchPreflightCommand - ) - shellArgs = resolved.shellArgs - effectiveCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } - } - seedPowerlevel10kWizardEnv(finalEnv, { envToDelete: args.envToDelete }) - if ( - finalEnv[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - ) { - addWslEnvKeys(finalEnv, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) - } - const requestedEnv = args.env - expandWindowsPathEnvironmentVariables(finalEnv) - promoteAgentTeamsShimPath( - finalEnv, - requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined - ) - // Why: raw requested PATH promotion runs after the host-env scrub. - stripLegacyTerminalShimEnv(finalEnv, process.platform) - // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. - dropIncoherentCondaActivationEnv(finalEnv, process.platform) - - // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). - const worktreeId = args.worktreeId - const historyEnabled = worktreeId && (this.opts.isHistoryEnabled?.() ?? true) - // Effective shell for history injection: WSL's outer exe is wsl.exe but the inner login shell is bash. - const isWslTerminal = - Boolean(wslInfo || worktreeWslContext || preferredWslContext) || - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - const effectiveShellPath = isWslTerminal ? 'bash' : shellPath - let historyResult: ReturnType | null = null - if (historyEnabled) { - historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, { - wslDistro: launchWslDistro - }) - if (isWslTerminal && launchWslDistro) { - injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro) - addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(worktreeId, historyResult) - } else { - // Why: injectHistoryEnv is what normally clears it, so when history is off - // an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146. - delete finalEnv.ORCA_HISTFILE - // Same for an exported `fish_history` from the fish pane that launched this - // Orca: history off means fish's own default, not another worktree's file. - dropInheritedOrcaFishHistory(finalEnv) - // And for an exported HISTFILE: history off means the shell's own default, - // not the history file of the worktree this Orca was launched from. - dropInheritedOrcaHistFile(finalEnv) - } - - if (!wslInfo && process.platform !== 'win32') { - // Why after history injection: the wrapper is what repairs a worktree - // HISTFILE that the system zshrc clobbers, so the decision to wrap has to - // see whether this spawn actually injected one. - const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - // Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed. - const waitsForShellReady = - Boolean(args.command) && - (!isCodexStartupCommand || - shouldUseShellReadyStartupDelivery({ - command: args.command as string, - startupCommandDelivery: args.startupCommandDelivery - })) - // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch - // config below may name features for this shell. - delete finalEnv.ORCA_SHELL_FEATURES - getFallbackShellReadyConfig = (shell) => - getShellLaunchConfig( - shell, - selectShellStartupFeatures({ - shellPath: shell, - env: finalEnv, - hasStartupCommand: Boolean(args.command), - waitsForShellReady, - // Why identical: the identity marker exists so the readiness - // handshake can bind output to the right shell PID. - emitsStartupIdentity: waitsForShellReady - }) - ) - const shellLaunch = getFallbackShellReadyConfig(shellPath) - Object.assign(finalEnv, shellLaunch.env) - shellArgs = shellLaunch.args ?? shellArgs - shellReadyLaunch = args.command ? shellLaunch : null - primaryLaunchEnvKeys = Object.keys(shellLaunch.env) - } - - // Why: the async macOS capability probe runs before node-pty exists. - await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell()) - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. - const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null - if (concurrentWinner) { - return concurrentWinner - } - const spawnResult = spawnShellWithFallback({ - shellPath, - shellArgs, - cols: args.cols, - rows: args.rows, - cwd: effectiveCwd, - env: finalEnv, - termName: finalEnv.TERM, - ptySpawn: pty.spawn, - getShellReadyConfig: getFallbackShellReadyConfig, - launchEnvKeys: primaryLaunchEnvKeys, - // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: historyResult?.historyDir - ? (env, fallbackShell) => - updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) - : undefined, - windowsFallbackAttempts - }) - args.onPtySpawnCommitted?.() - shellPath = spawnResult.shellPath - // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. - if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { - startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs - } - if (args.command && getFallbackShellReadyConfig) { - shellReadyLaunch = getFallbackShellReadyConfig(shellPath) - } - - if (process.platform !== 'win32') { - finalEnv.SHELL = shellPath - } - - const proc = spawnResult.process - const spawnedShellIsWsl = - process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - const spawnedWslDistro = spawnedShellIsWsl - ? (launchWslDistro ?? undefined) - : process.platform === 'win32' - ? null - : undefined - createPtyPhysicalExit(id) - ptyReportsChildExitStatus.set(id, spawnResult.reportsChildExitStatus !== false) - ptyProcesses.set(id, proc) - ptyInitialCwd.set(id, cwd) - if (spawnedWslDistro !== undefined) { - ptyWslDistroById.set(id, spawnedWslDistro) - } - // Why both: launchAgent is explicit intent that survives command rewrites; recognition catches bare agent command lines. - if (args.launchAgent || startupAgentRecognition) { - ptyAgentSessionIds.add(id) - } - ptyShellName.set(id, getSpawnedShellName(shellPath)) - if (finalEnv.ORCA_TERMINAL_HANDLE) { - ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) - } - if (args.worktreeId) { - ptyWorktreeId.set(id, args.worktreeId) - } - ptyAgentForegroundContextPaths.set( - id, - getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) - ) - ptyLoadGeneration.set(id, loadGeneration) - ptyIncarnations.set(id, incarnationId) - this.opts.onSpawned?.(id, incarnationId) - - const emitIngressData = (emission: PtyIngressEmission): void => { - const sequenceChars = emission.rawEndSeq - emission.rawStartSeq - if (emission.transformed || sequenceChars !== emission.data.length) { - this.opts.onData?.(id, emission.data, Date.now(), sequenceChars, true) - } else { - this.opts.onData?.(id, emission.data, Date.now()) - } - for (const cb of dataListeners) { - cb( - emission.transformed || sequenceChars !== emission.data.length - ? { - id, - data: emission.data, - sequenceChars, - seq: emission.rawEndSeq, - transformed: true - } - : { id, data: emission.data } - ) - } - } - const startupIngress = new PtyStartupIngress({ - ...(args.startupIngress ? { intent: args.startupIngress } : {}), - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath, - wslDistro: spawnedWslDistro - }), - write: (data) => proc.write(data), - onEmission: emitIngressData - }) - startupIngressByPty.set(id, startupIngress) - - // Shell-ready startup command support - let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null - let shellReadyTimeout: ReturnType | null = null - let shellStartupPid: number | null = null - let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null - let shellStartupOutputScanState = shellReadyLaunch?.supportsReadyMarker - ? createShellStartupOutputScanState() - : null - const shellReadyPromise = args.command - ? new Promise((resolve) => { - resolveShellReady = resolve - }) - : Promise.resolve({ postMarkerBytesObserved: false }) - const finishShellReady = (signal: ShellReadySignal): void => { - if (!resolveShellReady) { - return - } - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - const resolve = resolveShellReady - resolveShellReady = null - resolve(signal) - } - const releaseHeldShellReadyBytes = (): void => { - if (!shellStartupOutputScanState) { - return - } - const heldBytes = drainShellStartupOutputScanState(shellStartupOutputScanState) - shellStartupOutputScanState = null - if (heldBytes.length === 0) { - return - } - startupIngress.accept(heldBytes) - } - if (shellStartupOutputScanState) { - shellPromptReadinessProbe = createShellPromptReadinessProbe({ - slavePath: readPtySlavePath(proc), - shellPath, - shellCwd: effectiveCwd, - shellPathEnv: finalEnv.PATH, - getShellPid: () => shellStartupPid, - onPromptReady: () => { - console.warn( - `[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` - ) - releaseHeldShellReadyBytes() - finishShellReady({ postMarkerBytesObserved: true }) - } - }) - } - if (args.command) { - if (shellReadyLaunch?.supportsReadyMarker) { - shellReadyTimeout = setTimeout(() => { - releaseHeldShellReadyBytes() - finishShellReady({ postMarkerBytesObserved: false }) - }, STARTUP_COMMAND_READY_MAX_WAIT_MS) - } else { - finishShellReady({ postMarkerBytesObserved: false }) - } - } - let startupCommandCleanup: (() => void) | null = null - if (args.command) { - ptyCleanupCallbacks.set(id, () => { - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - releaseHeldShellReadyBytes() - startupCommandCleanup?.() - startupCommandCleanup = null - resolveShellReady = null - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - }) - } - - const disposables: { dispose: () => void }[] = [] - const onDataDisposable = proc.onData((rawData) => { - let data = rawData - if (shellStartupOutputScanState && resolveShellReady) { - const scanned = scanShellStartupOutput(shellStartupOutputScanState, data) - data = scanned.output - if (scanned.shellPid) { - shellStartupPid = scanned.shellPid - } - if (scanned.ready) { - finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) - } - } - startupIngress.accept(data) - if (resolveShellReady && data.length > 0) { - shellPromptReadinessProbe?.notifyOutput(data) - } - }) - if (onDataDisposable) { - disposables.push(onDataDisposable) - } - - const onExitDisposable = proc.onExit(({ exitCode, signal }) => { - // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the - // cause is built here, where the signal and the spawn's trustworthiness - // are both still in hand. - const cause = resolveProcessExitCause({ - exitCode, - signal, - hostReportsChildExitStatus: ptyReportsChildExitStatus.get(id) - }) - const wasTerminationRequested = ptyTerminationMode.has(id) - ptyPhysicalExits.get(id)?.markExited() - // Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - startupCommandCleanup?.() - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - clearPtyState(id) - startupIngress.drainAndClose() - startupIngressByPty.delete(id) - // Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md. - destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) - ptyReportsChildExitStatus.delete(id) - this.opts.onExit?.(id, exitCode, incarnationId, cause) - for (const cb of exitListeners) { - cb({ id, code: exitCode, incarnationId, cause }) - } - }) - if (onExitDisposable) { - ptyExitDisposables.set(id, onExitDisposable) - } - ptyDisposables.set(id, disposables) - - if (args.command && !startupCommandDeliveredInShellArgs) { - // Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit. - const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase() - const bracketedPasteSafe = - process.platform !== 'win32' && - isBracketedPasteSafeShell({ - shellName: spawnedShellName, - waitsForShellReady: shellReadyLaunch?.supportsReadyMarker === true - }) - writeStartupCommandWhenShellReady( - shellReadyPromise, - proc, - args.command, - (cleanup) => { - startupCommandCleanup = cleanup - }, - { bracketedPasteSafe } - ) - } - - // Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child. - const rawPid = proc.pid - const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null - return { - id, - incarnationId, - pid, - ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) - } + spawn(args: PtySpawnOptions): Promise { + return spawnLocalPty(args, () => this.opts) } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. @@ -1099,372 +71,62 @@ export class LocalPtyProvider implements IPtyProvider { return ptyProcesses.has(id) } write(id: string, data: string): boolean { - // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of - // those is still held, which they must not overtake (#13137, #7329, #15559). - if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) { - return true - } - const proc = ptyProcesses.get(id) - if (!proc) { - return false - } - proc.write(data) - return true + return writeLocalPty(id, data) } resize(id: string, cols: number, rows: number): void { - ptyProcesses.get(id)?.resize(cols, rows) + resizeLocalPty(id, cols, rows) } - // Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure. pauseProducer(id: string): void { - try { - ptyProcesses.get(id)?.pause() - } catch { - /* PTY already destroyed */ - } + pauseLocalPtyProducer(id) } resumeProducer(id: string): void { - try { - ptyProcesses.get(id)?.resume() - } catch { - /* PTY already destroyed */ - } + resumeLocalPtyProducer(id) } - // Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check. - async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - const proc = ptyProcesses.get(id) - if (!proc || proc.cols <= 0 || proc.rows <= 0) { - return null - } - return { cols: proc.cols, rows: proc.rows } + getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { + return getLocalPtyAppliedSize(id) } - async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - cancelPendingLocalPtySpawns(id) - const pending = ptyShutdownOperations.get(id) - if (pending) { - if (opts.immediate === true) { - pending.immediate = true - if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) { - this.requestTrackedPtyShutdown(id, pending.proc, true) - } - } - await pending.promise - return - } - const proc = ptyProcesses.get(id) - if (!proc) { - return - } - const entry: PtyShutdownOperation = { - promise: Promise.resolve(), - immediate: opts.immediate === true, - rootSignalled: false, - proc - } - entry.promise = this.shutdownTrackedPty(id, proc, entry) - ptyShutdownOperations.set(id, entry) - try { - await entry.promise - } finally { - if (ptyShutdownOperations.get(id) === entry) { - ptyShutdownOperations.delete(id) - } - } + shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + return shutdownLocalPty(id, opts) } - private async shutdownTrackedPty( - id: string, - proc: pty.IPty, - operation: PtyShutdownOperation - ): Promise { - const physicalExit = ptyPhysicalExits.get(id) - const signalRoot = (): void => { - // Why: natural exit can race the sweep — never signal after this PTY loses ownership. - if (ptyProcesses.get(id) !== proc) { - return - } - // Cancel startup delivery now, but keep the exit listener and ownership maps until node-pty reports physical exit. - runPtyCleanup(id) - operation.rootSignalled = true - this.requestTrackedPtyShutdown(id, proc, operation.immediate) - } - if (ptyAgentSessionIds.has(id)) { - // Why: POSIX needs a pre-kill descendant snapshot; Windows tree-kills only when the - // identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd - // (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone. - await killWithDescendantSweep(proc.pid, signalRoot, { - ownsRoot: () => ptyProcesses.get(id) === proc, - terminateOwnedTree: () => terminatePtyJob(proc) - }) - } else if (process.platform === 'win32' && operation.immediate) { - // Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll - // skips the console reap), so a live `pnpm i`/`node` keeps the ConPTY console alive and - // holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`; - // otherwise root close alone, and detached children may block physical stop (#10004). - await killWithDescendantSweep(proc.pid, signalRoot, { - ownsRoot: () => ptyProcesses.get(id) === proc, - terminateOwnedTree: () => terminatePtyJob(proc) - }) - } else { - signalRoot() - } - await waitForPtyPhysicalExit(id, physicalExit) + sendSignal(id: string, signal: string): Promise { + return sendLocalPtySignal(id, signal) } - private requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void { - const previousMode = ptyTerminationMode.get(id) - // Why: ConPTY has no graceful signal — its first bare kill closes the pseudoconsole, so treat it as a final force request. - const requestedMode = immediate || process.platform === 'win32' ? 'force' : 'graceful' - if (!previousMode || (requestedMode === 'force' && previousMode !== 'force')) { - ptyTerminationMode.set(id, requestedMode) - try { - killLocalPtyProcess(proc, immediate) - if (requestedMode === 'graceful') { - armLocalPtyForceKill(id, proc) - } else { - clearLocalPtyForceKillTimer(id) - } - } catch (error) { - if (previousMode) { - ptyTerminationMode.set(id, previousMode) - } else { - ptyTerminationMode.delete(id) - } - throw error - } - } - } - - async sendSignal(id: string, signal: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return - } - const signalRootPid = (): void => { - try { - process.kill(proc.pid, signal) - } catch { - /* Process may already be dead */ - } - } - // Why only SIGWINCH: see posix-pty-foreground-group — a real resize reaches the - // tty's foreground group, which proc.pid is never a member of. - if (signal === 'SIGWINCH') { - signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), signal, signalRootPid) - return - } - signalRootPid() - } - - async getCwd(id: string): Promise { - const proc = ptyProcesses.get(id) - // Why: '' not throw on unknown id — renderer reads empty as "try next fallback"; throwing is noisy for a normal case. - if (!proc) { - return '' - } - // Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it. - return resolveProcessCwd(proc.pid) + getCwd(id: string): Promise { + return getLocalPtyCwd(id) } async getInitialCwd(_id: string): Promise { return '' } - async clearBuffer(id: string): Promise { - // Why: ConPTY keeps its own screen buffer, so xterm clear() alone leaves a stale-cursor gap on the next prompt; POSIX no-op. - // No PSReadLine form-feed nudge here (unlike the daemon): safe only at an empty prompt, which this provider can't detect. - try { - startupIngressByPty.get(id)?.snapshotBarrier() - ptyProcesses.get(id)?.clear() - } catch { - /* PTY may have just exited */ - } + clearBuffer(id: string): Promise { + return clearLocalPtyBuffer(id) } closeStartupQueryAuthority(id: string): number { - return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0 + return closeLocalPtyStartupQueryAuthority(id) } acknowledgeDataEvent(_id: string, _charCount: number): void { /* no flow control for local */ } - async hasChildProcesses(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return false - } - try { - const foreground = proc.process - const shell = ptyShellName.get(id) - if (!shell) { - return true - } - return foreground !== shell - } catch { - return false - } + hasChildProcesses(id: string): Promise { + return hasLocalPtyChildProcesses(id) } - async getForegroundProcess(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - ptyLastRecognizedForeground.delete(id) - return null - } - const fallbackProcess = resolveForegroundFallbackProcess( - proc.process || null, - ptyShellName.get(id) - ) - const cachedEntry = ptyLastRecognizedForeground.get(id) - const cachedAgent = cachedEntry?.name ?? null - let paneMembershipUnavailable = false - let cachedAgentAliveInJob = false - // Why: job membership preserves a live cached agent without the whole-table - // scan (incomplete under Windows load). Job, not console: this asks "is - // anything besides the shell alive?", which needs no console attachment and - // so needs no forked helper (#10857). - if ( - process.platform === 'win32' && - canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess) - ) { - try { - const paneProcessIds = readWindowsPtyJobProcessIds(proc) - if (ptyProcesses.get(id) !== proc) { - return null - } - const verdict = judgeCachedAgentJobEvidence({ - jobProcessIds: paneProcessIds, - jobSupported: isWindowsPtyJobReadable(), - shellPid: proc.pid, - anchorProcessId: cachedEntry?.pid ?? null, - identityAgeMs: Date.now() - (cachedEntry?.at ?? 0) - }) - if (verdict === 'confirmed' || verdict === 'unproven') { - return cachedAgent - } - if (verdict === 'exited') { - // The shell stands alone in a complete, inescapable job list: no - // successor is possible, so the identity retires before the scan. - ptyLastRecognizedForeground.delete(id) - } else if (verdict === 'anchor-exited' && cachedEntry) { - // The recognized process died but another member remains -- a - // leftover, or a restarted successor. Keep the name as unanchored, - // age-bounded evidence and let this cycle's scan decide: deleting - // here made a degraded scan read a mid-restart agent as an exit. - ptyLastRecognizedForeground.set(id, { ...cachedEntry, pid: null }) - } - cachedAgentAliveInJob = verdict === 'recheck' - paneMembershipUnavailable = verdict === 'unavailable' - } catch { - paneMembershipUnavailable = true - } - } - try { - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths: ptyAgentForegroundContextPaths.get(id), - ...(cachedEntry?.pid != null - ? { anchorProcessId: cachedEntry.pid, anchorProcessName: cachedEntry.name } - : {}) - } - ) - // Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id. - if (ptyProcesses.get(id) !== proc) { - return null - } - // Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead. - const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null - const resolvedAgent = resolution.processName - ? recognizeAgentProcessFromCommandLine(resolution.processName) - : null - // A recycled anchor pid keeps job membership truthful but the identity - // dead; the scan proving the pid now runs a non-agent settles it. - const anchorContradicted = resolution.anchorPidForeign === true - // Why: incomplete snapshot + unavailable job read isn't exit proof; and an - // anchor pid still alive in the job outranks a snapshot that lost its row. - const stableResolution = - (paneMembershipUnavailable || cachedAgentAliveInJob) && - !anchorContradicted && - resolvedAgent === null - ? { ...resolution, available: false } - : resolution - const stable = resolveStableForegroundProcess(stableResolution, lastRecognizedAgent) - if (stable.lastRecognizedAgent && stableResolution.available) { - // Only a positive recognition restarts the age bound. - ptyLastRecognizedForeground.set(id, { - name: stable.lastRecognizedAgent, - pid: - stable.lastRecognizedAgent === resolution.processName - ? (resolution.processId ?? null) - : null, - at: Date.now() - }) - } else if (stable.lastRecognizedAgent && cachedAgentAliveInJob && !anchorContradicted) { - // The anchor pid in the job is proof of life; restamp so the - // short-circuit resumes instead of scanning on every call. - const entry = ptyLastRecognizedForeground.get(id) - if (entry) { - ptyLastRecognizedForeground.set(id, { ...entry, at: Date.now() }) - } - } else if (!stable.lastRecognizedAgent) { - ptyLastRecognizedForeground.delete(id) - } - return stable.processName - } catch { - if (ptyProcesses.get(id) !== proc) { - return null - } - // Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit). - return ptyLastRecognizedForeground.get(id)?.name ?? null - } + getForegroundProcess(id: string): Promise { + return getLocalPtyForegroundProcess(id) } - async confirmForegroundProcess(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return null - } - try { - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), - { - contextPaths: ptyAgentForegroundContextPaths.get(id), - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) - } - ) - // Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session. - if (ptyProcesses.get(id) !== proc) { - return null - } - return resolution.available ? resolution.processName : null - } catch { - return null - } + confirmForegroundProcess(id: string): Promise { + return confirmLocalPtyForegroundProcess(id) } - async confirmShellForeground(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return false - } - const confirmed = await confirmShellForegroundProcess( - proc.pid, - ptyShellName.get(id), - process.platform === 'win32' - ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } - : {} - ) - return ptyProcesses.get(id) === proc && confirmed + confirmShellForeground(id: string): Promise { + return confirmLocalPtyShellForeground(id) } async serialize(_ids: string[]): Promise { @@ -1474,47 +136,20 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise { - return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ - id, - ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), - cwd: ptyInitialCwd.get(id) ?? '', - title: proc.process || ptyShellName.get(id) || 'shell', - ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) - })) + listProcesses(): Promise { + return listLocalPtyProcesses() } - async getDefaultShell(): Promise { - if (process.platform === 'win32') { - return this.opts.getWindowsShell?.() || process.env.COMSPEC || 'powershell.exe' - } - return process.env.SHELL || '/bin/zsh' + getDefaultShell(): Promise { + return getDefaultLocalPtyShell(() => this.opts) } - async getProfiles(): Promise<{ name: string; path: string }[]> { - if (process.platform === 'win32') { - const profiles: { name: string; path: string }[] = [ - { name: 'PowerShell', path: 'powershell.exe' }, - { name: 'Command Prompt', path: 'cmd.exe' } - ] - const gitBashPath = resolveGitBashPath() - if (gitBashPath) { - profiles.push({ name: 'Git Bash', path: gitBashPath }) - } - if (await isWslAvailableAsync()) { - profiles.push({ name: 'WSL', path: 'wsl.exe' }) - } - return profiles - } - const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] - return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + getProfiles(): Promise<{ name: string; path: string }[]> { + return getLocalPtyProfiles() } onData(callback: DataCallback): () => void { - dataListeners.add(callback) - return () => dataListeners.delete(callback) + return onLocalPtyData(callback) } // Local PTYs don't replay -- this is for remote reconnection @@ -1523,54 +158,29 @@ export class LocalPtyProvider implements IPtyProvider { } onExit(callback: ExitCallback): () => void { - exitListeners.add(callback) - return () => exitListeners.delete(callback) + return onLocalPtyExit(callback) } // ─── Local-only helpers (not part of IPtyProvider interface) ─────── /** Kill orphaned PTYs from previous page loads. */ killOrphanedPtys(currentGeneration: number): { id: string }[] { - const killed: { id: string }[] = [] - for (const [id, proc] of ptyProcesses) { - if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { - requestPtyTermination(id, proc) - killed.push({ id }) - } - } - return killed + return killOrphanedLocalPtys(currentGeneration) } /** Advance the load generation counter (called on renderer reload). */ advanceGeneration(): number { - return ++loadGeneration + return advanceLoadGeneration() } /** Get a writable reference to a PTY (for runtime controller). */ getPtyProcess(id: string): pty.IPty | undefined { - return ptyProcesses.get(id) + return getLocalPtyProcess(id) } /** Kill all in-process local PTYs. Call on app quit. */ killAll(): void { - cancelAllPendingLocalPtySpawns() - for (const [id, proc] of ptyProcesses) { - runPtyCleanup(id) - disposePtyListeners(id) - disposePtyExitListener(id) - if (!(process.platform === 'win32' && ptyTerminationMode.has(id))) { - try { - proc.kill() - } catch { - /* Process may already be dead. */ - } - } - // Why: app quit can't retain NAPI callbacks into FreeEnvironment; process exit is the final handle boundary here. - destroyPtyProcess(proc, { alreadyKilled: true }) - // Why: app quit replaces node-pty's onExit as final owner; overlapping shutdown waiters must join this boundary. - ptyPhysicalExits.get(id)?.markExited() - clearPtyState(id) - } + killAllLocalPtys() } } @@ -1580,5 +190,5 @@ export function _resetLocalPtyProviderStateForTest(): void { for (const id of ptyProcesses.keys()) { clearPtyState(id) } - loadGeneration = 0 + resetLoadGeneration() } diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts new file mode 100644 index 00000000000..88278925081 --- /dev/null +++ b/src/main/providers/local-pty-session-activation.ts @@ -0,0 +1,186 @@ +import type * as pty from 'node-pty' +import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' +import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { resolveProcessExitCause } from '../../shared/terminal-exit-cause' +import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' +import { getSpawnedShellName } from './local-pty-launch-helpers' +import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import { + clearPtyState, + dataListeners, + exitListeners, + getLoadGeneration, + ptyAgentForegroundContextPaths, + ptyAgentSessionIds, + ptyDisposables, + ptyExitDisposables, + ptyIncarnations, + ptyInitialCwd, + ptyLoadGeneration, + ptyPhysicalExits, + ptyProcesses, + ptyReportsChildExitStatus, + ptyShellName, + ptyTerminalHandle, + ptyTerminationMode, + ptyWorktreeId, + ptyWslDistroById, + startupIngressByPty +} from './local-pty-provider-state' +import { createLocalPtyShellReadinessSession } from './local-pty-shell-readiness-session' +import { destroyPtyProcess, createPtyPhysicalExit } from './local-pty-termination' +import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready-startup-command' +import type { PtySpawnOptions, PtySpawnResult } from './types' + +export function activateLocalPtySession(args: { + id: string + incarnationId: string + spawn: PtySpawnOptions + getOptions: () => LocalPtyProviderOptions + plan: LocalPtyLaunchPlan + env: Record + proc: pty.IPty + reportsChildExitStatus: boolean + spawnedWslDistro: string | null | undefined +}): PtySpawnResult { + const { id, incarnationId, spawn, getOptions, plan, env, proc, spawnedWslDistro } = args + createPtyPhysicalExit(id) + ptyReportsChildExitStatus.set(id, args.reportsChildExitStatus) + ptyProcesses.set(id, proc) + ptyInitialCwd.set(id, plan.cwd) + if (spawnedWslDistro !== undefined) { + ptyWslDistroById.set(id, spawnedWslDistro) + } + // Why both: launchAgent is explicit intent that survives command rewrites; recognition catches bare agent command lines. + if (spawn.launchAgent || plan.startupAgentRecognition) { + ptyAgentSessionIds.add(id) + } + ptyShellName.set(id, getSpawnedShellName(plan.shellPath)) + if (env.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, env.ORCA_TERMINAL_HANDLE) + } + if (spawn.worktreeId) { + ptyWorktreeId.set(id, spawn.worktreeId) + } + ptyAgentForegroundContextPaths.set( + id, + getAgentForegroundContextPaths({ cwd: spawn.cwd, worktreeId: spawn.worktreeId }) + ) + ptyLoadGeneration.set(id, getLoadGeneration()) + ptyIncarnations.set(id, incarnationId) + getOptions().onSpawned?.(id, incarnationId) + + const emitIngressData = (emission: PtyIngressEmission): void => { + const sequenceChars = emission.rawEndSeq - emission.rawStartSeq + if (emission.transformed || sequenceChars !== emission.data.length) { + getOptions().onData?.(id, emission.data, Date.now(), sequenceChars, true) + } else { + getOptions().onData?.(id, emission.data, Date.now()) + } + for (const cb of dataListeners) { + cb( + emission.transformed || sequenceChars !== emission.data.length + ? { + id, + data: emission.data, + sequenceChars, + seq: emission.rawEndSeq, + transformed: true + } + : { id, data: emission.data } + ) + } + } + const startupIngress = new PtyStartupIngress({ + ...(spawn.startupIngress ? { intent: spawn.startupIngress } : {}), + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: plan.shellPath, + wslDistro: spawnedWslDistro + }), + write: (data) => proc.write(data), + onEmission: emitIngressData + }) + startupIngressByPty.set(id, startupIngress) + + // Shell-ready startup command support + const readiness = createLocalPtyShellReadinessSession({ + id, + spawn, + plan, + env, + proc, + startupIngress + }) + const disposables: { dispose: () => void }[] = [] + const onDataDisposable = proc.onData((rawData) => { + readiness.acceptData(rawData) + }) + if (onDataDisposable) { + disposables.push(onDataDisposable) + } + + const onExitDisposable = proc.onExit(({ exitCode, signal }) => { + // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the + // cause is built here, where the signal and the spawn's trustworthiness + // are both still in hand. + const cause = resolveProcessExitCause({ + exitCode, + signal, + hostReportsChildExitStatus: ptyReportsChildExitStatus.get(id) + }) + const wasTerminationRequested = ptyTerminationMode.has(id) + ptyPhysicalExits.get(id)?.markExited() + // Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid. + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + readiness.prepareForExit() + clearPtyState(id) + startupIngress.drainAndClose() + startupIngressByPty.delete(id) + // Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md. + destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) + ptyReportsChildExitStatus.delete(id) + getOptions().onExit?.(id, exitCode, incarnationId, cause) + for (const cb of exitListeners) { + cb({ id, code: exitCode, incarnationId, cause }) + } + }) + if (onExitDisposable) { + ptyExitDisposables.set(id, onExitDisposable) + } + ptyDisposables.set(id, disposables) + + if (spawn.command && !plan.startupCommandDeliveredInShellArgs) { + // Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit. + const spawnedShellName = getSpawnedShellName(plan.shellPath).toLowerCase() + const bracketedPasteSafe = + process.platform !== 'win32' && + isBracketedPasteSafeShell({ + shellName: spawnedShellName, + waitsForShellReady: plan.shellReadyLaunch?.supportsReadyMarker === true + }) + writeStartupCommandWhenShellReady( + readiness.shellReadyPromise, + proc, + spawn.command, + (cleanup) => { + readiness.setStartupCommandCleanup(cleanup) + }, + { bracketedPasteSafe } + ) + } + + // Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child. + const rawPid = proc.pid + const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null + return { + id, + incarnationId, + pid, + ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) + } +} diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts new file mode 100644 index 00000000000..0d02b45e5df --- /dev/null +++ b/src/main/providers/local-pty-session-operations.ts @@ -0,0 +1,170 @@ +import { existsSync } from 'node:fs' +import { basename } from 'node:path' +import type * as pty from 'node-pty' +import { readPtsName } from '../pty/node-pty-pts-name' +import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' +import { isWslAvailableAsync } from '../wsl' +import { resolveGitBashPath } from '../git-bash' +import { resolveProcessCwd } from './process-cwd' +import { + dataListeners, + exitListeners, + ptyIncarnations, + ptyInitialCwd, + ptyProcesses, + ptyShellName, + ptyTerminalHandle, + ptyWorktreeId, + ptyWslDistroById, + startupIngressByPty, + type DataCallback, + type ExitCallback +} from './local-pty-provider-state' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import type { PtyProcessInfo } from './types' + +export function writeLocalPty(id: string, data: string): boolean { + // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of + // those is still held, which they must not overtake (#13137, #7329, #15559). + if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) { + return true + } + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + proc.write(data) + return true +} + +export function resizeLocalPty(id: string, cols: number, rows: number): void { + ptyProcesses.get(id)?.resize(cols, rows) +} + +// Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure. +export function pauseLocalPtyProducer(id: string): void { + try { + ptyProcesses.get(id)?.pause() + } catch { + /* PTY already destroyed */ + } +} + +export function resumeLocalPtyProducer(id: string): void { + try { + ptyProcesses.get(id)?.resume() + } catch { + /* PTY already destroyed */ + } +} + +// Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check. +export async function getLocalPtyAppliedSize( + id: string +): Promise<{ cols: number; rows: number } | null> { + const proc = ptyProcesses.get(id) + if (!proc || proc.cols <= 0 || proc.rows <= 0) { + return null + } + return { cols: proc.cols, rows: proc.rows } +} + +export async function sendLocalPtySignal(id: string, signal: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return + } + const signalRootPid = (): void => { + try { + process.kill(proc.pid, signal) + } catch { + /* Process may already be dead */ + } + } + // Why only SIGWINCH: see posix-pty-foreground-group — a real resize reaches the + // tty's foreground group, which proc.pid is never a member of. + if (signal === 'SIGWINCH') { + signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), signal, signalRootPid) + return + } + signalRootPid() +} + +export async function getLocalPtyCwd(id: string): Promise { + const proc = ptyProcesses.get(id) + // Why: '' not throw on unknown id — renderer reads empty as "try next fallback"; throwing is noisy for a normal case. + if (!proc) { + return '' + } + // Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it. + return resolveProcessCwd(proc.pid) +} + +export async function clearLocalPtyBuffer(id: string): Promise { + // Why: ConPTY keeps its own screen buffer, so xterm clear() alone leaves a stale-cursor gap on the next prompt; POSIX no-op. + // No PSReadLine form-feed nudge here (unlike the daemon): safe only at an empty prompt, which this provider can't detect. + try { + startupIngressByPty.get(id)?.snapshotBarrier() + ptyProcesses.get(id)?.clear() + } catch { + /* PTY may have just exited */ + } +} + +export function closeLocalPtyStartupQueryAuthority(id: string): number { + return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0 +} + +export async function listLocalPtyProcesses(): Promise { + return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ + id, + ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), + cwd: ptyInitialCwd.get(id) ?? '', + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), + ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) + })) +} + +export async function getDefaultLocalPtyShell( + getOptions: () => LocalPtyProviderOptions +): Promise { + if (process.platform === 'win32') { + return getOptions().getWindowsShell?.() || process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' +} + +export async function getLocalPtyProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + const profiles: { name: string; path: string }[] = [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + const gitBashPath = resolveGitBashPath() + if (gitBashPath) { + profiles.push({ name: 'Git Bash', path: gitBashPath }) + } + if (await isWslAvailableAsync()) { + profiles.push({ name: 'WSL', path: 'wsl.exe' }) + } + return profiles + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) +} + +export function onLocalPtyData(callback: DataCallback): () => void { + dataListeners.add(callback) + return () => dataListeners.delete(callback) +} + +export function onLocalPtyExit(callback: ExitCallback): () => void { + exitListeners.add(callback) + return () => exitListeners.delete(callback) +} + +export function getLocalPtyProcess(id: string): pty.IPty | undefined { + return ptyProcesses.get(id) +} diff --git a/src/main/providers/local-pty-shell-readiness-session.ts b/src/main/providers/local-pty-shell-readiness-session.ts new file mode 100644 index 00000000000..4557f7a6928 --- /dev/null +++ b/src/main/providers/local-pty-shell-readiness-session.ts @@ -0,0 +1,148 @@ +import type * as pty from 'node-pty' +import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' +import { + createShellPromptReadinessProbe, + type ShellPromptReadinessProbe +} from '../shell-prompt-readiness-probe' +import { + createShellStartupOutputScanState, + drainShellStartupOutputScanState, + scanShellStartupOutput +} from '../shell-startup-output-scanner' +import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import { ptyCleanupCallbacks } from './local-pty-provider-state' +import { + STARTUP_COMMAND_READY_MAX_WAIT_MS, + type ShellReadySignal +} from './local-pty-shell-ready-startup-command' +import type { PtySpawnOptions } from './types' + +export type LocalPtyShellReadinessSession = { + shellReadyPromise: Promise + acceptData(rawData: string): void + setStartupCommandCleanup(cleanup: () => void): void + prepareForExit(): void +} + +export function createLocalPtyShellReadinessSession(args: { + id: string + spawn: PtySpawnOptions + plan: LocalPtyLaunchPlan + env: Record + proc: pty.IPty + startupIngress: PtyStartupIngress +}): LocalPtyShellReadinessSession { + const { id, spawn, plan, env, proc, startupIngress } = args + let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null + let shellReadyTimeout: ReturnType | null = null + let shellStartupPid: number | null = null + let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null + let shellStartupOutputScanState = plan.shellReadyLaunch?.supportsReadyMarker + ? createShellStartupOutputScanState() + : null + const shellReadyPromise = spawn.command + ? new Promise((resolve) => { + resolveShellReady = resolve + }) + : Promise.resolve({ postMarkerBytesObserved: false }) + const finishShellReady = (signal: ShellReadySignal): void => { + if (!resolveShellReady) { + return + } + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null + const resolve = resolveShellReady + resolveShellReady = null + resolve(signal) + } + const releaseHeldShellReadyBytes = (): void => { + if (!shellStartupOutputScanState) { + return + } + const heldBytes = drainShellStartupOutputScanState(shellStartupOutputScanState) + shellStartupOutputScanState = null + if (heldBytes.length === 0) { + return + } + startupIngress.accept(heldBytes) + } + if (shellStartupOutputScanState) { + shellPromptReadinessProbe = createShellPromptReadinessProbe({ + slavePath: readPtySlavePath(proc), + shellPath: plan.shellPath, + shellCwd: plan.effectiveCwd, + shellPathEnv: env.PATH, + getShellPid: () => shellStartupPid, + onPromptReady: () => { + console.warn( + `[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` + ) + releaseHeldShellReadyBytes() + finishShellReady({ postMarkerBytesObserved: true }) + } + }) + } + if (spawn.command) { + if (plan.shellReadyLaunch?.supportsReadyMarker) { + shellReadyTimeout = setTimeout(() => { + releaseHeldShellReadyBytes() + finishShellReady({ postMarkerBytesObserved: false }) + }, STARTUP_COMMAND_READY_MAX_WAIT_MS) + } else { + finishShellReady({ postMarkerBytesObserved: false }) + } + } + let startupCommandCleanup: (() => void) | null = null + if (spawn.command) { + ptyCleanupCallbacks.set(id, () => { + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + releaseHeldShellReadyBytes() + startupCommandCleanup?.() + startupCommandCleanup = null + resolveShellReady = null + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null + }) + } + + return { + shellReadyPromise, + acceptData: (rawData) => { + let data = rawData + if (shellStartupOutputScanState && resolveShellReady) { + const scanned = scanShellStartupOutput(shellStartupOutputScanState, data) + data = scanned.output + if (scanned.shellPid) { + shellStartupPid = scanned.shellPid + } + if (scanned.ready) { + finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) + } + } + startupIngress.accept(data) + if (resolveShellReady && data.length > 0) { + shellPromptReadinessProbe?.notifyOutput(data) + } + }, + setStartupCommandCleanup: (cleanup) => { + startupCommandCleanup = cleanup + }, + prepareForExit: () => { + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + startupCommandCleanup?.() + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null + } + } +} diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts new file mode 100644 index 00000000000..56b5ef08890 --- /dev/null +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -0,0 +1,81 @@ +import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { removeInheritedNoColor } from '../pty/terminal-color-env' +import { isWindowsGitBashShellPath } from '../git-bash' +import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' +import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import { awaitCancelableLocalPtySpawn } from './local-pty-spawn-state' +import type { PtySpawnOptions } from './types' + +export function buildLocalPtySpawnEnvironment(args: { + id: string + spawn: PtySpawnOptions + getOptions: () => LocalPtyProviderOptions + plan: LocalPtyLaunchPlan +}): Record | Promise> { + const { id, spawn, getOptions, plan } = args + const spawnEnv: Record = { + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working. + TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + // Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them. + FORCE_HYPERLINK: '1' + } as Record + // Why: Orca can be launched from an Orca terminal; pane identity belongs to the child PTY, not the parent shell. + removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env) + removeAppImageRuntimeEnv(spawnEnv) + removeInheritedNoColor(spawnEnv) + for (const key of spawn.envToDelete ?? []) { + delete spawnEnv[key] + } + if (spawn.env?.TERM) { + spawnEnv.TERM = spawn.env.TERM + } + + spawnEnv.LANG ??= 'en_US.UTF-8' + + // Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK. + if (process.platform === 'win32') { + spawnEnv.PYTHONUTF8 ??= '1' + if (isWindowsGitBashShellPath(plan.shellPath)) { + // Why: Git for Windows login files otherwise cd to $HOME, ignoring node-pty's cwd for repo-scoped terminals. + spawnEnv.CHERE_INVOKING ??= '1' + } + } + + if (!getOptions().buildSpawnEnv) { + return spawnEnv + } + // Why (#16441): building the env now awaits Codex hook installs and trust + // grants, so shutdown must be able to cancel this session id here too. + return awaitCancelableLocalPtySpawn( + id, + getOptions().buildSpawnEnv!(id, spawnEnv, { + command: spawn.command, + launchAgent: spawn.launchAgent, + codexHomePathOverride: spawn.codexHomePathOverride, + cwd: plan.cwd, + shellPath: plan.shellPath, + isWsl: plan.isWslShell, + wslDistro: plan.launchWslDistro + }) + ) +} + +export function enforceLocalPtySpawnEnvironmentOverrides( + spawn: PtySpawnOptions, + finalEnv: Record +): void { + // Why: app-level env hooks can re-add scrubbed vars; delete last so shims like Claude Agent Teams keep their PATH. + for (const key of spawn.envToDelete ?? []) { + delete finalEnv[key] + } + if (spawn.env?.TERM) { + finalEnv.TERM = spawn.env.TERM + } +} diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts new file mode 100644 index 00000000000..283f8b40c62 --- /dev/null +++ b/src/main/providers/local-pty-spawn-state.ts @@ -0,0 +1,65 @@ +import type { PtySpawnResult } from './types' +import { + pendingLocalPtySpawns, + ptyProcesses, + ptyWslDistroById, + type PendingLocalPtySpawn +} from './local-pty-provider-state' + +/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty + * process exists yet, so cancellation can only be observed after the await. */ +export async function awaitCancelableLocalPtySpawn( + id: string, + operation: T | Promise +): Promise { + const pendingSpawn: PendingLocalPtySpawn = { canceled: false } + const pending = pendingLocalPtySpawns.get(id) ?? new Set() + pending.add(pendingSpawn) + pendingLocalPtySpawns.set(id, pending) + try { + const result = await operation + if (pendingSpawn.canceled) { + throw new Error(`PTY spawn canceled: ${id}`) + } + return result + } finally { + pending.delete(pendingSpawn) + if (pending.size === 0) { + pendingLocalPtySpawns.delete(id) + } + } +} + +export function cancelPendingLocalPtySpawns(id: string): void { + const pending = pendingLocalPtySpawns.get(id) + if (!pending) { + return + } + for (const pendingSpawn of pending) { + pendingSpawn.canceled = true + } +} + +export function cancelAllPendingLocalPtySpawns(): void { + for (const id of pendingLocalPtySpawns.keys()) { + cancelPendingLocalPtySpawns(id) + } +} + +export function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null { + const existing = ptyProcesses.get(id) + if (!existing) { + return null + } + try { + existing.resize(cols, rows) + } catch { + /* Existing PTY may reject resize during teardown; still return the live handle. */ + } + return { + id, + pid: existing.pid, + ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), + isReattach: true + } +} diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts new file mode 100644 index 00000000000..19436a8c405 --- /dev/null +++ b/src/main/providers/local-pty-spawn.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto' +import { win32 as pathWin32 } from 'node:path' +import * as pty from 'node-pty' +import { SessionNotFoundError } from '../daemon/daemon-errors' +import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' +import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment' +import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' +import { createLocalPtyLaunchPlan, DeferredLocalPtyLaunchPlan } from './local-pty-launch-plan' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import { allocatePtyId, ptyShutdownOperations } from './local-pty-provider-state' +import { activateLocalPtySession } from './local-pty-session-activation' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from './local-pty-spawn-environment' +import { awaitCancelableLocalPtySpawn, reattachLocalPty } from './local-pty-spawn-state' +import { spawnShellWithFallback } from './local-pty-utils' +import { updateHistoryEnvForFallback, type HistoryInjectionResult } from '../terminal-history' +import type { PtySpawnOptions, PtySpawnResult } from './types' + +export async function spawnLocalPty( + args: PtySpawnOptions, + getOptions: () => LocalPtyProviderOptions +): Promise { + const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true) + if (reattachId) { + const pendingShutdown = ptyShutdownOperations.get(reattachId) + if (pendingShutdown) { + await pendingShutdown.promise + } + const existing = reattachLocalPty(reattachId, args.cols, args.rows) + if (existing) { + return existing + } + } + if (args.attachOnly) { + throw new SessionNotFoundError(args.sessionId ?? '') + } + const id = allocatePtyId(reattachId ?? undefined) + const incarnationId = randomUUID() + const planResult = createLocalPtyLaunchPlan(args, getOptions) + const plan = + planResult instanceof DeferredLocalPtyLaunchPlan + ? planResult.finish(await planResult.availability) + : planResult + const envResult = buildLocalPtySpawnEnvironment({ + id, + spawn: args, + getOptions, + plan + }) + const finalEnv = envResult instanceof Promise ? await envResult : envResult + enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) + const historyResult = finalizeLocalPtySpawnEnvironment({ + spawn: args, + getOptions, + plan, + env: finalEnv + }) + + // Why: the async macOS capability probe runs before node-pty exists. + await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell()) + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. + const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null + if (concurrentWinner) { + return concurrentWinner + } + const spawnResult = spawnShellWithFallback({ + shellPath: plan.shellPath, + shellArgs: plan.shellArgs, + cols: args.cols, + rows: args.rows, + cwd: plan.effectiveCwd, + env: finalEnv, + termName: finalEnv.TERM, + ptySpawn: pty.spawn, + getShellReadyConfig: plan.getFallbackShellReadyConfig, + launchEnvKeys: plan.primaryLaunchEnvKeys, + // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). + onBeforeFallbackSpawn: historyResult?.historyDir + ? (env, fallbackShell) => + updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) + : undefined, + windowsFallbackAttempts: plan.windowsFallbackAttempts + }) + args.onPtySpawnCommitted?.() + plan.shellPath = spawnResult.shellPath + // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. + if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { + plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs + } + if (args.command && plan.getFallbackShellReadyConfig) { + plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) + } + + if (process.platform !== 'win32') { + finalEnv.SHELL = plan.shellPath + } + + const proc = spawnResult.process + const spawnedShellIsWsl = + process.platform === 'win32' && pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' + const spawnedWslDistro = spawnedShellIsWsl + ? (plan.launchWslDistro ?? undefined) + : process.platform === 'win32' + ? null + : undefined + return activateLocalPtySession({ + id, + incarnationId, + spawn: args, + getOptions, + plan, + env: finalEnv, + proc, + reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, + spawnedWslDistro + }) +} diff --git a/src/main/providers/local-pty-termination.ts b/src/main/providers/local-pty-termination.ts new file mode 100644 index 00000000000..470d5b600ab --- /dev/null +++ b/src/main/providers/local-pty-termination.ts @@ -0,0 +1,268 @@ +import type * as pty from 'node-pty' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { killWithDescendantSweep } from '../pty-descendant-termination' +import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' +import { terminatePtyJob } from '../windows/windows-pty-job' +import { + clearLocalPtyForceKillTimer, + clearPtyState, + disposePtyExitListener, + disposePtyListeners, + ptyAgentSessionIds, + ptyForceKillTimers, + ptyPhysicalExits, + ptyProcesses, + ptyShutdownOperations, + ptyTerminationMode, + ptyLoadGeneration, + runPtyCleanup, + type PtyShutdownOperation +} from './local-pty-provider-state' +import { + cancelAllPendingLocalPtySpawns, + cancelPendingLocalPtySpawns +} from './local-pty-spawn-state' + +export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 +export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000 +export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 + +export function createPtyPhysicalExit(id: string): void { + ptyPhysicalExits.set(id, new PhysicalExitTracker()) +} + +function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { + if (!physicalExit) { + return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) + } + return physicalExit.waitForExit( + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, + () => new Error(`Timed out waiting for PTY process exit: ${id}`) + ) +} + +function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void { + if (process.platform === 'win32') { + proc.kill() + return + } + if (!immediate) { + proc.kill('SIGTERM') + return + } + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) +} + +function armLocalPtyForceKill( + id: string, + proc: pty.IPty, + options: { delayMs?: number; attemptsRemaining?: number } = {} +): void { + if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { + return + } + const attemptsRemaining = options.attemptsRemaining ?? 2 + const timer = setTimeout(() => { + ptyForceKillTimers.delete(id) + if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { + return + } + ptyTerminationMode.set(id, 'force') + try { + killLocalPtyProcess(proc, true) + } catch (error) { + ptyTerminationMode.set(id, 'graceful') + console.error('[pty] failed to force-kill PTY after graceful deadline', { id, error }) + // Why: a transient native rejection must not consume the only SIGKILL owner while shutdown still awaits physical exit. + if (attemptsRemaining > 1) { + armLocalPtyForceKill(id, proc, { + delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, + attemptsRemaining: attemptsRemaining - 1 + }) + } + } + }, options.delayMs ?? LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) + timer.unref?.() + ptyForceKillTimers.set(id, timer) +} + +/** + * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. + */ +export function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { + // Why: neutralize proc.kill before destroy(), whose close-listener SIGHUPs a possibly-recycled POSIX pid; destroy() frees the ptmx fd (docs/fix-pty-fd-leak.md); on Windows destroy() is itself kill(). + if (process.platform === 'win32' && options.alreadyKilled) { + return + } + if (process.platform !== 'win32') { + ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + try { + ;(proc as unknown as { destroy?: () => void }).destroy?.() + } catch { + /* swallow — already torn down */ + } +} + +/** + * Requests local PTY termination while retaining physical-exit ownership. + */ +function requestPtyTermination(id: string, proc: pty.IPty): void { + runPtyCleanup(id) + disposePtyListeners(id) + const previousMode = ptyTerminationMode.get(id) + // Why: cleanup neutralizes proc.kill below, so escalate an outstanding graceful request before its deadline is disabled. + if (previousMode !== 'force') { + clearLocalPtyForceKillTimer(id) + ptyTerminationMode.set(id, 'force') + try { + killLocalPtyProcess(proc, true) + } catch { + if (previousMode === 'graceful') { + ptyTerminationMode.set(id, previousMode) + armLocalPtyForceKill(id, proc, { + delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, + attemptsRemaining: 1 + }) + } else { + ptyTerminationMode.delete(id) + } + /* Process may already be dead. */ + return + } + } + // Why: shutdown and orphan cleanup can race; keep onExit + tracker installed until the OS proves the child was reaped. + destroyPtyProcess(proc, { alreadyKilled: true }) +} + +function requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void { + const previousMode = ptyTerminationMode.get(id) + // Why: ConPTY has no graceful signal — its first bare kill closes the pseudoconsole, so treat it as a final force request. + const requestedMode = immediate || process.platform === 'win32' ? 'force' : 'graceful' + if (!previousMode || (requestedMode === 'force' && previousMode !== 'force')) { + ptyTerminationMode.set(id, requestedMode) + try { + killLocalPtyProcess(proc, immediate) + if (requestedMode === 'graceful') { + armLocalPtyForceKill(id, proc) + } else { + clearLocalPtyForceKillTimer(id) + } + } catch (error) { + if (previousMode) { + ptyTerminationMode.set(id, previousMode) + } else { + ptyTerminationMode.delete(id) + } + throw error + } + } +} + +async function shutdownTrackedPty( + id: string, + proc: pty.IPty, + operation: PtyShutdownOperation +): Promise { + const physicalExit = ptyPhysicalExits.get(id) + const signalRoot = (): void => { + // Why: natural exit can race the sweep — never signal after this PTY loses ownership. + if (ptyProcesses.get(id) !== proc) { + return + } + // Cancel startup delivery now, but keep the exit listener and ownership maps until node-pty reports physical exit. + runPtyCleanup(id) + operation.rootSignalled = true + requestTrackedPtyShutdown(id, proc, operation.immediate) + } + if (ptyAgentSessionIds.has(id)) { + // Why: POSIX needs a pre-kill descendant snapshot; Windows tree-kills only when the + // identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd + // (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone. + await killWithDescendantSweep(proc.pid, signalRoot, { + ownsRoot: () => ptyProcesses.get(id) === proc, + terminateOwnedTree: () => terminatePtyJob(proc) + }) + } else if (process.platform === 'win32' && operation.immediate) { + // Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll + // skips the console reap), so a live `pnpm i`/`node` keeps the ConPTY console alive and + // holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`; + // otherwise root close alone, and detached children may block physical stop (#10004). + await killWithDescendantSweep(proc.pid, signalRoot, { + ownsRoot: () => ptyProcesses.get(id) === proc, + terminateOwnedTree: () => terminatePtyJob(proc) + }) + } else { + signalRoot() + } + await waitForPtyPhysicalExit(id, physicalExit) +} + +export async function shutdownLocalPty( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean } +): Promise { + cancelPendingLocalPtySpawns(id) + const pending = ptyShutdownOperations.get(id) + if (pending) { + if (opts.immediate === true) { + pending.immediate = true + if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) { + requestTrackedPtyShutdown(id, pending.proc, true) + } + } + await pending.promise + return + } + const proc = ptyProcesses.get(id) + if (!proc) { + return + } + const entry: PtyShutdownOperation = { + promise: Promise.resolve(), + immediate: opts.immediate === true, + rootSignalled: false, + proc + } + entry.promise = shutdownTrackedPty(id, proc, entry) + ptyShutdownOperations.set(id, entry) + try { + await entry.promise + } finally { + if (ptyShutdownOperations.get(id) === entry) { + ptyShutdownOperations.delete(id) + } + } +} + +export function killOrphanedLocalPtys(currentGeneration: number): { id: string }[] { + const killed: { id: string }[] = [] + for (const [id, proc] of ptyProcesses) { + if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { + requestPtyTermination(id, proc) + killed.push({ id }) + } + } + return killed +} + +export function killAllLocalPtys(): void { + cancelAllPendingLocalPtySpawns() + for (const [id, proc] of ptyProcesses) { + runPtyCleanup(id) + disposePtyListeners(id) + disposePtyExitListener(id) + if (!(process.platform === 'win32' && ptyTerminationMode.has(id))) { + try { + proc.kill() + } catch { + /* Process may already be dead. */ + } + } + // Why: app quit can't retain NAPI callbacks into FreeEnvironment; process exit is the final handle boundary here. + destroyPtyProcess(proc, { alreadyKilled: true }) + // Why: app quit replaces node-pty's onExit as final owner; overlapping shutdown waiters must join this boundary. + ptyPhysicalExits.get(id)?.markExited() + clearPtyState(id) + } +} diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts new file mode 100644 index 00000000000..f7967d710f7 --- /dev/null +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -0,0 +1,91 @@ +import { win32 as pathWin32 } from 'node:path' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' +import { addWslEnvKeys } from '../wsl-env' +import { parseWslPath } from '../wsl' +import { isWindowsGitBashShellPath } from '../git-bash' +import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from './windows-shell-args' +import type { PtySpawnOptions } from './types' + +export function finalizeWindowsLocalPtySpawnEnvironment(args: { + spawn: PtySpawnOptions + plan: LocalPtyLaunchPlan + env: Record +}): void { + const { spawn, plan, env } = args + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + if (plan.launchWslDistro && plan.launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!plan.launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + plan.shellPath, + plan.cwd, + plan.defaultCwd, + { + distro: codexHomeWslInfo.distro + } + ) + plan.shellArgs = resolved.shellArgs + plan.effectiveCwd = resolved.effectiveCwd + plan.validationCwd = resolved.validationCwd + plan.startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + // Why: Orca's Codex home is host-local; WSL Codex must use its Linux-side ~/.codex, not a Windows path. + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + // Why: WSL Codex homes are Linux paths Windows can't use; also drop ORCA_CODEX_HOME (shell-ready restores CODEX_HOME from it). + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } + + const shellBasename = pathWin32.basename(plan.shellPath).toLowerCase() + const codexLaunchPreflightCommand = env.ORCA_CODEX_LAUNCH_PREFLIGHT + if ( + codexLaunchPreflightCommand && + (shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(plan.shellPath)) + ) { + if (shellBasename === 'cmd.exe') { + // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. + env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } + const resolved = resolveWindowsShellLaunchArgs( + plan.shellPath, + plan.cwd, + plan.defaultCwd, + plan.launchWslContext, + spawn.command, + codexLaunchPreflightCommand + ) + plan.shellArgs = resolved.shellArgs + plan.effectiveCwd = resolved.effectiveCwd + plan.validationCwd = resolved.validationCwd + plan.startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true + } +} diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index befefb5a2c8..fc1478330d9 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -26,7 +26,7 @@ main/git/command-runner/wsl-command-resolution.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts -main/providers/local-pty-provider.ts +main/providers/local-pty-spawn.ts main/rate-limits/claude-pty.ts main/rate-limits/codex-fetcher.ts main/wsl-availability.ts diff --git a/src/main/wsl/wsl-invocation-boundary.test.ts b/src/main/wsl/wsl-invocation-boundary.test.ts index dbe708f4821..29906362a47 100644 --- a/src/main/wsl/wsl-invocation-boundary.test.ts +++ b/src/main/wsl/wsl-invocation-boundary.test.ts @@ -141,6 +141,22 @@ function bindsWslBinaryToASpawnedIdentifier(source: string): boolean { return false } +function passesComparedWslShellPathToSpawnSpec(source: string): boolean { + for (const match of source.matchAll( + /\bbasename\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\)\.toLowerCase\(\)\s*===\s*['"`]wsl\.exe['"`]/g + )) { + const shellPath = match[1]!.replace(/[.$]/g, '\\$&') + const spawnCall = new RegExp( + `\\b\\w*(?:spawn|exec|run)\\w*\\s*\\(\\s*(?:${shellPath}\\b|\\{[\\s\\S]{0,2000}?\\bshellPath\\s*:\\s*${shellPath}\\b)`, + 'i' + ) + if (spawnCall.test(source)) { + return true + } + } + return false +} + function findSpawnSites(): string[] { const offenders = new Set() for (const path of collectSourceFiles(SOURCE_ROOT)) { @@ -157,7 +173,10 @@ function findSpawnSites(): string[] { offenders.add(relativePath) } } - if (bindsWslBinaryToASpawnedIdentifier(source)) { + if ( + bindsWslBinaryToASpawnedIdentifier(source) || + passesComparedWslShellPathToSpawnSpec(source) + ) { offenders.add(relativePath) } } @@ -346,6 +365,7 @@ describe('wsl.exe is spawned through one runner', () => { // so it passed even for a scanner that found nothing. This fails the moment // detection stops seeing a call that is definitely there. expect(offenders).toContain('main/git/command-runner/wsl-command-resolution.ts') + expect(offenders).toContain('main/providers/local-pty-spawn.ts') }) it('adds no new direct wsl.exe spawn', () => { diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 0bd70cba10d..f2f79203041 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -272,7 +272,7 @@ const DIRECT_SINGLE_SOURCE_SURFACES: readonly { marker: 'isCursorAgentTitle' }, { - path: 'src/main/providers/local-pty-provider.ts', + path: 'src/main/providers/local-pty-session-activation.ts', classification: 'action-consumer', marker: 'launchAgent' },