diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d0f6e1fbf61..4717da652fc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -359,14 +359,12 @@ jobs: src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ + src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ @@ -403,14 +401,12 @@ jobs: --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-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 507b5396650..5b9ab226502 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -11,12 +11,10 @@ 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-zsh-launch-environment.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] @@ -31,8 +29,12 @@ const testFilePatterns = [ 'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', 'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}' ] +// Why the harness import counts: the zsh startup hook runs from a `precmd`, so +// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness +// rather than calling spawnSync('zsh') themselves. Without this branch the rule +// silently stops noticing the very tests that need the lane's zsh install. const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/ + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('cancels superseded runs for the same pull request', () => { @@ -154,6 +156,9 @@ describe('PR workflow parallelism', () => { it('keeps every real-zsh test in the dedicated shell lane', () => { const discoveredFiles = globSync(testFilePatterns) + // Why this file is excluded: it carries the detector pattern as a literal + // and would otherwise match itself. + .filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs') .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) .sort() diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt deleted file mode 100644 index 2c84163caa7..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt deleted file mode 100644 index 7b275404b38..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index fdd43798f72..1b89c740f2b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca daemon zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# 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. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt deleted file mode 100644 index 8a4e38c3aa3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt deleted file mode 100644 index a7556c7f006..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt deleted file mode 100644 index ee1befa1429..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 33d4d04d2a6..d73f2d4c4c9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# 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. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt deleted file mode 100644 index 4776da936c3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt deleted file mode 100644 index c76f4f6973d..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt deleted file mode 100644 index 79540246999..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 750978b1cc9..40e10d17210 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -1,58 +1,46 @@ # Orca relay zsh overlay wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_USER_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR fi - -export ZDOTDIR='/zsh' - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + precmd_functions=(${precmd_functions:#__orca_deferred_init}) if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -86,16 +74,8 @@ __orca_shell_epilogue() { omp() { __orca_omp "$@"; } fi fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -121,11 +101,14 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt deleted file mode 100644 index 9c84f4df2ec..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts index 2e6b3e19613..43eda0f148b 100644 --- a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts +++ b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts @@ -6,18 +6,17 @@ import { join } from 'node:path' import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec' +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildDaemonShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = join(root, 'zsh') - const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) return [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), buildZshStartupHook(getDaemonZshWrapperSpec())], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] ] diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index b23201ee04c..3d9d0e7c045 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -1,15 +1,12 @@ -import type { ZshStartupWrapperSpec } from '../zsh-startup-wrapper-builder' +import type { ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' /** The zsh wrapper the daemon (local fork and SSH host) launches shells with. */ -export function getDaemonZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca daemon zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index b680cb06954..6772a52d46e 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -132,17 +132,6 @@ async function runInteractiveZshRc(args: { return output } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('daemon shell-ready launch config', () => { // Always runs, so the CI lane cannot report green with every live fish test skipped. it('has the fish the live tests need when CI requires one', () => { @@ -353,7 +342,7 @@ describePosix('daemon shell-ready launch config', () => { 15_000 ) - it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited ZDOTDIR points at a wrapper dir', async () => { // Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (recursion loop). const previousZdotdir = process.env.ZDOTDIR const previousHome = process.env.HOME @@ -362,8 +351,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -390,7 +378,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -410,7 +397,7 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited one points at a wrapper dir', async () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME @@ -420,8 +407,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -441,34 +427,23 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else: zsh reads .zprofile, .zshrc and .zlogin through ZDOTDIR, + // which is the user's own again by the time it looks for them. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -476,8 +451,8 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -652,7 +627,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -671,7 +645,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -695,7 +669,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -728,34 +702,20 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract. const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ZSHENV') - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - expect(zshenv).toContain('export ZDOTDIR=') - }) - - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Save spawn-env value before sourcing user .zshenv - expect(zshenv).toContain('_orca_user_zdotdir="$_orca_resolved_config_dir"') - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') + ) }) }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 277817b9c61..dcc3aed3d75 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,10 +16,7 @@ import { import { resolveShellWrapperRoot } from '../shell-wrapper-content-address' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildDaemonShellReadyWrapperFiles } from './daemon-shell-ready-wrapper-fileset' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' @@ -149,8 +146,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh'), [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index 93cd869bee0..cfdb693d228 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -355,7 +355,10 @@ describe('registerPtyHandlers', () => { expect(shell).toBe('/bin/zsh') expect(args).toEqual(['-l']) expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) - expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME) + // Why absent: this HOME holds no zsh startup file, so there is no user + // config dir to hand back and Orca must not invent one — the wrapper + // leaves ZDOTDIR unset, exactly as an unwrapped login zsh would. + expect(options.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/providers/__tests__/shell-ready-framework-example.test.ts b/src/main/providers/__tests__/shell-ready-framework-example.test.ts index bf2a425ed7c..d130f2cb9de 100644 --- a/src/main/providers/__tests__/shell-ready-framework-example.test.ts +++ b/src/main/providers/__tests__/shell-ready-framework-example.test.ts @@ -85,8 +85,7 @@ EOF ) expect(stdout).toMatchInlineSnapshot(` - "ORCA_ORIG_ZDOTDIR= - ZDOTDIR=/.config/zsh-remote + "ZDOTDIR=/.config/zsh-remote " `) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index 1851a42e1a8..afad9569340 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -5,21 +5,15 @@ */ import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' -export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getLocalZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { @@ -36,14 +30,13 @@ export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { // config builds the matching values the same way (local-pty-shell-ready.ts). // path.join would emit backslashes on Windows, where a shell literal reads them // as escapes -- and would desync the written path from the launched one. +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildLocalShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = `${root}/zsh` - const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) return [ - [`${zshDir}/.zshenv`, zsh.zshenv], - [`${zshDir}/.zprofile`, zsh.zprofile], - [`${zshDir}/.zshrc`, zsh.zshrc], - [`${zshDir}/.zlogin`, zsh.zlogin], + [`${zshDir}/.zshenv`, buildZshStartupHook(getLocalZshWrapperSpec())], [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] ] diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 96e8c901f29..62fd806a4e4 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -11,7 +11,7 @@ import { } from './local-pty-shell-ready-test-harness' // Why: rcfile content is pure, so a static import is equivalent to the fresh-module import used for wrapper writing. import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' -import { getZshShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-generation' +import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation' import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' @@ -136,17 +136,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('local PTY shell-ready launch config', () => { let userDataPath: string let previousOrcaOrigZdotdir: string | undefined @@ -204,8 +193,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -232,7 +220,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -262,8 +249,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -283,35 +269,28 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + // The handback is what makes a nested Orca unable to inherit this dir, and + // what stops /etc/zshrc deriving HISTFILE from it. + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') - expect(zlogin).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else is written: zsh reads .zprofile, .zshrc and .zlogin + // through ZDOTDIR, which is the user's again by the time it looks. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } + // No emulation probe survives: nothing after this file is read via ZDOTDIR, + // so sh/ksh emulation entered by a user file can no longer hide anything. + expect(zshenv).not.toContain('$(emulate') }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -319,8 +298,8 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -329,9 +308,7 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).not.toContain('add-zle-hook-widget line-init') // Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source. expect(zshenv).toContain('== "user:__orca_prompt_mark"') - expect(readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8')).toContain( - '__orca_shell_epilogue' - ) + expect(zshenv).toContain('__orca_deferred_init') }) it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => { @@ -384,8 +361,8 @@ describePosix('local PTY shell-ready launch config', () => { // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. it('emits OSC 133 C/D markers in the bash wrapper so agent exit cleanup fires', async () => { const bashRc = getBashShellReadyRcfileContent() - // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. - const zshRc = getZshShellReadyWrapperFiles().zshenv + // Why .zshenv: the zsh markers live in the deferred hook, behind `markers`. + const zshRc = getZshShellReadyWrapperFile() // The exact escape sequences terminal-command-lifecycle parses (133;D = finished, 133;C = start). expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') @@ -489,7 +466,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -507,7 +483,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -530,7 +506,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -562,70 +538,37 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract; only the surrounding + // machinery went away. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain( - '__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"' + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain( - '__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' - ) - expect(zshenv).toContain('export ZDOTDIR=') }) - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: if user .zshenv returns early or doesn't set ZDOTDIR, fall back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. + it('bakes no generation-time path into the zsh hook', async () => { + // Why: issue #8003 — a wrapper generated on Windows is sourced inside WSL + // via /mnt/c, where the generation-time path does not exist. The old file + // baked that path as a ZDOTDIR fallback and re-derived the runtime one from + // `%x` to avoid using it. Nothing re-points ZDOTDIR at the wrapper dir any + // more, so there is no path to bake and the whole class is gone. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - // Save spawn-env value before sourcing user .zshenv - expect(zshenv.indexOf('_orca_user_zdotdir="$_orca_resolved_config_dir"')).toBeLessThan( - zshenv.indexOf('source "${_orca_zshenv_path}"') - ) - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - }) - - it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c, so the baked generation-time path is absent. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Why: derive wrapper dir from %x, not env $ZDOTDIR — zsh corrupts non-ASCII usernames in its 0x84-0x9D token range. - expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"') - // Keep $ZDOTDIR only as a fallback when %x yields nothing; the final restore re-validates with -f, so no stat here. - expect(zshenv).toContain( - 'if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then\n' + - ' _orca_wrapper_zdotdir_self="${ZDOTDIR:-}"\n' + - 'fi' - ) - // Trust the runtime path only when it still holds a wrapper .zshenv; else fall back to the generation-time literal. - expect(zshenv).toContain( - 'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' + - ' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' + - 'else\n' + - ` export ZDOTDIR='${join(getShellReadyWrapperRoot(), 'zsh')}'\n` + - 'fi' - ) - // Capture must happen before the wrapper unsets ZDOTDIR to source user files. - expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"')).toBeLessThan( - zshenv.indexOf('unset ZDOTDIR') - ) + expect(zshenv).not.toContain(getShellReadyWrapperRoot()) + expect(zshenv).not.toContain('%x') }) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 8d39bcc028a..eb65e58437c 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -4,10 +4,7 @@ * Why: the wrappers emit an OSC 777 marker after startup files finish, which the * readiness scanner watches for before a startup command is written. */ -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperFiles -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildLocalShellReadyWrapperFiles, @@ -18,8 +15,8 @@ import { shellReadyWrappersExist } from './local-pty-shell-ready-wrapper-root' -export function getZshShellReadyWrapperFiles(): ZshStartupWrapperFiles { - return buildZshStartupWrapperFiles(getLocalZshWrapperSpec(`${getShellReadyWrapperRoot()}/zsh`)) +export function getZshShellReadyWrapperFile(): string { + return buildZshStartupHook(getLocalZshWrapperSpec()) } /** True when every wrapper file is present and non-empty afterwards. */ diff --git a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts index 3d435b6731f..4c0237c19c5 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts @@ -48,13 +48,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('discovers ZDOTDIR when launched from SSH session', async () => { @@ -76,13 +76,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { @@ -137,14 +137,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should discover fresh value from .zshenv, not use stale wrapper path - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${currentZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${currentZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -175,14 +175,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should use fresh discovery (user updated .zshenv) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${freshZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${freshZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -214,7 +214,10 @@ describePosix('live zsh subprocess tests', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(inheritedZdotdir) + // One channel now does what ORCA_ORIG_ZDOTDIR and ORCA_ZSHENV_SOURCE_DIR + // split between them: the wrapper hands this back as ZDOTDIR, and zsh + // reads .zshenv through it. + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(inheritedZdotdir) const cleanEnv: Record = { ...process.env, @@ -224,10 +227,7 @@ describePosix('live zsh subprocess tests', () => { const result = spawnSync( 'zsh', - [ - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"' - ], + ['-c', 'echo "ZDOTDIR=${ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' @@ -235,7 +235,7 @@ describePosix('live zsh subprocess tests', () => { ) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${inheritedZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${inheritedZdotdir}`) expect(result.stdout).toContain('SOURCE_MARKER=inherited') } finally { if (previousZdotdir === undefined) { diff --git a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts b/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts deleted file mode 100644 index fde7b49bf46..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('automation and edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('matches normal zsh when user .zshenv calls exit', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\nexit 42\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "survived"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(42) - expect(result.stdout).not.toContain('survived') - }) - - it('survives user .zshenv with set -e and failing command', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'set -e\nfalse\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // No ZDOTDIR was reached after the failing command, so we fall back. - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('survives user .zshenv with set -u before ZDOTDIR is set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'set -u\nexport ZDOTDIR="$HOME/.config/zsh"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should work because wrapper uses ${ZDOTDIR:-} which is safe with set -u - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with nullglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt nullglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with extendedglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt extendedglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves exported .zshenv environment changes in the wrapper shell', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export MY_VAR=from-zshenv\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "MY_VAR=${MY_VAR:-unset}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('MY_VAR=from-zshenv') - }) - - it('handles empty HOME gracefully', async () => { - // When HOME is empty, wrapper should not attempt to source /.zshenv - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { HOME: '' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty HOME falls back to empty ORCA_ORIG_ZDOTDIR - expect(result.stdout).toContain('ORCA_ORIG_ZDOTDIR=\n') - }) - - it('handles unset HOME gracefully', async () => { - // Why: zsh initializes HOME from /etc/passwd when unset at spawn, so the wrapper can still discover ZDOTDIR. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = {} - delete cleanEnv.HOME - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // zsh initializes HOME from passwd, wrapper discovers ZDOTDIR normally - expect(result.stdout).toMatch(/ORCA_ORIG_ZDOTDIR=.+/) - }) - - it('handles ZDOTDIR containing only "/"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="/"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Single slash normalizes to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR containing only slashes "///"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="///"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Multiple slashes normalize to "/" then to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles user .zshenv that unsets HOME', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `unset HOME\nexport ZDOTDIR="${xdgZshDir}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Subshell unsets HOME but wrapper HOME is in parent scope - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('handles user .zshenv that sets ZDOTDIR to empty string', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR=""\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty string should be normalized away, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles conditional unset of ZDOTDIR', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${xdgZshDir}"\nif [[ "\${TERM}" == "dumb" ]]; then\n unset ZDOTDIR\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test with TERM=dumb - let cleanEnv: Record = { - ...process.env, - HOME: testHome, - TERM: 'dumb' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR unset conditionally, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - - // Test with TERM=xterm - cleanEnv = { ...process.env, HOME: testHome, TERM: 'xterm-256color' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR not unset, uses discovered value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts deleted file mode 100644 index 40ceb8333d7..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('ZDOTDIR discovery with real zsh', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('preserves typeset -U path scoping when user .zshrc uses it', async () => { - // Why: PR #1737's function-wrapper made "typeset -U path" function-scoped; user rcfiles must source at top level. - - // Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="$HOME/.config/zsh" -` - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - `typeset -U path -path=(/custom/bin $path) -` - ) - - // Generate the Orca wrapper - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Verify the wrapper discovered XDG ZDOTDIR, sourced user .zshrc, and kept typeset -U path (proves top-level scoping). - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - [ - '-i', - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "PATH_HAS_CUSTOM=${PATH%%:*}"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - const output = result.stdout - expect(output).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') - }) - - it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c where the baked path is absent; renaming userData reproduces that split. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const movedUserData = `${userDataPath}-wsl-view` - renameSync(userDataPath, movedUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, movedUserData), - 'zsh' - ) - - // Cover both the WSL login shell (`exec zsh -l`) and the non-login local-pane flow so both restore paths stay pinned. - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [ - ...args, - '-c', - 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`) - // Why: `as const` makes .includes('-l') reject the tuple union type; check by position instead. - expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') - } - } finally { - rmSync(movedUserData, { recursive: true, force: true }) - } - }) - - it('loads user .zshrc when the wrapper dir contains a non-ASCII (token-range) path', async () => { - // Why: issue #8003 — non-ASCII usernames put UTF-8 bytes in zsh's 0x84-0x9D token range, corrupting env-imported $ZDOTDIR; derive from %x instead. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - // Move wrappers under a non-ASCII root so the baked literal is unusable and runtime $ZDOTDIR corrupts on import. - const nonAsciiUserData = join(dirname(userDataPath), '홍길동-wsl-view') - renameSync(userDataPath, nonAsciiUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, nonAsciiUserData), - 'zsh' - ) - - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [...args, '-c', 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - } - } finally { - rmSync(nonAsciiUserData, { recursive: true, force: true }) - } - }) - - it('preserves top-level .zshenv path and function side effects', async () => { - // Why: .zshenv is the normal place for always-on env/path setup; dropping side effects regresses zsh startup. - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `typeset -U path -path=(/env/bin $path) -export MY_VAR=from-zshenv -orca_zshenv_func() { echo "from-zshenv-function"; } -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - [ - '-c', - 'echo "PATH_HEAD=${PATH%%:*}" && echo "MY_VAR=${MY_VAR:-unset}" && orca_zshenv_func' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('PATH_HEAD=/env/bin') - expect(result.stdout).toContain('MY_VAR=from-zshenv') - expect(result.stdout).toContain('from-zshenv-function') - }) - - it('sources user startup files with their own ZDOTDIR in scope', async () => { - // Why: plugin managers such as Antidote resolve files from $ZDOTDIR while startup files are sourced. - const xdgZshDir = join(testHome, '.config', 'zsh') - const zdotdirLog = join(testHome, 'zdotdir.log') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync( - join(xdgZshDir, '.zprofile'), - 'printf "zprofile=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - 'printf "zshrc=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zlogin'), - 'printf "zlogin=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - ['-l', '-i', '-c', 'printf "command=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8', - timeout: 5000 - } - ) - - expect(result.status).toBe(0) - expect(readFileSync(zdotdirLog, 'utf8')).toBe( - [ - `zprofile=${xdgZshDir}`, - `zshrc=${xdgZshDir}`, - `zlogin=${xdgZshDir}`, - `command=${xdgZshDir}`, - '' - ].join('\n') - ) - }) - - it('survives early return in user .zshenv without crashing', async () => { - // Why: early return is a common non-interactive-skip pattern; top-level sourcing must keep the wrapper running. - writeFileSync( - join(testHome, '.zshenv'), - `[[ -o interactive ]] || return 0 -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - ['-c', 'echo "survived" && echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('survived') - // ZDOTDIR discovery yields nothing (early return before export), fallback to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => { - // Why: vanilla zsh users don't set ZDOTDIR, so the fallback chain must land on HOME. - writeFileSync( - join(testHome, '.zshenv'), - `# Vanilla zsh config, no ZDOTDIR -export MY_VAR=foo -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts deleted file mode 100644 index 32b162ae7a6..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('high-priority edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('discovers ZDOTDIR when .zshenv sources another file that sets it', async () => { - // Multi-file sourcing pattern - const commonSh = join(testHome, '.config', 'shell', 'common.sh') - mkdirSync(dirname(commonSh), { recursive: true }) - writeFileSync(commonSh, 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync(join(testHome, '.zshenv'), 'source ~/.config/shell/common.sh\n') - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves ZDOTDIR with spaces in path', async () => { - const spacePath = join(testHome, 'My Config', 'zsh') - mkdirSync(spacePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${spacePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${spacePath}`) - }) - - it('falls back when .zshenv has syntax error', async () => { - writeFileSync(join(testHome, '.zshenv'), 'syntax error {{{\nexport ZDOTDIR=broken\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Syntax error causes discovery to fail, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles framework pattern with ${ZDOTDIR:-$HOME}', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export ZDOTDIR="${ZDOTDIR:-$HOME}"\n# prezto-style pattern\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Framework pattern defaults to HOME when ZDOTDIR unset - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('captures last ZDOTDIR value when set multiple times', async () => { - const firstPath = join(testHome, '.config', 'zsh') - const lastPath = join(testHome, '.local', 'zsh') - mkdirSync(firstPath, { recursive: true }) - mkdirSync(lastPath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${firstPath}"\nexport ZDOTDIR="${lastPath}"\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${lastPath}`) - }) - - it('handles conditional ZDOTDIR based on environment', async () => { - const localPath = join(testHome, '.config', 'zsh') - const remotePath = join(testHome, '.config', 'zsh-remote') - mkdirSync(localPath, { recursive: true }) - mkdirSync(remotePath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `if [[ -n "$SSH_CONNECTION" ]]; then\n export ZDOTDIR="${remotePath}"\nelse\n export ZDOTDIR="${localPath}"\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test without SSH_CONNECTION - let cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.SSH_CONNECTION - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${localPath}`) - - // Test with SSH_CONNECTION - cleanEnv = { ...process.env, HOME: testHome, SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${remotePath}`) - }) - - it('preserves explicit ZDOTDIR="$HOME" from user .zshenv', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back when discovered ZDOTDIR does not exist', async () => { - const nonexistent = join(testHome, '.config', 'zsh-missing') - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${nonexistent}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Validation rejects non-existent path, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('does not source /.zshenv when HOME is empty', async () => { - // Can't create /.zshenv in the test, so verify the wrapper logic guards against it. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Verify wrapper checks the resolved source root is non-empty before sourcing - expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') - }) - - it('handles ZDOTDIR with single quote in path', async () => { - const quotePath = join(testHome, "config'zsh") - mkdirSync(quotePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${quotePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${quotePath}`) - }) - - it('does not evaluate command substitution in ZDOTDIR', async () => { - const safePath = join(testHome, '.config', 'zsh') - mkdirSync(safePath, { recursive: true }) - // Attempt command substitution - should be treated as literal path component - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${safePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should contain the safe path, not any command-substituted value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${safePath}`) - }) - - it('handles whitespace-only ZDOTDIR (tabs and newlines)', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="\t\t\n\n"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Whitespace-only should be normalized to empty, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR with multiple trailing slashes', async () => { - const cleanPath = join(testHome, '.config', 'zsh') - mkdirSync(cleanPath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${cleanPath}///"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should normalize to path without trailing slashes - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${cleanPath}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 3deff780625..b6ae89b1a62 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -17,10 +17,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../shell-startup-features' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { ensureShellReadyWrappers } from './local-pty-shell-ready-wrapper-generation' import { getShellReadyWrapperRoot, @@ -82,8 +79,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 75aed3d2f47..97fee6a6fde 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { selectShellStartupFeatures } from './shell-startup-features' +import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { importFreshLocalPtyShellReady, @@ -160,15 +161,19 @@ describePosix('zsh launch config', () => { try { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - // Empty dir: not a config root whoever wrote it. - expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + // Empty dir: not a config root whoever wrote it. Absent rather than $HOME + // because the wrapper hands this value straight back to the shell, and a + // user with no ZDOTDIR must end up with none. + expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined() // Stamped as Orca-owned: rejected by positive identification, even though // the path shape is not one of Orca's. writeFileSync(join(foreignWrapper, '.zshrc'), '') writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '') const stamped = await importFreshLocalPtyShellReady() - expect(stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + expect( + stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR + ).toBeUndefined() // A real user config dir still round-trips. rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE)) @@ -238,35 +243,26 @@ describePosix('epilogue under hostile user shell options', () => { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = execFileSync( - ZSH_PATH, - [ - ...(launch.args ?? ['-l']), - '-i', - '-c', - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "PRECMD=${precmd_functions[*]:-none}"; ' + - 'print -r -- "OPENCODE=${OPENCODE_CONFIG_DIR:-none}"; ' + - 'print -r -- "ZDOTDIR=${ZDOTDIR:-}"; print -r -- "HISTFILE=${HISTFILE:-}"' - ], - { - encoding: 'utf8', - timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - ...spawnEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home - } - } - ) + // Why a PTY: every feature is delivered from a precmd hook, and a shell + // started with -c never reaches a prompt to run one. + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + ...spawnEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'], + commands: [ + 'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"' + ] + }) - expect(output).toContain('LINEINIT=user:__orca_prompt_mark') - expect(output).toContain('PRECMD=__orca_osc133_precmd') - expect(output).toContain(`OPENCODE=${opencodeDir}`) - expect(output).toContain(`ZDOTDIR=${home}`) - expect(output).toContain(`HISTFILE=${scoped}`) + expect(values.LINEINIT).toBe('user:__orca_prompt_mark') + expect(values.PRECMD).toContain('__orca_osc133_precmd') + expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir) + expect(values.ZDOTDIR).toBe(home) + expect(values.HISTFILE).toBe(scoped) } ) }) @@ -295,13 +291,10 @@ describePosix('history-only pane in a real zsh', () => { rmSync(home, { recursive: true, force: true }) }) - const OBSERVABLES = - 'print -r -- "PRECMD=${precmd_functions[*]}"; ' + - 'print -r -- "PREEXEC=${preexec_functions[*]}"; ' + - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "ZDOTDIR=$ZDOTDIR"; ' + - 'print -r -- "FEATURES=[${ORCA_SHELL_FEATURES:-}]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-}]"' + function withoutInheritedZdotdir(env: Record): Record { + const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env + return rest + } async function launchHistoryOnly(): Promise<{ args: string[] @@ -327,11 +320,11 @@ describePosix('history-only pane in a real zsh', () => { env: { PATH: '/usr/bin:/bin', ...spawnEnv, - ...launch.env, - // Why override: the launch config reads the real process env, and this - // run must resolve the user's config against the sandbox home. - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home + // Why ORCA_ORIG_ZDOTDIR is stripped: the launch config computes it from + // the real process env, which would leak the developer's own ZDOTDIR + // into the run. This sandbox home has none, so the pane must end up with + // none — which is also what makes it comparable to an unwrapped pane. + ...withoutInheritedZdotdir(launch.env) }, zdotdir: launch.env.ZDOTDIR } @@ -357,34 +350,38 @@ describePosix('history-only pane in a real zsh', () => { expect(output).not.toContain('history') }) - itWithZsh('emits no OSC 133 and matches an unwrapped pane', async () => { - const { args, env } = await launchHistoryOnly() + itWithZsh( + 'emits no OSC 133 and leaves a pane observably identical to an unwrapped one', + async () => { + // Why a PTY: the hook runs from the first prompt's precmd sweep, so a shell + // started with -c would report a pane Orca had not finished setting up. + const { env } = await launchHistoryOnly() + const capture = [ + 'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"', + 'LINEINIT="${widgets[zle-line-init]:-none}"' + ] + const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES'] - const wrapped = execFileSync(ZSH_PATH, [...args, '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env - }) - const unwrapped = execFileSync(ZSH_PATH, ['-l', '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } - }) + const wrapped = await runZshPty({ env, commands: capture, report }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + commands: capture, + report + }) - expect(wrapped).not.toContain('\x1b]133;') - expect(wrapped).toContain('PRECMD=\n') - expect(wrapped).toContain('LINEINIT=none') - // Startup files run in the same order with the same hooks, so the pane is - // observably what it was before it got wrapped. - const comparable = (output: string): string[] => - output - .split('\n') - .filter((line) => !line.startsWith('ORCA_HISTFILE=') && !line.startsWith('ZDOTDIR=')) - expect(comparable(wrapped)).toEqual(comparable(unwrapped)) - // The one carried-over difference, unchanged from every pane Orca already - // wrapped: ZDOTDIR ends up explicitly set to the user's config dir, which - // is the value zsh itself defaults to when it is unset. - expect(wrapped).toContain(`ZDOTDIR=${home}`) - expect(unwrapped).toContain('ZDOTDIR=\n') - }) + expect(wrapped.output).not.toContain('\x1b]133;') + // The whole point of removing the hook rather than parking a no-op in its + // place: a history-only pane leaves no Orca name in the user's hook arrays. + expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD) + expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC) + // Why compared and not pinned to 'none': a host whose global zsh config + // installs its own zle-line-init widget has one either way, and what Orca + // owes is that it looks the same wrapped as unwrapped. + expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT) + expect(wrapped.values.PRECMD).not.toContain('orca') + // ZDOTDIR matches too, because the wrapper hands back exactly what it found. + expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR) + expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET') + } + ) }) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 6bcfde6bfb7..8e498b11de1 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -2,10 +2,6 @@ // small drift here breaks different terminal transports in different ways. import { SHELL_STARTUP_FEATURE_ENV } from './shell-startup-features' -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - /** Basename of the file every Orca-generated zsh wrapper dir is stamped with. */ export const ZSH_WRAPPER_DIR_MARKER_FILE = '.orca-shell-wrapper' @@ -24,9 +20,15 @@ export const ZSH_WRAPPER_DIR_MARKER_CONTENT = `# Orca-generated zsh startup wrap * .zshenv is sourced means nothing the user's config spawns can see or inherit * Orca's feature selection. */ -export const ZSH_FEATURE_CHANNEL_BLOCK = `typeset -ga _orca_shell_features +export const ZSH_FEATURE_CHANNEL_BLOCK = `builtin typeset -ga _orca_shell_features _orca_shell_features=(\${(s:,:)\${${SHELL_STARTUP_FEATURE_ENV}:-}}) builtin unset ${SHELL_STARTUP_FEATURE_ENV} +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="\${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE __orca_has_feature() { (( \${_orca_shell_features[(Ie)$1]} )) }` /** The bash rcfile equivalent of ZSH_FEATURE_CHANNEL_BLOCK. */ @@ -39,238 +41,81 @@ __orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }` export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity && printf "\\033]777;orca-shell-start:%s\\007" "$$"` /** - * Resolves the directory Orca should treat as the user's zsh config root. + * The first executable lines of the wrapper: give ZDOTDIR back to the user. * - * Why positive identification: Orca may only reject a config dir it can prove is - * its own. A stamped marker file (or Orca's own wrapper path shape, for - * wrappers written by older builds) is that proof. Guessing at other - * terminals' wrapper dirs by name never can be, so this never tries. + * Why before anything else: every later startup file — the user's .zprofile, + * the system /etc/zshrc, .zshrc, .zlogin — is found through ZDOTDIR. Handing it + * back here means zsh reads all of them from the user's own directory, exactly + * as it would with no wrapper at all. In particular /etc/zshrc's unguarded + * `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` then derives the user's own path + * instead of one inside Orca's wrapper dir, so #11044 cannot happen rather than + * having to be repaired afterwards. * - * Why every generated file redefines it instead of relying on .zshenv: one - * wrapper dir can be rewritten by two concurrently installed builds, so a shell - * can read one build's .zshenv and another's .zshrc. A .zshrc that called a - * function only the newer .zshenv defines printed `command not found` AND left - * the out-parameter empty, which skipped sourcing the user's own startup file. + * ORCA_ORIG_ZDOTDIR is consumed: it has done its job, and leaving it exported + * would hand a stale value to everything this pane launches. * - * Why an Orca-private out-parameter and not `REPLY`: `REPLY` is zsh's shared - * scratch global, so a user config is entitled to constrain it. `typeset -r - * REPLY` made the very first assignment a fatal error and aborted the wrapper - * file; `typeset -i REPLY` silently turned every resolved path into `0`. Both - * left HISTFILE inside Orca's wrapper dir. Harmless while Orca wrapped a few - * percent of panes; not harmless now that it wraps every zsh pane. - * - * Why `typeset -g`: .zshrc/.zlogin call this AFTER the user's own config, so - * creating a plain global inside a function would print a warning per call - * under `setopt warn_create_global`. + * Why the value is vetted rather than trusted: the launch config only sets this + * when it resolved a usable dir, but a pane also inherits its parent's + * environment, so a stale ORCA_ORIG_ZDOTDIR written by an older build can arrive + * on its own. Handing that back would point ZDOTDIR at an Orca wrapper dir — the + * self-loop the Node-side ownership check exists to prevent, arriving by a route + * that check never sees. Identification stays positive, as it is in Node: a + * stamped marker file, or Orca's own path shape for wrappers older builds wrote. */ -export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="\${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="\${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/${ZSH_WRAPPER_DIR_MARKER_FILE}" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -}` - -/** - * The stricter resolver .zshenv uses for a ZDOTDIR this shell INHERITED. - * - * Why only .zshenv needs it: nothing after .zshenv re-reads an inherited value — - * the later files resolve ORCA_ORIG_ZDOTDIR, which .zshenv already vetted. - */ -export const ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK = `# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "\${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +export const ZSH_ZDOTDIR_HANDBACK_BLOCK = `__orca_usable_zdotdir() { + [[ -n "\${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/${ZSH_WRAPPER_DIR_MARKER_FILE}" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 +} +if __orca_usable_zdotdir "\${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR +fi +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir` + +/** + * Sources the user's own .zshenv, then arms the deferred hook. + * + * Why `{ } always { }`: the whole compound command is parsed before any of it + * runs, so the registration below is already parsed as zsh even if the user's + * .zshenv switches the shell into sh emulation. Sourcing at wrapper top level + * (not in a function or subshell) keeps the user's exports, functions, fpath + * typesets and options in their normal scope. + * + * Why guarded on absence: the hook is idempotent, but appending it twice would + * still leave a dead name in the user's precmd_functions. + */ +export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ + builtin typeset _orca_user_zshenv="\${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-composition' -/** - * Fork-free precondition for the `$(emulate)` probe: options that both - * `emulate sh` and `emulate ksh` turn on, so all-off proves zsh emulation. - * - * Why: the probe is a command substitution, which forks a zsh carrying every - * function, alias and completion the user's config has loaded by that point — - * the most expensive line in the wrapper, and one every zsh pane pays now that - * wrapping widened to all of them. Measured on zsh 5.9 / macOS, 150 login - * startups per arm, with a user .zshenv + .zprofile + .zshrc present: 9.97 - * ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped once these three - * probes are skipped — about half of what wrapping costs. - * - * Why a hint in front of the real probe and not a replacement for it: these - * options say nothing about `emulation`, which is what zsh's `sourcehome()` - * branches on, so a config that sets one by hand must still get the exact - * answer. OR, not AND, so the only way past it is to enter emulation and then - * unset all three; every real `emulate sh`/`emulate ksh` sets them. A false - * positive costs exactly the fork this saves. - * - * Why `2>/dev/null`: `[[ -o ]]` prints `no such option` to stderr and - * returns false rather than aborting, so on a zsh too old for one of these - * names the only symptom would be that text in the user's pane. All three - * predate every zsh Orca supports, so this is belt and braces, not a fallback. - */ -export const ZSH_BOURNE_EMULATION_OPTION_HINT = - '[[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null' - -/** - * Hands the pane back to the user unwrapped when zsh has entered sh/ksh - * emulation, and stops reading the rest of the current wrapper file. - * - * Why: zsh's `sourcehome()` ignores ZDOTDIR entirely once the shell is in sh or - * ksh emulation, so a user .zshenv (or .zprofile) ending in `emulate sh` means - * NO later wrapper file is ever read — the epilogue runs zero times, while the - * user's own $HOME startup files load normally. The pane looks fine and writes - * its history inside Orca's wrapper dir, where the user will never find it. - * - * Nothing can repair that from here (`/etc/zshrc` assigns HISTFILE after - * .zshenv and .zprofile both), so the wrapper does the next best thing: it - * restores the user's own ZDOTDIR and consumes Orca's variables, leaving - * exactly the shell an unwrapped pane would have produced. Degrading to the - * pre-wrapping behaviour is the bar; degrading to something worse is not. - * - * Why a positive `sh|ksh` match rather than `!= zsh`: a zsh too old for the - * query form of `emulate` prints nothing, and must not unwrap every pane. - * - * Why `sourcedUserFileTest` gates the probe rather than it running - * unconditionally: `$(emulate)` forks, and every zsh pane now pays for it. The - * gate loses no coverage — this wrapper file is itself read through ZDOTDIR, so - * anything that had already entered emulation (a system /etc/zshenv or - * /etc/zprofile) would have hidden this very file too. The only thing that can - * have entered emulation by this line is the user file this file just sourced. - * - * Why ZSH_BOURNE_EMULATION_OPTION_HINT gates it further: see that constant. - */ -export function getZshEmulationDegradeBlock(options: { - userZdotdirExpression: string - sourcedUserFileTest: string -}): string { - return `if [[ ${options.sourcedUserFileTest} ]] && ${ZSH_BOURNE_EMULATION_OPTION_HINT}; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR=${options.userZdotdirExpression} - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi` -} - -/** The ZDOTDIR-discovery body of the wrapper .zshenv (no header, no epilogue). */ -export function getZshEnvDiscoveryBody(zshDir: string): string { - return `# 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. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="\${\${(%):-%x}:h}" -if [[ -z "\${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="\${ZDOTDIR:-}" -fi -while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "\${_orca_zshenv_path:-}" ]]; then - source "\${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="\${ZDOTDIR:-}" - -while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}" -done - -case "\${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then - [[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_ORIG_ZDOTDIR"', - sourcedUserFileTest: '-n "${_orca_zshenv_path:-}"' -})} -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}" -else - export ZDOTDIR=${quotePosixSingle(zshDir)} -fi -unset _orca_wrapper_zdotdir_self -` -} - -/** - * The relay variant of the discovery body: it trusts the ZDOTDIR the remote - * shell already inherited instead of re-deriving one, and republishes it as - * ORCA_USER_ZDOTDIR for the later wrapper files. - * - * Why separate: this diverged from the discovery template before unification - * and is preserved here — reconciling the two is a follow-up. - */ -export function getZshOverlayEnvBody(zshDir: string): string { - return `__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_USER_ZDOTDIR"', - sourcedUserFileTest: '-f "$ORCA_ORIG_ZDOTDIR/.zshenv"' -})} - -export ZDOTDIR=${quotePosixSingle(zshDir)} -` -} - /** * Restores the worktree-scoped HISTFILE that macOS `/etc/zshrc` destroys. * + * Bash only. The zsh wrapper no longer needs this: it hands ZDOTDIR back before + * /etc/zshrc runs, so the value that file derives is already the user's own, and + * the scoped path is re-applied from a non-exported variable in the deferred + * hook. The `elif` below is inert under bash, where ZDOTDIR is normally unset. + * * That file assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no * check-before-set, and it runs before any wrapper file Orca controls — so the * injected value is already gone, and because ZDOTDIR still points at Orca's @@ -281,7 +126,7 @@ export ZDOTDIR=${quotePosixSingle(zshDir)} * cannot be inherited by anything the shell later spawns if it no longer exists * once it has been consumed. HISTFILE itself stays exported. */ -export const ZSH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then +export const BASH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then HISTFILE="$ORCA_HISTFILE" builtin unset ORCA_HISTFILE elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then @@ -293,33 +138,6 @@ elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then HISTFILE="\${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" fi` -export function getZshStartupFileSourceBlock(options: { - fileName: '.zprofile' | '.zshrc' | '.zlogin' - homeExpression?: string - interactiveOnly?: boolean - skipWhenHomeIsCurrentZdotdir?: boolean -}): string { - const homeExpression = options.homeExpression ?? '"${ORCA_ORIG_ZDOTDIR:-$HOME}"' - const checks = [ - options.skipWhenHomeIsCurrentZdotdir ? '"$_orca_home" != "$ZDOTDIR"' : null, - options.interactiveOnly ? '-o interactive' : null, - `-f "$_orca_home/${options.fileName}"` - ].filter(Boolean) - - return `__orca_resolve_user_config_dir ${homeExpression} -_orca_home="$_orca_resolved_config_dir" -if [[ ${checks.join(' && ')} ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/${options.fileName}" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi -` -} - // Why: zsh precmd fires before zle switches the PTY into line-editing mode, // so the marker must be emitted from zle-line-init. Registering it through // add-zle-hook-widget is unsafe: the azhw dispatcher aborts its hook chain @@ -369,12 +187,3 @@ export function getFishShellReadyInitCommand(escapedMarker: string): string { functions -e __orca_shell_ready_marker end` } - -export function getZshFinalZdotdirRestoreBlock(homeExpression = '"${ORCA_ORIG_ZDOTDIR:-$HOME}"') { - return `__orca_resolve_user_config_dir ${homeExpression} -# Why: after Orca's last wrapper file has loaded, the interactive shell should -# expose the same ZDOTDIR a normal zsh startup would expose. -export ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_home _orca_resolved_config_dir -` -} diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 6e6d10ab0ff..0fa1eadbe14 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -31,18 +31,17 @@ const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({ emitsStartupIdentity: true }) +// Why one zsh file: the wrapper hands ZDOTDIR back on its first lines, so zsh +// reads .zprofile, .zshrc and .zlogin from the user's own directory. const WRAPPER_FILES = [ ['zsh-zshenv', join('zsh', '.zshenv')], - ['zsh-zprofile', join('zsh', '.zprofile')], - ['zsh-zshrc', join('zsh', '.zshrc')], - ['zsh-zlogin', join('zsh', '.zlogin')], ['bash-rcfile', join('bash', 'rcfile')] ] as const const SNAPSHOT_DIR = join(__dirname, '__fixtures__', 'shell-wrapper-snapshots') -// Why: the wrapper root is a temp dir per run, and the baked ZDOTDIR literal is -// the only path-dependent byte in the output; pin it to a stable placeholder. +// Why still normalized: the zsh hook no longer bakes a wrapper path at all, but +// the bash rcfile can still carry one, and a temp root differs per run. function withStableRoot(content: string, root: string): string { return content.split(root).join('') } diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 741f892d734..566ce8ae4b1 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -1,63 +1,34 @@ /** - * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup. + * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup, and + * that the rest of Orca's startup features arrive with it. * * macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before every wrapper file Orca controls. So the - * value `injectHistoryEnv` put in the spawn env is already gone by the time the - * user reaches a prompt — and because ZDOTDIR still points at Orca's wrapper - * dir, the replacement lands inside it. Per-worktree history was therefore a - * silent no-op on the primary platform (#11044). + * check-before-set. Orca used to fight that by keeping its own ZDOTDIR in place + * across `/etc/zshrc` and repairing the damage afterwards (#11044). It now hands + * ZDOTDIR back before that file runs, so the value `/etc/zshrc` derives is the + * user's own path and the scoped one is re-applied from the deferred hook. * - * Only a real zsh can show this: the string the wrapper emits looks correct - * either way, and the whole bug lives in what /etc/zshrc does between the spawn - * env and the first prompt. + * Only a real zsh on a real PTY can show any of this: the wrapper text looks + * correct either way, the whole question is what `/etc/zshrc` does between the + * spawn env and the first prompt, and the hook is a `precmd` — which a shell + * started with `-c` never reaches. * * These tests drive the REAL launch decision (`selectShellStartupFeatures` + * `getShellLaunchConfig`) rather than an inline copy of the gate, so a pane that * Orca would not wrap cannot pass here by construction. */ -import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' +import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' -// Why probe and execute the same binary: guarding on `zsh` from PATH but then -// running a hardcoded `/bin/zsh` lets the guard pass on a host that installs zsh -// elsewhere, and the test fails for a missing binary rather than a wrapper -// defect. The absolute path is resolved once so the sandboxed PATH below cannot -// lose it. -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -function runZsh( - args: string[], - env: Record, - probe = 'echo "RESULT=$HISTFILE"' -): string { - // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) -} - -/** Both streams, because a wrapper that breaks does it on stderr. */ -function runZshCapturingStderr(args: string[], env: Record, probe: string): string { - const result = spawnSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) - return `${result.stdout ?? ''}${result.stderr ?? ''}` -} - /** * True when this host's system zshrc is the thing that destroys HISTFILE. * @@ -75,11 +46,7 @@ const systemZshrcClobbersHistfile = (() => { const output = execFileSync(ZSH_PATH, ['-l', '-i', '-c', 'echo "RESULT=$HISTFILE"'], { encoding: 'utf8', timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - HISTFILE: join(home, 'injected-history') - } + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: join(home, 'injected-history') } }) return !output.includes(join(home, 'injected-history')) } catch { @@ -91,314 +58,372 @@ const systemZshrcClobbersHistfile = (() => { const itWithClobber = systemZshrcClobbersHistfile ? itWithZsh : it.skip -/** The launch config Orca produces for a plain pane whose only Orca-owned - * concern is a worktree-scoped HISTFILE. */ -function launchPlainHistoryPane(home: string, scopedHistfile: string) { +/** The launch config Orca produces for a pane with exactly these features. */ +function launchPane( + home: string, + scopedHistfile: string | null, + overrides: Partial[0]> = {} +) { const env: Record = { HOME: home, - HISTFILE: scopedHistfile, - ORCA_HISTFILE: scopedHistfile + ...(scopedHistfile ? { HISTFILE: scopedHistfile, ORCA_HISTFILE: scopedHistfile } : {}) } const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, hasStartupCommand: false, waitsForShellReady: false, - emitsStartupIdentity: false + emitsStartupIdentity: false, + ...overrides }) const launch = getShellLaunchConfig(ZSH_PATH, features) return { features, launch, - env: { ...env, ...launch.env, ...sandboxConfigDir(home) } + // Why ORCA_ORIG_ZDOTDIR overridden: the launch config resolves the user's + // config dir from the real process env, and these runs must resolve against + // the sandbox home instead. + env: { PATH: '/usr/bin:/bin', ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home } } } -// Why override: the launch config resolves the user's config dir from the real -// process env, and these runs must resolve against the sandbox home instead. -function sandboxConfigDir(home: string): Record { - return { ORCA_ORIG_ZDOTDIR: home, ORCA_ZSHENV_SOURCE_DIR: home } +const USER_FILES = { + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' } -function withTempHome(run: (home: string) => void): void { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - try { - run(home) - } finally { - rmSync(home, { recursive: true, force: true }) +function withHome(files: Record, run: (home: string) => Promise) { + return async () => { + const home = makeZshHome(files) + try { + await run(home) + } finally { + rmSync(home, { recursive: true, force: true }) + } } } -const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' +describe.skipIf(process.platform === 'win32')( + 'worktree-scoped HISTFILE survives zsh startup', + () => { + itWithZsh( + 'wraps a plain pane once Orca injected a worktree HISTFILE', + withHome(USER_FILES, async (home) => { + const { features, launch } = launchPane(home, join(home, 'zsh_history')) -describe('worktree-scoped HISTFILE survives zsh startup', () => { - itWithZsh('wraps a plain pane once Orca injected a worktree HISTFILE', () => { - withTempHome((home) => { - const { features, launch } = launchPlainHistoryPane(home, join(home, 'zsh_history')) + // The whole reason wrapping widened: this pane has no overlay env and no + // startup command, so before #15258 nothing pointed it at a wrapper. + expect(features).toEqual(['history']) + expect(launch.env.ZDOTDIR).toBeTruthy() + expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') + }) + ) - // The whole bug: this pane has no overlay env and no startup command, so - // before this change nothing pointed it at a wrapper at all. - expect(features).toEqual(['history']) - expect(launch.env.ZDOTDIR).toBeTruthy() - expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') - }) - }) + itWithClobber( + 'keeps the injected path that the system zshrc would otherwise clobber', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - itWithClobber('keeps the injected path that the system zshrc would otherwise clobber', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) - const output = runZsh(launch.args ?? ['-l'], env) + expect(values.HISTFILE).toBe(scoped) + }) + ) - expect(histfileOf(output)).toBe(scoped) - }) - }) + itWithZsh( + 'hands ZDOTDIR back before the user’s own startup files load', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - itWithClobber('never leaves history inside Orca’s own wrapper directory', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - - const output = runZsh(launch.args ?? ['-l'], env) - - // The exact failure mode of #11044: history written into shell-ready/zsh. - expect(output).not.toContain(launch.env.ZDOTDIR) - }) - }) - - itWithClobber( - 'repairs the clobber for a shell that re-enters the wrapper with no features', - () => { - // Why: a non-interactive zsh runs .zshenv (which exports Orca's wrapper - // ZDOTDIR) but never the epilogue that restores it, so an interactive zsh - // started from there re-enters the wrapper with the feature channel already - // consumed. /etc/zshrc still lands HISTFILE inside the wrapper dir — #11044 - // with no per-worktree scoping involved — so the repair cannot be gated on - // a feature that no longer exists by then. - withTempHome((home) => { - const { launch } = launchPlainHistoryPane(home, join(home, 'orca-history', 'zsh_history')) - - const output = runZsh(launch.args ?? ['-l'], { - HOME: home, - ZDOTDIR: launch.env.ZDOTDIR, - ORCA_ORIG_ZDOTDIR: home + const { values } = await runZshPty({ + env, + report: [ + 'ZDOTDIR', + 'ORCA_TEST_USER_ZSHENV', + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC' + ] }) - expect(histfileOf(output)).toBe(join(home, '.zsh_history')) + // Each user file is read from the user's own dir, exactly as unwrapped. + expect(values.ORCA_TEST_USER_ZSHENV).toBe('1') + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ZDOTDIR).toBe(home) + expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR) }) - } - ) + ) - itWithZsh('runs the epilogue for a login shell whose .zshrc ends in `emulate sh`', () => { - // Why: zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh - // emulation, so such a login shell reads $HOME/.zlogin instead of the - // wrapper's. Everything the epilogue owns — the HISTFILE repair, the OSC 133 - // hooks, the readiness widget every startup command waits on — would be - // skipped entirely. - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const env: Record = { - HOME: home, - HISTFILE: scoped, - ORCA_HISTFILE: scoped - } - const launch = getShellLaunchConfig( - ZSH_PATH, - selectShellStartupFeatures({ - shellPath: ZSH_PATH, + itWithZsh( + 'never leaves history inside Orca’s own wrapper directory', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) + + // #11044's exact failure mode. It is now unreachable rather than repaired: + // /etc/zshrc derives HISTFILE from a ZDOTDIR that is already the user's. + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + }) + ) + + itWithZsh( + 'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) + + const { values } = await runZshPty({ env, - hasStartupCommand: true, - waitsForShellReady: true, - // Why off: the identity marker is printed with no trailing newline, so - // it would prefix the first probe line and defeat the parser. + report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + + // Root-cause fix for #11146: the variables no longer exist after use. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', + withHome(USER_FILES, async (home) => { + // Why compared against an unwrapped run rather than asserted non-empty: + // what zsh defaults to is platform-specific. macOS /etc/zshrc assigns + // HISTFILE, so it is always set there; a stock Ubuntu zsh leaves it EMPTY. + // The contract is that Orca's wrapper does not change it either way. + const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') } + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, emitsStartupIdentity: false }) - ) - writeFileSync(join(home, '.zshrc'), 'emulate sh\n') + const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = runZsh( - launch.args ?? ['-l'], - { ...env, ...launch.env, ...sandboxConfigDir(home) }, - 'echo "RESULT=$HISTFILE"; echo "WIDGET=[${widgets[zle-line-init]:-none}]"' - ) + const wrapped = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['HISTFILE'] + }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: ['HISTFILE'] + }) - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain('WIDGET=[user:__orca_prompt_mark]') - }) - }) + expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE) + }) + ) + } +) - itWithZsh('consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', () => { - withTempHome((home) => { +describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => { + itWithZsh( + 'emits the identity, readiness and OSC 133 markers a startup command waits on', + withHome(USER_FILES, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { env, features } = launchPane(home, scoped, { + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true + }) + expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity'])) - const output = execFileSync( - ZSH_PATH, - [...(launch.args ?? ['-l']), '-i', '-c', 'echo "LEAK=[${ORCA_HISTFILE:-}]"'], - { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } + const { output, values } = await runZshPty({ + env, + commands: ['true'], + report: ['HISTFILE'] + }) + + expect(output).toMatch(MARKERS.identity) + expect(output).toContain(MARKERS.ready) + expect(output).toContain(MARKERS.promptStart) + expect(output).toContain(MARKERS.commandStart) + expect(output).toMatch(MARKERS.commandDone) + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'restores Orca’s overlay values after the user’s config overwrites them', + withHome( + { + ...USER_FILES, + '.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n' + }, + async (home) => { + const overlayEnv = { + ORCA_CODEX_HOME: '/orca/codex', + ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim' } - ) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) - // Root-cause fix for #11146: the variable no longer exists after use. - expect(output).toContain('LEAK=[]') - }) - }) + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['CODEX_HOME', 'PATH'] + }) - itWithZsh('leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', () => { - // Why compared against an unwrapped run rather than asserted non-empty: what - // zsh defaults to is platform-specific. macOS `/etc/zshrc` assigns HISTFILE, - // so it is always set there; a stock Ubuntu zsh has no such file and leaves - // it EMPTY. The contract is that Orca's wrapper does not change it either - // way, which is the same assertion on both. - withTempHome((home) => { - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: { HOME: home, ORCA_CODEX_HOME: join(home, 'codex') }, - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false - }) - const launch = getShellLaunchConfig(ZSH_PATH, features) - const wrapped = runZsh(launch.args ?? ['-l'], { - HOME: home, - ORCA_CODEX_HOME: join(home, 'codex'), - ...launch.env, - ...sandboxConfigDir(home) - }) - const unwrapped = runZsh(['-l'], { HOME: home }) - - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(wrapped).not.toContain('ORCA_HISTFILE') - }) - }) + // The point of running last: the user's .zshrc set both of these after + // the spawn env did, and Orca's values still win. + expect(values.CODEX_HOME).toBe('/orca/codex') + expect(values.PATH.startsWith('/orca/shim:')).toBe(true) + } + ) + ) }) -const PROBE = - 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' - -describe('the wrapper survives a hostile user zsh config', () => { - // Why both forms: `REPLY` is zsh's shared scratch global, and the two ways a - // user config constrains it fail differently. `typeset -r` makes the - // wrapper's first assignment fatal and prints into the pane; `typeset -i` - // turns every resolved path into 0 with no error text at all. Both aborted - // the wrapper before the epilogue, leaving history inside Orca's own wrapper - // dir — on 100% of zsh panes, not the few percent wrapped before. - it.each(['typeset -r REPLY', 'typeset -i REPLY'])( - 'resolves the user config dir when the user .zshrc runs `%s`', - (declaration) => { +describe.skipIf(process.platform === 'win32')( + 'the wrapper survives a hostile user zsh config', + () => { + /** + * Why these three cases and not the old degrade matrix: zsh's `sourcehome()` + * ignores ZDOTDIR once the shell is in sh/ksh emulation, which used to hide + * every wrapper file after the one that entered it — so emulation from + * `.zshenv` or `.zprofile` cost the pane all of Orca's features. Only one + * wrapper file is read now, and it is read before any user file can change + * modes, so these are wins rather than degradations. + */ + it.each([ + ['.zshenv', 'emulate sh'], + ['.zshenv', 'emulate ksh'], + ['.zprofile', 'emulate sh'], + ['.zshrc', 'emulate sh'], + ['.zshrc', 'setopt no_unset'], + ['.zshrc', 'setopt ksharrays'] + ])('still scopes history when the user %s runs `%s`', async (file, statement) => { if (!hasZsh) { return } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshrc'), `${declaration}\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) - expect(output).toContain('ORCA_HISTFILE=[unset]') - expect(output).not.toContain('__orca_resolve_user_config_dir:') + const home = makeZshHome({ + ...USER_FILES, + [file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n` }) - } - ) + try { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - // Why these two files and not .zshrc: zsh's sourcehome() ignores ZDOTDIR once - // the shell is in sh/ksh emulation, so emulation entered from .zshenv or - // .zprofile hides EVERY later wrapper file — the epilogue runs zero times and - // can repair nothing. A .zshrc that does it is already covered by running the - // epilogue from the wrapper .zshrc, which /etc/zshrc has run before. - it.each([ - ['.zshenv', 'emulate sh'], - ['.zshenv', 'emulate ksh'], - ['.zprofile', 'emulate sh'] - ])('degrades to an unwrapped pane when the user %s runs `%s`', (file, statement) => { - if (!hasZsh) { - return - } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, file), `${statement}\n`) + const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] }) - const wrapped = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - const unwrapped = runZsh(['-l'], { HOME: home, HISTFILE: scoped }) - - // The bar: never worse off than the pane Orca did not wrap. What that is - // differs per host (macOS /etc/zshrc clobbers HISTFILE, stock Ubuntu does - // not), so it is read from an unwrapped run rather than hardcoded. - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(histfileOf(wrapped)).not.toContain(launch.env.ZDOTDIR) - expect(wrapped).toContain(`ZDOTDIR=[${home}]`) - // Nothing this pane spawns may inherit a history path no wrapper file - // will ever consume. - expect(wrapped).toContain('ORCA_HISTFILE=[unset]') + expect(values.HISTFILE).toBe(scoped) + expect(values.ORCA_HISTFILE).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } }) - }) -}) + + itWithZsh( + 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env, launch } = launchPane(home, scoped) + + const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] + const { values } = await runZshPty({ env, report }) + // Why compared against an unwrapped run rather than asserted to differ + // from the scoped path: whether the scoped value survives at all is the + // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it + // does not; a host with no such assignment keeps whatever the spawn env + // set. The contract on both is the same — this pane is the pane the user + // would have had unwrapped. + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, + report + }) + + expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook + // leaks nothing to the pane's children. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ZDOTDIR).toBe(home) + }) + ) + + itWithZsh( + 'loads the user config even when a startup file writes an unrelated ZDOTDIR', + withHome( + { ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` }, + async (home) => { + const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ + env, + report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC'] + }) + + // A ZDOTDIR the user's own .zshenv exports is theirs by construction and + // needs no discovery machinery: zsh reads .zshrc through it directly. + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + } + ) + ) + } +) /** - * Records every argument-less `emulate` — i.e. exactly the wrapper's emulation - * probe, and not the epilogue's `emulate -L zsh` — into a file the test reads. - * - * Why a shadowing function and not a timing assertion: the cost being removed - * is a fork, and a fork is countable where milliseconds are flaky. Placed in - * the user .zshenv so it is defined before all three probe sites. + * The relay writes its own variant of the hook: no OSC 133 (its bash rcfile owns + * those on remote hosts) and a remote CLI bin dir on PATH instead of the + * agent-teams shim. It used to have a whole second ZDOTDIR shape too, which is + * why it drifted from the desktop template; now the only differences are the + * spec flags, and this pins that the variant still works in a real shell. */ -function probeCounterConfig(logPath: string): string { - return [ - 'emulate() {', - ` (( $# == 0 )) && print -r -- probe >> ${JSON.stringify(logPath)}`, - ' builtin emulate "$@"', - '}' - ].join('\n') -} +describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => { + itWithZsh( + 'scopes history and restores the remote CLI path without emitting OSC 133', + withHome(USER_FILES, async (home) => { + const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-')) + try { + expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true) + const scoped = join(home, 'orca-history', 'zsh_history') -const probeCount = (logPath: string): number => - existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean).length : 0 + const { output, values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: join(relayRoot, 'zsh'), + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'overlay,history,ready', + ORCA_HISTFILE: scoped, + ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin' + }, + commands: ['true'], + report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE'] + }) -describe('the emulation probe forks only when it can change the answer', () => { - itWithZsh('never forks for a pane whose config stays in zsh emulation', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - // All three probe sites are gated on having sourced the matching user - // file, so every one of them has to exist for this to mean anything. - writeFileSync(join(home, '.zshenv'), probeCounterConfig(log)) - writeFileSync(join(home, '.zprofile'), '') - writeFileSync(join(home, '.zshrc'), '') - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBe(0) - // The pane still has to work: the saving is a fork, not a feature. - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) + expect(values.HISTFILE).toBe(scoped) + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true) + expect(output).toContain(MARKERS.ready) + // Remote panes get their command lifecycle from the bash rcfile, so the + // zsh variant must stay silent here. + expect(output).not.toContain(MARKERS.promptStart) + expect(output).not.toContain(MARKERS.commandStart) + } finally { + rmSync(relayRoot, { recursive: true, force: true }) + } }) - }) - - // Why kept exact rather than replaced by the option test: those options say - // nothing about `emulation`, which is what zsh's sourcehome() branches on. - itWithZsh('still forks to get the exact answer once a Bourne option is set', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshenv'), `${probeCounterConfig(log)}\nsetopt shwordsplit\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBeGreaterThan(0) - // shwordsplit alone is not emulation, so the pane stays wrapped. - expect(histfileOf(output)).toBe(scoped) - }) - }) + ) }) diff --git a/src/main/zsh-startup-hook-pty-harness.ts b/src/main/zsh-startup-hook-pty-harness.ts new file mode 100644 index 00000000000..a4de4e3bba9 --- /dev/null +++ b/src/main/zsh-startup-hook-pty-harness.ts @@ -0,0 +1,220 @@ +/** + * Drives a real zsh through a real PTY, to the first prompt and beyond. + * + * Why a PTY and not `zsh -i -c ''`: everything Orca owns now runs from a + * `precmd` hook, and `-c` never reaches a prompt, so `precmd` never fires. A + * probe run that way would report the wrapper doing nothing at all — for the + * right reason, at the wrong question. These tests have to reach a prompt to + * mean anything. + * + * Results come back through a file rather than stdout because a PTY echoes the + * command being typed, so matching on stdout matches the echo of the probe as + * readily as its output. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' + +export const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 + +export const ZSH_PATH = hasZsh + ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() + : '' + +/** OSC sequences the wrapper emits, as the terminal would receive them. */ +export const MARKERS = { + identity: /\]777;orca-shell-start:\d+/, + ready: ']777;orca-shell-ready', + promptStart: ']133;A', + commandStart: ']133;C', + commandDone: /\]133;D;\d+/ +} as const + +export type ZshPtyRun = { + /** Everything the terminal received, escape sequences intact. */ + output: string + /** `name=` pairs the probe wrote, parsed. */ + values: Record + /** + * True when the shell exited before reaching a prompt — what a user `.zshenv` + * that calls `exit` produces. A real outcome to compare, not a failure. + */ + exitedBeforePrompt: boolean +} + +export type ZshPtyOptions = { + env: Record + /** Shell variables to report, e.g. `['HISTFILE', 'ZDOTDIR']`. */ + report?: readonly string[] + /** Commands to run at the prompt before reporting. */ + commands?: readonly string[] + cwd?: string + timeoutMs?: number +} + +/** + * Why the value syntax is `NAME=<...>`: an unset variable and an empty one must + * be distinguishable, and the delimiters survive a terminal's line wrapping. + * + * Why the trailing `;` before `}`: a config that leaves the shell in sh + * emulation needs it, and without it zsh sits in a `cursh>` continuation + * waiting for the group to close — which reads exactly like a hung wrapper. + */ +function buildProbe(report: readonly string[], resultPath: string): string { + const prints = report.map((name) => `print -r -- "${name}=<\${${name}:-UNSET}>";`).join(' ') + return `{ ${prints} } > ${JSON.stringify(resultPath)}` +} + +function parseValues(resultPath: string): Record { + if (!existsSync(resultPath)) { + return {} + } + const values: Record = {} + for (const line of readFileSync(resultPath, 'utf8').split('\n')) { + const match = /^(\w+)=<(.*)>$/.exec(line.trim()) + if (match) { + values[match[1]] = match[2] + } + } + return values +} + +/** + * Runs an interactive login zsh under a PTY, waits for its first prompt, runs + * the requested commands, and reports the requested shell variables. + * + * Readiness is detected by a sentinel baked into PS1 rather than a fixed sleep, + * so a slow prompt framework makes the run slower, never flaky. + */ +export async function runZshPty(options: ZshPtyOptions): Promise { + const sentinel = '@@ORCA-PTY-READY@@' + const workDir = mkdtempSync(join(tmpdir(), 'orca-zsh-pty-')) + const resultPath = join(workDir, 'probe.txt') + const timeoutMs = options.timeoutMs ?? 20_000 + + const proc = pty.spawn(ZSH_PATH, ['-l', '-i'], { + name: 'xterm-256color', + cols: 200, + rows: 40, + cwd: options.cwd ?? workDir, + env: { + ...options.env, + // Why the sentinel is env-borne: the prompt is overwritten from the PTY + // below, and it has to survive whatever prompt the user's config installs. + ORCA_PTY_SENTINEL: sentinel + } + }) + + let output = '' + let answeredCompinit = false + let lastDataAt = Date.now() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + proc.onData((data) => { + output += data + lastDataAt = Date.now() + // Why this is answered rather than configured away: a host whose global + // zshrc runs `compinit` over directories it considers insecure — CI runners + // do — stops startup and ASKS, and a real PTY will sit at that question + // until the timeout. A pipe-backed `zsh -i -c` never saw it. ZSH_DISABLE_COMPFIX + // does not help: that is an oh-my-zsh convention, and plain `compinit` (which + // is what asks) ignores it. Answering keeps the shell on the path a user + // pressing `y` would take, and both arms of a comparison get the same + // treatment, so it cannot tilt one against the other. + if (!answeredCompinit && output.includes('Ignore insecure directories')) { + answeredCompinit = true + proc.write('y\r') + } + if (resolveReady && output.includes(sentinel)) { + resolveReady() + resolveReady = undefined + } + }) + let hasExited = false + const exited = new Promise((resolve) => { + proc.onExit(() => { + hasExited = true + resolve() + }) + }) + + let timer: ReturnType | null = null + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out waiting for the zsh prompt:\n${output}`)), + timeoutMs + ) + }) + + /** + * Resolves once the shell has produced nothing for `quietMs`, or exited. + * + * Why quiescence and not a fixed sleep before typing: startup output has to + * finish before the PS1 line is typed, or a shell still asking a question + * (compinit, above) eats it as the answer. A fast host waits milliseconds; a + * slow prompt framework waits as long as it needs. + */ + async function waitForQuiet(quietMs: number): Promise { + while (!hasExited) { + const idleFor = Date.now() - lastDataAt + if (idleFor >= quietMs) { + return + } + await new Promise((resolve) => setTimeout(resolve, quietMs - idleFor)) + } + } + + try { + await Promise.race([waitForQuiet(250), exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + // Why the prompt is replaced rather than parsed: it only has to carry the + // sentinel from the SECOND prompt on — the first is where the deferred hook + // does its work, and that has already happened by now. + // + // Why PS1 and not PROMPT: a config that leaves the shell in sh emulation + // renders PS1, where PROMPT is just an ordinary variable. In zsh's own mode + // the two name the same parameter, so PS1 covers both. + proc.write(`PS1="$ORCA_PTY_SENTINEL"\r`) + // Why `exited` is raced here too: a user .zshenv that calls `exit` never + // reaches a prompt, and that is an outcome worth comparing rather than a + // twenty-second timeout. + await Promise.race([ready, exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + for (const command of options.commands ?? []) { + proc.write(`${command}\r`) + } + if (options.report?.length) { + proc.write(`${buildProbe(options.report, resultPath)}\r`) + } + proc.write('exit\r') + await Promise.race([exited, timedOut]) + return { output, values: parseValues(resultPath), exitedBeforePrompt: false } + } finally { + if (timer !== null) { + clearTimeout(timer) + } + try { + proc.kill() + } catch { + // Already exited normally. + } + rmSync(workDir, { recursive: true, force: true }) + } +} + +/** Writes a throwaway $HOME with the given zsh startup files. */ +export function makeZshHome(files: Record): string { + const home = mkdtempSync(join(tmpdir(), 'orca-zsh-home-')) + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(home, name), content) + } + return home +} diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts new file mode 100644 index 00000000000..e5e45d2c22a --- /dev/null +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -0,0 +1,492 @@ +/** + * Real-zsh proof that a wrapped pane resolves the user's zsh config to exactly + * what an unwrapped pane would, for every odd or hostile `.zshenv` shape. + * + * These cases were previously asserted one expected value at a time against + * `ORCA_ORIG_ZDOTDIR` — the output of Orca's own shell-side ZDOTDIR discovery. + * That discovery is gone: the wrapper hands ZDOTDIR back on its first lines and + * zsh resolves the rest natively, so there is no Orca-computed value left to + * assert on. The contract those tests were really protecting is the one below, + * and stated as an equivalence it is stricter — it pins the wrapped pane to + * whatever the host's own zsh does, including on hosts where that differs, + * rather than to a value hardcoded here. + * + * Each case runs twice on a real PTY, once wrapped and once not, and the two + * must agree on where the config came from and what it exported. + */ +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { getShellLaunchConfig } from './providers/local-pty-shell-ready' +import { selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip + +/** What both arms must agree on: where config came from, and what it exported. */ +const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const + +/** + * Every case writes `$HOME/.zshenv`. `.zshrc` is written into whichever dir the + * case points ZDOTDIR at, so "did the right .zshrc load" is observable. + */ +type ConfigCase = { + name: string + /** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */ + setup: (home: string) => string +} + +const CASES: ConfigCase[] = [ + { + name: 'no ZDOTDIR at all', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n') + return home + } + }, + { + name: 'ZDOTDIR exported to an XDG dir', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR set by a file the .zshenv sources', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + const common = join(home, '.config', 'shell', 'common.sh') + mkdirSync(dirname(common), { recursive: true }) + writeFileSync(common, `export ZDOTDIR="${dir}"\n`) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR with spaces in the path', + setup: (home) => { + const dir = join(home, 'My Config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR set more than once', + setup: (home) => { + const first = join(home, 'first') + const dir = join(home, 'second') + mkdirSync(first, { recursive: true }) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR written with a trailing slash', + setup: (home) => { + const dir = join(home, 'trailing') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR pointing at a directory that does not exist', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n` + ) + return home + } + }, + { + name: 'ZDOTDIR set to the empty string', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n') + return home + } + }, + { + name: 'ZDOTDIR explicitly set to $HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n') + return home + } + }, + { + name: 'a .zshenv with a syntax error', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n') + return home + } + }, + { + name: 'a .zshenv running set -u before anything else', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n') + return home + } + }, + { + name: 'a .zshenv running set -e with a failing command', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n') + return home + } + }, + { + name: 'a .zshenv setting extendedglob and nullglob', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n' + ) + return home + } + }, + { + name: 'a .zshenv that unsets HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n') + return home + } + }, + { + name: 'ZDOTDIR containing only slashes', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n') + return home + } + }, + { + name: 'a whitespace-only ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n' + ) + return home + } + }, + { + name: 'ZDOTDIR with a single quote in the path', + setup: (home) => { + const dir = join(home, "it's zsh") + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n` + ) + return dir + } + }, + { + name: 'a .zshenv that conditionally unsets ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n' + ) + return home + } + }, + { + name: 'a .zshenv using typeset -U path at top level', + setup: (home) => { + // Why this one matters: `path` is a top-level-only construct, so it also + // proves the user's .zshenv is sourced in the wrapper's own scope rather + // than inside a function or subshell. + writeFileSync( + join(home, '.zshenv'), + 'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n' + ) + return home + } + }, + { + name: 'a .zshenv defining a function and extending fpath', + setup: (home) => { + const fns = join(home, 'fns') + mkdirSync(fns, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n` + ) + return home + } + }, + { + name: 'a .zshenv that calls exit', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n') + return home + } + } +] + +function wrappedEnv(home: string): Record { + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ORCA_HISTFILE: join(home, 'scoped_history') }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) + // Why ORCA_ORIG_ZDOTDIR is dropped rather than pinned to the sandbox home: + // these cases are about a user who has no inherited ZDOTDIR, so the pane must + // resolve purely from HOME — and Orca must not invent a ZDOTDIR for it. The + // launch config computes this one from the real process env, which would + // otherwise leak the developer's own ZDOTDIR into the run. + const { ORCA_ORIG_ZDOTDIR: _dropped, ...env } = launch.env + return { + PATH: '/usr/bin:/bin', + HOME: home, + ORCA_HISTFILE: join(home, 'scoped_history'), + ...env + } +} + +describe.skipIf(process.platform === 'win32')( + 'a wrapped pane resolves the user config exactly as an unwrapped one does', + () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))( + 'matches unwrapped zsh for %s', + async (_name, testCase) => { + if (!hasZsh) { + return + } + const home = makeZshHome({}) + try { + const zshrcDir = testCase.setup(home) + mkdirSync(zshrcDir, { recursive: true }) + writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n') + + const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: REPORTED + }) + + expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt) + for (const key of REPORTED) { + expect( + wrapped.values[key], + `${key} differs between a wrapped and an unwrapped pane` + ).toBe(unwrapped.values[key]) + } + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + } +) + +/** + * Regressions the four-file wrapper was built to fix, re-pinned against the one + * that replaced it. Each names the change that introduced the behaviour, because + * "the machinery is gone" is only a good answer if the reason it existed is gone + * with it. + */ +describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => { + let userDataPath = '' + let previousUserDataPath: string | undefined + + beforeAll(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-regression-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterAll(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + /** + * Generates the tree, then points ZDOTDIR at it under a different root. + * + * Why the full spawn env and not a bare ZDOTDIR: "the user's .zshrc loaded" is + * equally true of a pane that never read the wrapper at all, so the run has to + * be able to show the wrapper ran. ORCA_SHELL_FEATURES coming back consumed is + * that proof — only the wrapper's own .zshenv unsets it. + */ + async function runFromRelocatedRoot(home: string, movedRoot: string) { + const env = wrappedEnv(home) + const relocated = env.ZDOTDIR.replace(userDataPath, movedRoot) + expect(relocated, 'the relocated ZDOTDIR should differ from the generated one').not.toBe( + env.ZDOTDIR + ) + renameSync(userDataPath, movedRoot) + try { + return await runZshPty({ + env: { ...env, ZDOTDIR: relocated }, + report: ['ORCA_TEST_FROM_ZSHRC', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + } finally { + if (existsSync(movedRoot)) { + renameSync(movedRoot, userDataPath) + } + } + } + + itWithZsh( + 'loads the user .zshrc when the wrapper is sourced from a relocated path (#8003)', + async () => { + // Why relocation: on Windows+WSL the wrappers are generated with a Windows + // path but sourced via /mnt/c, so the generation-time path is absent at + // runtime. The old wrapper baked that path in as a ZDOTDIR fallback and had + // to re-derive the real one from `%x` to avoid using it; this one bakes no + // path, so the split cannot arise. Renaming the root reproduces it. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot(home, `${userDataPath}-wsl-view`) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + // The wrapper really was read from the relocated path. + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + + itWithZsh('loads the user .zshrc from a non-ASCII wrapper path (#8003)', async () => { + // Why non-ASCII: a Korean Windows login puts UTF-8 bytes in zsh's 0x84-0x9D + // token range, and zsh corrupts environment values containing them while + // processing startup files. That corrupted the env-imported $ZDOTDIR, the + // wrapper's self-check failed, and it fell back to the unusable baked path — + // a bare prompt with none of the user's config. Nothing is baked now, and a + // value this wrapper cannot use degrades to $HOME, where zsh itself looks. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot( + home, + join(dirname(userDataPath), '홍길동-wsl-view') + ) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => { + // Why it mattered: user startup files resolve plugin and theme paths from + // $ZDOTDIR, so sourcing them with Orca's dir in place sent those lookups into + // the wrapper. The old wrapper swapped ZDOTDIR around each source; this one + // never takes it away, so each file sees what it would see unwrapped. + const home = makeZshHome({}) + const xdg = join(home, '.config', 'zsh') + mkdirSync(xdg, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`) + writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n') + writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n') + try { + const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg) + expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg) + expect(wrapped.values).toEqual(unwrapped.values) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => { + // Why the shell checks this and not only Node: the launch config sets + // ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane also inherits + // its parent's environment, so a stale value written by an older build can + // arrive on its own — a route the Node-side check never sees. Handing that + // back would point ZDOTDIR at a wrapper dir, which is the self-loop the + // ownership check exists to prevent. Identification stays positive: a stamped + // marker file, or Orca's own path shape for wrappers older builds wrote. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + const foreign = join(home, 'other-terminal', 'zsh') + mkdirSync(foreign, { recursive: true }) + writeFileSync(join(foreign, '.zshrc'), 'export ORCA_TEST_FROM_WRAPPER_DIR=1\n') + writeFileSync(join(foreign, ZSH_WRAPPER_DIR_MARKER_FILE), '') + try { + const { values } = await runZshPty({ + env: { ...wrappedEnv(home), ORCA_ORIG_ZDOTDIR: foreign }, + report: ['ZDOTDIR', 'ORCA_TEST_FROM_ZSHRC', 'ORCA_TEST_FROM_WRAPPER_DIR'] + }) + + // Rejected, so zsh falls back to $HOME and the user's own config loads. + expect(values.ZDOTDIR).toBe('UNSET') + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_TEST_FROM_WRAPPER_DIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => { + // Why this closes #11044's plain shape rather than repairing it: that bug was + // a nested zsh inheriting Orca's ZDOTDIR, so /etc/zshrc derived HISTFILE + // inside the wrapper dir. A pane can no longer hand any child a ZDOTDIR that + // is Orca's, because it does not have one itself past the first few lines. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runZshPty({ + env: wrappedEnv(home), + commands: [ + 'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"', + 'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"' + ], + report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR'] + }) + + // Neither channel survives into a child, and no ZDOTDIR of Orca's does. + // `UNSET` here is the probe's rendering of an empty capture, i.e. `env` + // printed no ZDOTDIR line at all. + expect(values.ORCA_CHILD_ENV).toBe('0') + expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('survives a user .zshenv that returns early (#1947)', async () => { + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n', + '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' + }) + try { + const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values).toEqual(unwrapped.values) + expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index a337b186a4d..0a2bb9c8026 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,41 +1,42 @@ /** - * The single source of the zsh startup wrapper files (.zshenv/.zprofile/.zshrc/ - * .zlogin) Orca writes for every transport: local PTY, daemon/SSH, and relay. + * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, + * and relay. * - * Why: the three generators were copies that drifted apart, so a fix landed in - * one transport and silently missed the other two. Everything they genuinely - * disagree on is a field on ZshStartupWrapperSpec, so the disagreements are - * visible in one place instead of spread across three template literals. + * Orca needs to run code AFTER the user's own zsh startup files. The old shape + * bought that by keeping ZDOTDIR pointed at Orca's wrapper dir for the whole of + * startup and sourcing each user file by hand — four generated files, and a + * fake ZDOTDIR live while `/etc/zshrc` ran. That one decision was the root of a + * whole bug family: `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` + * unconditionally, so history landed inside Orca's own dir (#11044); zsh's + * `sourcehome()` ignores ZDOTDIR once the shell enters sh/ksh emulation, so a + * user file ending in `emulate sh` hid every later wrapper file; and one wrapper + * dir shared by two installed builds could mix files from both. * - * Every generated file redefines the helpers it calls, so the epilogue is the - * only thing one file needs another to have defined — see EPILOGUE_CALL. + * This shape gives ZDOTDIR back before anything else can observe it, then defers + * Orca's work to a `precmd` hook that runs at the first prompt — after + * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads + * from the user's own directory exactly as in an unwrapped shell. #11044 becomes + * unreachable rather than repaired, and the emulation and mixed-build classes + * stop existing. * - * All order-sensitive Orca work lives in ONE `__orca_shell_epilogue` defined in - * .zshenv and invoked exactly once — from .zshrc for a non-login shell, from - * .zlogin for a login shell. Each feature inside it is an independent guard on - * the allowlist snapshotted (and destroyed) at the top of .zshenv, so a pane - * wrapped only for one feature runs only that feature's code — except the - * HISTFILE repair, which undoes damage the wrapper's own ZDOTDIR caused and so - * must also run for a shell that re-entered the wrapper with no allowlist. + * ORDER IS LOAD-BEARING: every function is defined ABOVE the `source` of the + * user's `.zshenv`. A user `.zshenv` ending in `emulate sh` puts the rest of + * this file under sh parsing rules, and zsh-only syntax then fails to parse, + * taking the whole wrapper with it. Function bodies are parsed at definition + * time, so defining them first makes them immune; `emulate -L zsh` inside the + * hook restores zsh option semantics for the body at call time. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { - getZshEnvDiscoveryBody, - getZshEmulationDegradeBlock, - getZshFinalZdotdirRestoreBlock, - getZshOverlayEnvBody, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_BOURNE_EMULATION_OPTION_HINT, ZSH_FEATURE_CHANNEL_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK + ZSH_USER_ZSHENV_SOURCE_BLOCK, + ZSH_ZDOTDIR_HANDBACK_BLOCK } from './shell-templates' -/** Runtime values the wrapper re-exports after the user's own startup files ran. */ +/** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean @@ -47,36 +48,17 @@ export type ZshWrapperRestoreSpec = { codexLaunchPreflight: boolean } -export type ZshStartupWrapperSpec = { - /** First line of every generated file, e.g. `# Orca zsh shell-ready wrapper`. */ +export type ZshStartupHookSpec = { + /** First line of the generated file, e.g. `# Orca zsh shell-ready wrapper`. */ headerLabel: string - /** Wrapper ZDOTDIR baked into .zshenv as the fallback literal. */ - zshDir: string - /** - * How .zshenv finds the user's real ZDOTDIR. `discover-user-zdotdir` sources - * the user .zshenv and reads what it exported; `overlay-user-zdotdir` trusts - * the inherited ZDOTDIR and republishes it as ORCA_USER_ZDOTDIR. - */ - zshenvStrategy: 'discover-user-zdotdir' | 'overlay-user-zdotdir' - /** zsh expression the wrapper resolves the user's startup-file dir from. */ - homeExpression?: string readyMarkerEscaped: string /** OSC 133 command-lifecycle hooks (behind the `markers` feature). */ osc133CommandMarkers: boolean - /** Skip the user .zshrc when its dir is already the wrapper ZDOTDIR. */ - skipUserZshrcWhenHomeIsWrapperDir: boolean - /** Comment heading the overlay restores inside the epilogue. */ + /** Comment heading the overlay restores inside the hook. */ overlayRestoreComment: string restores: ZshWrapperRestoreSpec } -export type ZshStartupWrapperFiles = { - zshenv: string - zprofile: string - zshrc: string - zlogin: string -} - const AGENT_TEAMS_PATH_RESTORE_BLOCK = `__orca_restore_agent_teams_path() { [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 case "$PATH" in @@ -92,29 +74,25 @@ const REMOTE_CLI_BIN_DIR_RESTORE = `[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && const CODEX_HOME_RESTORE = `# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"` -const ZSH_OSC133_COMMAND_MARKER_BLOCK = `__orca_osc133_precmd() { +/** + * The OSC 133 hooks, defined at top level so their bodies are parsed before the + * user's `.zshenv` can change the parsing mode. + */ +const ZSH_OSC133_FUNCTION_BLOCK = `__orca_osc133_precmd() { local exit_code=$? if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command + builtin printf "\\033]133;D;%s\\007" "$exit_code" + builtin unset __orca_in_command fi - printf "\\033]133;A\\007" + builtin printf "\\033]133;A\\007" } __orca_osc133_preexec() { - printf "\\033]133;C\\007" + builtin printf "\\033]133;C\\007" # Why typeset -g: a plain assignment here creates a global inside a function, # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 -} -# Why: prepend so Orca captures $? before user prompt hooks can overwrite it. -precmd_functions=(__orca_osc133_precmd \${precmd_functions[@]}) -preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})` + builtin typeset -g __orca_in_command=1 +}` -/** - * Blocks already carrying a trailing newline (the omp wrapper, the codex - * preflight, the shared source/restore templates) keep it, so joining on a - * single newline reproduces the blank-line spacing of the originals. - */ function joinBlocks(blocks: (string | null)[]): string { return blocks.filter((block): block is string => block !== null).join('\n') } @@ -126,7 +104,7 @@ function indentBlock(block: string, indent: string): string { .join('\n') } -/** One epilogue feature: `if __orca_has_feature ; then ... fi`. */ +/** One hook feature: `if __orca_has_feature ; then ... fi`. */ function featureGuard(name: string, body: (string | null)[]): string | null { const blocks = body.filter((block): block is string => block !== null) if (blocks.length === 0) { @@ -136,7 +114,7 @@ function featureGuard(name: string, body: (string | null)[]): string | null { } /** The env/PATH restores that must outlast the user's own startup files. */ -function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] { +function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { return [ spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, @@ -150,122 +128,66 @@ function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] } /** - * Every Orca-owned, order-sensitive step, in one function called once. - * - * Why a function in .zshenv rather than inline code in .zshrc/.zlogin: login and - * non-login shells load a different last file, and duplicating the body in both - * is how the two copies drifted before. The once-flag makes a double invocation - * (re-sourced rc files) a no-op rather than a double-registered prompt hook. + * Everything Orca owns that must run after the user's config, in one function + * invoked from the first prompt's precmd sweep and then retired. */ -function buildEpilogue(spec: ZshStartupWrapperSpec): string { - return `__orca_shell_epilogue() { +function buildDeferredInit(spec: ZshStartupHookSpec): string { + // Why substitute in the markers case and remove otherwise: OSC 133 needs a + // permanent precmd, so swapping this hook for it keeps the array position the + // user's own hooks were registered around. With no permanent hook to leave + // behind, removing is what keeps a history-only pane observably identical to + // the unwrapped pane it was — no stray Orca name in `precmd_functions`. + // Verified on zsh 5.9 that self-removal mid-sweep skips no later hook, from + // the head, the middle and the tail of the array. + const permanentPrecmd = spec.osc133CommandMarkers + ? ` if __orca_has_feature markers; then + precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) + else + precmd_functions=(\${precmd_functions:#__orca_deferred_init}) + fi` + : ` precmd_functions=(\${precmd_functions:#__orca_deferred_init})` + + return `__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions +${permanentPrecmd} ${joinBlocks([ featureGuard('overlay', getOverlayRestoreBlocks(spec)), - // Why ungated: this repairs damage Orca's own ZDOTDIR caused, so it must also - // run for a shell that re-enters the wrapper with no feature channel left - // (a nested zsh under an inherited wrapper ZDOTDIR) — the plain #11044 shape. - indentBlock(ZSH_HISTFILE_RESTORE_BLOCK, ' '), - featureGuard('markers', [spec.osc133CommandMarkers ? ZSH_OSC133_COMMAND_MARKER_BLOCK : null]), - featureGuard('ready', [getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped)]) + // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file + // ran, so the value it derives is the user's own path. #11044 is unreachable. + ` if [[ -n "\${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" + fi`, + featureGuard('ready', [ + indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped), '') + ]) ])} -${indentBlock(getZshFinalZdotdirRestoreBlock(spec.homeExpression).replace(/\n$/, ''), ' ')} - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir +${ + spec.osc133CommandMarkers + ? ` # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd\n` + : '' +} builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature }` } -function buildZshenv(spec: ZshStartupWrapperSpec): string { +export function buildZshStartupHook(spec: ZshStartupHookSpec): string { return `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_ZDOTDIR_HANDBACK_BLOCK, ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - spec.zshenvStrategy === 'discover-user-zdotdir' - ? getZshEnvDiscoveryBody(spec.zshDir) - : getZshOverlayEnvBody(spec.zshDir), - buildEpilogue(spec) + spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + buildDeferredInit(spec), + ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` } - -/** - * Why guarded: one wrapper dir can be shared by two concurrently installed Orca - * builds, so a shell can read one build's .zshenv and another's .zshrc/.zlogin. - * The epilogue is the only cross-file dependency left — every other function - * these files call is redefined at the top of each of them — so an older .zshenv - * costs this shell the epilogue's features and nothing else, without printing - * "command not found" into the user's pane. Braced subscript because sh/ksh - * emulation (KSH_ARRAYS) rejects the unbraced `$+functions[...]` form outright. - */ -const EPILOGUE_CALL = '(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue' - -/** - * A login shell normally runs the epilogue from .zlogin, after the user's own - * .zlogin. The exception: zsh's `sourcehome()` ignores ZDOTDIR once the shell is - * in sh/ksh emulation, so a user .zshrc ending in `emulate sh` makes zsh read - * $HOME/.zlogin and never the wrapper's — no OSC 133 hooks, no ready widget, and - * HISTFILE left pointing inside the wrapper dir. Running the epilogue here - * instead means the user's own .zlogin can undo its overlay restores, which - * beats not running it at all. An older zsh whose `emulate` has no query form - * prints nothing, fails the comparison, and runs it here too; the epilogue's - * once-flag makes the later .zlogin call a no-op. - * - * Of the wrapper's three emulation probes this is the costliest — it forks a - * zsh that has just finished loading the user's whole .zshrc — so - * ZSH_BOURNE_EMULATION_OPTION_HINT skips it for any shell not in Bourne - * emulation. Braces because `A || B && C` groups as `(A || B) && C` in a shell. - */ -const ZSHRC_EPILOGUE_INVOCATION = `if [[ ! -o login ]] || { ${ZSH_BOURNE_EMULATION_OPTION_HINT} && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - ${EPILOGUE_CALL} -fi` - -export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshStartupWrapperFiles { - return { - zshenv: buildZshenv(spec), - zprofile: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zprofile', - homeExpression: spec.homeExpression - }), - // Why here too: a user .zprofile ending in `emulate sh` hides .zshrc and - // .zlogin from the wrapper exactly as a user .zshenv does, and .zprofile - // is the last wrapper file that still runs before /etc/zshrc clobbers - // HISTFILE — so this is the last point where degrading cleanly is possible. - getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$_orca_home"', - sourcedUserFileTest: '-f "$_orca_home/.zprofile"' - }) - ])}\n`, - zshrc: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: spec.homeExpression, - interactiveOnly: true, - skipWhenHomeIsCurrentZdotdir: spec.skipUserZshrcWhenHomeIsWrapperDir - }), - ZSHRC_EPILOGUE_INVOCATION - ])}\n`, - zlogin: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: spec.homeExpression, - interactiveOnly: true - }), - EPILOGUE_CALL - ])}\n` - } -} diff --git a/src/main/zsh-wrapper-dir-ownership.ts b/src/main/zsh-wrapper-dir-ownership.ts index 6eebe5b6132..0567212d516 100644 --- a/src/main/zsh-wrapper-dir-ownership.ts +++ b/src/main/zsh-wrapper-dir-ownership.ts @@ -43,15 +43,22 @@ function usableInheritedZdotdir(value: string | undefined): string | null { return value } -export function resolveInheritedZdotdir(env: EnvLike, homeFallback = ''): string { +/** + * The ZDOTDIR the user genuinely has, or null. + * + * Why null rather than a $HOME fallback: the wrapper hands this value straight + * back to the shell, and a user with no ZDOTDIR must end up with none — not with + * one Orca invented. `ZDOTDIR=$HOME` and an unset ZDOTDIR look identical to zsh + * when it reads startup files, but they are different environments for + * everything the pane goes on to launch. + */ +export function resolveInheritedZdotdir(env: EnvLike): string | null { return ( - usableInheritedZdotdir(env.ZDOTDIR) ?? - usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? - env.HOME ?? - homeFallback + usableInheritedZdotdir(env.ZDOTDIR) ?? usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? null ) } -export function resolveInheritedZshenvSourceDir(env: EnvLike, homeFallback = ''): string { - return usableInheritedZdotdir(env.ZDOTDIR) ?? env.HOME ?? homeFallback +/** Spawn-env entry for the wrapper's ZDOTDIR handback; absent when there is none. */ +export function inheritedZdotdirEnv(inherited: string | null): Record { + return inherited ? { ORCA_ORIG_ZDOTDIR: inherited } : {} } diff --git a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts index af88295c7e6..796dc290b37 100644 --- a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts +++ b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts @@ -1,67 +1,107 @@ /** - * Real-zsh proof that a wrapper dir written by two different Orca builds still + * Real-zsh proof that a wrapper dir holding files from two Orca builds still * loads the user's own zsh config. * - * One wrapper dir (`/shell-ready/zsh`, or `~/.orca-relay/shell-ready/ - * zsh` for a remote host) is shared by every concurrently installed build, and - * each rewrites it on spawn. A shell can therefore read one build's `.zshenv` - * and another's `.zprofile`/`.zshrc`/`.zlogin`. Only a real zsh shows the cost: - * a helper the newer files call but the older `.zshenv` never defined both - * prints `command not found` into the pane AND leaves `$REPLY` empty, which - * silently skips sourcing the user's own startup files. + * A shared dir used to mean a shell could read one build's `.zshenv` and + * another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file + * redefined the helpers it called. #15285 removed the hazard for the desktop and + * daemon trees by naming each one after a hash of its contents, so two builds + * never write the same directory. + * + * The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready` + * — so this is where the scenario is still reachable, and it is now much smaller: + * Orca writes one file, and that file hands ZDOTDIR back before anything else + * runs. Both halves are pinned here: + * + * 1. Files an older build left beside the hook are inert — zsh reads .zprofile, + * .zshrc and .zlogin from the user's own directory and never from here. + * 2. Generation does not delete them. An older build checks for all four before + * calling its tree complete; deleting them makes it rewrite its own `.zshenv` + * over ours, and an older `.zshenv` points ZDOTDIR at a directory that would + * then hold no `.zshrc` at all — the user losing their entire config, which is + * the failure this file exists to prevent. */ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' +import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness' -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -/** - * A `.zshenv` from a build that predates everything the other three files now - * call: it exports the wrapper ZDOTDIR and nothing else. - */ -function olderBuildZshenv(zshDir: string): string { - return `# Orca zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="$HOME" -export ZDOTDIR=${JSON.stringify(zshDir)} -` +/** The three files an older build wrote alongside its own `.zshenv`. */ +const OLDER_BUILD_FILES = { + '.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n', + '.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n' } describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => { - itWithZsh('still sources the user config when the .zshenv is from an older build', () => { + itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-')) - const home = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-home-')) + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' + }) try { - expect(ensureShellReadyWrappersAt(root)).toBe(true) + expect(ensureOverlayRestoreWrappers(root)).toBe(true) const zshDir = join(root, 'zsh') - writeFileSync(join(zshDir, '.zshenv'), olderBuildZshenv(zshDir)) - for (const file of ['.zprofile', '.zshrc', '.zlogin']) { - writeFileSync(join(home, file), `echo "RAN ${file}"\n`) + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) } - const result = spawnSync(ZSH_PATH, ['-l', '-i', '-c', 'exit 0'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: zshDir } + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: zshDir, + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'history', + ORCA_HISTFILE: join(home, 'scoped_history') + }, + report: [ + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC', + 'ORCA_TEST_STALE_ZPROFILE', + 'ORCA_TEST_STALE_ZSHRC', + 'ORCA_TEST_STALE_ZLOGIN', + 'HISTFILE' + ] }) - // Why both streams: zsh prints `command not found` on stderr and the - // fixture files echo on stdout, and this asserts about each. - const output = `${result.stdout}${result.stderr}` - expect(output).not.toContain('command not found') - expect(output).toContain('RAN .zprofile') - expect(output).toContain('RAN .zshrc') - expect(output).toContain('RAN .zlogin') + // The user's own files loaded; the older build's leftovers did not. + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) } finally { rmSync(root, { recursive: true, force: true }) rmSync(home, { recursive: true, force: true }) } }) + + it('leaves an older build’s files in place so that build can still use them', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-')) + try { + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + const zshDir = join(root, 'zsh') + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) + } + + // A second generation pass is what an older build's launch would race. + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + expect(existsSync(join(zshDir, name))).toBe(true) + expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c1bd4365a50..92ae2437f76 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -59,17 +59,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describe('isRelayWslShell', () => { it.each(['wsl.exe', 'WSL.EXE', 'C:\\Windows\\System32\\wsl.exe', 'wsl'])( 'recognizes %s on a Windows relay', @@ -111,20 +100,17 @@ describe('getRelayShellLaunchConfig', () => { expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - const userZdotdirResolution = - '__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - expect(zshenv).toContain('export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"') - const zprofile = readFileSync(join(zshRoot, '.zprofile'), 'utf8') - const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') - const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') - expect(zprofile).toContain(userZdotdirResolution) - expect(zshrc).toContain(userZdotdirResolution) - expect(zlogin).toContain(userZdotdirResolution) - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') + // Why no ORCA_USER_ZDOTDIR: the relay used to republish the inherited + // ZDOTDIR under that name so its three later wrapper files could prefer it + // over the spawn-time value. There are no later wrapper files, and a + // ZDOTDIR the user's own .zshenv exports simply stands. + expect(zshenv).not.toContain('ORCA_USER_ZDOTDIR') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(zshRoot, name))).toBe(false) + } // Why .zshenv: the final restore is the last step of the one epilogue. - expectFinalZdotdirRestoreContext(zshenv) } ) @@ -222,7 +208,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"' + 'builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"' ) }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 58ab9ab75e4..f617903056b 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -6,7 +6,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../main/shell-startup-features' -import { resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -134,7 +134,7 @@ export function getRelayShellLaunchConfig( return { args: POSIX_LOGIN_ARGS, env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(env, process.env.HOME ?? ''), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(env)), ZDOTDIR: join(root, 'zsh'), ...featureEnv }, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 197f900268b..ca7867c98e2 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -5,15 +5,12 @@ import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, + BASH_HISTFILE_RESTORE_BLOCK, ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../main/shell-templates' import { writeShellWrapperFiles } from '../main/shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../main/zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../main/zsh-startup-wrapper-builder' /** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. * Split from pty-shell-launch.ts so the launch-config decisions stay readable @@ -21,19 +18,16 @@ import { const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' -// Why: the relay .zshenv republishes the inherited ZDOTDIR as ORCA_USER_ZDOTDIR, -// so later wrapper files prefer it over the spawn-time ORCA_ORIG_ZDOTDIR. -const RELAY_HOME_EXPRESSION = '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - -function getRelayZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +// Why the relay no longer needs its own ZDOTDIR shape: it used to republish the +// inherited value as ORCA_USER_ZDOTDIR so the later wrapper files could prefer +// it over the spawn-time ORCA_ORIG_ZDOTDIR. There are no later wrapper files +// now, and ZDOTDIR itself carries the answer, so the relay and desktop bodies +// are one template again. +function getRelayZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca relay zsh overlay wrapper', - zshDir, - zshenvStrategy: 'overlay-user-zdotdir', - homeExpression: RELAY_HOME_EXPRESSION, readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: false, - skipUserZshrcWhenHomeIsWrapperDir: false, overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { @@ -50,7 +44,7 @@ export function ensureOverlayRestoreWrappers(root: string): boolean { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') - const zsh = buildZshStartupWrapperFiles(getRelayZshWrapperSpec(zshDir)) + const zshenv = buildZshStartupHook(getRelayZshWrapperSpec()) const bashRc = `# Orca relay bash overlay wrapper ${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} @@ -78,7 +72,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} +${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1 @@ -162,11 +156,9 @@ trap '__orca_osc133_preexec' DEBUG unset __orca_initializing_wrapper ` + // Only .zshenv: see local-pty-shell-ready-wrapper-generation.ts. const files = [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), zshenv], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(bashDir, 'rcfile'), bashRc] ] as const