diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 1fd809c2f17..8e2c43f62b8 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -250,6 +250,46 @@ describe('createPtySubprocess', () => { expect(spawnMock.mock.calls.at(-1)?.[2].env.HISTFILE).toBe(expected) }) + it.each([ + // ORCA_HISTFILE is exported into every pane, so a daemon started from an + // Orca pane inherits one. Left in place it BOTH re-scopes the pane to + // another worktree's history file (#11146) and wraps a zsh pane nothing + // asked to wrap, since `history` is selected on its presence. + ['drops an inherited Orca ORCA_HISTFILE', undefined, undefined], + [ + 'keeps the path this spawn injected', + '/fake/userData/terminal-history/00112233445566aa/zsh_history', + '/fake/userData/terminal-history/00112233445566aa/zsh_history' + ], + ['keeps a caller-supplied value', '/home/me/.zsh_history', '/home/me/.zsh_history'] + ])('%s', async (_name, requested, expected) => { + spawnMock.mockReturnValue(mockPtyProcess()) + const saved = process.env.ORCA_HISTFILE + process.env.ORCA_HISTFILE = '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history' + + try { + await createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: '/bin/zsh', + ...(requested === undefined ? {} : { env: { ORCA_HISTFILE: requested } }) + }) + } finally { + if (saved === undefined) { + delete process.env.ORCA_HISTFILE + } else { + process.env.ORCA_HISTFILE = saved + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.ORCA_HISTFILE).toBe(expected) + // The wrapping consequence: no inherited value may point a pane at Orca's + // ZDOTDIR that the client scoped no history for. + expect(env.ORCA_SHELL_FEATURES).toBe(expected === undefined ? undefined : 'history') + }) + it('does not inherit ELECTRON_RUN_AS_NODE from the daemon process env', async () => { // Why: the daemon is forked with ELECTRON_RUN_AS_NODE=1. If that flag // reaches user shells, nested Electron commands run as plain Node. diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 0b4e1d3312b..937300c1943 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -665,6 +665,14 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { @@ -498,8 +498,8 @@ describePosix('daemon shell-ready launch config', () => { const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$REPLY" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$REPLY"') + 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') @@ -931,7 +931,7 @@ describePosix('daemon shell-ready launch config', () => { const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') // Save spawn-env value before sourcing user .zshenv - expect(zshenv).toContain('_orca_user_zdotdir="$REPLY"') + 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}}') diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 5151a6cbe8e..72790537751 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -96,7 +96,7 @@ function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.z function expectFinalZdotdirRestoreContext(content: string) { expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$REPLY"') + expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') } describePosix('local PTY shell-ready launch config', () => { @@ -246,8 +246,8 @@ describePosix('local PTY shell-ready launch config', () => { const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$REPLY" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$REPLY"') + 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') @@ -538,7 +538,7 @@ describePosix('local PTY shell-ready launch config', () => { const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') // Save spawn-env value before sourcing user .zshenv - expect(zshenv.indexOf('_orca_user_zdotdir="$REPLY"')).toBeLessThan( + expect(zshenv.indexOf('_orca_user_zdotdir="$_orca_resolved_config_dir"')).toBeLessThan( zshenv.indexOf('source "${_orca_zshenv_path}"') ) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 91f6122b78b..7bd5ae5abd5 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -50,13 +50,24 @@ export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity * 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 - * $REPLY empty, which skipped sourcing the user's own startup file entirely. + * the out-parameter empty, which skipped sourcing the user's own startup file. + * + * 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`. */ export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_dir() { - REPLY="\${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="\${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/${ZSH_WRAPPER_DIR_MARKER_FILE}" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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 }` @@ -71,17 +82,61 @@ export const ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK = `# Why stricter for an in # 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:-}" - [[ "$REPLY" == "$HOME" ]] && return 0 + [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 done - REPLY="$HOME" + _orca_resolved_config_dir="$HOME" }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-composition' +/** + * 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. + */ +export function getZshEmulationDegradeBlock(options: { + userZdotdirExpression: string + sourcedUserFileTest: string +}): string { + return `if [[ ${options.sourcedUserFileTest} ]]; 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 @@ -106,9 +161,9 @@ _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="$REPLY" +_orca_user_zdotdir="$_orca_resolved_config_dir" __orca_resolve_inherited_config_dir "\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$REPLY" +_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 @@ -140,7 +195,14 @@ 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="$REPLY" +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. @@ -149,7 +211,7 @@ if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self else export ZDOTDIR=${quotePosixSingle(zshDir)} fi -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_wrapper_zdotdir_self ` } @@ -163,10 +225,16 @@ unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discove */ export function getZshOverlayEnvBody(zshDir: string): string { return `__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$REPLY" +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="$REPLY" +export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" + +${getZshEmulationDegradeBlock({ + userZdotdirExpression: '"$ORCA_USER_ZDOTDIR"', + sourcedUserFileTest: '-f "$ORCA_ORIG_ZDOTDIR/.zshenv"' +})} + export ZDOTDIR=${quotePosixSingle(zshDir)} ` } @@ -210,7 +278,7 @@ export function getZshStartupFileSourceBlock(options: { ].filter(Boolean) return `__orca_resolve_user_config_dir ${homeExpression} -_orca_home="$REPLY" +_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; @@ -277,7 +345,7 @@ export function getZshFinalZdotdirRestoreBlock(homeExpression = '"${ORCA_ORIG_ZD 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="$REPLY" -unset _orca_home REPLY +export ZDOTDIR="$_orca_resolved_config_dir" +unset _orca_home _orca_resolved_config_dir ` } diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index c40780775b3..a5c0548f43a 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -52,6 +52,49 @@ async function expectWrapperFiles(transport: string, root: string): Promise() + for (const line of content.split('\n')) { + if (/^[ \t]*#/.test(line) || /^[ \t]*local\b/.test(line)) { + continue + } + for (const name of [ + LINE_START_ASSIGNMENT.exec(line)?.[1], + ...[...line.matchAll(INLINE_EXPORT)].map((match) => match[1]) + ]) { + if (name && !/^_{0,2}orca_/i.test(name) && !CONTRACT_GLOBALS.has(name)) { + names.add(name) + } + } + } + return [...names].sort() +} + // Why: all three generators are POSIX-only (the launch configs skip wrapping on // win32), and native Windows path separators would make the fixtures unstable. const describePosix = process.platform === 'win32' ? describe.skip : describe @@ -90,6 +133,31 @@ describePosix('generated shell wrapper files', () => { await expectWrapperFiles('relay', root) }) + // Why a rule and not another fixture: `REPLY`, zsh's shared scratch global, + // was the wrapper's resolver out-parameter. A user config that constrained it + // (`typeset -r REPLY`) aborted the wrapper at its first executable line — on + // every zsh pane, once wrapping widened past overlay/startup panes. The + // fixtures above would have shown that only to a reader who knew to look. + it.each([ + ['local', (): void => void ensureShellReadyWrappersAt(root), (): string => root], + [ + 'daemon', + (): void => { + process.env.ORCA_USER_DATA_PATH = root + getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) + }, + (): string => join(root, 'shell-ready') + ], + ['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root] + ])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => { + generate() + + for (const [, relativePath] of WRAPPER_FILES) { + const content = readFileSync(join(dir(), relativePath), 'utf8') + expect({ [relativePath]: foreignGlobalsWritten(content) }).toEqual({ [relativePath]: [] }) + } + }) + it('fish shell-ready init commands', async () => { process.env.ORCA_USER_DATA_PATH = root const local = getLocalShellLaunchConfig('/usr/bin/fish', STARTUP_COMMAND_FEATURES) diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt index cd85ec3af88..2c84163caa7 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt @@ -1,13 +1,13 @@ # Orca daemon zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt index 5dfefd14d1c..36c75e3d29f 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt @@ -1,13 +1,13 @@ # Orca daemon zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; @@ -18,3 +18,15 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi +if [[ -f "$_orca_home/.zprofile" ]]; then + case "$(emulate 2>/dev/null)" in + sh|ksh) + export ZDOTDIR="$_orca_home" + # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is + # inherited by everything this pane spawns, including a nested Orca. + builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self + unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null + return 0 + ;; + esac +fi diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 00213a73bc8..17d11cf24c2 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -5,10 +5,10 @@ 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() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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 @@ -16,12 +16,12 @@ __orca_resolve_user_config_dir() { # 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:-}" - [[ "$REPLY" == "$HOME" ]] && return 0 + [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 done - REPLY="$HOME" + _orca_resolved_config_dir="$HOME" } # 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 @@ -45,9 +45,9 @@ _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="$REPLY" +_orca_user_zdotdir="$_orca_resolved_config_dir" __orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$REPLY" +_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 @@ -79,7 +79,22 @@ 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="$REPLY" +export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" +unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir + +if [[ -n "${_orca_zshenv_path:-}" ]]; 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. @@ -88,7 +103,7 @@ if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:- else export ZDOTDIR='/zsh' fi -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_wrapper_zdotdir_self __orca_shell_epilogue() { # Why first: this body runs after the user's own config, so it would otherwise @@ -173,7 +188,9 @@ __orca_shell_epilogue() { } __orca_osc133_preexec() { printf "\033]133;C\007" - __orca_in_command=1 + # 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[@]}) @@ -206,8 +223,8 @@ __orca_shell_epilogue() { __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="$REPLY" - unset _orca_home REPLY + 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 } diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt index f35598b865e..6f45dca80a2 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt @@ -1,13 +1,13 @@ # Orca daemon zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt index 4e4aad69dbf..a7556c7f006 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt @@ -1,13 +1,13 @@ # Orca zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt index 4164aa99db6..96105bd173b 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt @@ -1,13 +1,13 @@ # Orca zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; @@ -18,3 +18,15 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi +if [[ -f "$_orca_home/.zprofile" ]]; then + case "$(emulate 2>/dev/null)" in + sh|ksh) + export ZDOTDIR="$_orca_home" + # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is + # inherited by everything this pane spawns, including a nested Orca. + builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self + unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null + return 0 + ;; + esac +fi diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt index ef2852dc9bc..90700b19403 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -5,10 +5,10 @@ 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() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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 @@ -16,12 +16,12 @@ __orca_resolve_user_config_dir() { # 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:-}" - [[ "$REPLY" == "$HOME" ]] && return 0 + [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 done - REPLY="$HOME" + _orca_resolved_config_dir="$HOME" } # 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 @@ -45,9 +45,9 @@ _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="$REPLY" +_orca_user_zdotdir="$_orca_resolved_config_dir" __orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$REPLY" +_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 @@ -79,7 +79,22 @@ 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="$REPLY" +export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" +unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir + +if [[ -n "${_orca_zshenv_path:-}" ]]; 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. @@ -88,7 +103,7 @@ if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:- else export ZDOTDIR='/zsh' fi -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_wrapper_zdotdir_self __orca_shell_epilogue() { # Why first: this body runs after the user's own config, so it would otherwise @@ -173,7 +188,9 @@ __orca_shell_epilogue() { } __orca_osc133_preexec() { printf "\033]133;C\007" - __orca_in_command=1 + # 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[@]}) @@ -206,8 +223,8 @@ __orca_shell_epilogue() { __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="$REPLY" - unset _orca_home REPLY + 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 } diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt index 84ce60c1acd..ef60afbf3f5 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt @@ -1,13 +1,13 @@ # Orca zsh shell-ready wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt index 52a6c828ae1..c76f4f6973d 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt @@ -1,13 +1,13 @@ # Orca relay zsh overlay wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt index 5f5d3ef1c72..9a0936db85d 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt @@ -1,13 +1,13 @@ # Orca relay zsh overlay wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; @@ -18,3 +18,15 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi +if [[ -f "$_orca_home/.zprofile" ]]; then + case "$(emulate 2>/dev/null)" in + sh|ksh) + export ZDOTDIR="$_orca_home" + # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is + # inherited by everything this pane spawns, including a nested Orca. + builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self + unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null + return 0 + ;; + esac +fi diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 73e2c765b51..df7b7883c69 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -5,10 +5,10 @@ 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() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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 @@ -16,18 +16,32 @@ __orca_resolve_user_config_dir() { # 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:-}" - [[ "$REPLY" == "$HOME" ]] && return 0 + [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 done - REPLY="$HOME" + _orca_resolved_config_dir="$HOME" } __orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$REPLY" +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="$REPLY" +export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" + +if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]]; 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 +fi + export ZDOTDIR='/zsh' __orca_shell_epilogue() { @@ -110,8 +124,8 @@ __orca_shell_epilogue() { __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="$REPLY" - unset _orca_home REPLY + 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 } diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt index 27af607ccfa..1fc1794596e 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt @@ -1,13 +1,13 @@ # Orca relay zsh overlay wrapper __orca_resolve_user_config_dir() { - REPLY="${1:-}" - while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done - if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then - REPLY="$HOME" + 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="$REPLY" +_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; diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 3ec3bc37f58..17ff8cae7ef 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -48,6 +48,16 @@ function runZsh( }) } +/** Both streams, because a wrapper that breaks does it on stderr. */ +function runZshCapturingStderr(args: string[], env: Record, probe: string): string { + const result = spawnSync(ZSH_PATH, [...args, '-i', '-c', probe], { + encoding: 'utf8', + timeout: 20_000, + env: { PATH: '/usr/bin:/bin', ...env } + }) + return `${result.stdout ?? ''}${result.stderr ?? ''}` +} + /** * True when this host's system zshrc is the thing that destroys HISTFILE. * @@ -267,3 +277,69 @@ describe('worktree-scoped HISTFILE survives zsh startup', () => { }) }) }) + +describe('the wrapper survives a hostile user zsh config', () => { + const PROBE = + 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + + 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' + + // 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) => { + 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:') + }) + } + ) + + // 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 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]') + }) + }) +}) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index a16d41d85bd..eccedcfb6ac 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -22,6 +22,7 @@ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { getZshEnvDiscoveryBody, + getZshEmulationDegradeBlock, getZshFinalZdotdirRestoreBlock, getZshOverlayEnvBody, getZshShellReadyMarkerRegistrationBlock, @@ -100,7 +101,9 @@ const ZSH_OSC133_COMMAND_MARKER_BLOCK = `__orca_osc133_precmd() { } __orca_osc133_preexec() { printf "\\033]133;C\\007" - __orca_in_command=1 + # 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[@]}) @@ -227,6 +230,14 @@ export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshSta 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([ diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index ba10a1da965..0a1b492a1ea 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -220,6 +220,50 @@ describe('PtyHandler', () => { } ) + // Why unconditionally, not only with isolation on: injectRelayHistoryEnv is + // what normally mints (and first clears) ORCA_HISTFILE, and it runs only + // with isolation on. An inherited one — the relay can be launched from an + // Orca pane — would otherwise reach the remote wrapper on the disabled and + // revive paths, re-exporting another worktree's history path (#11146) and + // wrapping a zsh pane nothing asked to wrap. + it.each([ + [ + 'a relay-minted path', + `${process.env.HOME ?? ''}/.orca-remote/terminal-history/aabbccddeeff0011-zsh_history` + ], + ['a desktop-minted path', '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history'], + ['a user value', '/home/me/.zsh_history'] + ])( + 'drops %s inherited as ORCA_HISTFILE from the relay process env', + async (_kind, inherited) => { + const previous = process.env.ORCA_HISTFILE + process.env.ORCA_HISTFILE = inherited + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_HISTFILE + } else { + process.env.ORCA_HISTFILE = previous + } + } + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_HISTFILE).toBeUndefined() + } + ) + + it('drops an ORCA_HISTFILE handed over in the client env', async () => { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_HISTFILE: '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history' } + }) + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_HISTFILE).toBeUndefined() + }) + it('drops a desktop-minted session handed over in the client env', async () => { await dispatcher.callRequest('pty.spawn', { cols: 80, diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 359c940fb8e..c7dfb0a533d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -641,6 +641,13 @@ export class PtyHandler { // on, yet an inherited Orca HISTFILE must not scope a pane to someone else's // worktree on the disabled and revive paths either. dropInheritedOrcaHistFile(result) + // Why unconditionally: ORCA_HISTFILE is Orca-owned and minted below by + // injectRelayHistoryEnv, which also runs only with isolation on. An + // inherited one (the relay can be launched from an Orca pane) would + // otherwise reach the wrapper on the disabled and revive paths, scoping the + // pane to another worktree's history file — and wrapping a zsh pane that + // nothing asked to wrap, since `history` is selected on its presence. + delete result.ORCA_HISTFILE // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. for (const key of envToDelete) { delete result[key] diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index d1ab2a12df2..c1bd4365a50 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -67,7 +67,7 @@ function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.z function expectFinalZdotdirRestoreContext(content: string) { expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$REPLY"') + expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') } describe('isRelayWslShell', () => { @@ -113,7 +113,7 @@ describe('getRelayShellLaunchConfig', () => { 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="$REPLY"') + 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') @@ -222,7 +222,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="$REPLY"' + 'export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"' ) })