mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Split local PTY provider responsibilities
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, string>
|
||||
}): 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<typeof injectHistoryEnv> | 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
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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<typeof recognizeAgentProcessFromCommandLine>
|
||||
defaultCwd: string
|
||||
cwd: string
|
||||
wslInfo: ReturnType<typeof parseWslPath>
|
||||
worktreeWslContext: WslLaunchContext | undefined
|
||||
preferredWslContext: WslLaunchContext | undefined
|
||||
launchWslContext: WslLaunchContext | undefined
|
||||
}
|
||||
|
||||
export type LocalPtyLaunchPlan = {
|
||||
startupAgentRecognition: ReturnType<typeof recognizeAgentProcessFromCommandLine>
|
||||
defaultCwd: string
|
||||
cwd: string
|
||||
wslInfo: ReturnType<typeof parseWslPath>
|
||||
worktreeWslContext: WslLaunchContext | undefined
|
||||
preferredWslContext: WslLaunchContext | undefined
|
||||
launchWslContext: WslLaunchContext | undefined
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
effectiveCwd: string
|
||||
validationCwd: string
|
||||
startupCommandDeliveredInShellArgs: boolean
|
||||
windowsFallbackAttempts: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
|
||||
shellReadyLaunch: ReturnType<typeof getShellLaunchConfig> | null
|
||||
getFallbackShellReadyConfig:
|
||||
| ((shell: string) => ReturnType<typeof getShellLaunchConfig>)
|
||||
| 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<boolean>,
|
||||
readonly finish: (available: boolean) => LocalPtyLaunchPlan
|
||||
) {}
|
||||
}
|
||||
|
||||
function finalizeLocalPtyLaunchPlan(
|
||||
seed: LocalPtyLaunchSeed,
|
||||
shell: {
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
effectiveCwd: string
|
||||
validationCwd: string
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
windowsFallbackAttempts?: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
|
||||
}
|
||||
): 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
|
||||
})
|
||||
}
|
||||
@@ -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<string, string>) => env)
|
||||
const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => 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).
|
||||
|
||||
@@ -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<Record<string, string>>((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<void>((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 }
|
||||
|
||||
@@ -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<void>
|
||||
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<string, pty.IPty>()
|
||||
export const ptyIncarnations = new Map<string, string>()
|
||||
// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown.
|
||||
export const ptyAgentSessionIds = new Set<string>()
|
||||
// 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<string, PtyShutdownOperation>()
|
||||
export const pendingLocalPtySpawns = new Map<string, Set<PendingLocalPtySpawn>>()
|
||||
export const ptyShellName = new Map<string, string>()
|
||||
export const ptyAgentForegroundContextPaths = new Map<string, string[]>()
|
||||
// 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<string, string>()
|
||||
export const ptyWorktreeId = new Map<string, string>()
|
||||
export const ptyInitialCwd = new Map<string, string>()
|
||||
// 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<string, string | null>()
|
||||
// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination.
|
||||
export const ptyDisposables = new Map<string, { dispose: () => void }[]>()
|
||||
export const ptyExitDisposables = new Map<string, { dispose: () => void }>()
|
||||
export const ptyCleanupCallbacks = new Map<string, () => void>()
|
||||
export const ptyTerminationMode = new Map<string, 'graceful' | 'force'>()
|
||||
export const ptyPhysicalExits = new Map<string, PhysicalExitTracker>()
|
||||
// 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<string, boolean>()
|
||||
export const ptyForceKillTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
let loadGeneration = 0
|
||||
export const ptyLoadGeneration = new Map<string, number>()
|
||||
|
||||
export const dataListeners = new Set<DataCallback>()
|
||||
export const exitListeners = new Set<ExitCallback>()
|
||||
export const startupIngressByPty = new Map<string, PtyStartupIngress>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
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<string, string> | Promise<Record<string, string>>
|
||||
/** 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<boolean>
|
||||
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
|
||||
}
|
||||
@@ -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<void>((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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, string>
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
// 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<PtyProcessInfo[]> {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
@@ -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<ShellReadySignal>
|
||||
acceptData(rawData: string): void
|
||||
setStartupCommandCleanup(cleanup: () => void): void
|
||||
prepareForExit(): void
|
||||
}
|
||||
|
||||
export function createLocalPtyShellReadinessSession(args: {
|
||||
id: string
|
||||
spawn: PtySpawnOptions
|
||||
plan: LocalPtyLaunchPlan
|
||||
env: Record<string, string>
|
||||
proc: pty.IPty
|
||||
startupIngress: PtyStartupIngress
|
||||
}): LocalPtyShellReadinessSession {
|
||||
const { id, spawn, plan, env, proc, startupIngress } = args
|
||||
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
|
||||
let shellReadyTimeout: ReturnType<typeof setTimeout> | 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<ShellReadySignal>((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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> | Promise<Record<string, string>> {
|
||||
const { id, spawn, getOptions, plan } = args
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...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<string, string>
|
||||
// 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<string, string>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
@@ -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<T>(
|
||||
id: string,
|
||||
operation: T | Promise<T>
|
||||
): Promise<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<PtySpawnResult> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
}): 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>()
|
||||
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', () => {
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user