From 2326cbbb5df6fed7d252ee8459c537c2b73f6861 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:47:04 -0700 Subject: [PATCH] fix(shell): repair worktree HISTFILE in plain zsh panes via one positive feature channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain zsh pane — no startup command, no agent overlay — was never wrapped, so Orca's HISTFILE repair never ran in it. macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no check-before-set and runs before any file Orca controls, so per-worktree history was a silent no-op for every ordinary pane on the primary platform. Wrapping those panes needs a way to say which wrapper features a shell should turn on. That channel is one exported variable, ORCA_SHELL_FEATURES, carrying a comma-separated positive allowlist from a closed set (history, markers, ready, identity, overlay). The wrapper .zshenv reads it into a plain, non-exported array and unsets it in its first executable lines, before the user's own .zshenv — so the selection survives .zshenv -> .zprofile -> .zshrc -> .zlogin in this process but physically cannot reach a child. There is no negative or suppression variable anywhere; an absent or inherited value can only ever mean fewer features. ORCA_HISTFILE is consumed and destroyed the same way, which removes the root cause of #11146 instead of patching it. All order-sensitive wrapper work now lives in one `__orca_shell_epilogue` defined in .zshenv and invoked exactly once, from .zshrc for a non-login shell and .zlogin for a login shell, with each feature an independent guard. Selection is a pure function of spawn env and launch intent, so a pane wrapped only for history gets no OSC 133 and is observably identical to the unwrapped pane it used to be. Generation is now fail-closed: wrapper files are written to a temp name and renamed, every required path is verified non-empty, and ZDOTDIR is only set when that holds. Previously a failed write still pointed ZDOTDIR at an empty dir and the user silently lost their entire zsh config. Orca also recognised only its own `*/shell-ready/zsh` dir shape when deciding what the user's ZDOTDIR was, so being launched from any other terminal that had hijacked ZDOTDIR captured that as the user's config dir. Ownership is now established positively — a stamped marker file, or Orca's own dir shape for wrappers written by older builds — and an inherited ZDOTDIR holding no zsh startup file is ignored. No vendor is detected by name. --- .../daemon/daemon-bash-shell-ready-rcfile.ts | 11 +- .../daemon-zsh-shell-ready-wrapper-spec.ts | 7 +- .../pty-subprocess-managed-agent-env.test.ts | 18 +- src/main/daemon/pty-subprocess.ts | 60 ++-- src/main/daemon/shell-ready.test.ts | 93 ++++-- src/main/daemon/shell-ready.ts | 193 +++++------ src/main/ipc/pty-ipc-mock-registry.ts | 4 + src/main/ipc/pty-ipc-spawn-drivers.ts | 3 +- src/main/ipc/pty-ipc-suite-environment.ts | 3 +- .../pty-listener-teardown-and-orphans.test.ts | 2 +- .../pty-login-shell-startup-commands.test.ts | 10 +- src/main/ipc/pty-wsl-cwd-validation.test.ts | 6 +- .../shell-script-test.ts | 22 +- ...al-pty-provider-foreground-process.test.ts | 2 + .../local-pty-provider-io-events.test.ts | 2 + ...cal-pty-provider-session-inventory.test.ts | 2 + ...local-pty-provider-shell-readiness.test.ts | 2 + .../local-pty-provider-shutdown.test.ts | 2 + ...ocal-pty-provider-spawn-cwd-safety.test.ts | 2 + .../local-pty-provider-spawn-env.test.ts | 4 +- .../local-pty-provider-spawn-session.test.ts | 2 + .../local-pty-provider-test-harness.ts | 3 +- ...-pty-provider-windows-shell-launch.test.ts | 2 + src/main/providers/local-pty-provider.ts | 80 +++-- .../local-pty-shell-ready-bash-rcfile.ts | 11 +- .../local-pty-shell-ready-test-harness.ts | 18 +- ...pty-shell-ready-wrapper-generation.test.ts | 84 +++-- ...ocal-pty-shell-ready-wrapper-generation.ts | 70 ++-- .../local-pty-shell-ready-wrapper-root.ts | 19 +- ...shell-ready-zsh-launch-environment.test.ts | 6 +- src/main/providers/local-pty-shell-ready.ts | 116 ++++--- src/main/providers/windows-shell-args.test.ts | 5 +- src/main/providers/windows-shell-args.ts | 4 +- .../shell-startup-feature-channel.test.ts | 312 ++++++++++++++++++ src/main/shell-startup-features.ts | 95 ++++++ .../shell-startup-launch-intent-fixtures.ts | 25 ++ src/main/shell-templates.ts | 208 +++++++----- src/main/shell-wrapper-file-writer.ts | 44 +++ ...ll-wrapper-generated-file-snapshot.test.ts | 21 +- .../daemon-bash-rcfile.txt | 16 +- .../daemon-fish-init.txt | 8 +- .../daemon-zsh-zlogin.txt | 101 +----- .../daemon-zsh-zprofile.txt | 6 +- .../daemon-zsh-zshenv.txt | 182 ++++++++-- .../daemon-zsh-zshrc.txt | 96 +----- .../local-bash-rcfile.txt | 16 +- .../local-fish-init.txt | 8 +- .../local-zsh-zlogin.txt | 101 +----- .../local-zsh-zprofile.txt | 6 +- .../local-zsh-zshenv.txt | 182 ++++++++-- .../local-zsh-zshrc.txt | 97 +----- .../relay-bash-rcfile.txt | 17 +- .../relay-zsh-zlogin.txt | 83 +---- .../relay-zsh-zprofile.txt | 6 +- .../relay-zsh-zshenv.txt | 122 ++++++- .../relay-zsh-zshrc.txt | 63 +--- .../zsh-scoped-histfile.live-shell.test.ts | 184 ++++++++--- src/main/zsh-startup-wrapper-builder.ts | 182 +++++----- src/main/zsh-user-config-dir-fixture.ts | 17 + src/main/zsh-wrapper-dir-ownership.ts | 57 ++++ src/relay/pty-handler-revive.test.ts | 16 +- ...y-handler-startup-command-delivery.test.ts | 10 +- src/relay/pty-handler.ts | 21 +- src/relay/pty-shell-launch.test.ts | 68 ++-- src/relay/pty-shell-launch.ts | 106 +++--- src/relay/pty-shell-overlay-wrappers.ts | 67 ++-- 66 files changed, 2039 insertions(+), 1372 deletions(-) create mode 100644 src/main/shell-startup-feature-channel.test.ts create mode 100644 src/main/shell-startup-features.ts create mode 100644 src/main/shell-startup-launch-intent-fixtures.ts create mode 100644 src/main/shell-wrapper-file-writer.ts create mode 100644 src/main/zsh-user-config-dir-fixture.ts create mode 100644 src/main/zsh-wrapper-dir-ownership.ts 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 } }