fix(terminal): recover readiness after startup exec (#14027)

This commit is contained in:
Jinwoo Hong
2026-08-12 14:31:42 -07:00
committed by GitHub
parent ebe5125476
commit 90b8554fc9
28 changed files with 1869 additions and 49 deletions
+3 -1
View File
@@ -239,7 +239,8 @@ jobs:
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
@@ -275,6 +276,7 @@ jobs:
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
+98 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-10",
"updatedAt": "2026-08-12",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,103 @@
}
},
"gates": [
{
"id": "terminal-session.shell-ready-exec-prompt-fallback",
"title": "Startup exec falls back to the identified shell's line editor",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-session-readiness",
"layer": "posix-pty-and-shell-startup",
"surfaces": ["queued terminal startup commands", "zsh and bash startup files"],
"platforms": ["macos", "linux"],
"providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "local-daemon", "ssh-relay"],
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity stripping plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/13767"],
"invariant": "Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.",
"oracle": "Run the real generated wrappers under node-pty with disposable zsh and bash startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must strip the identity and release through the same prompt contract.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts"
],
"testFiles": [
"src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
"src/main/line-editor-ready-output-scanner.test.ts",
"src/main/shell-startup-identity-scanner.test.ts",
"src/main/shell-prompt-readiness-probe.test.ts",
"src/shared/pty-slave-line-discipline-echo.test.ts",
"src/shared/shell-process-readiness.test.ts",
"src/main/daemon/session.test.ts",
"src/main/daemon/shell-ready.test.ts",
"src/main/providers/local-pty-shell-ready.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/relay/pty-shell-launch.test.ts",
"src/relay/pty-handler.test.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
"assertions": [
"real zsh .zshenv/.zprofile and bash profile replacement shells receive queued input exactly once",
"same-shell raw silent reads retain queued input",
"child-process raw silent reads retain queued input while the identified shell is not foreground",
"a blocking zle-line-init read retains queued input until the hook completes",
"sqlite3 and a sqlite3 image renamed zsh cannot impersonate the launched shell executable path",
"zsh configurations without a line-editor enable sequence retain the existing timeout backstop"
]
},
{
"file": "src/relay/pty-handler.test.ts",
"assertions": [
"the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness",
"renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness",
"normal renderer-owned ready markers pass through unchanged and retain no readiness state"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
"result": "passed",
"durationSeconds": 4,
"summary": "All real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles plus identity, foreground, executable-image, protocol, and line-editor contracts passed."
}
],
"runtimeBudget": {
"p95Seconds": 6,
"scope": "real local zsh/bash exec and silent-read integration gate"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Fresh deterministic local runs pass; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 is byte-identical across runs. Latest main 09ec516ae5 and the candidate with its production implementation reverted both failed exactly the three zsh .zprofile/.zshenv and bash exec cases by deterministic 5-second timeout; the zle-line-init oracle also exposed unsafe baseline delivery, while all ten other safety controls passed. The candidate passed all 14 on macOS."
},
"performanceBudget": {
"required": true,
"evidence": "The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling."
},
"promotionCriteria": [
"Collect Linux, SSH-daemon, and paired-runtime live evidence.",
"Collect CI and soak history without unexplained flakes.",
"Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green."
],
"knownGaps": [
"The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.",
"An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.",
"A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.",
"The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.",
"The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.",
"Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code."
],
"demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect."
},
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
@@ -8,6 +8,7 @@ const dependencyAction = parse(
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
+2
View File
@@ -1062,6 +1062,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
return {
pid: proc.pid,
shellPath,
shellCwd: spawnCwd,
shellPathEnv: env.PATH,
...(slavePath ? { slavePath } : {}),
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
@@ -0,0 +1,419 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createPtySubprocess } from './pty-subprocess'
import { Session } from './session'
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0
const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0
const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN'
const READ_STARTED_FILE = '.orca-read-started'
type ShellFixture = {
name: string
shellPath: string
startupFile: string
replacement: string
command: string
instrumentationOutput: string
secretRead: string
childRead: string
}
const FIXTURES: ShellFixture[] = [
{
name: 'zsh profile',
shellPath: '/bin/zsh',
startupFile: '.zprofile',
replacement: 'exec -a kiro-cli-term /bin/zsh -o noglobalrcs -l -i',
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
},
{
name: 'zsh environment',
shellPath: '/bin/zsh',
startupFile: '.zshenv',
replacement: 'exec /bin/zsh -o noglobalrcs -l -i',
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
},
{
name: 'bash profile',
shellPath: '/bin/bash',
startupFile: '.bash_profile',
replacement: 'exec -a figterm-test /bin/bash --noprofile --norc -l -i',
command: `printf 'ORCA_STARTUP_%s:PC=%s\\n' COMMAND_RAN "$PROMPT_COMMAND"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PC=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -s -n 1\n`,
childRead: `/bin/bash --noprofile --norc -c ': > "$HOME/${READ_STARTED_FILE}"; read -s -n 1'\n`
}
]
function count(text: string, needle: string): number {
return text.split(needle).length - 1
}
function waitForOutput(
subscribe: (settle: () => void) => void,
isDone: () => boolean,
timeoutMs = 5_000
): Promise<void> {
if (isDone()) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const deadline = setTimeout(
() => reject(new Error('Timed out waiting for PTY output')),
timeoutMs
)
const settle = (): void => {
if (!isDone()) {
return
}
clearTimeout(deadline)
resolve()
}
subscribe(settle)
settle()
})
}
function waitForCondition(isDone: () => boolean, timeoutMs = 5_000): Promise<void> {
return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (!isDone()) {
return
}
clearInterval(interval)
clearTimeout(deadline)
resolve()
}, 10)
const deadline = setTimeout(() => {
clearInterval(interval)
reject(new Error('Timed out waiting for fixture state'))
}, timeoutMs)
})
}
function runCleanupActions(...actions: (() => void)[]): void {
for (const action of actions) {
try {
action()
} catch {}
}
}
type RunningFixture = {
session: Session
subprocess: ReturnType<typeof createPtySubprocess>
output: () => string
readStarted: () => boolean
subscribe: (settle: () => void) => void
cleanup: () => Promise<void>
}
function startFixture(
fixture: ShellFixture,
startupContent: string,
extraFiles: Record<string, string> = {}
): RunningFixture {
const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-'))
const previousHome = process.env.HOME
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
let subprocess: ReturnType<typeof createPtySubprocess> | undefined
let session: Session | undefined
let consoleWarnSpy: { mockRestore: () => void } | undefined
try {
writeFileSync(join(tempHome, fixture.startupFile), startupContent)
for (const [fileName, content] of Object.entries(extraFiles)) {
writeFileSync(join(tempHome, fileName), content)
}
process.env.HOME = tempHome
delete process.env.ZDOTDIR
delete process.env.ORCA_ORIG_ZDOTDIR
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
subprocess = createPtySubprocess({
sessionId: `repro-13767-${fixture.startupFile}`,
cols: 80,
rows: 24,
cwd: tempHome,
command: fixture.command,
shellOverride: fixture.shellPath,
env: {
HOME: tempHome,
PATH: process.env.PATH ?? '/usr/bin:/bin',
SHELL: fixture.shellPath,
TERM: 'xterm-256color'
},
envToDelete: ['ORCA_EXEC_REPRO_DONE', 'ORCA_ORIG_ZDOTDIR', 'ZDOTDIR']
})
session = new Session({
sessionId: `repro-13767-${fixture.startupFile}`,
cols: 80,
rows: 24,
subprocess,
shellReadySupported: true
})
let output = ''
let onOutput = (): void => {}
session.attachClient({
onData: (data) => {
output += data
onOutput()
},
onExit: () => {}
})
session.write(fixture.command)
const runningSubprocess = subprocess
const runningSession = session
const activeConsoleWarnSpy = consoleWarnSpy
return {
session: runningSession,
subprocess: runningSubprocess,
output: () => output,
readStarted: () => existsSync(join(tempHome, READ_STARTED_FILE)),
subscribe: (settle) => {
onOutput = settle
},
cleanup: async () => {
if (runningSession.isAlive) {
try {
await runningSession.forceKillAndWaitForExit(3_000)
} catch {
runCleanupActions(() => runningSubprocess.forceKill())
}
}
runCleanupActions(
() => runningSession.dispose(),
() => activeConsoleWarnSpy.mockRestore(),
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
() => rmSync(tempHome, { recursive: true, force: true })
)
}
}
} catch (error) {
runCleanupActions(
() => subprocess?.forceKill(),
() => session?.dispose(),
() => !session && subprocess?.dispose(),
() => consoleWarnSpy?.mockRestore(),
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
() => rmSync(tempHome, { recursive: true, force: true })
)
throw error
}
}
function restoreEnvironment(
home: string | undefined,
zdotdir: string | undefined,
originalZdotdir: string | undefined
): void {
setEnvironmentValue('HOME', home)
setEnvironmentValue('ZDOTDIR', zdotdir)
setEnvironmentValue('ORCA_ORIG_ZDOTDIR', originalZdotdir)
}
function setEnvironmentValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
async function runExecOracle(fixture: ShellFixture): Promise<void> {
const running = startFixture(
fixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
${fixture.replacement}
fi
`
)
try {
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
expect(running.session.shellState).toBe('ready')
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
expect(running.output()).toContain(fixture.instrumentationOutput)
expect(running.output()).not.toContain('orca-shell-start')
} finally {
await running.cleanup()
}
}
async function runReadOracle(fixture: ShellFixture, child: boolean): Promise<void> {
const running = startFixture(fixture, child ? fixture.childRead : fixture.secretRead)
try {
await waitForCondition(running.readStarted)
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
running.subprocess.write('x\r')
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
expect(running.output()).not.toContain('orca-shell-start')
} finally {
await running.cleanup()
}
}
describePosix('#13767 shell-ready marker loss across exec', () => {
it('continues setup cleanup after force-kill failure', () => {
const dispose = vi.fn()
const restore = vi.fn()
runCleanupActions(
() => {
throw new Error('force kill failed')
},
dispose,
restore
)
expect(dispose).toHaveBeenCalledOnce()
expect(restore).toHaveBeenCalledOnce()
})
for (const fixture of FIXTURES) {
const runnable = fixture.shellPath.endsWith('zsh') ? hasZsh : hasBash
const testCase = runnable ? it : it.skip
testCase(
`releases at the real ${fixture.name} prompt after exec`,
() => runExecOracle(fixture),
10_000
)
testCase(
`keeps queued input out of a real ${fixture.name} silent read`,
() => runReadOracle(fixture, false),
10_000
)
testCase(
`keeps queued input out of a child read during ${fixture.name} startup`,
() => runReadOracle(fixture, true),
10_000
)
}
const zshFixture = FIXTURES[0] as ShellFixture
const zshTest = hasZsh ? it : it.skip
zshTest(
'keeps queued input out of a zle-line-init read after exec',
async () => {
const running = startFixture(
zshFixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
fi
`,
{
'.zshrc': `zle-line-init() {
print -n 'HOOK_SECRET> '
read -sk 1
print -r -- HOOK_DONE
}
zle -N zle-line-init
`
}
)
try {
try {
await waitForOutput(running.subscribe, () => running.output().includes('HOOK_SECRET> '))
} catch (error) {
throw new Error(`ZLE hook startup output: ${JSON.stringify(running.output())}`, {
cause: error
})
}
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
running.subprocess.write('x')
try {
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
} catch (error) {
throw new Error(`ZLE hook output: ${JSON.stringify(running.output())}`, { cause: error })
}
expect(running.output()).toContain('HOOK_DONE')
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
} finally {
await running.cleanup()
}
},
10_000
)
const sqliteTest = hasZsh && existsSync('/usr/bin/sqlite3') ? it : it.skip
sqliteTest(
'does not treat an exec-replaced readline program as the shell prompt',
async () => {
const running = startFixture(zshFixture, 'exec /usr/bin/sqlite3\n')
try {
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
sqliteTest(
'does not trust a non-shell executable renamed to the shell basename',
async () => {
const running = startFixture(
zshFixture,
'ln -s /usr/bin/sqlite3 "$HOME/zsh" && exec "$HOME/zsh"\n'
)
try {
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
zshTest(
'retains the timeout backstop when zsh disables bracketed paste',
async () => {
const running = startFixture(
zshFixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
fi
`,
{
'.zshrc': "zmodload zsh/zle\nunset zle_bracketed_paste\nPS1='NO_BRACKET_PROMPT> '\n"
}
)
try {
await waitForOutput(running.subscribe, () =>
running.output().includes('NO_BRACKET_PROMPT> ')
)
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
})
+69 -2
View File
@@ -13,6 +13,16 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity,
type ShellStartupIdentityScanState
} from '../shell-startup-identity-scanner'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { TuiAgent } from '../../shared/types'
@@ -60,6 +70,8 @@ export type SubprocessHandle = {
/** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready
* assumption against it so a fallback shell without a ready marker never gates startup commands. */
shellPath?: string
shellCwd?: string
shellPathEnv?: string
/** Slave device path, so startup replies can read the line discipline's ECHO bit before
* writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */
slavePath?: string
@@ -125,6 +137,9 @@ export class Session {
private releaseStartupDeviceAttributesResponder: (() => void) | null = null
private startupDeviceAttributesQueryFilter: StartupDeviceAttributesQueryFilter | null = null
private shellReadyScanState: ShellReadyScanState | null = null
private shellStartupIdentityScanState: ShellStartupIdentityScanState | null = null
private shellStartupPid: number | null = null
private shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | null = null
private postReadyFlushGate: PostReadyFlushGate
@@ -170,6 +185,7 @@ export class Session {
if (opts.shellReadySupported) {
this._shellState = 'pending'
this.shellReadyScanState = createShellReadyScanState()
this.shellStartupIdentityScanState = createShellStartupIdentityScanState()
// Why: `write` queues everything until the ready marker, including the renderer's DA1
// reply — and a shell that withholds its first prompt until DA1 is answered (fish) then
// never emits the marker that would release it. Answer from the daemon, past the queue.
@@ -195,6 +211,16 @@ export class Session {
onEmission: (emission) => this.emitSubprocessOutput(emission),
...(echoProbe ? { echoProbe } : {})
})
if (this._shellState === 'pending') {
this.shellPromptReadinessProbe = createShellPromptReadinessProbe({
slavePath: this.subprocess.slavePath,
shellPath: this.subprocess.shellPath,
shellCwd: this.subprocess.shellCwd,
shellPathEnv: this.subprocess.shellPathEnv,
getShellPid: () => this.shellStartupPid,
onPromptReady: () => this.onShellPromptReady()
})
}
this.subprocess.onData((data) => this.handleSubprocessData(data))
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
}
@@ -603,6 +629,8 @@ export class Session {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
}
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.shellReadyScanState = null
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
@@ -649,6 +677,14 @@ export class Session {
}
let releaseStartupDeviceAttributes = false
if (this._shellState === 'pending' && this.shellStartupIdentityScanState) {
const scanned = scanForShellStartupIdentity(this.shellStartupIdentityScanState, data)
data = scanned.output
if (scanned.shellPid) {
this.shellStartupPid = scanned.shellPid
this.shellStartupIdentityScanState = null
}
}
if (this._shellState === 'pending' && this.shellReadyScanState) {
const scanned = scanForShellReady(this.shellReadyScanState, data)
data = scanned.output
@@ -661,6 +697,9 @@ export class Session {
}
this.startupIngress.accept(data)
if (this._shellState === 'pending' && data.length > 0) {
this.shellPromptReadinessProbe?.notifyOutput(data)
}
if (releaseStartupDeviceAttributes) {
this.releaseStartupDeviceAttributes()
}
@@ -696,6 +735,8 @@ export class Session {
}
this.releaseStartupDeviceAttributes()
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.releaseHeldShellReadyBytes()
this.startupIngress.drainAndClose()
this._exitCode = code
@@ -727,10 +768,19 @@ export class Session {
}
private releaseHeldShellReadyBytes(): string {
if (!this.shellReadyScanState) {
if (!this.shellReadyScanState && !this.shellStartupIdentityScanState) {
return ''
}
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
let heldBytes = this.shellStartupIdentityScanState
? drainShellStartupIdentityHeldBytes(this.shellStartupIdentityScanState)
: ''
this.shellStartupIdentityScanState = null
if (this.shellReadyScanState && heldBytes) {
heldBytes = scanForShellReady(this.shellReadyScanState, heldBytes).output
}
if (this.shellReadyScanState) {
heldBytes += drainShellReadyHeldBytes(this.shellReadyScanState)
}
this.shellReadyScanState = null
// Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it.
this.startupIngress.accept(heldBytes)
@@ -759,6 +809,9 @@ export class Session {
private transitionToReady(postMarkerBytesObserved = false): void {
this._shellState = 'ready'
this.shellReadyScanState = null
this.shellStartupIdentityScanState = null
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
if (this.shellReadyTimer) {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
@@ -775,11 +828,25 @@ export class Session {
return
}
this._shellState = 'timed_out'
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.releaseStartupDeviceAttributes()
this.releaseHeldShellReadyBytes()
this.flushPreReadyQueue()
}
private onShellPromptReady(): void {
if (this._shellState !== 'pending') {
return
}
console.warn(
`[Session] ${this.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
)
this.releaseHeldShellReadyBytes()
this.transitionToReady(true)
this.releaseStartupDeviceAttributes()
}
private flushPreReadyQueue(): void {
const queued = this.preReadyStdinQueue
this.preReadyStdinQueue = []
+2
View File
@@ -462,6 +462,7 @@ describePosix('daemon shell-ready launch config', () => {
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expectZdotdirSourceContext(zprofile, '.zprofile')
@@ -650,6 +651,7 @@ describePosix('daemon shell-ready launch config', () => {
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(bashRc).toContain('printf "\\033]133;C\\007"')
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
expect(bashRc).toContain(
+6 -2
View File
@@ -16,6 +16,7 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
getZshStartupFileSourceBlock
} from '../shell-templates'
@@ -88,6 +89,7 @@ function shellReadyWrappersExist(): boolean {
export function getDaemonBashShellReadyRcfileContent(): string {
return `# Orca daemon bash shell-ready wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
@@ -384,7 +386,8 @@ function getWrappedShellLaunchConfig(
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: join(root, 'zsh'),
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -396,7 +399,8 @@ function getWrappedShellLaunchConfig(
return {
args: ['--rcfile', join(root, 'bash', 'rcfile')],
env: {
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
createLineEditorReadyOutputScanState,
scanForLineEditorReadyOutput
} from './line-editor-ready-output-scanner'
describe('line editor ready output scanner', () => {
it.each(['\x1b[?2004h', '\x1b[?1034h'])('detects %j across chunks', (sequence) => {
const state = createLineEditorReadyOutputScanState()
expect(scanForLineEditorReadyOutput(state, `prompt${sequence.slice(0, 4)}`)).toBe(false)
expect(scanForLineEditorReadyOutput(state, sequence.slice(4))).toBe(true)
})
it('ignores ordinary prompt output', () => {
expect(
scanForLineEditorReadyOutput(createLineEditorReadyOutputScanState(), 'user@host % ')
).toBe(false)
})
})
@@ -0,0 +1,19 @@
const LINE_EDITOR_READY_SEQUENCES = ['\x1b[?2004h', '\x1b[?1034h'] as const
const MAX_SEQUENCE_LENGTH = Math.max(...LINE_EDITOR_READY_SEQUENCES.map((value) => value.length))
export type LineEditorReadyOutputScanState = {
tail: string
}
export function createLineEditorReadyOutputScanState(): LineEditorReadyOutputScanState {
return { tail: '' }
}
export function scanForLineEditorReadyOutput(
state: LineEditorReadyOutputScanState,
data: string
): boolean {
const combined = state.tail + data
state.tail = combined.slice(-(MAX_SEQUENCE_LENGTH - 1))
return LINE_EDITOR_READY_SEQUENCES.some((sequence) => combined.includes(sequence))
}
@@ -62,6 +62,8 @@ export async function shellScriptTest(
const env: Record<string, string> = {
...config.env,
// Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream.
ORCA_SHELL_STARTUP_IDENTITY: '0',
// Why: the framework creates user startup files under testHome after
// computing the wrapper config; route wrapper discovery to that fixture.
HOME: testHome,
+27 -2
View File
@@ -15,7 +15,8 @@ const {
readWindowsConptyProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsMock
wslUncDirectoryExistsMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
@@ -28,7 +29,8 @@ const {
readWindowsConptyProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsMock: vi.fn()
wslUncDirectoryExistsMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('fs', () => ({
@@ -107,6 +109,10 @@ vi.mock('../wsl', () => ({
wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args)
}))
vi.mock('../shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import {
_resetLocalPtyProviderStateForTest,
LOCAL_PTY_FORCE_KILL_RETRY_MS,
@@ -174,6 +180,7 @@ describe('LocalPtyProvider', () => {
isWslAvailableAsyncMock.mockResolvedValue(true)
wslUncDirectoryExistsMock.mockReset()
wslUncDirectoryExistsMock.mockReturnValue(true)
createShellPromptReadinessProbeMock.mockReset()
exitCb = undefined
mockProc = {
@@ -534,6 +541,24 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('verifies shell identity against the exact spawn PATH', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' })
})
await provider.spawn({
cols: 80,
rows: 24,
command: 'printf ready',
env: { PATH: '/pre-hook/bin' }
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin')
expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith(
expect.objectContaining({ shellPathEnv: '/post-hook/bin' })
)
})
it('does not inherit NODE_ENV from the Orca process env', async () => {
// Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets
// `development` in dev runs); leaking it breaks `next build` and Vitest.
+56 -2
View File
@@ -75,6 +75,15 @@ import {
createPtySlaveEchoProbe,
readPtySlavePath
} from '../../shared/pty-slave-line-discipline-echo'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity
} from '../shell-startup-identity-scanner'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import {
expandWindowsEnvironmentVariables,
expandWindowsPathEnvironmentVariables
@@ -951,6 +960,11 @@ export class LocalPtyProvider implements IPtyProvider {
// Shell-ready startup command support
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 shellStartupIdentityScanState = shellReadyLaunch?.supportsReadyMarker
? createShellStartupIdentityScanState()
: null
const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker
? createShellReadyScanState()
: null
@@ -967,6 +981,8 @@ export class LocalPtyProvider implements IPtyProvider {
clearTimeout(shellReadyTimeout)
shellReadyTimeout = null
}
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
const resolve = resolveShellReady
resolveShellReady = null
resolve(signal)
@@ -975,12 +991,35 @@ export class LocalPtyProvider implements IPtyProvider {
if (!shellReadyScanState) {
return
}
const heldBytes = drainShellReadyHeldBytes(shellReadyScanState)
let heldBytes = shellStartupIdentityScanState
? drainShellStartupIdentityHeldBytes(shellStartupIdentityScanState)
: ''
shellStartupIdentityScanState = null
if (heldBytes) {
heldBytes = scanForShellReady(shellReadyScanState, heldBytes).output
}
heldBytes += drainShellReadyHeldBytes(shellReadyScanState)
if (heldBytes.length === 0) {
return
}
startupIngress.accept(heldBytes)
}
if (shellReadyScanState) {
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(() => {
@@ -1002,20 +1041,33 @@ export class LocalPtyProvider implements IPtyProvider {
startupCommandCleanup?.()
startupCommandCleanup = null
resolveShellReady = null
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
})
}
const disposables: { dispose: () => void }[] = []
const onDataDisposable = proc.onData((rawData) => {
let data = rawData
if (shellStartupIdentityScanState && resolveShellReady) {
const scanned = scanForShellStartupIdentity(shellStartupIdentityScanState, data)
data = scanned.output
if (scanned.shellPid) {
shellStartupPid = scanned.shellPid
shellStartupIdentityScanState = null
}
}
if (shellReadyScanState && resolveShellReady) {
const scanned = scanForShellReady(shellReadyScanState, rawData)
const scanned = scanForShellReady(shellReadyScanState, data)
data = scanned.output
if (scanned.matched) {
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
}
}
startupIngress.accept(data)
if (resolveShellReady && data.length > 0) {
shellPromptReadinessProbe?.notifyOutput(data)
}
})
if (onDataDisposable) {
disposables.push(onDataDisposable)
@@ -1033,6 +1085,8 @@ export class LocalPtyProvider implements IPtyProvider {
shellReadyTimeout = null
}
startupCommandCleanup?.()
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
clearPtyState(id)
startupIngress.drainAndClose()
startupIngressByPty.delete(id)
@@ -485,6 +485,7 @@ describePosix('local PTY shell-ready launch config', () => {
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expectZdotdirSourceContext(zprofile, '.zprofile')
+6 -2
View File
@@ -21,6 +21,7 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
getZshStartupFileSourceBlock
} from '../shell-templates'
export {
@@ -92,6 +93,7 @@ function resolveOriginalZshenvSourceDir(): string {
export function getBashShellReadyRcfileContent(): string {
return `# Orca bash shell-ready wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
@@ -368,7 +370,8 @@ function getWrappedShellLaunchConfig(
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -379,7 +382,8 @@ function getWrappedShellLaunchConfig(
return {
args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`],
env: {
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lineEditorProbe = vi.hoisted(() => vi.fn())
const processReadinessProbe = vi.hoisted(() => vi.fn())
const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value)))
vi.mock('../shared/pty-slave-line-discipline-echo', () => ({
createPtySlaveLineEditorProbe: () => lineEditorProbe
}))
vi.mock('../shared/shell-process-readiness', () => ({
readShellProcessReadiness: processReadinessProbe,
resolveShellExecutablePath: resolveExecutablePath
}))
import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe'
describe('shell prompt readiness probe', () => {
beforeEach(() => {
vi.useFakeTimers()
lineEditorProbe.mockReset()
processReadinessProbe.mockReset()
resolveExecutablePath.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('accepts only the identified shell pid in line-editor mode and foreground', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(onPromptReady).toHaveBeenCalledOnce()
})
it('preserves an unset child PATH for executable resolution', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined)
})
it.each([
['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }],
['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }],
['different foreground process', 'line-editor', null],
[
'non-shell replacement image',
'line-editor',
{ executablePath: '/usr/bin/sqlite3', foreground: true }
],
[
'non-shell image with the shell basename',
'line-editor',
{ executablePath: '/tmp/zsh', foreground: true }
]
])('rejects %s', async (_name, terminalState, rows) => {
lineEditorProbe.mockResolvedValue(terminalState)
processReadinessProbe.mockResolvedValue(rows)
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(onPromptReady).not.toHaveBeenCalled()
if (terminalState === 'other') {
expect(processReadinessProbe).not.toHaveBeenCalled()
}
})
it('does no external work when the ready marker cancels the settle window', async () => {
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
probe?.dispose()
await vi.advanceTimersByTimeAsync(10)
expect(lineEditorProbe).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('invalidates an in-flight result when newer output arrives', async () => {
const pending: { resolve?: (value: string) => void } = {}
lineEditorProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
)
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.notifyOutput('\x1b[?2004h')
pending.resolve?.('line-editor')
await vi.advanceTimersByTimeAsync(0)
expect(onPromptReady).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('does not inspect a process after disposal during a line-editor probe', async () => {
const pending: { resolve?: (value: string) => void } = {}
lineEditorProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
)
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.dispose()
pending.resolve?.('line-editor')
await vi.advanceTimersByTimeAsync(0)
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('invalidates process readiness that resolves after disposal', async () => {
const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } =
{}
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve))
)
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.dispose()
pending.resolve?.({ executablePath: '/bin/zsh', foreground: true })
await vi.advanceTimersByTimeAsync(0)
expect(onPromptReady).not.toHaveBeenCalled()
})
it('ignores slow startup output until the line editor enables its protocol', async () => {
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
for (let index = 0; index < 20; index += 1) {
probe?.notifyOutput(`startup ${index}\n`)
await vi.advanceTimersByTimeAsync(20)
}
expect(lineEditorProbe).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('bounds rejected line-editor retries', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({
executablePath: '/usr/bin/sqlite3',
foreground: true
})
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
for (let index = 0; index < 10; index += 1) {
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
}
expect(lineEditorProbe).toHaveBeenCalledTimes(4)
expect(processReadinessProbe).toHaveBeenCalledTimes(4)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { createPtySlaveLineEditorProbe } from '../shared/pty-slave-line-discipline-echo'
import {
readShellProcessReadiness,
resolveShellExecutablePath
} from '../shared/shell-process-readiness'
import {
createLineEditorReadyOutputScanState,
scanForLineEditorReadyOutput
} from './line-editor-ready-output-scanner'
import { basename } from 'node:path'
export const SHELL_PROMPT_PROBE_SETTLE_MS = 50
export const MAX_SHELL_PROMPT_PROBES = 4
export type ShellPromptReadinessProbe = {
notifyOutput(data: string): void
dispose(): void
}
export function createShellPromptReadinessProbe(options: {
slavePath: string | undefined
getShellPid: () => number | null
shellPath: string | undefined
shellCwd?: string
shellPathEnv?: string
onPromptReady: () => void
settleMs?: number
}): ShellPromptReadinessProbe | null {
const lineEditorProbe = createPtySlaveLineEditorProbe(options.slavePath)
if (!lineEditorProbe) {
return null
}
const settleMs = options.settleMs ?? SHELL_PROMPT_PROBE_SETTLE_MS
const expectedShellName = options.shellPath ? basename(options.shellPath).toLowerCase() : null
const outputScanState = createLineEditorReadyOutputScanState()
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
let generation = 0
let probesStarted = 0
const probe = async (scheduledGeneration: number): Promise<void> => {
if (disposed || scheduledGeneration !== generation) {
return
}
const shellPid = options.getShellPid()
if (!shellPid || (await lineEditorProbe()) !== 'line-editor') {
return
}
if (disposed || scheduledGeneration !== generation) {
return
}
const [shell, expectedPath] = await Promise.all([
readShellProcessReadiness(shellPid),
options.shellPath
? resolveShellExecutablePath(
options.shellPath,
options.shellCwd ?? process.cwd(),
options.shellPathEnv
)
: Promise.resolve(null)
])
if (disposed || scheduledGeneration !== generation) {
return
}
if (
!shell?.foreground ||
!expectedShellName ||
!expectedPath ||
basename(shell.executablePath).toLowerCase() !== expectedShellName ||
shell.executablePath !== expectedPath
) {
return
}
disposed = true
options.onPromptReady()
}
return {
notifyOutput(data: string): void {
if (
disposed ||
probesStarted >= MAX_SHELL_PROMPT_PROBES ||
!scanForLineEditorReadyOutput(outputScanState, data)
) {
return
}
generation += 1
const scheduledGeneration = generation
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
timer = null
probesStarted += 1
void probe(scheduledGeneration).catch(() => {})
}, settleMs)
},
dispose(): void {
disposed = true
generation += 1
if (timer) {
clearTimeout(timer)
timer = null
}
}
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity
} from './shell-startup-identity-scanner'
describe('shell startup identity scanner', () => {
it('strips a split identity marker and returns its shell pid', () => {
const state = createShellStartupIdentityScanState()
expect(scanForShellStartupIdentity(state, 'before\x1b]777;orca-shell-st')).toEqual({
output: 'before',
shellPid: null
})
expect(scanForShellStartupIdentity(state, 'art:12345\x07after')).toEqual({
output: 'after',
shellPid: 12345
})
})
it('forwards lookalikes unchanged', () => {
const state = createShellStartupIdentityScanState()
const input = 'a\x1b]777;orca-shell-start:nope\x07b'
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
})
it('forwards an unrelated OSC ending in digits', () => {
const state = createShellStartupIdentityScanState()
const input = '\x1b]1337;remote-session:12345'
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
expect(state.heldBytes).toBe('')
})
it('releases an incomplete marker on teardown', () => {
const state = createShellStartupIdentityScanState()
scanForShellStartupIdentity(state, '\x1b]777;orca-shell-start:12')
expect(drainShellStartupIdentityHeldBytes(state)).toBe('\x1b]777;orca-shell-start:12')
})
it('does not retain an unbounded digit stream', () => {
const state = createShellStartupIdentityScanState()
const input = `\x1b]777;orca-shell-start:${'1'.repeat(100)}`
expect(scanForShellStartupIdentity(state, input).output).toBe(input)
expect(state.heldBytes).toBe('')
})
})
@@ -0,0 +1,72 @@
export const SHELL_STARTUP_IDENTITY_PREFIX = '\x1b]777;orca-shell-start:'
const POSSIBLE_PID_SUFFIX = /^\d{0,20}$/
export type ShellStartupIdentityScanState = {
heldBytes: string
}
export type ShellStartupIdentityScanResult = {
output: string
shellPid: number | null
}
export function createShellStartupIdentityScanState(): ShellStartupIdentityScanState {
return { heldBytes: '' }
}
export function drainShellStartupIdentityHeldBytes(state: ShellStartupIdentityScanState): string {
const heldBytes = state.heldBytes
state.heldBytes = ''
return heldBytes
}
function isPossibleMarker(candidate: string): boolean {
if (candidate.length <= SHELL_STARTUP_IDENTITY_PREFIX.length) {
return SHELL_STARTUP_IDENTITY_PREFIX.startsWith(candidate)
}
if (!candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
return false
}
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
return POSSIBLE_PID_SUFFIX.test(suffix)
}
export function scanForShellStartupIdentity(
state: ShellStartupIdentityScanState,
data: string
): ShellStartupIdentityScanResult {
let pending = state.heldBytes + data
let output = ''
state.heldBytes = ''
while (pending.length > 0) {
const start = pending.indexOf(SHELL_STARTUP_IDENTITY_PREFIX[0] as string)
if (start === -1) {
output += pending
break
}
output += pending.slice(0, start)
const candidate = pending.slice(start)
if (isPossibleMarker(candidate)) {
state.heldBytes = candidate
break
}
if (candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
const terminator = suffix.indexOf('\x07')
const pidText = terminator === -1 ? '' : suffix.slice(0, terminator)
if (/^\d+$/.test(pidText)) {
const shellPid = Number(pidText)
const markerLength = SHELL_STARTUP_IDENTITY_PREFIX.length + terminator + 1
return {
output: output + candidate.slice(markerLength),
shellPid: Number.isSafeInteger(shellPid) && shellPid > 0 ? shellPid : null
}
}
}
output += candidate[0]
pending = candidate.slice(1)
}
return { output, shellPid: null }
}
+6
View File
@@ -5,11 +5,17 @@ function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if [[ "\${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then
unset ORCA_SHELL_STARTUP_IDENTITY
printf "\\033]777;orca-shell-start:%s\\007" "$$"
fi`
export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
const header = headerPrefix
? `Orca ${headerPrefix} zsh shell-ready wrapper`
: 'Orca zsh shell-ready wrapper'
return `# ${header}
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
# file is generated with a Windows path but sourced via /mnt/c, so the baked
# literal is unusable there and ZDOTDIR must be restored from this value.
+287 -2
View File
@@ -12,8 +12,9 @@ import {
} from '../shared/setup-agent-sequencing'
import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockCreateShellPromptReadinessProbe: vi.fn(),
mockPtyInstance: {
// Why: attach now proves the backing pid is alive before replaying, so the
// default managed PTY must report a live pid. Reuse the test runner's own
@@ -38,6 +39,10 @@ vi.mock('../main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
vi.mock('../main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
}))
import {
IMMEDIATE_PTY_EXIT_TIMEOUT_MS,
MAX_RELAY_PTY_SESSIONS,
@@ -139,6 +144,11 @@ describe('PtyHandler', () => {
mockPtyInstance.clear.mockReset()
mockPtyInstance.pause.mockReset()
mockPtyInstance.resume.mockReset()
mockCreateShellPromptReadinessProbe.mockReset()
mockCreateShellPromptReadinessProbe.mockReturnValue({
notifyOutput: vi.fn(),
dispose: vi.fn()
})
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
@@ -829,6 +839,7 @@ describe('PtyHandler', () => {
vi.advanceTimersByTime(49)
const term = mockPtySpawn.mock.results[0]?.value
expect(handler.retainedStartupCommandCount).toBe(1)
expect(handler.retainedStartupCommandBytes).toBe('echo provider-owned'.length)
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
@@ -870,6 +881,9 @@ describe('PtyHandler', () => {
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(1)
expect(handler.retainedStartupCommandBytes).toBe(0)
vi.advanceTimersByTime(15_000)
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
@@ -906,7 +920,7 @@ describe('PtyHandler', () => {
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(0)
expect(handler.retainedStartupCommandCount).toBe(1)
}
)
@@ -1043,6 +1057,224 @@ describe('PtyHandler', () => {
}
)
it.skipIf(process.platform === 'win32')(
'recovers provider delivery when startup exec replaces the relay wrapper',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-exec-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-exec',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
await vi.advanceTimersByTimeAsync(8)
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
onPromptReady: () => void
}
expect(
mockCreateShellPromptReadinessProbe.mock.results[0]?.value.notifyOutput
).toHaveBeenCalledWith('\x1b[?2004hremote $ ')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b[?2004hremote $ '
})
promptOptions.onPromptReady()
await vi.advanceTimersByTimeAsync(50)
expect(term.write).toHaveBeenCalledWith('echo after-exec\n')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'signals renderer delivery when startup exec replaces the relay wrapper',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-exec-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-exec',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
await vi.advanceTimersByTimeAsync(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b[?2004hremote $ '
})
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
onPromptReady: () => void
}
promptOptions.onPromptReady()
await vi.advanceTimersByTimeAsync(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07'
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'forwards the supported ready marker to renderer delivery',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-ready-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(
`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready\x07remote $ `
)
await vi.advanceTimersByTimeAsync(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07remote $ '
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'releases split renderer readiness through one completed path',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-split-ready-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready`)
dataCallback?.('\x07remote $ ')
await vi.advanceTimersByTimeAsync(8)
const probe = mockCreateShellPromptReadinessProbe.mock.results[0]?.value
expect(probe.notifyOutput).not.toHaveBeenCalled()
expect(probe.dispose).toHaveBeenCalledOnce()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07remote $ '
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'does not retain renderer readiness state for unsupported shells',
async () => {
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/sh'
try {
await dispatcher.callRequest('pty.spawn', {
command: 'x'.repeat(256 * 1024),
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('0')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'flushes held shell-ready marker bytes when provider delivery falls back',
async () => {
@@ -1142,6 +1374,50 @@ describe('PtyHandler', () => {
).rejects.toThrow('PTY "pty-1" not found')
})
it.skipIf(process.platform === 'win32')(
'releases renderer readiness state when attach reaps a dead shell',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-dead-shell-ready-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'x'.repeat(256 * 1024),
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
expect(handler.retainedStartupCommandCount).toBe(1)
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow(
'PTY "pty-1" not found'
)
} finally {
aliveSpy.mockRestore()
}
expect(handler.retainedStartupCommandCount).toBe(0)
vi.advanceTimersByTime(15_000)
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it('settles concurrent immediate shutdown when attach proves the shell exited', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
@@ -2734,9 +3010,16 @@ describe('PtyHandler', () => {
ORCA_AGENT_HOOK_TOKEN: 'abc-uuid'
}))
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
const oldStartupIdentity = process.env.ORCA_SHELL_STARTUP_IDENTITY
process.env.ORCA_SHELL_STARTUP_IDENTITY = '1'
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
if (oldStartupIdentity === undefined) {
delete process.env.ORCA_SHELL_STARTUP_IDENTITY
} else {
process.env.ORCA_SHELL_STARTUP_IDENTITY = oldStartupIdentity
}
killSpy.mockRestore()
}
@@ -2749,6 +3032,8 @@ describe('PtyHandler', () => {
expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid')
expect(callArgs.env.TERM).toBe('xterm-256color')
expect(callArgs.env.TERM_PROGRAM).toBe('Orca')
expect(callArgs.env.ORCA_SHELL_READY_MARKER).toBe('0')
expect(callArgs.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('0')
})
it('fences both revived worktree identity and cwd with rollback', async () => {
+141 -24
View File
@@ -31,8 +31,19 @@ import {
createShellReadyScanState,
drainShellReadyHeldBytes,
scanForShellReady,
SHELL_READY_MARKER_PREFIX,
type ShellReadyScanState
} from '../main/shell-ready-marker-scanner'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity,
type ShellStartupIdentityScanState
} from '../main/shell-startup-identity-scanner'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../main/shell-prompt-readiness-probe'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import {
gitCredentialPromptGuardEnv,
@@ -147,6 +158,9 @@ type ManagedPty = {
worktreeId?: string
terminalHandle?: string
explicitTerm?: string
shellPath?: string
shellCwd?: string
shellPathEnv?: string
envToDelete: string[]
gitCredentialPromptGuarded: boolean
startupCommand?: ManagedStartupCommand
@@ -178,10 +192,14 @@ type PendingPtyOutput = RelayPtySourceOutput & {
}
type ManagedStartupCommand = {
command: string
command: string | null
providerDelivery: boolean
delivered: boolean
waitForShellReady: boolean
scanState: ShellReadyScanState | null
identityScanState: ShellStartupIdentityScanState | null
shellPid: number | null
promptProbe: ShellPromptReadinessProbe | null
timer: ReturnType<typeof setTimeout> | null
}
@@ -245,6 +263,7 @@ const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
const STARTUP_COMMAND_WRITE_DELAY_MS = 50
const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
const RENDERER_SHELL_READY_RETENTION_MS = 15_000
const PTY_FORCE_KILL_RETRY_DELAY_MS = 250
const PTY_FORCE_KILL_MAX_ATTEMPTS = 2
const ALLOWED_SIGNALS = new Set([
@@ -634,10 +653,26 @@ export class PtyHandler {
private releaseStartupCommand(managed: ManagedPty): void {
this.clearStartupCommandTimer(managed)
managed.startupCommand?.promptProbe?.dispose()
managed.startupCommand = undefined
}
private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
private drainStartupScanBytes(startup: ManagedStartupCommand): string {
let heldBytes = startup.identityScanState
? drainShellStartupIdentityHeldBytes(startup.identityScanState)
: ''
startup.identityScanState = null
if (startup.scanState && heldBytes) {
heldBytes = scanForShellReady(startup.scanState, heldBytes).output
}
if (startup.scanState) {
heldBytes += drainShellReadyHeldBytes(startup.scanState)
startup.scanState = null
}
return heldBytes
}
private scheduleStartupCommandResolution(managed: ManagedPty, delayMs: number): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
return
@@ -645,22 +680,25 @@ export class PtyHandler {
this.clearStartupCommandTimer(managed)
startup.timer = setTimeout(() => {
startup.timer = null
this.deliverStartupCommand(managed)
if (startup.providerDelivery) {
this.deliverStartupCommand(managed)
} else {
this.signalRendererShellReady(managed)
}
}, delayMs)
}
private deliverStartupCommand(managed: ManagedPty): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
if (!startup?.providerDelivery || !startup.command || startup.delivered || managed.disposed) {
return
}
startup.delivered = true
this.clearStartupCommandTimer(managed)
if (startup.scanState) {
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
if (heldBytes) {
managed.startupIngress?.accept(heldBytes)
}
startup.promptProbe?.dispose()
const heldBytes = this.drainStartupScanBytes(startup)
if (heldBytes) {
managed.startupIngress?.accept(heldBytes)
}
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed.
@@ -672,6 +710,19 @@ export class PtyHandler {
managed.pty.write(payload)
}
private signalRendererShellReady(managed: ManagedPty): void {
const startup = managed.startupCommand
if (!startup || startup.providerDelivery || startup.delivered || managed.disposed) {
return
}
startup.delivered = true
this.clearStartupCommandTimer(managed)
startup.promptProbe?.dispose()
managed.startupIngress?.accept(this.drainStartupScanBytes(startup))
managed.startupIngress?.accept(`${SHELL_READY_MARKER_PREFIX}\x07`)
managed.startupCommand = undefined
}
/** Wire onData/onExit listeners for a managed PTY and store it. */
private wireAndStore(managed: ManagedPty): void {
managed.physicalExit = new PhysicalExitTracker()
@@ -697,16 +748,48 @@ export class PtyHandler {
onEmission: emitIngressData,
...(echoProbe ? { echoProbe } : {})
})
const startup = managed.startupCommand
if (startup?.waitForShellReady) {
startup.promptProbe = createShellPromptReadinessProbe({
slavePath: readPtySlavePath(managed.pty),
shellPath: managed.shellPath,
shellCwd: managed.shellCwd,
shellPathEnv: managed.shellPathEnv,
getShellPid: () => startup.shellPid,
onPromptReady: () => {
if (startup.providerDelivery) {
this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
} else {
this.signalRendererShellReady(managed)
}
}
})
}
managed.pty.onData((data: string) => {
const startup = managed.startupCommand
if (startup?.identityScanState && !startup.delivered) {
const scanned = scanForShellStartupIdentity(startup.identityScanState, data)
data = scanned.output
if (scanned.shellPid) {
startup.shellPid = scanned.shellPid
startup.identityScanState = null
}
}
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
const scanned = scanForShellReady(startup.scanState, data)
data = scanned.output
if (scanned.matched) {
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
if (startup.providerDelivery) {
this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
} else {
this.signalRendererShellReady(managed)
}
}
}
managed.startupIngress?.accept(data)
if (startup && !startup.delivered && data.length > 0) {
startup.promptProbe?.notifyOutput(data)
}
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
managed.physicalExit?.markExited()
@@ -744,11 +827,11 @@ export class PtyHandler {
private releaseRelayIngress(managed: ManagedPty): void {
const startupCommand = managed.startupCommand
const scanState = startupCommand?.scanState
if (scanState) {
const held = drainShellReadyHeldBytes(scanState)
startupCommand.scanState = null
managed.startupIngress?.accept(held)
if (startupCommand) {
this.clearStartupCommandTimer(managed)
startupCommand.promptProbe?.dispose()
managed.startupIngress?.accept(this.drainStartupScanBytes(startupCommand))
managed.startupCommand = undefined
}
managed.startupIngress?.drainAndClose()
}
@@ -1471,11 +1554,15 @@ export class PtyHandler {
startupCommandDelivery:
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
})
const managedStartupCommand = shouldProviderDeliverCommand ? command : launchCommandHint
// Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output.
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
terminalWindowsWslDistro,
emitReadyMarker: shouldEmitShellReadyMarker
emitReadyMarker: shouldEmitShellReadyMarker,
emitStartupIdentity: shouldEmitShellReadyMarker
})
const rendererShellReadySupported =
!shouldProviderDeliverCommand && shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
if (context?.signal?.aborted || context?.isStale()) {
// Why: cancellation remains side-effect-free until the exact native spawn seam.
@@ -1496,7 +1583,12 @@ export class PtyHandler {
rows,
cwd,
// Why: relay shells inherit process.env; don't let an ambient Orca marker enable shell-ready unless requested.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
env: {
...spawnEnv,
ORCA_SHELL_READY_MARKER: '0',
ORCA_SHELL_STARTUP_IDENTITY: '0',
...shellLaunch.env
}
})
} catch (error) {
// Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here.
@@ -1535,6 +1627,9 @@ export class PtyHandler {
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
shellPath: shell,
shellCwd: cwd,
shellPathEnv: spawnEnv.PATH,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: shell,
@@ -1542,16 +1637,23 @@ export class PtyHandler {
}),
...(startupIngressIntent ? { startupIngressIntent } : {}),
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
...(managedStartupCommand && (shouldProviderDeliverCommand || rendererShellReadySupported)
? {
startupCommand: {
command,
command: shouldProviderDeliverCommand ? managedStartupCommand : null,
providerDelivery: shouldProviderDeliverCommand,
delivered: false,
waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
scanState:
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
? createShellReadyScanState()
: null,
identityScanState:
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
? createShellStartupIdentityScanState()
: null,
shellPid: null,
promptProbe: null,
timer: null
}
}
@@ -1568,11 +1670,13 @@ export class PtyHandler {
this.releaseStartupCommand(managed)
this.requestGracefulKill(managed, 'terminate stale')
} else if (managed.startupCommand) {
this.scheduleStartupCommandDelivery(
this.scheduleStartupCommandResolution(
managed,
managed.startupCommand.waitForShellReady
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
: STARTUP_COMMAND_WRITE_DELAY_MS
managed.startupCommand.providerDelivery
? managed.startupCommand.waitForShellReady
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
: STARTUP_COMMAND_WRITE_DELAY_MS
: RENDERER_SHELL_READY_RETENTION_MS
)
}
return {
@@ -2029,7 +2133,12 @@ export class PtyHandler {
rows: entry.rows,
cwd: entry.cwd,
// Why: no provider-delivered command is waiting for a ready marker.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
env: {
...spawnEnv,
ORCA_SHELL_READY_MARKER: '0',
ORCA_SHELL_STARTUP_IDENTITY: '0',
...shellLaunch.env
}
})
this.wireAndStore({
id: entry.id,
@@ -2208,6 +2317,14 @@ export class PtyHandler {
return count
}
get retainedStartupCommandBytes(): number {
let bytes = 0
for (const managed of this.ptys.values()) {
bytes += managed.startupCommand?.command?.length ?? 0
}
return bytes
}
get graceTimerActive(): boolean {
return this.graceTimer !== null
}
+21
View File
@@ -181,6 +181,12 @@ describe('getRelayShellLaunchConfig', () => {
expect(config.args).toEqual(['-l'])
expect(config.env.ZDOTDIR).toBe(zshRoot)
expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1')
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
'printf "\\033]777;orca-shell-start:%s\\007" "$$"'
)
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
'unset ORCA_SHELL_STARTUP_IDENTITY'
)
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
}
@@ -195,11 +201,26 @@ describe('getRelayShellLaunchConfig', () => {
const bashRc = readFileSync(config.args[1] as string, 'utf8')
expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1')
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(bashRc).toContain('unset ORCA_SHELL_STARTUP_IDENTITY')
expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"')
expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"')
}
)
it.skipIf(process.platform === 'win32')(
'wraps zsh when only startup identity emission is requested',
() => {
const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', {
emitStartupIdentity: true
})
expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh'))
expect(config.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('1')
expect(config.env.ORCA_SHELL_READY_MARKER).toBeUndefined()
}
)
itWithBash('runs the relay bash wrapper without fake C/D markers before the first prompt', () => {
const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir })
const output = runInteractiveBashRcfile(config.args[1] as string, homeDir)
+18 -6
View File
@@ -5,6 +5,7 @@ import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper'
import {
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
getZshStartupFileSourceBlock
} from '../main/shell-templates'
@@ -84,6 +85,7 @@ function ensureOverlayRestoreWrappers(root: string): void {
const bashDir = join(root, 'bash')
const zshEnv = `# Orca relay zsh overlay wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${ORCA_ORIG_ZDOTDIR%/}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
@@ -133,6 +135,7 @@ ${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HO
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
`
const bashRc = `# Orca relay bash overlay wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
@@ -264,10 +267,15 @@ export function getRelayShellLaunchConfig(
shellPath: string,
env: Record<string, string>,
platform: NodeJS.Platform = process.platform,
options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {}
options: {
emitReadyMarker?: boolean
emitStartupIdentity?: boolean
terminalWindowsWslDistro?: string | null
} = {}
): RelayShellLaunchConfig {
const shellName = shellBasename(shellPath)
const emitReadyMarker = options.emitReadyMarker === true
const emitStartupIdentity = options.emitStartupIdentity === true
if (platform === 'win32') {
// Why: pwsh also exists on POSIX remotes; Windows-specific shell args must
// only apply when the relay itself is running on native Windows.
@@ -283,9 +291,9 @@ export function getRelayShellLaunchConfig(
if (shellName !== 'zsh' && shellName !== 'bash') {
return { args: POSIX_LOGIN_ARGS, env: {} }
}
// Why: preserve plain zsh startup fast path; only force wrappers when
// shell-ready or overlay env restoration is requested.
if (shellName === 'zsh' && !hasOverlayRestoreEnv(env) && !emitReadyMarker) {
// Why: preserve plain zsh startup fast path unless markers or overlay restoration are requested.
const requiresZshWrapper = hasOverlayRestoreEnv(env) || emitReadyMarker || emitStartupIdentity
if (shellName === 'zsh' && !requiresZshWrapper) {
return { args: POSIX_LOGIN_ARGS, env: {} }
}
@@ -298,13 +306,17 @@ export function getRelayShellLaunchConfig(
env: {
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env),
ZDOTDIR: join(root, 'zsh'),
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {})
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}),
...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {})
}
}
}
return {
args: ['--rcfile', join(root, 'bash', 'rcfile')],
env: emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}
env: {
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}),
...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {})
}
}
}
@@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { createPtySlaveEchoProbe, readPtySlavePath } from './pty-slave-line-discipline-echo'
import {
createPtySlaveEchoProbe,
createPtySlaveLineEditorProbe,
readPtySlavePath
} from './pty-slave-line-discipline-echo'
/** Replies to the next stty call with the given output, or an error when `output` is null. */
function answerStty(output: string | null): void {
@@ -14,6 +18,7 @@ function answerStty(output: string | null): void {
const COOKED = 'speed 38400 baud;\nlflags: icanon isig iexten echo echoe echok echoctl\n'
const RAW = 'speed 38400 baud;\nlflags: -icanon -isig -iexten -echo -echoe -echok -echoctl\n'
const LINE_EDITOR = `${RAW}cchars: lnext = <undef>; min = 1; time = 0;\n`
beforeEach(() => {
execFileMock.mockReset()
@@ -132,3 +137,21 @@ describe('createPtySlaveEchoProbe', () => {
expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3'])
})
})
describe('createPtySlaveLineEditorProbe', () => {
it('requires raw quiet mode with the line editor disabling literal-next', async () => {
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
answerStty(LINE_EDITOR)
await expect(probe?.()).resolves.toBe('line-editor')
answerStty(`${RAW}cchars: lnext = ^V; min = 1; time = 0;\n`)
await expect(probe?.()).resolves.toBe('other')
answerStty(COOKED)
await expect(probe?.()).resolves.toBe('other')
})
it('fails closed when the terminal state is incomplete', async () => {
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
answerStty('lflags: -echo\ncchars: lnext = <undef>;\n')
await expect(probe?.()).resolves.toBe('unknown')
})
})
+36 -4
View File
@@ -10,10 +10,16 @@ export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown'
export type PtySlaveEchoProbe = () => Promise<PtySlaveLineDisciplineEcho>
export type PtySlaveLineEditorState = 'line-editor' | 'other' | 'unknown'
export type PtySlaveLineEditorProbe = () => Promise<PtySlaveLineEditorState>
const STTY_TIMEOUT_MS = 2_000
// `stty -a` prints the lflags as a space-separated list where a disabled flag is
// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter.
const ECHO_FLAG = /(?:^|\s)(-?)echo(?:\s|$)/
const ICANON_FLAG = /(?:^|\s)(-?)icanon(?:\s|$)/
const LNEXT_UNDEFINED = /(?:^|[;\s])lnext\s*=\s*<undef>(?:;|\s|$)/
function sttyArgs(ptsName: string, platform: NodeJS.Platform): readonly string[] {
// BSD/macOS take `-f`; Linux (GNU coreutils) takes `-F`.
@@ -30,7 +36,18 @@ function parseEchoFlag(sttyOutput: string): PtySlaveLineDisciplineEcho {
return match[1] === '-' ? 'quiet' : 'echoing'
}
type SttyProbeResult = { state: PtySlaveLineDisciplineEcho; permanent: boolean }
function parseLineEditorState(sttyOutput: string): PtySlaveLineEditorState {
const echo = ECHO_FLAG.exec(sttyOutput)
const icanon = ICANON_FLAG.exec(sttyOutput)
if (!echo || !icanon) {
return 'unknown'
}
return echo[1] === '-' && icanon[1] === '-' && LNEXT_UNDEFINED.test(sttyOutput)
? 'line-editor'
: 'other'
}
type SttyProbeResult = { stdout: string | null; permanent: boolean }
/**
* A spawn that never ran (`stty` absent) or a device that answered non-zero (reaped,
@@ -54,8 +71,8 @@ function runStty(ptsName: string, platform: NodeJS.Platform): Promise<SttyProbeR
(error, stdout) => {
resolve(
error
? { state: 'unknown', permanent: isPermanentSttyFailure(error) }
: { state: parseEchoFlag(stdout), permanent: false }
? { stdout: null, permanent: isPermanentSttyFailure(error) }
: { stdout, permanent: false }
)
}
)
@@ -83,6 +100,21 @@ export function createPtySlaveEchoProbe(
ptsName: string | undefined,
platform: NodeJS.Platform = process.platform
): PtySlaveEchoProbe | undefined {
return createSttyProbe(ptsName, platform, parseEchoFlag)
}
export function createPtySlaveLineEditorProbe(
ptsName: string | undefined,
platform: NodeJS.Platform = process.platform
): PtySlaveLineEditorProbe | undefined {
return createSttyProbe(ptsName, platform, parseLineEditorState)
}
function createSttyProbe<T extends string>(
ptsName: string | undefined,
platform: NodeJS.Platform,
parse: (output: string) => T | 'unknown'
): (() => Promise<T | 'unknown'>) | undefined {
if (platform === 'win32' || !ptsName) {
return undefined
}
@@ -101,6 +133,6 @@ export function createPtySlaveEchoProbe(
})
const result = await inFlight
unavailable = result.permanent
return result.state
return result.stdout === null ? 'unknown' : parse(result.stdout)
}
}
@@ -0,0 +1,73 @@
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseDarwinExecutablePath, resolveShellExecutablePath } from './shell-process-readiness'
describe('shell process readiness', () => {
it('extracts the primary text image from macOS lsof output', () => {
expect(parseDarwinExecutablePath('p42\nftxt\nn/bin/zsh\nftxt\nn/usr/lib/zsh/zle.so\n')).toBe(
'/bin/zsh'
)
})
it.skipIf(process.platform === 'win32')(
'resolves bare shell commands through the spawn PATH',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shell-path-'))
const link = join(root, 'shell-name')
await symlink(process.execPath, link)
try {
await expect(resolveShellExecutablePath('shell-name', dirname(root), root)).resolves.toBe(
await resolveShellExecutablePath(process.execPath, dirname(root), root)
)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'uses the POSIX exec default when PATH is unset',
async () => {
await expect(resolveShellExecutablePath('sh', process.cwd(), undefined)).resolves.toBe(
await resolveShellExecutablePath('/bin/sh', process.cwd(), '')
)
}
)
it.skipIf(process.platform === 'win32')(
'resolves relative shell paths against the PTY cwd',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-relative-shell-'))
const bin = join(root, 'bin')
await symlink(dirname(process.execPath), bin)
try {
await expect(
resolveShellExecutablePath(`./bin/${basename(process.execPath)}`, root, '')
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'skips searchable directories that shadow a later PATH executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shadowed-shell-'))
const first = join(root, 'first')
const second = join(root, 'second')
await mkdir(join(first, 'shell-name'), { recursive: true })
await mkdir(second)
await symlink(process.execPath, join(second, 'shell-name'))
try {
await expect(
resolveShellExecutablePath('shell-name', root, `${first}:${second}`)
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})
+80
View File
@@ -0,0 +1,80 @@
import { execFile as execFileCallback } from 'node:child_process'
import { constants } from 'node:fs'
import { access, readlink, realpath, stat } from 'node:fs/promises'
import { delimiter, isAbsolute, resolve } from 'node:path'
import { promisify } from 'node:util'
const execFile = promisify(execFileCallback)
const PROCESS_READINESS_TIMEOUT_MS = 3000
const DEFAULT_POSIX_EXEC_PATH = '/usr/bin:/bin'
export type ShellProcessReadiness = {
executablePath: string
foreground: boolean
}
export function parseDarwinExecutablePath(stdout: string): string | null {
const lines = stdout.split(/\r?\n/)
const textIndex = lines.indexOf('ftxt')
const pathLine = textIndex === -1 ? undefined : lines[textIndex + 1]
return pathLine?.startsWith('n') ? pathLine.slice(1) : null
}
async function readExecutablePath(pid: number): Promise<string | null> {
if (process.platform === 'linux') {
return readlink(`/proc/${pid}/exe`)
}
if (process.platform !== 'darwin') {
return null
}
const { stdout } = await execFile(
'/usr/sbin/lsof',
['-a', '-p', String(pid), '-d', 'txt', '-Fn'],
{
encoding: 'utf8',
timeout: PROCESS_READINESS_TIMEOUT_MS
}
)
return parseDarwinExecutablePath(stdout)
}
export async function readShellProcessReadiness(
pid: number
): Promise<ShellProcessReadiness | null> {
const [executablePath, { stdout }] = await Promise.all([
readExecutablePath(pid),
execFile('ps', ['-p', String(pid), '-o', 'stat='], {
encoding: 'utf8',
timeout: PROCESS_READINESS_TIMEOUT_MS
})
])
const status = stdout.trim()
return status && executablePath
? { executablePath: await realpath(executablePath), foreground: status.includes('+') }
: null
}
export async function resolveShellExecutablePath(
shellPath: string,
cwd: string,
pathEnv: string | undefined
): Promise<string | null> {
const candidates = shellPath.includes('/')
? [isAbsolute(shellPath) ? shellPath : resolve(cwd, shellPath)]
: (
pathEnv ??
(process.platform === 'win32' ? (process.env.PATH ?? '') : DEFAULT_POSIX_EXEC_PATH)
)
.split(delimiter)
.map((entry) => resolve(isAbsolute(entry) ? entry : resolve(cwd, entry), shellPath))
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK)
const canonicalPath = await realpath(candidate)
if ((await stat(canonicalPath)).isFile()) {
return canonicalPath
}
} catch {}
}
return null
}