diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 50eea7fc6d2..3c9678a5a8d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 \ diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 52197c29fa5..3d3b7274d4b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b3b66dea3f..165931dbeb1 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -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']) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 7566b81f6fe..9b87da0c8d5 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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: () => { diff --git a/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts b/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts new file mode 100644 index 00000000000..74907d84406 --- /dev/null +++ b/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts @@ -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 { + if (isDone()) { + return Promise.resolve() + } + return new Promise((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 { + return new Promise((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 + output: () => string + readStarted: () => boolean + subscribe: (settle: () => void) => void + cleanup: () => Promise +} + +function startFixture( + fixture: ShellFixture, + startupContent: string, + extraFiles: Record = {} +): 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 | 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 { + 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 { + 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 + ) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index f24a6a7fcc9..377f0fee184 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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 | null = null private killTimer: ReturnType | 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 = [] diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index b21e8c7e58b..1739ba05c85 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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( diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 38a26be8b75..2b1f27309f8 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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 } diff --git a/src/main/line-editor-ready-output-scanner.test.ts b/src/main/line-editor-ready-output-scanner.test.ts new file mode 100644 index 00000000000..332f1d65f04 --- /dev/null +++ b/src/main/line-editor-ready-output-scanner.test.ts @@ -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) + }) +}) diff --git a/src/main/line-editor-ready-output-scanner.ts b/src/main/line-editor-ready-output-scanner.ts new file mode 100644 index 00000000000..03467887a9a --- /dev/null +++ b/src/main/line-editor-ready-output-scanner.ts @@ -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)) +} diff --git a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts index c9217f17994..a1dd3727c54 100644 --- a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -62,6 +62,8 @@ export async function shellScriptTest( const env: Record = { ...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, diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 03e7127ce4c..909ca9b73f1 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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. diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 907695ea42f..a31bf4857b7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 | 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) diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index e37502d7709..a35eae779da 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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') diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 20bb80201ac..28c56b27d1f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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 } diff --git a/src/main/shell-prompt-readiness-probe.test.ts b/src/main/shell-prompt-readiness-probe.test.ts new file mode 100644 index 00000000000..c2c783953ac --- /dev/null +++ b/src/main/shell-prompt-readiness-probe.test.ts @@ -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) + }) +}) diff --git a/src/main/shell-prompt-readiness-probe.ts b/src/main/shell-prompt-readiness-probe.ts new file mode 100644 index 00000000000..11583fd2de7 --- /dev/null +++ b/src/main/shell-prompt-readiness-probe.ts @@ -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 | null = null + let generation = 0 + let probesStarted = 0 + + const probe = async (scheduledGeneration: number): Promise => { + 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 + } + } + } +} diff --git a/src/main/shell-startup-identity-scanner.test.ts b/src/main/shell-startup-identity-scanner.test.ts new file mode 100644 index 00000000000..2b1607c23e6 --- /dev/null +++ b/src/main/shell-startup-identity-scanner.test.ts @@ -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('') + }) +}) diff --git a/src/main/shell-startup-identity-scanner.ts b/src/main/shell-startup-identity-scanner.ts new file mode 100644 index 00000000000..234b69e6bbf --- /dev/null +++ b/src/main/shell-startup-identity-scanner.ts @@ -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 } +} diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index e6372979069..5b481217d90 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -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. diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 35d11461c53..8ddf1e67a12 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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 } | 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 } | 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 } + | 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 () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index c8b39c0f285..6dd3cd8a7fc 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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 | 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 } diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c4c5b5f11ad..04f7a261583 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -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) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5f58b89702c..7a6d7d7a097 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -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, 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' } : {}) + } } } diff --git a/src/shared/pty-slave-line-discipline-echo.test.ts b/src/shared/pty-slave-line-discipline-echo.test.ts index 4f6c3994a85..c5538f2328c 100644 --- a/src/shared/pty-slave-line-discipline-echo.test.ts +++ b/src/shared/pty-slave-line-discipline-echo.test.ts @@ -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 = ; 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 = ;\n') + await expect(probe?.()).resolves.toBe('unknown') + }) +}) diff --git a/src/shared/pty-slave-line-discipline-echo.ts b/src/shared/pty-slave-line-discipline-echo.ts index 3f515066450..1eb6bfde3c9 100644 --- a/src/shared/pty-slave-line-discipline-echo.ts +++ b/src/shared/pty-slave-line-discipline-echo.ts @@ -10,10 +10,16 @@ export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown' export type PtySlaveEchoProbe = () => Promise +export type PtySlaveLineEditorState = 'line-editor' | 'other' | 'unknown' + +export type PtySlaveLineEditorProbe = () => Promise + 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*(?:;|\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 { 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( + ptsName: string | undefined, + platform: NodeJS.Platform, + parse: (output: string) => T | 'unknown' +): (() => Promise) | 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) } } diff --git a/src/shared/shell-process-readiness.test.ts b/src/shared/shell-process-readiness.test.ts new file mode 100644 index 00000000000..9c3abad9783 --- /dev/null +++ b/src/shared/shell-process-readiness.test.ts @@ -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 }) + } + } + ) +}) diff --git a/src/shared/shell-process-readiness.ts b/src/shared/shell-process-readiness.ts new file mode 100644 index 00000000000..2a631dcd82e --- /dev/null +++ b/src/shared/shell-process-readiness.ts @@ -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 { + 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 { + 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 { + 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 +}