From 16e437afc8d3f9b23f5ebd46ad200aa9bf41faef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:31:50 -0700 Subject: [PATCH] fix(terminal): preserve Bash exec edge cases --- config/reliability-gates.jsonc | 7 +- src/main/bash-startup-exec-preservation.ts | 69 +++++++++++++++---- .../providers/local-pty-shell-ready.test.ts | 69 ++++++++++++++++++- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d1bbc9308ab..61414b7376f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -181,6 +181,7 @@ "assertions": [ "local zsh and bash wrappers survive Fig-shaped exec replacements", "bash prompt rewrites are re-pinned before exec and temporary hooks are removed after normal startup", + "bash nounset, redirection-only exec, and failed-exec prompt-array paths preserve user shell state", "zsh exec targets and redirections expand against the user ZDOTDIR before wrapper re-entry", "zsh bare exec redirections and user exec functions retain native behavior" ] @@ -199,8 +200,8 @@ "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/providers/local-pty-shell-ready.test.ts src/relay/pty-shell-launch.test.ts", "result": "passed", - "durationSeconds": 4.65, - "summary": "All 107 focused local, daemon, and relay assertions passed, including real zsh/bash exec restoration, command modifiers, ZDOTDIR expansion ordering, and DEBUG trap invocation preservation when the user enables functrace during startup." + "durationSeconds": 6.19, + "summary": "All 110 focused local, daemon, and relay assertions passed, including real zsh/bash exec restoration, command modifiers, ZDOTDIR expansion ordering, nounset and prompt-array cleanup, redirection-only exec behavior, and DEBUG trap invocation preservation when the user enables functrace during startup." } ], "runtimeBudget": { @@ -213,7 +214,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "PR head 05c0ac91dd1 failed the exact exec \"$ZDOTDIR/zsh-replacement\" oracle with status 127 after expanding the target inside Orca's wrapper directory. The just-in-time shim candidate passed that oracle plus all 107 focused assertions. The pre-fix Bash guard reported USER_DEBUG_NESTED=0 after a profile enabled functrace; the reviewed guard reports USER_DEBUG_NESTED=1 while keeping the ordinary non-functrace trap unmultiplied. The existing #13767 red/green evidence independently proves the original replacement-shell readiness failure." + "evidence": "PR head 05c0ac91dd1 failed the exact exec \"$ZDOTDIR/zsh-replacement\" oracle with status 127 after expanding the target inside Orca's wrapper directory. The just-in-time shim candidate passed that oracle. The pre-fix Bash guard reported USER_DEBUG_NESTED=0 after a profile enabled functrace; the reviewed guard reports USER_DEBUG_NESTED=1 while keeping the ordinary non-functrace trap unmultiplied. Exact head 205e63e4338 armed prompt hooks for redirection-only exec, failed with an unbound PROMPT_COMMAND under nounset, and ran a trailing prompt-array element five times; the reviewed candidate keeps the bridge unarmed, restores the replacement hooks, and runs each array element once. All 110 focused assertions pass. The existing #13767 red/green evidence independently proves the original replacement-shell readiness failure." }, "performanceBudget": { "required": true, diff --git a/src/main/bash-startup-exec-preservation.ts b/src/main/bash-startup-exec-preservation.ts index 8876189d266..8a9cd507a72 100644 --- a/src/main/bash-startup-exec-preservation.ts +++ b/src/main/bash-startup-exec-preservation.ts @@ -63,6 +63,7 @@ __orca_install_exec_prompt_hooks() { unset PROMPT_COMMAND PROMPT_COMMAND="$_orca_exec_joined" fi + PROMPT_COMMAND="\${PROMPT_COMMAND:-}" PROMPT_COMMAND="\${PROMPT_COMMAND//__orca_exec_osc133_precmd;/}" PROMPT_COMMAND="\${PROMPT_COMMAND//;__orca_exec_osc133_precmd/}" PROMPT_COMMAND="\${PROMPT_COMMAND//__orca_exec_osc133_prompt_done;/}" @@ -90,7 +91,10 @@ __orca_startup_command_is_exec() { local _orca_exec_command="$1" _orca_exec_char _orca_exec_previous local _orca_exec_word="" _orca_exec_quote="" _orca_exec_name="" local _orca_exec_index=0 _orca_exec_nested_parens=0 _orca_exec_nested_braces=0 - local _orca_exec_modifier=0 _orca_exec_escaped=0 + local _orca_exec_modifier=0 _orca_exec_escaped=0 _orca_exec_found=0 + local _orca_exec_has_redirection=0 _orca_exec_skip_option_value=0 + local _orca_exec_skip_redirection_target=0 _orca_exec_options_done=0 + local _orca_exec_redirection_prefix="" while (( _orca_exec_index <= \${#_orca_exec_command} )); do if (( _orca_exec_index == \${#_orca_exec_command} )); then _orca_exec_char=" " @@ -126,23 +130,62 @@ __orca_startup_command_is_exec() { elif [[ "$_orca_exec_char" == "{" && "$_orca_exec_previous" == "$" ]]; then _orca_exec_nested_braces=1 _orca_exec_word="\${_orca_exec_word}$_orca_exec_char" + elif [[ "$_orca_exec_char" == "<" || "$_orca_exec_char" == ">" ]]; then + if (( !_orca_exec_has_redirection )); then + _orca_exec_redirection_prefix="$_orca_exec_word" + _orca_exec_has_redirection=1 + fi + _orca_exec_word="\${_orca_exec_word}$_orca_exec_char" elif [[ "$_orca_exec_char" == [[:space:]] ]]; then if [[ -n "$_orca_exec_word" ]]; then - if [[ "$_orca_exec_word" == "exec" ]]; then - return 0 + if (( !_orca_exec_found && _orca_exec_has_redirection )) && + [[ "$_orca_exec_redirection_prefix" == "exec" ]]; then + _orca_exec_found=1 + _orca_exec_redirection_prefix="" fi - _orca_exec_name="\${_orca_exec_word%%=*}" - if (( !_orca_exec_modifier )) && [[ "$_orca_exec_word" == *=* && - "$_orca_exec_name" == [a-zA-Z_]* && "$_orca_exec_name" != *[!a-zA-Z0-9_]* ]]; then - : - elif (( !_orca_exec_modifier )) && [[ "$_orca_exec_word" == "builtin" || "$_orca_exec_word" == "command" ]]; then - _orca_exec_modifier=1 - elif (( _orca_exec_modifier )) && [[ "$_orca_exec_word" == -* ]]; then - : + if (( _orca_exec_found )); then + if (( _orca_exec_skip_redirection_target )); then + _orca_exec_skip_redirection_target=0 + elif (( _orca_exec_has_redirection )); then + if [[ -n "$_orca_exec_redirection_prefix" && + "$_orca_exec_redirection_prefix" == *[!0-9]* && + ! "$_orca_exec_redirection_prefix" =~ ^[{][a-zA-Z_][a-zA-Z0-9_]*[}]$ ]]; then + return 0 + fi + case "$_orca_exec_word" in + *">"|*"<"|*">&"|*"<&"|*"<<-"|*">|") _orca_exec_skip_redirection_target=1 ;; + esac + elif (( _orca_exec_skip_option_value )); then + _orca_exec_skip_option_value=0 + elif (( _orca_exec_options_done )); then + return 0 + else + case "$_orca_exec_word" in + -a) _orca_exec_skip_option_value=1 ;; + -c|-l|-cl|-lc) ;; + --) _orca_exec_options_done=1 ;; + -*) return 1 ;; + *) return 0 ;; + esac + fi + elif [[ "$_orca_exec_word" == "exec" ]]; then + _orca_exec_found=1 else - return 1 + _orca_exec_name="\${_orca_exec_word%%=*}" + if (( !_orca_exec_modifier )) && [[ "$_orca_exec_word" == *=* && + "$_orca_exec_name" == [a-zA-Z_]* && "$_orca_exec_name" != *[!a-zA-Z0-9_]* ]]; then + : + elif (( !_orca_exec_modifier )) && [[ "$_orca_exec_word" == "builtin" || "$_orca_exec_word" == "command" ]]; then + _orca_exec_modifier=1 + elif (( _orca_exec_modifier )) && [[ "$_orca_exec_word" == -* ]]; then + : + else + return 1 + fi fi _orca_exec_word="" + _orca_exec_has_redirection=0 + _orca_exec_redirection_prefix="" fi else _orca_exec_word="\${_orca_exec_word}$_orca_exec_char" @@ -203,8 +246,10 @@ __orca_remove_exec_prompt_hooks() { [[ -n "$_orca_exec_prompt_part" ]] || continue _orca_exec_joined="\${_orca_exec_joined}\${_orca_exec_joined:+;}$_orca_exec_prompt_part" done + unset PROMPT_COMMAND PROMPT_COMMAND="$_orca_exec_joined" fi + PROMPT_COMMAND="\${PROMPT_COMMAND:-}" PROMPT_COMMAND="\${PROMPT_COMMAND//__orca_exec_osc133_precmd;/}" PROMPT_COMMAND="\${PROMPT_COMMAND//;__orca_exec_osc133_precmd/}" PROMPT_COMMAND="\${PROMPT_COMMAND//__orca_exec_osc133_prompt_done;/}" diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 2fb3fa92cb2..f22e603a0e4 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -606,7 +606,7 @@ describePosix('local PTY shell-ready launch config', () => { 'if [[ -z "${ORCA_EXEC_REPRO_DONE:-}" ]]; then', ' export ORCA_EXEC_REPRO_DONE=1', ' PROMPT_COMMAND=\'printf "USER_EXEC_PROMPT\\n"\'', - ' Q_START_TEXT="local seed" exec -a figterm-test bash --noprofile --norc -l -i', + ' Q_START_TEXT="local seed" exec -a 3> /dev/null figterm-test bash --noprofile --norc -l -i', 'fi', '' ].join('\n') @@ -641,6 +641,73 @@ describePosix('local PTY shell-ready launch config', () => { expect(output).toContain('CHILD_TEMP_PC=unset') }) + itWithBash('does not arm bash exec hooks for redirection-only commands', async () => { + const { getBashShellReadyRcfileContent } = await importFreshLocalPtyShellReady() + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'unset PROMPT_COMMAND', + 'exec 3>"$HOME/startup-redirection"', + 'printf "REDIRECTION_PC=%s:ARMED=%s\\n" "${PROMPT_COMMAND+set}" "$_orca_exec_prompt_hooks_installed"', + 'exec 3>&-', + '' + ].join('\n') + ) + + const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath) + + expect(output).toContain('REDIRECTION_PC=:ARMED=0') + }) + + itWithBash('survives nounset before an exec replacement without PROMPT_COMMAND', async () => { + const { getBashShellReadyRcfileContent } = await importFreshLocalPtyShellReady() + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'if [[ -z "${ORCA_EXEC_REPRO_DONE:-}" ]]; then', + ' export ORCA_EXEC_REPRO_DONE=1', + ' unset PROMPT_COMMAND', + ' set -u', + ' exec -a figterm-test bash --noprofile --norc -l -i', + 'fi', + '' + ].join('\n') + ) + + const output = runInteractiveBashRcfile( + getBashShellReadyRcfileContent(), + userDataPath, + 'printf \'NOUNSET_EXEC_PC=%s\\n\' "$PROMPT_COMMAND"\nexit 0\n' + ) + + expect(output).toContain('NOUNSET_EXEC_PC=__orca_exec_osc133_precmd') + expect(output).not.toContain('unbound variable') + }) + + itWithBash('flattens a prompt array replaced after a failed exec', async () => { + const { getBashShellReadyRcfileContent } = await importFreshLocalPtyShellReady() + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'PROMPT_COMMAND=(\'printf "ARRAY_ZERO\\n"\' \'printf "ARRAY_ONE\\n"\')', + 'shopt -s execfail', + 'exec /orca-missing-replacement 2>/dev/null', + 'PROMPT_COMMAND=(\'printf "ARRAY_ZERO\\n"\' \'printf "ARRAY_ONE\\n"\')', + '' + ].join('\n') + ) + + const output = runInteractiveBashRcfile( + getBashShellReadyRcfileContent(), + userDataPath, + 'exit 0\n' + ) + + expect(output.split('ARRAY_ZERO')).toHaveLength(2) + expect(output.split('ARRAY_ONE')).toHaveLength(2) + expect(output).not.toContain('__orca_exec_osc133_precmd: command not found') + }) + itWithBash('preserves user-enabled functrace and nested DEBUG trap behavior', async () => { const { getBashShellReadyRcfileContent } = await importFreshLocalPtyShellReady() writeFileSync(