diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 72bb113a7cb..142c0c131d9 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,12 +1,19 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' -import { SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' +import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' export function getDaemonBashShellReadyRcfileContent(): string { return `# Orca daemon bash shell-ready wrapper +${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -52,7 +59,7 @@ __orca_osc133_precmd() { # Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry) # so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not # displaced by one of Orca's own hooks. - [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]] && printf "${SHELL_READY_MARKER}" + [[ -n "$__orca_ready_marker" ]] && printf "${SHELL_READY_MARKER}" return "$exit_code" } __orca_osc133_preexec() { diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index 73f6dc21595..b23201ee04c 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -10,16 +10,13 @@ export function getDaemonZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { readyMarkerEscaped: SHELL_READY_MARKER, osc133CommandMarkers: true, skipUserZshrcWhenHomeIsWrapperDir: true, - interactiveRestoreComment: + overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", - loginRestoreComment: - '# Why: .zlogin is the final login startup file before the prompt is shown.', restores: { agentTeamsPath: true, remoteCliBinDir: false, codexHome: true, codexLaunchPreflight: true - }, - readyMarkerOrder: 'before-zdotdir-restore' + } } } diff --git a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts index 6f243626f9d..ed63b84afec 100644 --- a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts +++ b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts @@ -112,7 +112,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell wrapper when OpenCode config must survive shell startup', async () => { @@ -141,7 +141,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell wrapper when MiMo home must survive shell startup', async () => { @@ -170,7 +170,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell wrapper when typed OMP commands need the status extension', async () => { @@ -198,7 +198,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell wrapper when Codex home must survive shell startup', async () => { @@ -227,7 +227,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell wrapper when Agent Teams shim path must survive shell startup', async () => { @@ -257,7 +257,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('keeps plain Codex startup commands on the no-marker wrapper', async () => { @@ -284,7 +284,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('uses shell-ready wrapper for delivery-hinted Codex startup commands', async () => { @@ -312,7 +312,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).toContain('ready') }) it('uses shell-ready wrapper for Codex native prefill flags', async () => { @@ -339,7 +339,7 @@ describe('createPtySubprocess', () => { const lastCall = spawnMock.mock.calls.at(-1)! expect(lastCall[1]).toEqual(['-l']) expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) - expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(lastCall[2].env.ORCA_SHELL_FEATURES).toContain('ready') }) it('deletes requested env keys after merging daemon process env', async () => { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 399d05e1bc9..95dc5fd57fb 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -5,11 +5,8 @@ import { release } from 'node:os' import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session-subprocess-handle' import { DaemonProtocolError } from './types' -import { - getMarkerlessShellLaunchConfig, - getShellReadyLaunchConfig, - resolvePtyShellPath -} from './shell-ready' +import { getShellLaunchConfig, resolvePtyShellPath } from './shell-ready' +import { selectShellStartupFeatures } from '../shell-startup-features' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, @@ -824,33 +821,34 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise | null = null - if (opts.command && isCodexStartupCommand) { - const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery + // Why: OpenCode/Codex path restoration, OMP's typed-command status wrapper, + // and the worktree HISTFILE repair all need shell-ready code that runs after + // the user's own startup files. + // Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path. + const waitsForShellReady = + Boolean(opts.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: opts.command as string, + startupCommandDelivery: opts.startupCommandDelivery + })) + // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch config + // below may name features for this shell. + delete env.ORCA_SHELL_FEATURES + const shellLaunch = getShellLaunchConfig( + shellPath, + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: Boolean(opts.command), + waitsForShellReady, + // Why identical: the identity marker exists so the readiness handshake + // can bind output to the right shell PID. + emitsStartupIdentity: waitsForShellReady }) - // Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path. - shellLaunch = shouldWaitForShellReady - ? getShellReadyLaunchConfig(shellPath) - : getMarkerlessShellLaunchConfig(shellPath) - } else if (opts.command) { - shellLaunch = getShellReadyLaunchConfig(shellPath) - } else { - shellLaunch = - env.ORCA_OPENCODE_CONFIG_DIR || - env.ORCA_MIMOCODE_HOME || - env.ORCA_OMP_STATUS_EXTENSION || - env.ORCA_CODEX_HOME || - env.ORCA_AGENT_TEAMS_SHIM_DIR - ? getMarkerlessShellLaunchConfig(shellPath) - : null - } - if (shellLaunch) { - Object.assign(env, shellLaunch.env) - } - shellArgs = shellLaunch?.args ?? ['-l'] + ) + Object.assign(env, shellLaunch.env) + shellArgs = shellLaunch.args ?? ['-l'] } seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) if ( diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index b74acccabab..2ae17262b4f 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -3,8 +3,12 @@ import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as ShellReadyModule from './shell-ready' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { + OVERLAY_ONLY_FEATURES, + STARTUP_COMMAND_FEATURES +} from '../shell-startup-launch-intent-fixtures' +import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates' import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' import { @@ -14,9 +18,16 @@ import { } from '../shell-startup-output-scanner' import { HeadlessEmulator } from './headless-emulator' -async function importFreshShellReady(): Promise { +async function importFreshShellReady() { vi.resetModules() - return import('./shell-ready') + const module = await import('./shell-ready') + return { + ...module, + getShellReadyLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, STARTUP_COMMAND_FEATURES), + getMarkerlessShellLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, OVERLAY_ONLY_FEATURES) + } } async function importFreshDaemonBashRcfile(): Promise { @@ -68,7 +79,7 @@ async function runInteractiveZshLogin(args: { ZDOTDIR: args.wrapperZdotdir, ORCA_ORIG_ZDOTDIR: args.tempHome, ORCA_ZSHENV_SOURCE_DIR: args.tempHome, - ORCA_SHELL_READY_MARKER: '1' + ORCA_SHELL_FEATURES: 'ready' } }) let output = '' @@ -106,7 +117,7 @@ async function runInteractiveZshRc(args: { HOME: args.zdotdir, TERM: 'xterm-256color', ZDOTDIR: args.zdotdir, - ORCA_SHELL_READY_MARKER: '1' + ORCA_SHELL_FEATURES: 'ready' } }) let output = '' @@ -140,7 +151,7 @@ function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): strin env: { ...process.env, HOME: tempDir, - ORCA_SHELL_READY_MARKER: '1', + ORCA_SHELL_FEATURES: 'ready', TERM: process.env.TERM || 'xterm' }, timeout: 5000 @@ -175,7 +186,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="$_orca_home"') + expect(content).toContain('export ZDOTDIR="$REPLY"') } describePosix('daemon shell-ready launch config', () => { @@ -259,7 +270,9 @@ describePosix('daemon shell-ready launch config', () => { const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish') expect(config.supportsReadyMarker).toBe(true) - expect(config.env).toEqual({ ORCA_SHELL_READY_MARKER: '1' }) + // Why empty: fish's selection is baked into the init command text, so it + // needs no exported feature variable at all. + expect(config.env).toEqual({}) expect(config.args?.slice(0, 2)).toEqual(['-l', '-C']) const init = config.args?.[2] ?? '' expect(init).toContain('--on-event fish_prompt') @@ -415,14 +428,15 @@ describePosix('daemon shell-ready launch config', () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME + const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh' - process.env.ORCA_ORIG_ZDOTDIR = '/Users/alice/.config/zsh' - process.env.HOME = '/Users/alice' + process.env.ORCA_ORIG_ZDOTDIR = userZdotdir + process.env.HOME = userDataPath try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + 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 @@ -482,15 +496,17 @@ describePosix('daemon shell-ready launch config', () => { const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8') const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') - expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('"$REPLY" == */shell-ready/zsh ]]; then') + expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$REPLY"') expectZdotdirSourceContext(zprofile, '.zprofile') expectZdotdirSourceContext(zshrc, '.zshrc') expectZdotdirSourceContext(zlogin, '.zlogin') - expectFinalZdotdirRestoreContext(zshrc) - expectFinalZdotdirRestoreContext(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) + expect(zshrc).toContain('[[ -o login ]] || __orca_shell_epilogue') }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -498,14 +514,16 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') - expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') - expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"') + // Why .zshenv: the widget registration lives in the epilogue, which .zlogin + // (login) and .zshrc (non-login) both call exactly once. + const zshenv = readFileSync(join(userDataPath, 'shell-ready', '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:}"') + expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') // Why: add-zle-hook-widget aborts its chain when an earlier hook exits non-zero, so don't register the marker through it. - expect(zlogin).not.toContain('add-zle-hook-widget line-init') + 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(zlogin).toContain('== "user:__orca_prompt_mark"') + expect(zshenv).toContain('== "user:__orca_prompt_mark"') }) // Why: oh-my-zsh vi-mode's zle-line-init returns non-zero; add-zle-hook-widget then aborts the chain and the marker never fires. @@ -621,8 +639,10 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') getShellReadyLaunchConfig('/bin/bash') - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + // Why one file: every restore now lives in the single epilogue in .zshenv, + // which .zshrc and .zlogin each invoke on their own startup path. + const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zlogin = zshrc const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const restoreLine = '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' @@ -668,7 +688,8 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') getShellReadyLaunchConfig('/bin/bash') - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') + // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. + const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') @@ -800,12 +821,13 @@ describePosix('daemon shell-ready launch config', () => { it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { // Why: only the wrapper self-loop should be rejected; a real user ZDOTDIR must round-trip so their configs load. const previousZdotdir = process.env.ZDOTDIR - process.env.ZDOTDIR = '/Users/alice/.config/zsh' + const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') + process.env.ZDOTDIR = userZdotdir try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/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 @@ -866,11 +888,12 @@ describePosix('daemon shell-ready launch config', () => { it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => { // Why: guard must match suffix not substring — `/Users/alice/shell-ready/zsh-custom` must round-trip unchanged. const previousZdotdir = process.env.ZDOTDIR - process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom' + const userZdotdir = makeUserZdotdir(userDataPath, 'shell-ready', 'zsh-custom') + process.env.ZDOTDIR = userZdotdir try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/shell-ready/zsh-custom') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -889,12 +912,10 @@ describePosix('daemon shell-ready launch config', () => { const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"') + 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( - 'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' - ) + expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') expect(zshenv).toContain('export ZDOTDIR=') }) @@ -907,7 +928,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_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"') + expect(zshenv).toContain('_orca_user_zdotdir="$REPLY"') // Fallback chain: discovered → normalized spawn-env path → HOME expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index bbcbdca6856..a04965e27f5 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -1,13 +1,27 @@ import { tmpdir } from 'node:os' -import { basename, dirname, join, win32 as pathWin32 } from 'node:path' -import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { basename, join, win32 as pathWin32 } from 'node:path' +import { statSync } from 'node:fs' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' -import { getFishShellReadyInitCommand } from '../shell-templates' +import { + getFishShellReadyInitCommand, + ZSH_WRAPPER_DIR_MARKER_CONTENT, + ZSH_WRAPPER_DIR_MARKER_FILE +} from '../shell-templates' +import { + encodeShellStartupFeatures, + SHELL_STARTUP_FEATURE_ENV, + type ShellStartupFeature +} from '../shell-startup-features' +import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' +import { + resolveInheritedZdotdir, + resolveInheritedZshenvSourceDir +} from '../zsh-wrapper-dir-ownership' import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' @@ -24,105 +38,60 @@ function getShellReadyWrapperRoot(): string { return join(userDataPath || tmpdir(), userDataPath ? 'shell-ready' : 'orca-shell-ready') } -// Why: if our own process inherited ZDOTDIR from a parent shell that was -// itself an Orca PTY (e.g. the user launched Orca from a terminal inside a -// running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir. -// Propagating it as the new PTY's ORCA_ORIG_ZDOTDIR makes the wrapper's -// `source "$ORCA_ORIG_ZDOTDIR/.zshenv"` line source itself recursively — -// zsh gives "job table full or recursion limit exceeded" and the shell -// never reaches a usable prompt. -// -// Any path component ending in `/shell-ready/zsh` is an Orca wrapper dir -// (regardless of whether it came from this daemon's userData, a packaged -// Orca, or a different dev build). Treat it as if ZDOTDIR were unset so the -// caller falls back to HOME for the user's real config root. -function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { - if (!value) { - return null - } - // Why: tolerate trailing slashes — some shell startup scripts export - // `ZDOTDIR="$dir/"`, and without normalization the suffix check would - // miss the self-loop path and restore the recursion bug. Also collapses - // a pathological `ZDOTDIR=/` to empty so we fall back to HOME rather than - // sourcing `/.zshenv` (which is never the user's real config). - const normalized = value.replace(/\/+$/, '') - if (!normalized || normalized.endsWith('/shell-ready/zsh')) { - return null - } - return value -} - -function resolveOriginalZdotdir(): string { - return ( - normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || - normalizeOriginalZdotdirCandidate(process.env.ORCA_ORIG_ZDOTDIR) || - process.env.HOME || - '' - ) -} - -function resolveOriginalZshenvSourceDir(): string { - return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' -} - function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { return [ join(root, 'zsh', '.zshenv'), join(root, 'zsh', '.zprofile'), join(root, 'zsh', '.zshrc'), join(root, 'zsh', '.zlogin'), + join(root, 'zsh', ZSH_WRAPPER_DIR_MARKER_FILE), join(root, 'bash', 'rcfile') ] } +// Why non-empty and not just present: a partial write leaves a zero-byte +// .zshenv, and pointing ZDOTDIR at that dir makes zsh skip the user's config. function shellReadyWrappersExist(): boolean { - return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) + return getRequiredShellReadyWrapperPaths().every((path) => { + try { + return statSync(path).size > 0 + } catch { + return false + } + }) } -function ensureShellReadyWrappers(): void { +/** True when every wrapper file is present and non-empty afterwards. */ +function ensureShellReadyWrappers(): boolean { if (process.platform === 'win32') { - return + return false } if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { - return + return true } didEnsureShellReadyWrappers = true const root = getShellReadyWrapperRoot() const zshDir = join(root, 'zsh') - const bashDir = join(root, 'bash') - const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) - const bashRc = getDaemonBashShellReadyRcfileContent() - const files = [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], - [join(bashDir, 'rcfile'), bashRc] - ] as const - - try { - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) - } - } catch (error) { - // Why: wrapper file creation can fail due to read-only filesystems, permission - // issues, or disk space. Rather than crashing, log the error and continue. - // The shell will launch without the wrapper, which means no shell-ready marker - // but at least the PTY is usable. - const errorMessage = - error instanceof Error - ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` - : String(error) - console.error(`[daemon/shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`) - console.error('[daemon/shell-ready] Shell will launch without wrapper (no shell-ready marker)') - // Reset the flag so next attempt will try again + const written = writeShellWrapperFiles( + [ + [join(zshDir, '.zshenv'), zsh.zshenv], + [join(zshDir, '.zprofile'), zsh.zprofile], + [join(zshDir, '.zshrc'), zsh.zshrc], + [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], + [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] + ], + '[daemon/shell-ready]' + ) + if (!written || !shellReadyWrappersExist()) { + // Why reset: the next launch retries instead of trusting a half-written tree. didEnsureShellReadyWrappers = false + return false } + return true } export function resolvePtyShellPath(env: Record): string { @@ -146,44 +115,59 @@ export function supportsPtyStartupBarrier(env: Record): boolean return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) } -type ShellLaunchConfig = { +export type ShellLaunchConfig = { args: string[] | null env: Record supportsReadyMarker: boolean } -function getWrappedShellLaunchConfig( +const UNWRAPPED: ShellLaunchConfig = { + args: null, + env: {}, + supportsReadyMarker: false +} + +/** + * The one launch-config entry point: args + env for a shell that should start + * with exactly `features` enabled. An empty selection is never wrapped. + */ +export function getShellLaunchConfig( shellPath: string, - options: { emitReadyMarker: boolean } + features: readonly ShellStartupFeature[] ): ShellLaunchConfig { const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() if (shellName === 'zsh') { - ensureShellReadyWrappers() - const root = getShellReadyWrapperRoot() + if (features.length === 0) { + return UNWRAPPED + } + if (!ensureShellReadyWrappers()) { + // Why plain login zsh: ZDOTDIR pointed at an incomplete wrapper dir makes + // zsh skip the user's whole config. Losing Orca's features is recoverable. + return { args: ['-l'], env: {}, supportsReadyMarker: false } + } return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), - ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), - ZDOTDIR: join(root, 'zsh'), - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', - ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' + ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), + ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh'), + [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, - supportsReadyMarker: options.emitReadyMarker + supportsReadyMarker: features.includes('ready') } } if (shellName === 'bash') { - ensureShellReadyWrappers() - const root = getShellReadyWrapperRoot() + if (features.length === 0 || !ensureShellReadyWrappers()) { + return UNWRAPPED + } return { - args: ['--rcfile', join(root, 'bash', 'rcfile')], + args: ['--rcfile', join(getShellReadyWrapperRoot(), 'bash', 'rcfile')], env: { - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', - ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' + [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, - supportsReadyMarker: options.emitReadyMarker + supportsReadyMarker: features.includes('ready') } } @@ -200,30 +184,19 @@ function getWrappedShellLaunchConfig( } } - // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped. - if (shellName === 'fish' && options.emitReadyMarker) { + // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped. The + // selection is baked into the init command, so fish needs no feature env var. + if (shellName === 'fish' && features.includes('ready')) { return { args: [ '-l', '-C', `${getFishShellReadyInitCommand(SHELL_READY_MARKER)}\n${getFishCodexShellLaunchPreflight()}` ], - env: { ORCA_SHELL_READY_MARKER: '1' }, + env: {}, supportsReadyMarker: true } } - return { - args: null, - env: {}, - supportsReadyMarker: false - } -} - -export function getShellReadyLaunchConfig(shellPath: string): ShellLaunchConfig { - return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true }) -} - -export function getMarkerlessShellLaunchConfig(shellPath: string): ShellLaunchConfig { - return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false }) + return UNWRAPPED } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index c99c0e6f6a6..48ac401b29f 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -17,6 +17,8 @@ export const mkdirSyncMock: Mock = vi.fn() export const readFileSyncMock: Mock = vi.fn() export const writeFileSyncMock: Mock = vi.fn() export const chmodSyncMock: Mock = vi.fn() +export const renameSyncMock: Mock = vi.fn() +export const rmSyncMock: Mock = vi.fn() export const getPathMock: Mock = vi.fn() export const loginPreflightExecFileMock: Mock = vi.fn() export const spawnMock: Mock = vi.fn() @@ -85,6 +87,8 @@ export const fsModuleMock = () => ({ readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock, chmodSync: chmodSyncMock, + renameSync: renameSyncMock, + rmSync: rmSyncMock, constants: { X_OK: 1, R_OK: 4 diff --git a/src/main/ipc/pty-ipc-spawn-drivers.ts b/src/main/ipc/pty-ipc-spawn-drivers.ts index e9bed3a022f..46a19ab9e92 100644 --- a/src/main/ipc/pty-ipc-spawn-drivers.ts +++ b/src/main/ipc/pty-ipc-spawn-drivers.ts @@ -122,7 +122,8 @@ export function createPtyIpcSpawnDrivers(ctx: { statSyncMock.mockImplementation((target: string) => ({ isDirectory: () => target !== BUNDLED_CLI_PATH, isFile: () => target === BUNDLED_CLI_PATH, - mode: 0o755 + mode: 0o755, + size: 1 })) if (!launcherExecutable) { accessSyncMock.mockImplementation((target: string) => { diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index ddf9e1a4787..45d23c3d940 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -164,7 +164,8 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { // Why: wrapper roots resolve from ORCA_USER_DATA_PATH; mirror the mocked userData so ZDOTDIR/wrapper assertions match. process.env.ORCA_USER_DATA_PATH = '/tmp/orca-user-data' existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) + // size: the shell wrapper writer verifies each generated file is non-empty. + statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755, size: 1 }) readFileSyncMock.mockReturnValue('') openCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({ ORCA_OPENCODE_HOOK_PORT: '4567', diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index d148ebe3cff..1064aa59294 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -86,7 +86,7 @@ describe('registerPtyHandlers', () => { env: expect.objectContaining({ SHELL: '/bin/zsh', ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', - ORCA_SHELL_READY_MARKER: '0', + ORCA_SHELL_FEATURES: 'overlay,history,markers', ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' }) }) diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index 0c548093788..5310346501b 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -122,7 +122,7 @@ describe('registerPtyHandlers', () => { expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') - expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -163,7 +163,7 @@ describe('registerPtyHandlers', () => { expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(options.env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') - expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -306,7 +306,7 @@ describe('registerPtyHandlers', () => { }) const [, , options] = spawnMock.mock.calls[0]! - expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') await Promise.resolve() vi.advanceTimersByTime(49) @@ -338,7 +338,7 @@ describe('registerPtyHandlers', () => { }) const [, , options] = spawnMock.mock.calls[0]! - expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('last login: today\r\n') @@ -405,7 +405,7 @@ describe('registerPtyHandlers', () => { }) const [, , options] = spawnMock.mock.calls[0]! - expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('\x1b]777;orca-shell-ready\x07') diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index 53b92099149..9eaa54ebedc 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -166,7 +166,7 @@ describe('registerPtyHandlers', () => { if (target === missingCwd) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } - return { isDirectory: () => true, mode: 0o755 } + return { isDirectory: () => true, mode: 0o755, size: 1 } }) try { @@ -229,7 +229,7 @@ describe('registerPtyHandlers', () => { if (target === '/repo/app/deleted-folder') { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } - return { isDirectory: () => true, mode: 0o755 } + return { isDirectory: () => true, mode: 0o755, size: 1 } }) // Why: without the renderer opt-in the provider surfaces its normal missing-directory error — API/runtime callers keep exact cwd semantics. @@ -266,7 +266,7 @@ describe('registerPtyHandlers', () => { if (target === '/repo/app/deleted-folder') { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } - return { isDirectory: () => true, mode: 0o755 } + return { isDirectory: () => true, mode: 0o755, size: 1 } }) // Why: a reattach must keep the session's exact cwd; remapping would silently detach the restored terminal from its recorded state. diff --git a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts index a1dd3727c54..016103e76c1 100644 --- a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -2,7 +2,8 @@ import { tmpdir } from 'node:os' import { join, basename } from 'node:path' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { getShellReadyLaunchConfig } from '../../local-pty-shell-ready' +import { getShellLaunchConfig } from '../../local-pty-shell-ready' +import { selectShellStartupFeatures } from '../../../shell-startup-features' import { escapeRegex } from '../../../../shared/string-utils' const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m @@ -58,12 +59,25 @@ export async function shellScriptTest( const runScript = hasRunMarker ? parts[1].trim() : script.trim() const wrapperShell = detectShellFromCommand(runScript, options.shell || '/bin/zsh') - const config = getShellReadyLaunchConfig(wrapperShell) + const config = getShellLaunchConfig( + wrapperShell, + selectShellStartupFeatures({ + shellPath: wrapperShell, + env: {}, + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true + }) + ) const env: Record = { ...config.env, - // Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream. - ORCA_SHELL_STARTUP_IDENTITY: '0', + // Why: these examples inspect startup-file discovery, not the PTY-owner + // protocol stream, so drop the tokens that write to stdout. + ORCA_SHELL_FEATURES: (config.env.ORCA_SHELL_FEATURES ?? '') + .split(',') + .filter((feature) => feature !== 'identity' && feature !== 'markers') + .join(','), // Why: the framework creates user startup files under testHome after // computing the wrapper config; route wrapper discovery to that fixture. HOME: testHome, diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts index 795948f6fc8..93c7a3e4db5 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts index 6df46e813c2..a3f2d6c901e 100644 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ b/src/main/providers/local-pty-provider-io-events.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-session-inventory.test.ts b/src/main/providers/local-pty-provider-session-inventory.test.ts index cd4d7256fff..2abe881354e 100644 --- a/src/main/providers/local-pty-provider-session-inventory.test.ts +++ b/src/main/providers/local-pty-provider-session-inventory.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 948e1bf2e7d..c4621382e66 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts index 888a51fc648..96ec4ec7582 100644 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty-provider-shutdown.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts index 1410fd12981..01aec7c7f4b 100644 --- a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts +++ b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index 01c9a8460f6..3b226731919 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -40,6 +40,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) @@ -421,7 +423,7 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[1]).toEqual(['-l']) expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/) - expect(spawnCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(spawnCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') }) it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => { diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 187077c4679..96340a8436c 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider-test-harness.ts b/src/main/providers/local-pty-provider-test-harness.ts index 419a2e78869..121b2e67e5a 100644 --- a/src/main/providers/local-pty-provider-test-harness.ts +++ b/src/main/providers/local-pty-provider-test-harness.ts @@ -74,7 +74,8 @@ export function installLocalPtyProviderEnvSandbox(): void { export function applyLocalPtyProviderMockDefaults(mocks: LocalPtyProviderMocks): void { mocks.existsSyncMock.mockReturnValue(true) - mocks.statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) + // size: the wrapper writer verifies each generated file is non-empty. + mocks.statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755, size: 1 }) mocks.accessSyncMock.mockReturnValue(undefined) mocks.mkdirSyncMock.mockReset() mocks.writeFileSyncMock.mockReset() diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 67c05f461b9..a2ca237dcec 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -38,6 +38,8 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, chmodSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), constants: { X_OK: 1 } })) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index a1f9c26f207..a2ab3fbfd0e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -32,7 +32,8 @@ import { spawnShellWithFallback } from './local-pty-utils' import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' -import { getMarkerlessShellLaunchConfig, getShellReadyLaunchConfig } from './local-pty-shell-ready' +import { getShellLaunchConfig } from './local-pty-shell-ready' +import { selectShellStartupFeatures } from '../shell-startup-features' import { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS @@ -596,9 +597,9 @@ export class LocalPtyProvider implements IPtyProvider { let validationCwd: string let startupCommandDeliveredInShellArgs = false let windowsFallbackAttempts: ReturnType = [] - let shellReadyLaunch: ReturnType | null = null + let shellReadyLaunch: ReturnType | null = null let getFallbackShellReadyConfig: - | ((shell: string) => ReturnType) + | ((shell: string) => ReturnType) | undefined if (wslInfo) { shellPath = 'wsl.exe' @@ -815,44 +816,6 @@ export class LocalPtyProvider implements IPtyProvider { ) { addWslEnvKeys(finalEnv, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) } - if (!wslInfo && process.platform !== 'win32') { - // Why: OpenCode/Codex PATH restoration and OMP's status wrapper need shell-ready code after user startup files run. - const needsNoMarkerWrapper = - finalEnv.ORCA_OPENCODE_CONFIG_DIR || - finalEnv.ORCA_MIMOCODE_HOME || - finalEnv.ORCA_OMP_STATUS_EXTENSION || - finalEnv.ORCA_CODEX_HOME || - finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR - const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - let shellLaunch: ReturnType | null = null - if (args.command && isCodexStartupCommand) { - const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ - command: args.command, - startupCommandDelivery: args.startupCommandDelivery - }) - // Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed. - getFallbackShellReadyConfig = (shell) => - shouldWaitForShellReady - ? getShellReadyLaunchConfig(shell) - : getMarkerlessShellLaunchConfig(shell) - shellLaunch = shouldWaitForShellReady - ? getShellReadyLaunchConfig(shellPath) - : getMarkerlessShellLaunchConfig(shellPath) - } else if (args.command) { - getFallbackShellReadyConfig = (shell) => getShellReadyLaunchConfig(shell) - shellLaunch = getShellReadyLaunchConfig(shellPath) - } else if (needsNoMarkerWrapper) { - getFallbackShellReadyConfig = (shell) => getMarkerlessShellLaunchConfig(shell) - shellLaunch = getMarkerlessShellLaunchConfig(shellPath) - } else { - getFallbackShellReadyConfig = undefined - } - if (shellLaunch) { - Object.assign(finalEnv, shellLaunch.env) - shellArgs = shellLaunch.args ?? shellArgs - shellReadyLaunch = args.command ? shellLaunch : null - } - } const requestedEnv = args.env expandWindowsPathEnvironmentVariables(finalEnv) promoteAgentTeamsShimPath( @@ -889,6 +852,41 @@ export class LocalPtyProvider implements IPtyProvider { dropInheritedOrcaFishHistory(finalEnv) } + if (!wslInfo && process.platform !== 'win32') { + // Why after history injection: the wrapper is what repairs a worktree + // HISTFILE that the system zshrc clobbers, so the decision to wrap has to + // see whether this spawn actually injected one. + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + // Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed. + const waitsForShellReady = + Boolean(args.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: args.command as string, + startupCommandDelivery: args.startupCommandDelivery + })) + // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch + // config below may name features for this shell. + delete finalEnv.ORCA_SHELL_FEATURES + getFallbackShellReadyConfig = (shell) => + getShellLaunchConfig( + shell, + selectShellStartupFeatures({ + shellPath: shell, + env: finalEnv, + hasStartupCommand: Boolean(args.command), + waitsForShellReady, + // Why identical: the identity marker exists so the readiness + // handshake can bind output to the right shell PID. + emitsStartupIdentity: waitsForShellReady + }) + ) + const shellLaunch = getFallbackShellReadyConfig(shellPath) + Object.assign(finalEnv, shellLaunch.env) + shellArgs = shellLaunch.args ?? shellArgs + shellReadyLaunch = args.command ? shellLaunch : null + } + await prepareLocalPtySpawn(id) if (args.signal?.aborted) { throw new Error('client_disconnected') diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 013caada49c..77c21d39a5f 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -7,12 +7,19 @@ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' -import { SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' +import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-wrapper-root' export function getBashShellReadyRcfileContent(): string { return `# Orca bash shell-ready wrapper +${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -107,7 +114,7 @@ ${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} __orca_prepend_prompt_command "__orca_osc133_precmd" # Why: append the marker through PROMPT_COMMAND so it fires after the login # startup files have rebuilt the prompt, without re-running user rc files. -if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then +if [[ -n "$__orca_ready_marker" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER_ESCAPED}" } diff --git a/src/main/providers/local-pty-shell-ready-test-harness.ts b/src/main/providers/local-pty-shell-ready-test-harness.ts index d73abf7271d..4cf384d68b5 100644 --- a/src/main/providers/local-pty-shell-ready-test-harness.ts +++ b/src/main/providers/local-pty-shell-ready-test-harness.ts @@ -1,6 +1,9 @@ import { afterEach, describe, vi } from 'vitest' import { spawnSync } from 'node:child_process' -import type * as LocalPtyShellReadyModule from './local-pty-shell-ready' +import { + OVERLAY_ONLY_FEATURES, + STARTUP_COMMAND_FEATURES +} from '../shell-startup-launch-intent-fixtures' // Why: can't import electron (bundled into the plain-node daemon-entry fork), so tests set the wrapper root via ORCA_USER_DATA_PATH instead of mocking app. export function setTestUserDataPath(path: string): void { @@ -18,9 +21,18 @@ export function restoreUserDataPathAfterEach(): void { }) } -export async function importFreshLocalPtyShellReady(): Promise { +export async function importFreshLocalPtyShellReady() { vi.resetModules() - return import('./local-pty-shell-ready') + const module = await import('./local-pty-shell-ready') + // Why the two adapters: the module exposes one feature-driven entry point, and + // these are the two launch intents the call sites select between. + return { + ...module, + getShellReadyLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, STARTUP_COMMAND_FEATURES), + getMarkerlessShellLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, OVERLAY_ONLY_FEATURES) + } } /** Plain suite call only; annotated because the inferred `describe.skip` type names vitest-runner internals. */ 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 a60b76c8dcf..2ccf6c347ee 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -11,7 +11,8 @@ 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 { getZshShellReadyRcfileContent } from './local-pty-shell-ready-wrapper-generation' +import { getZshShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-generation' +import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' restoreUserDataPathAfterEach() @@ -95,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="$_orca_home"') + expect(content).toContain('export ZDOTDIR="$REPLY"') } describePosix('local PTY shell-ready launch config', () => { @@ -127,7 +128,9 @@ describePosix('local PTY shell-ready launch config', () => { const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish') expect(config.supportsReadyMarker).toBe(true) - expect(config.env).toEqual({ ORCA_SHELL_READY_MARKER: '1' }) + // Why empty: fish's selection is baked into the init command text, so it + // needs no exported feature variable at all. + expect(config.env).toEqual({}) expect(config.args?.slice(0, 2)).toEqual(['-l', '-C']) const init = config.args?.[2] ?? '' expect(init).toContain('--on-event fish_prompt') @@ -173,14 +176,15 @@ describePosix('local PTY shell-ready launch config', () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME + const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh' - process.env.ORCA_ORIG_ZDOTDIR = '/Users/alice/.config/zsh' - process.env.HOME = '/Users/alice' + process.env.ORCA_ORIG_ZDOTDIR = userZdotdir + process.env.HOME = userDataPath try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + 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 @@ -240,15 +244,18 @@ describePosix('local PTY shell-ready launch config', () => { const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8') const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') - expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('"$REPLY" == */shell-ready/zsh ]]; then') + expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$REPLY"') expectZdotdirSourceContext(zprofile, '.zprofile') expectZdotdirSourceContext(zshrc, '.zshrc') expectZdotdirSourceContext(zlogin, '.zlogin') - expectFinalZdotdirRestoreContext(zshrc) - expectFinalZdotdirRestoreContext(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) + expect(zshrc).toContain('[[ -o login ]] || __orca_shell_epilogue') + expect(zlogin).toContain('__orca_shell_epilogue') }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -256,14 +263,19 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') - expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') - expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"') + // Why .zshenv: the widget registration lives in the epilogue, which .zlogin + // (login) and .zshrc (non-login) both call exactly once. + const zshenv = readFileSync(join(userDataPath, 'shell-ready', '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:}"') + expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') // Why: add-zle-hook-widget aborts its chain on a non-zero earlier hook (e.g. oh-my-zsh vi-mode); don't register the marker through it. - expect(zlogin).not.toContain('add-zle-hook-widget line-init') + 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(zlogin).toContain('== "user:__orca_prompt_mark"') + expect(zshenv).toContain('== "user:__orca_prompt_mark"') + expect(readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')).toContain( + '__orca_shell_epilogue' + ) }) it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => { @@ -271,8 +283,10 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + // Why one file: every restore now lives in the single epilogue in .zshenv, + // which .zshrc and .zlogin each invoke on their own startup path. + const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zlogin = zshrc const bashRc = getBashShellReadyRcfileContent() const restoreLine = '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' @@ -314,7 +328,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() - const zshRc = getZshShellReadyRcfileContent() + // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. + const zshRc = getZshShellReadyWrapperFiles().zshenv // The exact escape sequences terminal-command-lifecycle parses (133;D = finished, 133;C = start). expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') @@ -323,9 +338,10 @@ describePosix('local PTY shell-ready launch config', () => { expect(bashRc).toContain('__orca_prepend_prompt_command "__orca_osc133_precmd"') // DEBUG is armed after setup; lastIndexOf skips the dispatcher's conditional re-arm. expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan( - bashRc.indexOf('if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then') + bashRc.indexOf('if [[ -n "$__orca_ready_marker" ]]; then') ) - // Sanity: zsh wrapper emits the same markers — both branches must stay in sync. + // Sanity: zsh wrapper emits the same markers — both branches must stay in + // sync. They live in the .zshenv epilogue, behind the `markers` feature. expect(zshRc).toContain('printf "\\033]133;D;%s\\007"') expect(zshRc).toContain('printf "\\033]133;C\\007"') }) @@ -411,12 +427,13 @@ describePosix('local PTY shell-ready launch config', () => { it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { const previousZdotdir = process.env.ZDOTDIR - process.env.ZDOTDIR = '/Users/alice/.config/zsh' + const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') + process.env.ZDOTDIR = userZdotdir try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/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 @@ -474,11 +491,12 @@ describePosix('local PTY shell-ready launch config', () => { it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => { const previousZdotdir = process.env.ZDOTDIR - process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom' + const userZdotdir = makeUserZdotdir(userDataPath, 'shell-ready', 'zsh-custom') + process.env.ZDOTDIR = userZdotdir try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/shell-ready/zsh-custom') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -497,11 +515,13 @@ describePosix('local PTY shell-ready launch config', () => { const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"') + expect(zshenv).toContain( + '__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"' + ) expect(zshenv).toContain('source "${_orca_zshenv_path}"') expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') expect(zshenv).toContain( - 'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' + '__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' ) expect(zshenv).toContain('export ZDOTDIR=') }) @@ -515,7 +535,9 @@ 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).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"') + expect(zshenv.indexOf('_orca_user_zdotdir="$REPLY"')).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}}') diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 6dac269b7f4..12f613893ed 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -4,11 +4,13 @@ * 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 { mkdirSync, writeFileSync, chmodSync } from 'node:fs' import { buildZshStartupWrapperFiles, + type ZshStartupWrapperFiles, type ZshStartupWrapperSpec } from '../zsh-startup-wrapper-builder' +import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' +import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { getShellReadyWrapperRoot, @@ -26,73 +28,53 @@ function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: true, skipUserZshrcWhenHomeIsWrapperDir: true, - interactiveRestoreComment: + overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", - loginRestoreComment: - '# Why: .zlogin is the final login startup file before the prompt is shown.', restores: { agentTeamsPath: true, remoteCliBinDir: false, codexHome: true, codexLaunchPreflight: true - }, - readyMarkerOrder: 'before-zdotdir-restore', - legacyFormatting: { - unindentedMimocodeRestore: true, - codexHomeRestoreComment: - "# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files." } } } -export function getZshShellReadyRcfileContent(): string { +export function getZshShellReadyWrapperFiles(): ZshStartupWrapperFiles { return buildZshStartupWrapperFiles(getLocalZshWrapperSpec(`${getShellReadyWrapperRoot()}/zsh`)) - .zshrc } -export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void { +/** True when every wrapper file is present and non-empty afterwards. */ +export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): boolean { if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) { - return + return true } didEnsureShellReadyWrappers = true const zshDir = `${root}/zsh` - const bashDir = `${root}/bash` - const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) - const bashRc = getBashShellReadyRcfileContent() - const files = [ - [`${zshDir}/.zshenv`, zsh.zshenv], - [`${zshDir}/.zprofile`, zsh.zprofile], - [`${zshDir}/.zshrc`, zsh.zshrc], - [`${zshDir}/.zlogin`, zsh.zlogin], - [`${bashDir}/rcfile`, bashRc] - ] as const - - try { - for (const [path, content] of files) { - const dir = path.slice(0, path.lastIndexOf('/')) - mkdirSync(dir, { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) - } - } catch (error) { - // Why: degrade gracefully — a failed wrapper (read-only FS, perms, disk) just means no ready marker, PTY stays usable. - const errorMessage = - error instanceof Error - ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` - : String(error) - console.error(`[shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`) - console.error('[shell-ready] Shell will launch without wrapper (no shell-ready marker)') - // Reset the flag so next attempt will try again + const written = writeShellWrapperFiles( + [ + [`${zshDir}/.zshenv`, zsh.zshenv], + [`${zshDir}/.zprofile`, zsh.zprofile], + [`${zshDir}/.zshrc`, zsh.zshrc], + [`${zshDir}/.zlogin`, zsh.zlogin], + [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], + [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] + ], + '[shell-ready]' + ) + if (!written || !shellReadyWrappersExist(root)) { + // Why reset: the next launch retries instead of trusting a half-written tree. didEnsureShellReadyWrappers = false + return false } + return true } -export function ensureShellReadyWrappers(): void { +export function ensureShellReadyWrappers(): boolean { if (process.platform === 'win32') { - return + return false } - ensureShellReadyWrappersAt() + return ensureShellReadyWrappersAt() } diff --git a/src/main/providers/local-pty-shell-ready-wrapper-root.ts b/src/main/providers/local-pty-shell-ready-wrapper-root.ts index 0fdf1210751..e0163e1e8b2 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-root.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-root.ts @@ -3,7 +3,8 @@ * wrappers emit — the shared contract between wrapper generation and shell launch. */ import { tmpdir } from 'node:os' -import { existsSync } from 'node:fs' +import { statSync } from 'node:fs' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' export const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' @@ -19,10 +20,24 @@ export function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoo `${root}/zsh/.zprofile`, `${root}/zsh/.zshrc`, `${root}/zsh/.zlogin`, + `${root}/zsh/${ZSH_WRAPPER_DIR_MARKER_FILE}`, `${root}/bash/rcfile` ] } +/** + * Every wrapper file exists and has content. + * + * Why non-empty and not just present: a partial write (full disk, killed mid + * write) leaves a zero-byte .zshenv, and pointing ZDOTDIR at that directory + * makes zsh skip the user's entire configuration silently. + */ export function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { - return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) + return getRequiredShellReadyWrapperPaths(root).every((path) => { + try { + return statSync(path).size > 0 + } catch { + return false + } + }) } diff --git a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts index 8f483793f91..3d435b6731f 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts @@ -86,7 +86,11 @@ describePosix('live zsh subprocess tests', () => { }) it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { - const userZdotdir = join('/home', 'alice', '.config', 'zsh') + // Why a real dir: an inherited ZDOTDIR only counts as the user's config + // root when it actually holds a zsh startup file. + const userZdotdir = join(testHome, '.config', 'zsh') + mkdirSync(userZdotdir, { recursive: true }) + writeFileSync(join(userZdotdir, '.zshrc'), '') const previousZdotdir = process.env.ZDOTDIR const previousHome = process.env.HOME diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index f1a4cda012e..3deff780625 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -12,9 +12,19 @@ import { } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' import { getFishShellReadyInitCommand } from '../shell-templates' +import { + encodeShellStartupFeatures, + SHELL_STARTUP_FEATURE_ENV, + type ShellStartupFeature +} from '../shell-startup-features' +import { + resolveInheritedZdotdir, + resolveInheritedZshenvSourceDir +} from '../zsh-wrapper-dir-ownership' import { ensureShellReadyWrappers } from './local-pty-shell-ready-wrapper-generation' import { getShellReadyWrapperRoot, + shellReadyWrappersExist, SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-wrapper-root' export { @@ -25,68 +35,77 @@ export { } from '../shell-ready-marker-scanner' export type { ShellReadyScanResult, ShellReadyScanState } from '../shell-ready-marker-scanner' -// Why: an Orca wrapper ZDOTDIR recurses; treat it as unset and fall back to HOME. -function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { - if (!value) { - return null - } - // Why: strip trailing slashes so wrapper paths match; `/` falls back to HOME. - const normalized = value.replace(/\/+$/, '') - if (!normalized || normalized.endsWith('/shell-ready/zsh')) { - return null - } - return value -} - -function resolveOriginalZdotdir(): string { - return ( - normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || - normalizeOriginalZdotdirCandidate(process.env.ORCA_ORIG_ZDOTDIR) || - process.env.HOME || - '' - ) -} - -function resolveOriginalZshenvSourceDir(): string { - return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' -} - export type ShellReadyLaunchConfig = { args: string[] | null env: Record supportsReadyMarker: boolean } -function getWrappedShellLaunchConfig( +const UNWRAPPED: ShellReadyLaunchConfig = { + args: null, + env: {}, + supportsReadyMarker: false +} + +/** True when the wrapper tree is complete on disk right now. */ +function wrapperTreeUsable(): boolean { + const ensured = ensureShellReadyWrappers() + return ensured && shellReadyWrappersExist() +} + +/** Args that point bash at Orca's rcfile, or null when it is not usable. */ +export function getBashWrapperLaunchArgs(): string[] | null { + return shellReadyWrappersExist() + ? ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`] + : null +} + +/** + * The one launch-config entry point: args + env for a shell that should start + * with exactly `features` enabled. An empty selection is never wrapped. + */ +export function getShellLaunchConfig( shellPath: string, - options: { emitReadyMarker: boolean } + features: readonly ShellStartupFeature[] ): ShellReadyLaunchConfig { const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() if (shellName === 'zsh') { - ensureShellReadyWrappers() + if (features.length === 0) { + return UNWRAPPED + } + if (!wrapperTreeUsable()) { + // Why plain login zsh: ZDOTDIR pointed at an incomplete wrapper dir makes + // zsh skip the user's whole config. Losing Orca's features is recoverable. + return { args: ['-l'], env: {}, supportsReadyMarker: false } + } return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), - ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), + ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), + ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', - ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' + [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, - supportsReadyMarker: options.emitReadyMarker + supportsReadyMarker: features.includes('ready') } } if (shellName === 'bash') { + if (features.length === 0) { + return UNWRAPPED + } ensureShellReadyWrappers() + const args = getBashWrapperLaunchArgs() + if (!args) { + return UNWRAPPED + } return { - args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`], + args, env: { - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', - ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' + [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, - supportsReadyMarker: options.emitReadyMarker + supportsReadyMarker: features.includes('ready') } } @@ -103,30 +122,19 @@ function getWrappedShellLaunchConfig( } } - // Why: mirrors daemon/shell-ready.ts; markerless fish stays unwrapped. - if (shellName === 'fish' && options.emitReadyMarker) { + // Why: mirrors daemon/shell-ready.ts; markerless fish stays unwrapped. The + // selection is baked into the init command, so fish needs no feature env var. + if (shellName === 'fish' && features.includes('ready')) { return { args: [ '-l', '-C', `${getFishShellReadyInitCommand(SHELL_READY_MARKER_ESCAPED)}\n${getFishCodexShellLaunchPreflight()}` ], - env: { ORCA_SHELL_READY_MARKER: '1' }, + env: {}, supportsReadyMarker: true } } - return { - args: null, - env: {}, - supportsReadyMarker: false - } -} - -export function getShellReadyLaunchConfig(shellPath: string): ShellReadyLaunchConfig { - return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true }) -} - -export function getMarkerlessShellLaunchConfig(shellPath: string): ShellReadyLaunchConfig { - return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false }) + return UNWRAPPED } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index f0e2e1be4b3..5b50e162c61 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -384,8 +384,9 @@ describe('resolveWindowsShellLaunchArgs', () => { // Why: typed OMP keeps its existing shell integration, while typed Prime // commands must reach the user's binary without Orca rewriting argv. const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') - const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') - for (const wrapperFile of [bashRcfile, zshLogin]) { + // Why .zshenv: the omp wrapper is part of the epilogue defined there. + const zshEnv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + for (const wrapperFile of [bashRcfile, zshEnv]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') expect(wrapperFile).not.toContain('prime-agent()') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c7a5f3b8a53..8ad2d67b082 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -6,7 +6,7 @@ import { escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' -import { getMarkerlessShellLaunchConfig } from './local-pty-shell-ready' +import { getBashWrapperLaunchArgs } from './local-pty-shell-ready' import { ensureShellReadyWrappersAt } from './local-pty-shell-ready-wrapper-generation' import { encodePowerShellCommand, @@ -34,7 +34,7 @@ function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string { } ensureShellReadyWrappersAt() - const wrapperArgs = getMarkerlessShellLaunchConfig('bash').args + const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { return GIT_BASH_UTF8_LOGIN_COMMAND } diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts new file mode 100644 index 00000000000..12d88a413c8 --- /dev/null +++ b/src/main/shell-startup-feature-channel.test.ts @@ -0,0 +1,312 @@ +/** + * The feature channel's two load-bearing guarantees: + * + * 1. Leak-proof — the selection is a pure function of spawn env + launch intent, + * and the variable that carries it is destroyed by the wrapper before the + * user's own config runs. Neither an inherited value nor a stale one in + * `process.env` can turn a feature on or off for a shell or its children. + * (#15197 shipped exported switches twice and re-broke #11146 and agent + * status both times.) + * 2. Behaviour parity — a pane wrapped only because Orca injected a worktree + * HISTFILE runs exactly what an unwrapped pane runs, with no OSC 133. + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +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 { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { + importFreshLocalPtyShellReady, + restoreUserDataPathAfterEach, + setTestUserDataPath +} from './providers/local-pty-shell-ready-test-harness' + +restoreUserDataPathAfterEach() + +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 +const describePosix = process.platform === 'win32' ? describe.skip : describe + +const PLAIN_PANE = { + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false +} + +describe('shell startup feature selection', () => { + it('ignores an inherited ORCA_SHELL_FEATURES in the spawn env', () => { + // Why: the value a parent shell exported is not an input. If it were, a pane + // opened from another pane would inherit that pane's feature set. + expect( + selectShellStartupFeatures({ + shellPath: '/bin/zsh', + env: { ORCA_SHELL_FEATURES: 'overlay,markers,ready,identity,history' }, + ...PLAIN_PANE + }) + ).toEqual([]) + }) + + it('cannot be turned off for a pane that needs a feature', () => { + // Why: the allowlist is positive only — there is no value of the variable + // that suppresses anything, so an inherited one can never mean "less". + expect( + selectShellStartupFeatures({ + shellPath: '/bin/zsh', + env: { ORCA_SHELL_FEATURES: '', ORCA_HISTFILE: '/tmp/wt/zsh_history' }, + ...PLAIN_PANE + }) + ).toEqual(['history']) + }) + + it('gives a history-only pane no command markers', () => { + // Why: markers would be new output on a pane that emitted none before. + expect( + selectShellStartupFeatures({ + shellPath: '/bin/zsh', + env: { ORCA_HISTFILE: '/tmp/wt/zsh_history' }, + ...PLAIN_PANE + }) + ).not.toContain('markers') + }) + + it('keeps markers on a pane that already had them', () => { + expect( + selectShellStartupFeatures({ + shellPath: '/bin/zsh', + env: { ORCA_CODEX_HOME: '/tmp/codex' }, + ...PLAIN_PANE + }) + ).toContain('markers') + }) + + it('does not wrap bash for history alone', () => { + // Why: bash has no system rc that clobbers HISTFILE, and `--rcfile` would + // replace its login startup-file chain with Orca's approximation. + expect( + selectShellStartupFeatures({ + shellPath: '/bin/bash', + env: { ORCA_HISTFILE: '/tmp/wt/bash_history' }, + ...PLAIN_PANE + }) + ).toEqual([]) + }) +}) + +describePosix('zsh launch config', () => { + let userDataPath = '' + let previousFeatures: string | undefined + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-')) + setTestUserDataPath(userDataPath) + previousFeatures = process.env.ORCA_SHELL_FEATURES + }) + + afterEach(() => { + if (previousFeatures === undefined) { + delete process.env.ORCA_SHELL_FEATURES + } else { + process.env.ORCA_SHELL_FEATURES = previousFeatures + } + chmodSync(userDataPath, 0o755) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('publishes exactly the selected features, whatever process.env holds', async () => { + process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + + const config = getShellLaunchConfig('/bin/zsh', ['history']) + + expect(config.env.ORCA_SHELL_FEATURES).toBe('history') + }) + + it('falls back to plain login zsh when the wrapper tree cannot be written', async () => { + // Why: pointing ZDOTDIR at an unwritten wrapper dir makes zsh skip the + // user's entire configuration — silently, and for every future pane. + chmodSync(userDataPath, 0o500) + if ( + spawnSync('sh', ['-c', `touch ${JSON.stringify(join(userDataPath, 'probe'))}`]).status === 0 + ) { + return // running with write access regardless of mode (e.g. root) + } + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + + const config = getShellLaunchConfig('/bin/zsh', ['history']) + + expect(config).toEqual({ + args: ['-l'], + env: {}, + supportsReadyMarker: false + }) + }) + + it('does not treat another terminal’s hijacked ZDOTDIR as the user config dir', async () => { + // Why: Orca can be launched from a terminal that already owns ZDOTDIR. Only + // a dir Orca can prove is its own, or one holding no zsh startup file at + // all, may be rejected — never a vendor guessed at by name. + const home = mkdtempSync(join(tmpdir(), 'orca-stacked-zdotdir-')) + const foreignWrapper = join(home, 'other-terminal', 'zsh') + mkdirSync(foreignWrapper, { recursive: true }) + const previousZdotdir = process.env.ZDOTDIR + const previousHome = process.env.HOME + process.env.ZDOTDIR = foreignWrapper + process.env.HOME = home + 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) + + // 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) + + // A real user config dir still round-trips. + rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE)) + const real = await importFreshLocalPtyShellReady() + expect(real.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe( + foreignWrapper + ) + } finally { + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +/** A temp HOME whose four zsh startup files each announce that they ran. */ +function makeUserHome(): string { + const home = mkdtempSync(join(tmpdir(), 'orca-feature-home-')) + for (const file of ['.zshenv', '.zprofile', '.zshrc', '.zlogin']) { + writeFileSync(join(home, file), `print -r -- "RAN=${file}"\n`) + } + return home +} + +describePosix('history-only pane in a real zsh', () => { + let userDataPath = '' + let home = '' + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-live-')) + setTestUserDataPath(userDataPath) + home = makeUserHome() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + 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:-}]"' + + async function launchHistoryOnly(): Promise<{ + args: string[] + env: Record + zdotdir: string + }> { + const scoped = join(home, 'orca-history', 'zsh_history') + const spawnEnv: Record = { + HOME: home, + HISTFILE: scoped, + ORCA_HISTFILE: scoped + } + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: spawnEnv, + ...PLAIN_PANE + }) + expect(features).toEqual(['history']) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const launch = getShellLaunchConfig(ZSH_PATH, features) + return { + args: launch.args ?? ['-l'], + 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 + }, + zdotdir: launch.env.ZDOTDIR + } + } + + itWithZsh('unsets the feature channel for the shell and for a grandchild', async () => { + const { args, env } = await launchHistoryOnly() + + const output = execFileSync( + ZSH_PATH, + [ + ...args, + '-i', + '-c', + `print -r -- "SELF=[\${ORCA_SHELL_FEATURES:-}]"; zsh -c 'printenv ORCA_SHELL_FEATURES; print -r -- CHILD_CLEAN'` + ], + { encoding: 'utf8', timeout: 20_000, env } + ) + + expect(output).toContain('SELF=[]') + expect(output).toContain('CHILD_CLEAN') + // A grandchild that saw the variable would have printed its value first. + expect(output).not.toContain('history') + }) + + itWithZsh('emits no OSC 133 and matches an unwrapped pane', async () => { + const { args, env } = await launchHistoryOnly() + + 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 } + }) + + // eslint-disable-next-line no-control-regex + expect(wrapped).not.toMatch(/]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') + }) +}) diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts new file mode 100644 index 00000000000..bbe0d014638 --- /dev/null +++ b/src/main/shell-startup-features.ts @@ -0,0 +1,95 @@ +/** + * The single env variable Orca uses to tell a launched shell which startup + * features its wrapper should turn on, plus the pure selection that fills it. + * + * Why a positive allowlist the wrapper destroys before anything else runs: + * every earlier switch was a negative, exported one (`ORCA_SHELL_READY_MARKER=0`, + * `ORCA_SHELL_COMMAND_MARKERS=0`). Those live in the pane's PTY env, so every + * child inherits them — a pane launched with a feature suppressed suppressed it + * for an Orca started from that pane too. With an allowlist, an inherited or + * stale value can only ever mean *fewer* features, never more, and the wrapper + * unsets it before the user's own config (or anything it spawns) can see it. + */ + +export const SHELL_STARTUP_FEATURE_ENV = 'ORCA_SHELL_FEATURES' + +export const SHELL_STARTUP_FEATURES = [ + 'overlay', + 'history', + 'markers', + 'ready', + 'identity' +] as const + +export type ShellStartupFeature = (typeof SHELL_STARTUP_FEATURES)[number] + +/** Spawn-env keys that mean this pane carries an Orca overlay the wrapper must re-apply. */ +const OVERLAY_ENV_KEYS = [ + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_MIMOCODE_HOME', + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_CODEX_HOME', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_REMOTE_CLI_BIN_DIR' +] as const + +export type ShellStartupFeatureInput = { + /** Path (or bare name) of the shell being launched. */ + shellPath: string + /** The env this spawn will hand the shell — never `process.env`. */ + env: Record + /** True when Orca will deliver a startup command into this pane. */ + hasStartupCommand: boolean + /** True when that delivery waits for the wrapper's OSC 777 readiness marker. */ + waitsForShellReady: boolean + /** True when Orca needs the shell to announce its PID at startup. */ + emitsStartupIdentity: boolean +} + +function shellName(shellPath: string): string { + return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' +} + +/** + * Pure function of spawn env + launch intent. Nothing here reads + * `ORCA_SHELL_FEATURES`, so a value inherited from a parent shell cannot + * enable or disable anything for the shell Orca is about to launch. + */ +export function selectShellStartupFeatures(input: ShellStartupFeatureInput): ShellStartupFeature[] { + const overlay = OVERLAY_ENV_KEYS.some((key) => Boolean(input.env[key])) + // Exactly the panes Orca wrapped before history widened wrapping. + const wrappedBefore = overlay || input.hasStartupCommand + const ready = input.waitsForShellReady + // Why zsh only: the unguarded HISTFILE assignment lives in the *system zshrc*. + // bash has no equivalent, and wrapping bash for history alone would swap its + // login startup-file chain for Orca's approximation of one. + // Why also when Orca injected nothing: any wrapped pane has Orca's ZDOTDIR in + // place while the system zshrc runs, so the clobbered value it derives lands + // inside Orca's wrapper dir and has to be repaired the same way. + const history = + shellName(input.shellPath) === 'zsh' && (Boolean(input.env.ORCA_HISTFILE) || wrappedBefore) + + const features: ShellStartupFeature[] = [] + if (overlay) { + features.push('overlay') + } + if (history) { + features.push('history') + } + // Why gated on wrappedBefore: a pane wrapped only for history must stay + // observably identical to the unwrapped pane it was before this change. + if (wrappedBefore) { + features.push('markers') + } + if (ready) { + features.push('ready') + } + if (input.emitsStartupIdentity) { + features.push('identity') + } + return features +} + +export function encodeShellStartupFeatures(features: readonly ShellStartupFeature[]): string { + return features.join(',') +} diff --git a/src/main/shell-startup-launch-intent-fixtures.ts b/src/main/shell-startup-launch-intent-fixtures.ts new file mode 100644 index 00000000000..60a7ca94e06 --- /dev/null +++ b/src/main/shell-startup-launch-intent-fixtures.ts @@ -0,0 +1,25 @@ +/** + * The two launch intents Orca's call sites choose between, resolved through the + * real selector so tests cannot drift from the decision production makes. + * + * Test support only; nothing under src/main imports this at runtime. + */ +import { selectShellStartupFeatures } from './shell-startup-features' + +/** A pane Orca will write a startup command into. */ +export const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({ + shellPath: 'zsh', + env: {}, + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true +}) + +/** A pane carrying an Orca overlay but no startup command. */ +export const OVERLAY_ONLY_FEATURES = selectShellStartupFeatures({ + shellPath: 'zsh', + env: { ORCA_CODEX_HOME: '/tmp/orca-codex-home' }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false +}) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index f9e5283dea1..26e4ea53e61 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -1,24 +1,77 @@ // Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery; // 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, `'\\''`)}'` } -export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if [[ "\${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\\033]777;orca-shell-start:%s\\007" "$$" -fi` +/** Basename of the file every Orca-generated zsh wrapper dir is stamped with. */ +export const ZSH_WRAPPER_DIR_MARKER_FILE = '.orca-shell-wrapper' + +export const ZSH_WRAPPER_DIR_MARKER_CONTENT = `# Orca-generated zsh startup wrapper directory. +# Its presence is how Orca recognises its own wrapper dir instead of treating it +# as the user's ZDOTDIR. Do not edit; the whole directory is regenerated. +` + +/** + * The first executable lines of every zsh wrapper: read the feature allowlist, + * then destroy the variable. + * + * Why destroy it here: `_orca_shell_features` is a plain (non-exported) shell + * variable, so it survives .zshenv -> .zprofile -> .zshrc -> .zlogin in this + * process but physically cannot reach a child. Unsetting before the user's own + * .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 +_orca_shell_features=(\${(s:,:)\${${SHELL_STARTUP_FEATURE_ENV}:-}}) +builtin unset ${SHELL_STARTUP_FEATURE_ENV} +__orca_has_feature() { (( \${_orca_shell_features[(Ie)$1]} )) }` + +/** The bash rcfile equivalent of ZSH_FEATURE_CHANNEL_BLOCK. */ +export const BASH_FEATURE_CHANNEL_BLOCK = `_orca_shell_features=",\${${SHELL_STARTUP_FEATURE_ENV}:-}," +builtin unset ${SHELL_STARTUP_FEATURE_ENV} +__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }` + +// Why one line usable by both languages: __orca_has_feature is defined with the +// same name and semantics in the zsh and bash channel blocks above. +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. + * + * 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. + */ +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" + 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:-}" + [[ "$REPLY" == "$HOME" ]] && return 0 + local _orca_startup_file + for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do + [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + done + REPLY="$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' -export function getZshEnvTemplate( - zshDir: string, - headerLabel = 'Orca zsh shell-ready wrapper' -): string { - return `# ${headerLabel} -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -# Why: capture the runtime wrapper dir before it is unset below. On WSL this + +/** 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 @@ -35,26 +88,15 @@ fi while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do _orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}" done -_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" -_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" -_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" _orca_zshenv_path="" -unset ORCA_ZSHENV_SOURCE_DIR # Normalize fallback and source roots before reading user .zshenv so nested # Orca PTYs never source another Orca wrapper recursively. -while [[ "\${_orca_user_zdotdir}" == */ ]]; do - _orca_user_zdotdir="\${_orca_user_zdotdir%/}" -done -case "\${_orca_user_zdotdir}" in - ""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;; -esac -while [[ "\${_orca_zshenv_source_dir}" == */ ]]; do - _orca_zshenv_source_dir="\${_orca_zshenv_source_dir%/}" -done -case "\${_orca_zshenv_source_dir}" in - ""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;; -esac +__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_user_zdotdir="$REPLY" +__orca_resolve_inherited_config_dir "\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" +_orca_zshenv_source_dir="$REPLY" +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. @@ -82,15 +124,10 @@ if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ] _orca_discovered_zdotdir="" fi -export ORCA_ORIG_ZDOTDIR="\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}" - -while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do - ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" -done - -case "\${ORCA_ORIG_ZDOTDIR}" in - ""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac +# 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" # Why: use :- after user .zshenv — a pathological unset under set -u must not # abort the wrapper; empty falls through to the baked-literal branch. @@ -99,30 +136,24 @@ if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self else export ZDOTDIR=${quotePosixSingle(zshDir)} fi -unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self ` } /** - * The relay variant of the .zshenv above: it trusts the ZDOTDIR the remote + * 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 verbatim here — reconciling the two is a follow-up. + * and is preserved here — reconciling the two is a follow-up. */ -export function getZshOverlayEnvTemplate(zshDir: string, headerLabel: string): string { - return `# ${headerLabel} -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "\${ORCA_ORIG_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac +export function getZshOverlayEnvBody(zshDir: string): string { + return `__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" +export ORCA_ORIG_ZDOTDIR="$REPLY" [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "\${ORCA_USER_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; -esac +__orca_resolve_user_config_dir "\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +export ORCA_USER_ZDOTDIR="$REPLY" export ZDOTDIR=${quotePosixSingle(zshDir)} ` } @@ -136,11 +167,13 @@ export ZDOTDIR=${quotePosixSingle(zshDir)} * wrapper dir the replacement lands INSIDE it. Per-worktree history was a * silent no-op on the primary platform as a result (#11044). * - * Emitted after the user's own startup file has been sourced, so it is the last - * word, exactly like the CODEX_HOME/OPENCODE_CONFIG_DIR restores beside it. + * `builtin unset ORCA_HISTFILE` is the root-cause fix for #11146: the variable + * 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 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 @@ -163,10 +196,8 @@ export function getZshStartupFileSourceBlock(options: { `-f "$_orca_home/${options.fileName}"` ].filter(Boolean) - return `_orca_home=${homeExpression} -case "\${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac + return `__orca_resolve_user_config_dir ${homeExpression} +_orca_home="$REPLY" if [[ ${checks.join(' && ')} ]]; then _orca_wrapper_zdotdir="$ZDOTDIR" # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; @@ -188,31 +219,28 @@ fi // startup command on the pre-ready timeout. Instead, own zle-line-init: emit // the marker first, then chain to whatever widget was installed before. export function getZshShellReadyMarkerRegistrationBlock(escapedMarker: string): string { - return `if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. - if [[ "\${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then - : - elif (( \${+widgets[zle-line-init]} )) && [[ "\${widgets[zle-line-init]}" == user:* ]]; then - __orca_prev_line_init_fn="\${widgets[zle-line-init]#user:}" - else - __orca_prev_line_init_fn="" - fi - __orca_prompt_mark() { - printf "${escapedMarker}" - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. - if [[ -n "\${__orca_prev_line_init_fn:-}" ]]; then - "\${__orca_prev_line_init_fn}" "$@" - fi - } - zle -N zle-line-init __orca_prompt_mark + return `# Why: capture the prior zle-line-init so the marker chains to it. On a +# re-source we are already the bound widget, so keep the function captured +# the first time instead of clobbering it to empty (which would silently +# drop the user's widget on every prompt after the second source). Only +# user-defined widgets are chainable as plain functions; builtin/completion +# forms (rare for zle-line-init) are left unchained. +if [[ "\${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then + : +elif (( \${+widgets[zle-line-init]} )) && [[ "\${widgets[zle-line-init]}" == user:* ]]; then + __orca_prev_line_init_fn="\${widgets[zle-line-init]#user:}" +else + __orca_prev_line_init_fn="" fi -` +__orca_prompt_mark() { + printf "${escapedMarker}" + # Why: call the prior hook as a plain function, not an aliased widget, so + # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + if [[ -n "\${__orca_prev_line_init_fn:-}" ]]; then + "\${__orca_prev_line_init_fn}" "$@" + fi +} +zle -N zle-line-init __orca_prompt_mark` } // Why: fish has no ZDOTDIR-style wrapper dir, so the marker rides `--init-command`, @@ -222,23 +250,21 @@ fi // just *before* fish arms `?2004h`, which PostReadyFlushGate absorbs. `builtin // printf` so a user-defined printf can't silently swallow the marker and send // every launch to the ready timeout. +// +// Why no feature variable: the init command is composed per pane, so the +// selection is already baked into the text and nothing needs to be exported. export function getFishShellReadyInitCommand(escapedMarker: string): string { - return `if test "$ORCA_SHELL_READY_MARKER" = 1 - function __orca_shell_ready_marker --on-event fish_prompt - builtin printf "${escapedMarker}" - functions -e __orca_shell_ready_marker - end + return `function __orca_shell_ready_marker --on-event fish_prompt + builtin printf "${escapedMarker}" + functions -e __orca_shell_ready_marker end` } export function getZshFinalZdotdirRestoreBlock(homeExpression = '"${ORCA_ORIG_ZDOTDIR:-$HOME}"') { - return `_orca_home=${homeExpression} -case "\${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac + 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_home" -unset _orca_home +export ZDOTDIR="$REPLY" +unset _orca_home REPLY ` } diff --git a/src/main/shell-wrapper-file-writer.ts b/src/main/shell-wrapper-file-writer.ts new file mode 100644 index 00000000000..1e57a210bba --- /dev/null +++ b/src/main/shell-wrapper-file-writer.ts @@ -0,0 +1,44 @@ +/** + * Writes a generated shell wrapper tree atomically, and says whether it worked. + * + * Why the boolean matters: the launch config points ZDOTDIR at the wrapper dir. + * If a write failed — read-only FS, full disk, EACCES — the old code still + * pointed ZDOTDIR at that dir, so zsh found no .zshrc there and the user + * silently lost their entire configuration. Callers use the result to fall back + * to an unwrapped shell instead. + */ +import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export type ShellWrapperFile = readonly [path: string, content: string] + +export function writeShellWrapperFiles( + files: readonly ShellWrapperFile[], + logPrefix: string +): boolean { + try { + for (const [path, content] of files) { + mkdirSync(dirname(path), { recursive: true }) + // Why temp+rename: a shell reading the wrapper concurrently sees either + // the old file or the new one, never a truncated prefix of the new one. + const temporaryPath = `${path}.orca-tmp-${process.pid}` + try { + writeFileSync(temporaryPath, content, 'utf8') + chmodSync(temporaryPath, 0o644) + renameSync(temporaryPath, path) + } catch (error) { + rmSync(temporaryPath, { force: true }) + throw error + } + } + return true + } catch (error) { + const errorMessage = + error instanceof Error + ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` + : String(error) + console.error(`${logPrefix} Failed to write shell wrapper files: ${errorMessage}`) + console.error(`${logPrefix} Shell will launch unwrapped`) + return false + } +} diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index a8e7eb2d20f..c40780775b3 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -10,9 +10,20 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' -import { getShellReadyLaunchConfig as getDaemonShellReadyLaunchConfig } from './daemon/shell-ready' +import { getShellLaunchConfig as getDaemonShellLaunchConfig } from './daemon/shell-ready' import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' -import { getShellReadyLaunchConfig as getLocalShellReadyLaunchConfig } from './providers/local-pty-shell-ready' +import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready' +import { selectShellStartupFeatures } from './shell-startup-features' + +// Why the real selector: the snapshots then pin what a startup-command pane +// actually launches with, not a hand-written feature list. +const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({ + shellPath: 'zsh', + env: {}, + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true +}) const WRAPPER_FILES = [ ['zsh-zshenv', join('zsh', '.zshenv')], @@ -70,7 +81,7 @@ describePosix('generated shell wrapper files', () => { it('daemon wrappers', async () => { process.env.ORCA_USER_DATA_PATH = root - getDaemonShellReadyLaunchConfig('/bin/zsh') + getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) await expectWrapperFiles('daemon', join(root, 'shell-ready')) }) @@ -81,8 +92,8 @@ describePosix('generated shell wrapper files', () => { it('fish shell-ready init commands', async () => { process.env.ORCA_USER_DATA_PATH = root - const local = getLocalShellReadyLaunchConfig('/usr/bin/fish') - const daemon = getDaemonShellReadyLaunchConfig('/usr/bin/fish') + const local = getLocalShellLaunchConfig('/usr/bin/fish', STARTUP_COMMAND_FEATURES) + const daemon = getDaemonShellLaunchConfig('/usr/bin/fish', STARTUP_COMMAND_FEATURES) await expect(local.args?.[2]).toMatchFileSnapshot(snapshotPath('local', 'fish-init')) await expect(daemon.args?.[2]).toMatchFileSnapshot(snapshotPath('daemon', 'fish-init')) }) diff --git a/src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt index c55bde22b68..03453cb33d3 100644 --- a/src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -1,8 +1,14 @@ # Orca daemon bash shell-ready wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi +_orca_shell_features=",${ORCA_SHELL_FEATURES:-}," +builtin unset ORCA_SHELL_FEATURES +__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -84,7 +90,7 @@ __orca_osc133_precmd() { # Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry) # so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not # displaced by one of Orca's own hooks. - [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]] && printf "\033]777;orca-shell-ready\007" + [[ -n "$__orca_ready_marker" ]] && printf "\033]777;orca-shell-ready\007" return "$exit_code" } __orca_osc133_preexec() { diff --git a/src/main/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/shell-wrapper-snapshots/daemon-fish-init.txt index 94381e52c1a..9c8b9179a81 100644 --- a/src/main/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/shell-wrapper-snapshots/daemon-fish-init.txt @@ -1,8 +1,6 @@ -if test "$ORCA_SHELL_READY_MARKER" = 1 - function __orca_shell_ready_marker --on-event fish_prompt - builtin printf "\033]777;orca-shell-ready\007" - functions -e __orca_shell_ready_marker - end +function __orca_shell_ready_marker --on-event fish_prompt + builtin printf "\033]777;orca-shell-ready\007" + functions -e __orca_shell_ready_marker end if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt index b1a78cdfcfc..28cf56ec0fe 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt @@ -1,8 +1,6 @@ # Orca daemon zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then _orca_wrapper_zdotdir="$ZDOTDIR" # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; @@ -13,97 +11,4 @@ if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then unset _orca_wrapper_zdotdir fi -__orca_restore_agent_teams_path() { - [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 - case "$PATH" in - "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; - esac - export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" -} -__orca_restore_agent_teams_path -# Why: .zlogin is the final login startup file before the prompt is shown. -[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" -# Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - -[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 -# Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : - command codex "$@" - } -fi -unset __orca_codex_binary - -if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. - if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then - : - elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then - __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" - else - __orca_prev_line_init_fn="" - fi - __orca_prompt_mark() { - printf "\033]777;orca-shell-ready\007" - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. - if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then - "${__orca_prev_line_init_fn}" "$@" - fi - } - zle -N zle-line-init __orca_prompt_mark -fi - -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - +__orca_shell_epilogue diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt index cef7c417cfa..9073c69817b 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt @@ -1,8 +1,6 @@ # Orca daemon zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; 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-zshenv.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 0c95f943fd8..1d5a06aa1e0 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -1,8 +1,28 @@ # Orca daemon zsh shell-ready wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi +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() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$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:-}" + [[ "$REPLY" == "$HOME" ]] && return 0 + local _orca_startup_file + for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do + [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + done + REPLY="$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 # literal is unusable there and ZDOTDIR must be restored from this value. @@ -20,26 +40,15 @@ fi while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" done -_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}" -_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}" -_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" _orca_zshenv_path="" -unset ORCA_ZSHENV_SOURCE_DIR # Normalize fallback and source roots before reading user .zshenv so nested # Orca PTYs never source another Orca wrapper recursively. -while [[ "${_orca_user_zdotdir}" == */ ]]; do - _orca_user_zdotdir="${_orca_user_zdotdir%/}" -done -case "${_orca_user_zdotdir}" in - ""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;; -esac -while [[ "${_orca_zshenv_source_dir}" == */ ]]; do - _orca_zshenv_source_dir="${_orca_zshenv_source_dir%/}" -done -case "${_orca_zshenv_source_dir}" in - ""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;; -esac +__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_user_zdotdir="$REPLY" +__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" +_orca_zshenv_source_dir="$REPLY" +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. @@ -67,15 +76,10 @@ if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; _orca_discovered_zdotdir="" fi -export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" - -while [[ "${ORCA_ORIG_ZDOTDIR}" == */ ]]; do - ORCA_ORIG_ZDOTDIR="${ORCA_ORIG_ZDOTDIR%/}" -done - -case "${ORCA_ORIG_ZDOTDIR}" in - ""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac +# 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" # Why: use :- after user .zshenv — a pathological unset under set -u must not # abort the wrapper; empty falls through to the baked-literal branch. @@ -84,4 +88,122 @@ if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:- else export ZDOTDIR='/zsh' fi -unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self + +__orca_shell_epilogue() { + (( $+_orca_epilogue_done )) && return 0 + typeset -g _orca_epilogue_done=1 + if __orca_has_feature overlay; then + # Why: ~/.zshrc can export the user's default OpenCode config after spawn. + __orca_restore_agent_teams_path() { + [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" + } + __orca_restore_agent_teams_path + [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" + # Why: OMP does not auto-load Orca's managed status extension; wrap only + # interactive launch invocations so subcommands such as `omp config` keep + # their normal argv shape. + __orca_omp_should_skip_extension() { + case "${1:-}" in + help|--help|-h|--version|-v) return 0 ;; + __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + esac + return 1 + } + __orca_omp() { + local __orca_use_extension=1 + __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 + if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then + if [[ "${1:-}" == "launch" ]]; then + shift + command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + else + command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + fi + else + command omp "$@" + fi + } + if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then + omp() { __orca_omp "$@"; } + fi + + # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" + # Why: a typed alias expands inside the shell, after pane launch prep. + __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + codex() { + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + command codex "$@" + } + fi + unset __orca_codex_binary + fi + if __orca_has_feature history; then + 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 + 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" + __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[@]}) + fi + if __orca_has_feature ready; then + # Why: capture the prior zle-line-init so the marker chains to it. On a + # re-source we are already the bound widget, so keep the function captured + # the first time instead of clobbering it to empty (which would silently + # drop the user's widget on every prompt after the second source). Only + # user-defined widgets are chainable as plain functions; builtin/completion + # forms (rare for zle-line-init) are left unchained. + if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then + : + elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then + __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" + else + __orca_prev_line_init_fn="" + fi + __orca_prompt_mark() { + printf "\033]777;orca-shell-ready\007" + # Why: call the prior hook as a plain function, not an aliased widget, so + # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then + "${__orca_prev_line_init_fn}" "$@" + fi + } + 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="$REPLY" + unset _orca_home REPLY + 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 3e7c78831ce..f0356552593 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt @@ -1,8 +1,6 @@ # Orca daemon zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" 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; @@ -13,91 +11,5 @@ if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ] unset _orca_wrapper_zdotdir fi -__orca_restore_agent_teams_path() { - [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 - case "$PATH" in - "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; - esac - export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" -} -[[ ! -o login ]] && __orca_restore_agent_teams_path -if [[ ! -o login ]]; then - # Why: ~/.zshrc can export the user's default OpenCode config after spawn. - [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" - [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" - # Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - - [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 - # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : - command codex "$@" - } -fi -unset __orca_codex_binary - -fi -__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" - __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 [[ ! -o login ]]; then -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - -fi +# Why: a login shell still has .zlogin to load; it runs the epilogue instead. +[[ -o login ]] || __orca_shell_epilogue diff --git a/src/main/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/shell-wrapper-snapshots/local-bash-rcfile.txt index b0436f965b7..3041e85391d 100644 --- a/src/main/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -1,8 +1,14 @@ # Orca bash shell-ready wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi +_orca_shell_features=",${ORCA_SHELL_FEATURES:-}," +builtin unset ORCA_SHELL_FEATURES +__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -311,7 +317,7 @@ __orca_append_prompt_command() { __orca_prepend_prompt_command "__orca_osc133_precmd" # Why: append the marker through PROMPT_COMMAND so it fires after the login # startup files have rebuilt the prompt, without re-running user rc files. -if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then +if [[ -n "$__orca_ready_marker" ]]; then __orca_prompt_mark() { printf "\033]777;orca-shell-ready\007" } diff --git a/src/main/shell-wrapper-snapshots/local-fish-init.txt b/src/main/shell-wrapper-snapshots/local-fish-init.txt index 94381e52c1a..9c8b9179a81 100644 --- a/src/main/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/shell-wrapper-snapshots/local-fish-init.txt @@ -1,8 +1,6 @@ -if test "$ORCA_SHELL_READY_MARKER" = 1 - function __orca_shell_ready_marker --on-event fish_prompt - builtin printf "\033]777;orca-shell-ready\007" - functions -e __orca_shell_ready_marker - end +function __orca_shell_ready_marker --on-event fish_prompt + builtin printf "\033]777;orca-shell-ready\007" + functions -e __orca_shell_ready_marker end if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt index 7e0ae1a5131..a52e74f7f49 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt @@ -1,8 +1,6 @@ # Orca zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then _orca_wrapper_zdotdir="$ZDOTDIR" # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; @@ -13,97 +11,4 @@ if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then unset _orca_wrapper_zdotdir fi -__orca_restore_agent_teams_path() { - [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 - case "$PATH" in - "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; - esac - export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" -} -__orca_restore_agent_teams_path -# Why: .zlogin is the final login startup file before the prompt is shown. -[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" -# Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - -[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 -# Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : - command codex "$@" - } -fi -unset __orca_codex_binary - -if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. - if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then - : - elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then - __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" - else - __orca_prev_line_init_fn="" - fi - __orca_prompt_mark() { - printf "\033]777;orca-shell-ready\007" - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. - if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then - "${__orca_prev_line_init_fn}" "$@" - fi - } - zle -N zle-line-init __orca_prompt_mark -fi - -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - +__orca_shell_epilogue diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt index 6382e9b97e1..755618d8feb 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt @@ -1,8 +1,6 @@ # Orca zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; 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-zshenv.txt b/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt index c5a0bcbd0d9..33ae57023bd 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -1,8 +1,28 @@ # Orca zsh shell-ready wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi +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() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$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:-}" + [[ "$REPLY" == "$HOME" ]] && return 0 + local _orca_startup_file + for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do + [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + done + REPLY="$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 # literal is unusable there and ZDOTDIR must be restored from this value. @@ -20,26 +40,15 @@ fi while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" done -_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}" -_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}" -_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" _orca_zshenv_path="" -unset ORCA_ZSHENV_SOURCE_DIR # Normalize fallback and source roots before reading user .zshenv so nested # Orca PTYs never source another Orca wrapper recursively. -while [[ "${_orca_user_zdotdir}" == */ ]]; do - _orca_user_zdotdir="${_orca_user_zdotdir%/}" -done -case "${_orca_user_zdotdir}" in - ""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;; -esac -while [[ "${_orca_zshenv_source_dir}" == */ ]]; do - _orca_zshenv_source_dir="${_orca_zshenv_source_dir%/}" -done -case "${_orca_zshenv_source_dir}" in - ""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;; -esac +__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_user_zdotdir="$REPLY" +__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" +_orca_zshenv_source_dir="$REPLY" +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. @@ -67,15 +76,10 @@ if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; _orca_discovered_zdotdir="" fi -export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" - -while [[ "${ORCA_ORIG_ZDOTDIR}" == */ ]]; do - ORCA_ORIG_ZDOTDIR="${ORCA_ORIG_ZDOTDIR%/}" -done - -case "${ORCA_ORIG_ZDOTDIR}" in - ""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac +# 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" # Why: use :- after user .zshenv — a pathological unset under set -u must not # abort the wrapper; empty falls through to the baked-literal branch. @@ -84,4 +88,122 @@ if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:- else export ZDOTDIR='/zsh' fi -unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self +unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self + +__orca_shell_epilogue() { + (( $+_orca_epilogue_done )) && return 0 + typeset -g _orca_epilogue_done=1 + if __orca_has_feature overlay; then + # Why: ~/.zshrc can export the user's default OpenCode config after spawn. + __orca_restore_agent_teams_path() { + [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" + } + __orca_restore_agent_teams_path + [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" + # Why: OMP does not auto-load Orca's managed status extension; wrap only + # interactive launch invocations so subcommands such as `omp config` keep + # their normal argv shape. + __orca_omp_should_skip_extension() { + case "${1:-}" in + help|--help|-h|--version|-v) return 0 ;; + __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + esac + return 1 + } + __orca_omp() { + local __orca_use_extension=1 + __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 + if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then + if [[ "${1:-}" == "launch" ]]; then + shift + command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + else + command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + fi + else + command omp "$@" + fi + } + if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then + omp() { __orca_omp "$@"; } + fi + + # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" + # Why: a typed alias expands inside the shell, after pane launch prep. + __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + codex() { + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + command codex "$@" + } + fi + unset __orca_codex_binary + fi + if __orca_has_feature history; then + 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 + 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" + __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[@]}) + fi + if __orca_has_feature ready; then + # Why: capture the prior zle-line-init so the marker chains to it. On a + # re-source we are already the bound widget, so keep the function captured + # the first time instead of clobbering it to empty (which would silently + # drop the user's widget on every prompt after the second source). Only + # user-defined widgets are chainable as plain functions; builtin/completion + # forms (rare for zle-line-init) are left unchained. + if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then + : + elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then + __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" + else + __orca_prev_line_init_fn="" + fi + __orca_prompt_mark() { + printf "\033]777;orca-shell-ready\007" + # Why: call the prior hook as a plain function, not an aliased widget, so + # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then + "${__orca_prev_line_init_fn}" "$@" + fi + } + 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="$REPLY" + unset _orca_home REPLY + 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 1e9bbf79365..4213f169dae 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt @@ -1,8 +1,6 @@ # Orca zsh shell-ready wrapper -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +_orca_home="$REPLY" 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; @@ -13,92 +11,5 @@ if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ] unset _orca_wrapper_zdotdir fi -__orca_restore_agent_teams_path() { - [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 - case "$PATH" in - "${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; - esac - export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" -} -[[ ! -o login ]] && __orca_restore_agent_teams_path -if [[ ! -o login ]]; then - # Why: ~/.zshrc can export the user's default OpenCode config after spawn. - [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" - # Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - - # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. - [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 - # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : - command codex "$@" - } -fi -unset __orca_codex_binary - -fi -__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" - __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 [[ ! -o login ]]; then -_orca_home="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - -fi +# Why: a login shell still has .zlogin to load; it runs the epilogue instead. +[[ -o login ]] || __orca_shell_epilogue diff --git a/src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt index 14fd56d6836..4c76e53acf6 100644 --- a/src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -1,8 +1,14 @@ # Orca relay bash overlay wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi +_orca_shell_features=",${ORCA_SHELL_FEATURES:-}," +builtin unset ORCA_SHELL_FEATURES +__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -50,6 +56,7 @@ 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 @@ -291,7 +298,7 @@ __orca_append_prompt_command() { __orca_prepend_prompt_command "__orca_osc133_precmd" # Why: SSH startup commands are renderer-delivered; emit the same internal # readiness marker as local shells only when that delivery mode asks for it. -if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then +if [[ -n "$__orca_ready_marker" ]]; then __orca_prompt_mark() { printf "\033]777;orca-shell-ready\007" } diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt index 26fff23cbbc..6535c741e30 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt @@ -1,8 +1,6 @@ # Orca relay zsh overlay wrapper -_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" +_orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then _orca_wrapper_zdotdir="$ZDOTDIR" # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; @@ -13,79 +11,4 @@ if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then unset _orca_wrapper_zdotdir fi -# Why: .zlogin is the final zsh login startup file before the prompt. -[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" -[[ -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 -# Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 -_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - -if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. - if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then - : - elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then - __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" - else - __orca_prev_line_init_fn="" - fi - __orca_prompt_mark() { - printf "\033]777;orca-shell-ready\007" - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. - if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then - "${__orca_prev_line_init_fn}" "$@" - fi - } - zle -N zle-line-init __orca_prompt_mark -fi - +__orca_shell_epilogue diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt index 2e14e174e94..8071f1925db 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt @@ -1,8 +1,6 @@ # Orca relay zsh overlay wrapper -_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" +_orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; 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-zshenv.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 54eb71abe0d..1c8968a150f 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -1,15 +1,113 @@ # Orca relay zsh overlay wrapper -if [[ "${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then - unset ORCA_SHELL_STARTUP_IDENTITY - printf "\033]777;orca-shell-start:%s\007" "$$" -fi -export ORCA_ORIG_ZDOTDIR="${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "${ORCA_ORIG_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac +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() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$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:-}" + [[ "$REPLY" == "$HOME" ]] && return 0 + local _orca_startup_file + for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do + [[ -r "$REPLY/$_orca_startup_file" ]] && return 0 + done + REPLY="$HOME" +} +__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" +export ORCA_ORIG_ZDOTDIR="$REPLY" [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${ORCA_USER_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" +export ORCA_USER_ZDOTDIR="$REPLY" export ZDOTDIR='/zsh' + +__orca_shell_epilogue() { + (( $+_orca_epilogue_done )) && return 0 + typeset -g _orca_epilogue_done=1 + 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}" + [[ -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 + # Why: OMP does not auto-load Orca's managed status extension; wrap only + # interactive launch invocations so subcommands such as `omp config` keep + # their normal argv shape. + __orca_omp_should_skip_extension() { + case "${1:-}" in + help|--help|-h|--version|-v) return 0 ;; + __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + esac + return 1 + } + __orca_omp() { + local __orca_use_extension=1 + __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 + if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then + if [[ "${1:-}" == "launch" ]]; then + shift + command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + else + command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" + fi + else + command omp "$@" + fi + } + if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then + omp() { __orca_omp "$@"; } + fi + fi + if __orca_has_feature history; then + 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 + fi + if __orca_has_feature ready; then + # Why: capture the prior zle-line-init so the marker chains to it. On a + # re-source we are already the bound widget, so keep the function captured + # the first time instead of clobbering it to empty (which would silently + # drop the user's widget on every prompt after the second source). Only + # user-defined widgets are chainable as plain functions; builtin/completion + # forms (rare for zle-line-init) are left unchained. + if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then + : + elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then + __orca_prev_line_init_fn="${widgets[zle-line-init]#user:}" + else + __orca_prev_line_init_fn="" + fi + __orca_prompt_mark() { + printf "\033]777;orca-shell-ready\007" + # Why: call the prior hook as a plain function, not an aliased widget, so + # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then + "${__orca_prev_line_init_fn}" "$@" + fi + } + 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="$REPLY" + unset _orca_home REPLY + 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 d9af197bb17..7cbbf1f0232 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt @@ -1,8 +1,6 @@ # Orca relay zsh overlay wrapper -_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac +__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" +_orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then _orca_wrapper_zdotdir="$ZDOTDIR" # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; @@ -13,58 +11,5 @@ if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then unset _orca_wrapper_zdotdir fi -if [[ ! -o login ]]; 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}" - [[ -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 - # Why: OMP does not auto-load Orca's managed status extension; wrap only -# interactive launch invocations so subcommands such as `omp config` keep -# their normal argv shape. -__orca_omp_should_skip_extension() { - case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; - esac - return 1 -} -__orca_omp() { - local __orca_use_extension=1 - __orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0 - if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then - if [[ "${1:-}" == "launch" ]]; then - shift - command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - else - command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@" - fi - else - command omp "$@" - fi -} -if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } -fi - -if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$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 -fi -if [[ ! -o login ]]; then -_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "${_orca_home%/}" in - */shell-ready/zsh) _orca_home="$HOME" ;; -esac -# 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_home" -unset _orca_home - -fi +# Why: a login shell still has .zlogin to load; it runs the epilogue instead. +[[ -o login ]] || __orca_shell_epilogue diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 98af8914ec6..1a7351e36da 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -11,14 +11,18 @@ * 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. + * + * 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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { getZshShellReadyRcfileContent } from './providers/local-pty-shell-ready-wrapper-generation' -import { getZshEnvTemplate, ZSH_HISTFILE_RESTORE_BLOCK } from './shell-templates' +import { getShellLaunchConfig } from './providers/local-pty-shell-ready' +import { selectShellStartupFeatures } from './shell-startup-features' // 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 @@ -31,55 +35,142 @@ const ZSH_PATH = hasZsh : '' const itWithZsh = hasZsh ? it : it.skip -function runLoginZsh(home: string, zdotdir: string, env: Record): string { +function runZsh(args: string[], env: Record): string { // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { + return execFileSync(ZSH_PATH, [...args, '-i', '-c', 'echo "RESULT=$HISTFILE"'], { encoding: 'utf8', timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ZDOTDIR: zdotdir, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home, - ...env - } + env: { PATH: '/usr/bin:/bin', ...env } }) } -describe('worktree-scoped HISTFILE survives zsh startup', () => { - const withWrapper = (run: (home: string, zdotdir: string) => void): void => { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - const zdotdir = join(home, 'shell-ready', 'zsh') - mkdirSync(zdotdir, { recursive: true }) - writeFileSync(join(zdotdir, '.zshenv'), getZshEnvTemplate(zdotdir)) - writeFileSync(join(zdotdir, '.zshrc'), getZshShellReadyRcfileContent()) - writeFileSync(join(zdotdir, '.zlogin'), `${ZSH_HISTFILE_RESTORE_BLOCK}\n`) - try { - run(home, zdotdir) - } finally { - rmSync(home, { recursive: true, force: true }) - } +/** + * True when this host's system zshrc is the thing that destroys HISTFILE. + * + * Why probed and not keyed off `process.platform`: macOS ships the clobber in + * /etc/zshrc, stock Ubuntu ships no such assignment, and a hardened corporate + * image can go either way. Assertions that only mean something under a clobber + * are skipped where there is none rather than asserting a tautology. + */ +const systemZshrcClobbersHistfile = (() => { + if (!hasZsh) { + return false } + const home = mkdtempSync(join(tmpdir(), 'orca-histfile-probe-')) + try { + 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') + } + }) + return !output.includes(join(home, 'injected-history')) + } catch { + return false + } finally { + rmSync(home, { recursive: true, force: true }) + } +})() - itWithZsh('keeps the injected path that a system zshrc would otherwise clobber', () => { - withWrapper((home, zdotdir) => { - const scoped = join(home, 'orca-history', 'zsh_history') +const itWithClobber = systemZshrcClobbersHistfile ? itWithZsh : it.skip - const output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) +/** 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) { + const env: Record = { + HOME: home, + HISTFILE: scopedHistfile, + ORCA_HISTFILE: scopedHistfile + } + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) + return { + features, + launch, + env: { ...env, ...launch.env, ...sandboxConfigDir(home) } + } +} - expect(output).toContain(`RESULT=${scoped}`) +// Why override: the launch config resolves the user's config dir from the real +// process env, and these runs must resolve against the sandbox home instead. +function sandboxConfigDir(home: string): Record { + return { ORCA_ORIG_ZDOTDIR: home, ORCA_ZSHENV_SOURCE_DIR: home } +} + +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 }) + } +} + +const histfileOf = (output: string): string => + /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' + +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 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') }) }) - itWithZsh('never leaves history inside Orca’s own wrapper directory', () => { - withWrapper((home, zdotdir) => { + 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 output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) + const output = runZsh(launch.args ?? ['-l'], env) + + expect(histfileOf(output)).toBe(scoped) + }) + }) + + 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(zdotdir) + expect(output).not.toContain(launch.env.ZDOTDIR) + }) + }) + + itWithZsh('consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', () => { + withTempHome((home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { launch, env } = launchPlainHistoryPane(home, scoped) + + 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 } + } + ) + + // Root-cause fix for #11146: the variable no longer exists after use. + expect(output).toContain('LEAK=[]') }) }) @@ -89,16 +180,23 @@ describe('worktree-scoped HISTFILE survives zsh startup', () => { // 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. - withWrapper((home, zdotdir) => { - const wrapped = runLoginZsh(home, zdotdir, {}) - const unwrapped = execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } + 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 }) - const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) expect(wrapped).not.toContain('ORCA_HISTFILE') }) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 1d3edfacccf..025daf7d83e 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -6,16 +6,25 @@ * 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. + * + * 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. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { - getZshEnvTemplate, + getZshEnvDiscoveryBody, getZshFinalZdotdirRestoreBlock, - getZshOverlayEnvTemplate, + getZshOverlayEnvBody, getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, + ZSH_FEATURE_CHANNEL_BLOCK, + ZSH_HISTFILE_RESTORE_BLOCK, + ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK } from './shell-templates' /** Runtime values the wrapper re-exports after the user's own startup files ran. */ @@ -30,18 +39,6 @@ export type ZshWrapperRestoreSpec = { codexLaunchPreflight: boolean } -/** - * Pre-existing cosmetic drift in the .zshrc restore block. Carried as explicit - * fields so unifying the generators stays byte-identical; normalizing these - * away is a follow-up, not part of the unification. - */ -export type ZshWrapperLegacyFormatting = { - /** Local .zshrc lost the two-space indent on the MIMOCODE_HOME restore. */ - unindentedMimocodeRestore?: boolean - /** Only local .zshrc carries a comment above the CODEX_HOME restore. */ - codexHomeRestoreComment?: string -} - export type ZshStartupWrapperSpec = { /** First line of every generated file, e.g. `# Orca zsh shell-ready wrapper`. */ headerLabel: string @@ -56,18 +53,13 @@ export type ZshStartupWrapperSpec = { /** zsh expression the wrapper resolves the user's startup-file dir from. */ homeExpression?: string readyMarkerEscaped: string - /** OSC 133 command-lifecycle hooks in .zshrc. */ + /** 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 non-login restore block in .zshrc. */ - interactiveRestoreComment: string - /** Comment heading the restore block in .zlogin. */ - loginRestoreComment: string + /** Comment heading the overlay restores inside the epilogue. */ + overlayRestoreComment: string restores: ZshWrapperRestoreSpec - /** Where the ready-marker widget lands relative to the final ZDOTDIR restore. */ - readyMarkerOrder: 'before-zdotdir-restore' | 'after-zdotdir-restore' - legacyFormatting?: ZshWrapperLegacyFormatting } export type ZshStartupWrapperFiles = { @@ -77,18 +69,20 @@ export type ZshStartupWrapperFiles = { zlogin: string } -const AGENT_TEAMS_PATH_RESTORE_FUNCTION = `__orca_restore_agent_teams_path() { +const AGENT_TEAMS_PATH_RESTORE_BLOCK = `__orca_restore_agent_teams_path() { [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 case "$PATH" in "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; esac export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" -}` +} +__orca_restore_agent_teams_path` const OPENCODE_CONFIG_DIR_RESTORE = `[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"` const MIMOCODE_HOME_RESTORE = `[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"` const REMOTE_CLI_BIN_DIR_RESTORE = `[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac` -const CODEX_HOME_RESTORE = `[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"` +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() { local exit_code=$? @@ -115,78 +109,76 @@ function joinBlocks(blocks: (string | null)[]): string { return blocks.filter((block): block is string => block !== null).join('\n') } +function indentBlock(block: string, indent: string): string { + return block + .split('\n') + .map((line) => (line.length > 0 ? `${indent}${line}` : line)) + .join('\n') +} + +/** One epilogue feature: `if __orca_has_feature ; then ... fi`. */ +function featureGuard(name: string, body: (string | null)[]): string | null { + const blocks = body.filter((block): block is string => block !== null) + if (blocks.length === 0) { + return null + } + return ` if __orca_has_feature ${name}; then\n${indentBlock(joinBlocks(blocks).replace(/\n$/, ''), ' ')}\n fi` +} + /** The env/PATH restores that must outlast the user's own startup files. */ -function getRestoreLines( - spec: ZshStartupWrapperSpec, - indent: string, - legacyFormatting?: ZshWrapperLegacyFormatting -): (string | null)[] { - const mimocodeIndent = legacyFormatting?.unindentedMimocodeRestore ? '' : indent - const codexHomeComment = legacyFormatting?.codexHomeRestoreComment +function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] { return [ - `${indent}${OPENCODE_CONFIG_DIR_RESTORE}`, - `${mimocodeIndent}${MIMOCODE_HOME_RESTORE}`, - spec.restores.remoteCliBinDir ? `${indent}${REMOTE_CLI_BIN_DIR_RESTORE}` : null, - `${indent}${getPosixOmpShellWrapper()}`, - spec.restores.codexHome && codexHomeComment ? `${indent}${codexHomeComment}` : null, - spec.restores.codexHome ? `${indent}${CODEX_HOME_RESTORE}` : null, - ZSH_HISTFILE_RESTORE_BLOCK, - spec.restores.codexLaunchPreflight ? `${indent}${getPosixCodexShellLaunchPreflight()}` : null + spec.overlayRestoreComment, + spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, + OPENCODE_CONFIG_DIR_RESTORE, + MIMOCODE_HOME_RESTORE, + spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, + getPosixOmpShellWrapper(), + spec.restores.codexHome ? CODEX_HOME_RESTORE : null, + spec.restores.codexLaunchPreflight ? getPosixCodexShellLaunchPreflight() : null ] } -function buildZshrc(spec: ZshStartupWrapperSpec): string { - return `${joinBlocks([ - `# ${spec.headerLabel}`, - getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: spec.homeExpression, - interactiveOnly: true, - skipWhenHomeIsCurrentZdotdir: spec.skipUserZshrcWhenHomeIsWrapperDir - }), - spec.restores.agentTeamsPath - ? `${AGENT_TEAMS_PATH_RESTORE_FUNCTION}\n[[ ! -o login ]] && __orca_restore_agent_teams_path` - : null, - joinBlocks([ - 'if [[ ! -o login ]]; then', - ` ${spec.interactiveRestoreComment}`, - ...getRestoreLines(spec, ' ', spec.legacyFormatting), - 'fi' - ]), - spec.osc133CommandMarkers ? ZSH_OSC133_COMMAND_MARKER_BLOCK : null, - `if [[ ! -o login ]]; then\n${getZshFinalZdotdirRestoreBlock(spec.homeExpression)}\nfi` - ])}\n` +/** + * 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. + */ +function buildEpilogue(spec: ZshStartupWrapperSpec): string { + return `__orca_shell_epilogue() { + (( $+_orca_epilogue_done )) && return 0 + typeset -g _orca_epilogue_done=1 +${joinBlocks([ + featureGuard('overlay', getOverlayRestoreBlocks(spec)), + featureGuard('history', [ZSH_HISTFILE_RESTORE_BLOCK]), + featureGuard('markers', [spec.osc133CommandMarkers ? ZSH_OSC133_COMMAND_MARKER_BLOCK : null]), + featureGuard('ready', [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 +}` } -function buildZlogin(spec: ZshStartupWrapperSpec): string { - const finalZdotdirRestore = getZshFinalZdotdirRestoreBlock(spec.homeExpression) - const readyMarker = getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped) - const tail = - spec.readyMarkerOrder === 'before-zdotdir-restore' - ? [readyMarker, finalZdotdirRestore] - : [finalZdotdirRestore, readyMarker] - +function buildZshenv(spec: ZshStartupWrapperSpec): string { return `${joinBlocks([ `# ${spec.headerLabel}`, - getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: spec.homeExpression, - interactiveOnly: true - }), - spec.restores.agentTeamsPath - ? `${AGENT_TEAMS_PATH_RESTORE_FUNCTION}\n__orca_restore_agent_teams_path` - : null, - joinBlocks([spec.loginRestoreComment, ...getRestoreLines(spec, '')]), - ...tail + ZSH_FEATURE_CHANNEL_BLOCK, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, + ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, + spec.zshenvStrategy === 'discover-user-zdotdir' + ? getZshEnvDiscoveryBody(spec.zshDir) + : getZshOverlayEnvBody(spec.zshDir), + buildEpilogue(spec) ])}\n` } export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshStartupWrapperFiles { return { - zshenv: - spec.zshenvStrategy === 'discover-user-zdotdir' - ? getZshEnvTemplate(spec.zshDir, spec.headerLabel) - : getZshOverlayEnvTemplate(spec.zshDir, spec.headerLabel), + zshenv: buildZshenv(spec), zprofile: `${joinBlocks([ `# ${spec.headerLabel}`, getZshStartupFileSourceBlock({ @@ -194,7 +186,25 @@ export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshSta homeExpression: spec.homeExpression }) ])}\n`, - zshrc: buildZshrc(spec), - zlogin: buildZlogin(spec) + zshrc: `${joinBlocks([ + `# ${spec.headerLabel}`, + getZshStartupFileSourceBlock({ + fileName: '.zshrc', + homeExpression: spec.homeExpression, + interactiveOnly: true, + skipWhenHomeIsCurrentZdotdir: spec.skipUserZshrcWhenHomeIsWrapperDir + }), + '# Why: a login shell still has .zlogin to load; it runs the epilogue instead.', + '[[ -o login ]] || __orca_shell_epilogue' + ])}\n`, + zlogin: `${joinBlocks([ + `# ${spec.headerLabel}`, + getZshStartupFileSourceBlock({ + fileName: '.zlogin', + homeExpression: spec.homeExpression, + interactiveOnly: true + }), + '__orca_shell_epilogue' + ])}\n` } } diff --git a/src/main/zsh-user-config-dir-fixture.ts b/src/main/zsh-user-config-dir-fixture.ts new file mode 100644 index 00000000000..73fe9f35ad7 --- /dev/null +++ b/src/main/zsh-user-config-dir-fixture.ts @@ -0,0 +1,17 @@ +/** + * Builds a directory that counts as a user's real zsh config root. + * + * Why a fixture: the inherited-ZDOTDIR guard only accepts a directory that + * actually holds a zsh startup file, so tests can no longer use a made-up path. + * + * Test support only; nothing under src/main imports this at runtime. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export function makeUserZdotdir(parent: string, ...segments: string[]): string { + const dir = join(parent, ...segments) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.zshrc'), '') + return dir +} diff --git a/src/main/zsh-wrapper-dir-ownership.ts b/src/main/zsh-wrapper-dir-ownership.ts new file mode 100644 index 00000000000..6eebe5b6132 --- /dev/null +++ b/src/main/zsh-wrapper-dir-ownership.ts @@ -0,0 +1,57 @@ +/** + * Decides which inherited ZDOTDIR Orca may treat as the user's real zsh config + * dir, on the Node side of the launch. + * + * Why: Orca used to recognise only its OWN wrapper path shape (a path ending in + * shell-ready/zsh). Launched from any other terminal that had already + * hijacked ZDOTDIR, Orca captured that dir as "the user's config" and re-sourced + * it. Ownership is now established positively — a stamped marker file, or Orca's + * own dir shape for wrappers written by older builds — and a dir holding no zsh + * startup file at all is not a config dir whoever wrote it. No vendor is + * detected by name, because that can never be complete. + */ +import { existsSync } from 'node:fs' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' + +type EnvLike = Record + +const ZSH_STARTUP_FILES = ['.zshenv', '.zshrc', '.zprofile', '.zlogin'] as const + +export function isOrcaOwnedZshWrapperDir(dir: string): boolean { + const normalized = dir.replace(/\/+$/, '') + if (!normalized) { + return false + } + return ( + existsSync(`${normalized}/${ZSH_WRAPPER_DIR_MARKER_FILE}`) || + normalized.endsWith('/shell-ready/zsh') + ) +} + +/** The inherited value if it is usable as the user's config dir, else null. */ +function usableInheritedZdotdir(value: string | undefined): string | null { + if (!value) { + return null + } + const normalized = value.replace(/\/+$/, '') + if (!normalized || isOrcaOwnedZshWrapperDir(normalized)) { + return null + } + if (!ZSH_STARTUP_FILES.some((file) => existsSync(`${normalized}/${file}`))) { + return null + } + return value +} + +export function resolveInheritedZdotdir(env: EnvLike, homeFallback = ''): string { + return ( + usableInheritedZdotdir(env.ZDOTDIR) ?? + usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? + env.HOME ?? + homeFallback + ) +} + +export function resolveInheritedZshenvSourceDir(env: EnvLike, homeFallback = ''): string { + return usableInheritedZdotdir(env.ZDOTDIR) ?? env.HOME ?? homeFallback +} diff --git a/src/relay/pty-handler-revive.test.ts b/src/relay/pty-handler-revive.test.ts index 5af0041098c..7955abfeac1 100644 --- a/src/relay/pty-handler-revive.test.ts +++ b/src/relay/pty-handler-revive.test.ts @@ -79,15 +79,17 @@ describe('PtyHandler', () => { ORCA_AGENT_HOOK_TOKEN: 'abc-uuid' })) const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - const oldStartupIdentity = process.env.ORCA_SHELL_STARTUP_IDENTITY - process.env.ORCA_SHELL_STARTUP_IDENTITY = '1' + // Why seeded: a revived pane must not inherit a feature selection from the + // relay process's own environment. + const oldShellFeatures = process.env.ORCA_SHELL_FEATURES + process.env.ORCA_SHELL_FEATURES = 'ready,identity,markers,overlay' try { await dispatcher.callRequest('pty.revive', { state }) } finally { - if (oldStartupIdentity === undefined) { - delete process.env.ORCA_SHELL_STARTUP_IDENTITY + if (oldShellFeatures === undefined) { + delete process.env.ORCA_SHELL_FEATURES } else { - process.env.ORCA_SHELL_STARTUP_IDENTITY = oldStartupIdentity + process.env.ORCA_SHELL_FEATURES = oldShellFeatures } killSpy.mockRestore() } @@ -101,8 +103,8 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') expect(callArgs.env.TERM).toBe('xterm-256color') expect(callArgs.env.TERM_PROGRAM).toBe('Orca') - expect(callArgs.env.ORCA_SHELL_READY_MARKER).toBe('0') - expect(callArgs.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('0') + expect(callArgs.env.ORCA_SHELL_FEATURES).not.toContain('ready') + expect(callArgs.env.ORCA_SHELL_FEATURES).not.toContain('identity') }) it('fences both revived worktree identity and cwd with rollback', async () => { diff --git a/src/relay/pty-handler-startup-command-delivery.test.ts b/src/relay/pty-handler-startup-command-delivery.test.ts index a56d26baf29..8e522e68f59 100644 --- a/src/relay/pty-handler-startup-command-delivery.test.ts +++ b/src/relay/pty-handler-startup-command-delivery.test.ts @@ -122,7 +122,7 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as | { env?: Record } | undefined - expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') expect(handler.retainedStartupCommandCount).toBe(1) expect(handler.retainedStartupCommandBytes).toBe(0) vi.advanceTimersByTime(15_000) @@ -161,7 +161,7 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as | { env?: Record } | undefined - expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') expect(handler.retainedStartupCommandCount).toBe(1) } ) @@ -199,7 +199,7 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as | { env?: Record } | undefined - expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') } ) @@ -238,7 +238,7 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as | { env?: Record } | undefined - expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') } ) @@ -512,7 +512,7 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as | { env?: Record } | undefined - expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('0') + expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toBe('') expect(handler.retainedStartupCommandCount).toBe(0) } ) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index dffa7f31327..50fb034aeea 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -18,6 +18,7 @@ import { } from './pty-shell-utils' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { addWslEnvKeys } from '../shared/wsl-env' +import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../shared/startup-command-submission' @@ -1590,7 +1591,7 @@ export class PtyHandler { emitStartupIdentity: shouldEmitShellReadyMarker }) const rendererShellReadySupported = - !shouldProviderDeliverCommand && shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' + !shouldProviderDeliverCommand && shellLaunch.supportsReadyMarker if (context?.signal?.aborted || context?.isStale()) { // Why: cancellation remains side-effect-free until the exact native spawn seam. @@ -1610,11 +1611,11 @@ export class PtyHandler { cols, rows, cwd, - // Why: relay shells inherit process.env; don't let an ambient Orca marker enable shell-ready unless requested. + // Why the empty default: relay shells inherit process.env, and the launch + // config is the only thing allowed to name features for this shell. env: { ...spawnEnv, - ORCA_SHELL_READY_MARKER: '0', - ORCA_SHELL_STARTUP_IDENTITY: '0', + [SHELL_STARTUP_FEATURE_ENV]: '', ...shellLaunch.env } }) @@ -1671,11 +1672,10 @@ export class PtyHandler { command: shouldProviderDeliverCommand ? managedStartupCommand : null, providerDelivery: shouldProviderDeliverCommand, delivered: false, - waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1', - outputScanState: - shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' - ? createShellStartupOutputScanState() - : null, + waitForShellReady: shellLaunch.supportsReadyMarker, + outputScanState: shellLaunch.supportsReadyMarker + ? createShellStartupOutputScanState() + : null, shellPid: null, promptProbe: null, timer: null @@ -2189,8 +2189,7 @@ export class PtyHandler { // Why: no provider-delivered command is waiting for a ready marker. env: { ...spawnEnv, - ORCA_SHELL_READY_MARKER: '0', - ORCA_SHELL_STARTUP_IDENTITY: '0', + [SHELL_STARTUP_FEATURE_ENV]: '', ...shellLaunch.env } }) diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index b006bacc7bc..19c7e058289 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -66,7 +66,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="$_orca_home"') + expect(content).toContain('export ZDOTDIR="$REPLY"') } describe('isRelayWslShell', () => { @@ -109,22 +109,21 @@ describe('getRelayShellLaunchConfig', () => { expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) - expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - ) - expect(readFileSync(join(zshRoot, '.zprofile'), 'utf8')).toContain( - '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - ) + 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"') const zprofile = readFileSync(join(zshRoot, '.zprofile'), 'utf8') const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') - expect(zshrc).toContain('_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"') - expect(zlogin).toContain('_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"') + expect(zprofile).toContain(userZdotdirResolution) + expect(zshrc).toContain(userZdotdirResolution) + expect(zlogin).toContain(userZdotdirResolution) expectZdotdirSourceContext(zprofile, '.zprofile') expectZdotdirSourceContext(zshrc, '.zshrc') expectZdotdirSourceContext(zlogin, '.zlogin') - expectFinalZdotdirRestoreContext(zshrc) - expectFinalZdotdirRestoreContext(zlogin) + // Why .zshenv: the final restore is the last step of the one epilogue. + expectFinalZdotdirRestoreContext(zshenv) } ) @@ -133,7 +132,8 @@ describe('getRelayShellLaunchConfig', () => { getRelayShellLaunchConfig('C:\\Windows\\System32\\cmd.exe', { HOME: homeDir }, 'win32') ).toEqual({ args: [], - env: {} + env: {}, + supportsReadyMarker: false }) expect( getRelayShellLaunchConfig( @@ -143,14 +143,16 @@ describe('getRelayShellLaunchConfig', () => { ) ).toEqual({ args: ['-NoLogo'], - env: {} + env: {}, + supportsReadyMarker: false }) }) it('keeps PowerShell Core on POSIX remotes as a login shell', () => { expect(getRelayShellLaunchConfig('/usr/bin/pwsh', { HOME: homeDir }, 'linux')).toEqual({ args: ['-l'], - env: {} + env: {}, + supportsReadyMarker: false }) }) @@ -165,7 +167,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + 'export ORCA_USER_ZDOTDIR="$REPLY"' ) }) @@ -177,11 +179,13 @@ describe('getRelayShellLaunchConfig', () => { ORCA_MIMOCODE_HOME: '/tmp/orca-mimocode-overlay' }) const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') + // Why .zshenv: the overlay restores live in the one epilogue defined there. + const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) - expect(zshrc).toContain( + expect(config.env.ORCA_SHELL_FEATURES).toBe('overlay,history,markers') + expect(zshenv).toContain( '[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"' ) } @@ -195,7 +199,9 @@ describe('getRelayShellLaunchConfig', () => { const bashRc = readFileSync(rcfile, 'utf8') expect(config.args).toEqual(['--rcfile', rcfile]) - expect(config.env).toEqual({}) + // Why the empty allowlist: bash keeps its unconditional OSC 133 hooks, and + // an explicit empty value also overrides anything the relay inherited. + expect(config.env).toEqual({ ORCA_SHELL_FEATURES: '' }) expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') expect(bashRc).toContain('printf "\\033]133;C\\007"') } @@ -208,19 +214,17 @@ describe('getRelayShellLaunchConfig', () => { emitReadyMarker: true }) const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') + const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) - expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') - expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'printf "\\033]777;orca-shell-start:%s\\007" "$$"' - ) - expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'unset ORCA_SHELL_STARTUP_IDENTITY' - ) - expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"') + expect(config.supportsReadyMarker).toBe(true) + expect(config.env.ORCA_SHELL_FEATURES).toContain('ready') + expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + // Why: the channel is destroyed before the user's own config is sourced. + expect(zshenv).toContain('builtin unset ORCA_SHELL_FEATURES') + expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') + expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') } ) @@ -232,9 +236,10 @@ describe('getRelayShellLaunchConfig', () => { }) const bashRc = readFileSync(config.args[1] as string, 'utf8') - expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(config.supportsReadyMarker).toBe(true) + expect(config.env.ORCA_SHELL_FEATURES).toContain('ready') expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(bashRc).toContain('unset ORCA_SHELL_STARTUP_IDENTITY') + expect(bashRc).toContain('builtin unset ORCA_SHELL_FEATURES') expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"') expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"') } @@ -248,8 +253,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh')) - expect(config.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('1') - expect(config.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(config.env.ORCA_SHELL_FEATURES).toContain('identity') } ) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5a919c3e333..82436dded83 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,5 +1,12 @@ import { homedir } from 'node:os' import { join } from 'node:path' +import { + encodeShellStartupFeatures, + selectShellStartupFeatures, + SHELL_STARTUP_FEATURE_ENV, + type ShellStartupFeature +} from '../main/shell-startup-features' +import { 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'] @@ -7,6 +14,7 @@ const POSIX_LOGIN_ARGS = ['-l'] export type RelayShellLaunchConfig = { args: string[] env: Record + supportsReadyMarker: boolean } function shellBasename(shellPath: string): string { @@ -46,40 +54,10 @@ function windowsShellArgs( return null } -function hasOverlayRestoreEnv(env: Record): boolean { - return Boolean( - env.ORCA_OPENCODE_CONFIG_DIR || - env.ORCA_MIMOCODE_HOME || - env.ORCA_REMOTE_CLI_BIN_DIR || - env.ORCA_OMP_STATUS_EXTENSION - ) -} - function getWrapperRoot(env: Record): string { return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR) } -function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { - if (!value) { - return null - } - const normalized = value.replace(/\/+$/, '') - if (!normalized || normalized.endsWith('/shell-ready/zsh')) { - return null - } - return value -} - -function resolveOriginalZdotdir(env: Record): string { - return ( - normalizeOriginalZdotdirCandidate(env.ZDOTDIR) || - normalizeOriginalZdotdirCandidate(env.ORCA_ORIG_ZDOTDIR) || - env.HOME || - process.env.HOME || - '' - ) -} - export function getRelayShellLaunchConfig( shellPath: string, env: Record, @@ -91,8 +69,11 @@ export function getRelayShellLaunchConfig( } = {} ): RelayShellLaunchConfig { const shellName = shellBasename(shellPath) - const emitReadyMarker = options.emitReadyMarker === true - const emitStartupIdentity = options.emitStartupIdentity === true + const unwrapped: RelayShellLaunchConfig = { + args: POSIX_LOGIN_ARGS, + env: {}, + supportsReadyMarker: false + } if (platform === 'win32') { // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. @@ -101,39 +82,70 @@ export function getRelayShellLaunchConfig( windowsShellArgs(shellName, { terminalWindowsWslDistro: options.terminalWindowsWslDistro }) ?? [], - env: {} + env: {}, + supportsReadyMarker: false } } if (shellName !== 'zsh' && shellName !== 'bash') { - return { args: POSIX_LOGIN_ARGS, env: {} } + return unwrapped } - // Why: preserve plain zsh startup fast path unless markers or overlay restoration are requested. - const requiresZshWrapper = hasOverlayRestoreEnv(env) || emitReadyMarker || emitStartupIdentity - if (shellName === 'zsh' && !requiresZshWrapper) { - return { args: POSIX_LOGIN_ARGS, env: {} } + + // Why both map to the same flag: the relay only wraps for a startup command + // when that command's delivery asked for the readiness handshake. + const startupCommandRequested = + options.emitReadyMarker === true || options.emitStartupIdentity === true + const features = selectShellStartupFeatures({ + shellPath: shellName, + env, + hasStartupCommand: startupCommandRequested, + waitsForShellReady: options.emitReadyMarker === true, + emitsStartupIdentity: options.emitStartupIdentity === true + }) + // Why bash is always wrapped: its rcfile carries the OSC 133 command-lifecycle + // hooks unconditionally today, and dropping them would strand agent rows on + // "working". zsh keeps the plain startup fast path when nothing needs it. + if (features.length === 0 && shellName !== 'bash') { + return unwrapped } const root = getWrapperRoot(env) - ensureOverlayRestoreWrappers(root) + let wrappersReady = false + try { + wrappersReady = ensureOverlayRestoreWrappers(root) + } catch { + // Why swallow: a remote HOME can be read-only or root-owned (EACCES), and + // that must not stop the pane from opening at all. + wrappersReady = false + } + if (!wrappersReady) { + // Why plain login shell: ZDOTDIR pointed at an incomplete wrapper dir makes + // zsh skip the user's whole config. Losing Orca's features is recoverable. + return unwrapped + } + + const featureEnv = { + [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) + } + const supportsReadyMarker = features.includes('ready') if (shellName === 'zsh') { return { args: POSIX_LOGIN_ARGS, env: { - ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env), + ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(env, process.env.HOME ?? ''), ZDOTDIR: join(root, 'zsh'), - ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}), - ...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {}) - } + ...featureEnv + }, + supportsReadyMarker } } return { args: ['--rcfile', join(root, 'bash', 'rcfile')], - env: { - ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}), - ...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {}) - } + env: featureEnv, + supportsReadyMarker } } + +export type { ShellStartupFeature } diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c62e5a0c123..197f900268b 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,11 +1,15 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { + BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK + ZSH_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 @@ -30,26 +34,32 @@ function getRelayZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: false, skipUserZshrcWhenHomeIsWrapperDir: false, - interactiveRestoreComment: + overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', - loginRestoreComment: '# Why: .zlogin is the final zsh login startup file before the prompt.', restores: { agentTeamsPath: false, remoteCliBinDir: true, codexHome: false, codexLaunchPreflight: false - }, - readyMarkerOrder: 'after-zdotdir-restore' + } } } -export function ensureOverlayRestoreWrappers(root: string): void { +/** True when every overlay wrapper file is present and non-empty afterwards. */ +export function ensureOverlayRestoreWrappers(root: string): boolean { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') const zsh = buildZshStartupWrapperFiles(getRelayZshWrapperSpec(zshDir)) const bashRc = `# Orca relay bash overlay wrapper +${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +# Why a plain variable: the channel is consumed and destroyed in these first +# lines, so nothing this shell later spawns can see or inherit the selection. +__orca_ready_marker="" +__orca_has_feature ready && __orca_ready_marker=1 +unset _orca_shell_features +unset -f __orca_has_feature [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -124,7 +134,7 @@ ${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} __orca_prepend_prompt_command "__orca_osc133_precmd" # Why: SSH startup commands are renderer-delivered; emit the same internal # readiness marker as local shells only when that delivery mode asks for it. -if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then +if [[ -n "$__orca_ready_marker" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER_ESCAPED}" } @@ -157,23 +167,32 @@ unset __orca_initializing_wrapper [join(zshDir, '.zprofile'), zsh.zprofile], [join(zshDir, '.zshrc'), zsh.zshrc], [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(bashDir, 'rcfile'), bashRc] ] as const - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - let existing: string | null = null - try { - existing = readFileSync(path, 'utf8') - } catch { - existing = null - } - // Why: relay wrapper files persist under ~/.orca-relay across app - // upgrades. Existence alone is not enough; stale wrappers would miss - // later fixes such as preserving post-.zshenv ZDOTDIR. - if (existing !== content) { - writeFileSync(path, content, 'utf8') - } - chmodSync(path, 0o644) + // Why: relay wrapper files persist under ~/.orca-relay across app upgrades. + // Existence alone is not enough; stale wrappers would miss later fixes such + // as preserving post-.zshenv ZDOTDIR. + const stale = files.filter(([path, content]) => readFileOrNull(path) !== content) + if (stale.length > 0 && !writeShellWrapperFiles(stale, '[relay/shell-overlay]')) { + return false + } + return files.every(([path]) => isNonEmptyFile(path)) +} + +function readFileOrNull(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function isNonEmptyFile(path: string): boolean { + try { + return statSync(path).size > 0 + } catch { + return false } }