mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391)
* refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786)
Orca needs to run code after the user's own zsh startup files. It bought that by
keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and
sourcing each user file by hand -- four generated files per transport, with a
fake ZDOTDIR live while /etc/zshrc ran. That single decision is 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), and an epilogue had to repair it.
- zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a
user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file.
The emulation degrade blocks and their forked $(emulate) probes exist for that.
- One wrapper dir shared by two installed builds could mix files from both, so
every generated file had to redefine the helpers it called.
- The baked generation-time ZDOTDIR literal is unusable when a Windows-generated
wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be
re-derived from %x.
The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a
precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc
and .zlogin, every one of which zsh reads from the user's own directory exactly
as in an unwrapped shell. Each bug above stops being reachable rather than being
repaired, and their machinery goes with them: eight of thirteen exported blocks in
shell-templates.ts, both drifted discovery bodies (unifying them closes the
"reconciling the two is a follow-up" note the file carried), and the relay's
separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across
twelve files to 143 in three.
Two things the design has to get right, both found by running it rather than
reasoning about it:
- Every function is defined ABOVE the source of the user's .zshenv. A user file
ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and
the first prototype died there with `parse error near '\n'` -- silently, leaving
the pane unwrapped. Function bodies are parsed at definition time.
- ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it
resolved a usable dir, but a pane inherits its parent's environment too, so a
stale value from an older build can arrive on its own and would point ZDOTDIR
back at a wrapper dir. The ownership check Node applies now also runs in the
shell, where that route is visible.
Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is
absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does.
Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs
in a shell started with -c, so the existing `zsh -i -c` probes could not have
exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the
shell to a prompt and reports through a file rather than stdout, which a PTY echoes.
* test(shell): cover the relay variant of the zsh hook in a real shell
The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the
agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR
shape as well, which is how it drifted from the desktop template in the first
place; now the spec flags are the only difference, and this pins that.
* fix(shell): rebase the single-file hook onto content-addressed wrapper trees
#15285 landed content-addressed wrapper roots and a per-transport fileset module
while this branch was in flight. The fileset modules are now the single place the
tree is described, so 'only .zshenv' is stated once per transport and the
required-paths check follows from it rather than repeating the list.
* test(shell): point the mixed-build proof at the relay, the one fixed wrapper path
#15285 content-addressed the desktop and daemon trees, so two builds can no
longer write the same directory there and the scenario this file covers became
unreachable on those paths. The relay still writes a fixed ~/.orca-relay/
shell-ready, so that is where the hazard survives and where the proof belongs.
* fix(test): make the zsh PTY harness survive a startup that stops to ask
Two CI-only failures, both from driving a real PTY where the old probes drove a
pipe:
- A host whose global zshrc runs `compinit` over directories it considers
insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the
question; a PTY sits at it until the timeout. The harness now answers it.
ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain
compinit ignores it, which I confirmed by reproducing the prompt locally.
- The PS1 line was typed at t=0, so on such a host the question consumed it as
its answer. The harness now waits for the shell to fall quiet first, which
also stops a slow prompt framework racing the same write.
Also merges a duplicate vitest import the native code-quality audit flagged.
* fix(test): stop the live-shell assertions assuming macOS host behaviour
Two of them hardcoded what my machine does rather than what Orca owes:
- LINEINIT was pinned to 'none'. A host whose global zsh config installs its own
zle-line-init widget has one either way; the contract is that it looks the same
wrapped as unwrapped, which the assertion beside it already states.
- The dropped-precmd_functions case asserted HISTFILE was no longer the scoped
path. Whether the scoped value survives at all is the host's call: macOS
/etc/zshrc overwrites HISTFILE so it does not, and a host with no such
assignment keeps whatever the spawn env set. Now compared against an unwrapped
pane given the same env, which is the real contract on both.
Also notes, where the emulation cases live, that they only discriminate on a host
whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion
is ORCA_HISTFILE having been consumed.
* test(shell): re-pin the fixes the four-file wrapper was built for
Archaeology over the removed blocks: each existed for a bug, so each needs the
bug shown to be unreachable rather than just the code gone. Six restored or added,
each naming the change that introduced the behaviour.
- #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII
(token-range) one. The old file baked its generation-time path in and had to
re-derive the runtime one from %x to avoid using it; this one bakes nothing.
Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc
loaded' cannot pass on a pane that never read the wrapper at all.
- #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin
and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR
around each source; this one never takes it away, and the values now have to
match an unwrapped pane's.
- #1947: a user .zshenv that returns early.
- #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI
proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when
it resolved a usable dir, but a pane inherits its parent's environment too.
- #11044/#11146: a nested Orca inherits neither cross-process channel and no
ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather
than repaired. Verified the child-env probe detects a real leak before trusting
it to report the absence of one.
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+68
-143
@@ -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='<WRAPPER_ROOT>/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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+68
-143
@@ -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='<WRAPPER_ROOT>/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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+47
-64
@@ -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='<WRAPPER_ROOT>/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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,8 +85,7 @@ EOF
|
||||
)
|
||||
|
||||
expect(stdout).toMatchInlineSnapshot(`
|
||||
"ORCA_ORIG_ZDOTDIR=<HOME>
|
||||
ZDOTDIR=<HOME>/.config/zsh-remote
|
||||
"ZDOTDIR=<HOME>/.config/zsh-remote
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -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()]
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, string | undefined> = {
|
||||
...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) {
|
||||
|
||||
@@ -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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { 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<string, string | undefined> = {}
|
||||
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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = {
|
||||
...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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | undefined> = {
|
||||
...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<string, string | undefined> = {
|
||||
...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<string, string | undefined> = {
|
||||
...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<string, string | undefined> = {
|
||||
...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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<string, string | undefined> = { ...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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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<string, string>): Record<string, string> {
|
||||
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')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+68
-259
@@ -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 <unknown> ]]` 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
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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('<WRAPPER_ROOT>')
|
||||
}
|
||||
|
||||
@@ -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<string, string>,
|
||||
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<string, string>, 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<Parameters<typeof selectShellStartupFeatures>[0]> = {}
|
||||
) {
|
||||
const env: Record<string, string> = {
|
||||
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<string, string> {
|
||||
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<string, string>, run: (home: string) => Promise<void>) {
|
||||
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() ?? '<unmatched>'
|
||||
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<string, string> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 '<probe>'`: 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=<value>` pairs the probe wrote, parsed. */
|
||||
values: Record<string, string>
|
||||
/**
|
||||
* 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<string, string>
|
||||
/** 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<string, string> {
|
||||
if (!existsSync(resultPath)) {
|
||||
return {}
|
||||
}
|
||||
const values: Record<string, string> = {}
|
||||
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<ZshPtyRun> {
|
||||
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<void>((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<void>((resolve) => {
|
||||
proc.onExit(() => {
|
||||
hasExited = true
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const timedOut = new Promise<never>((_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<void> {
|
||||
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, string>): string {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-zsh-home-'))
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(home, name), content)
|
||||
}
|
||||
return home
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 <name>; then ... fi`. */
|
||||
/** One hook feature: `if __orca_has_feature <name>; 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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
return inherited ? { ORCA_ORIG_ZDOTDIR: inherited } : {}
|
||||
}
|
||||
|
||||
@@ -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 (`<userData>/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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user