diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dd366f225de..dcb365fa4f9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -336,8 +336,10 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ + src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts @@ -378,8 +380,10 @@ jobs: --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 9d7aa2625bc..b0f3c5c3062 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,7 +15,9 @@ const shellContractFiles = [ 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] const patchedNodePtyContractFiles = [ diff --git a/src/main/daemon/bash-prompt-command-composition.test.ts b/src/main/daemon/bash-prompt-command-composition.test.ts index 8b01c3a43f0..b404ab15ebc 100644 --- a/src/main/daemon/bash-prompt-command-composition.test.ts +++ b/src/main/daemon/bash-prompt-command-composition.test.ts @@ -29,7 +29,7 @@ function runInteractiveBash( { input, encoding: 'utf8', - env: { ...process.env, HOME: tempHome, ORCA_SHELL_READY_MARKER: '1', TERM: 'xterm' }, + env: { ...process.env, HOME: tempHome, ORCA_SHELL_FEATURES: 'ready', TERM: 'xterm' }, timeout: 5000 } ) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 25c16472e4e..0e05fd44041 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -233,14 +233,14 @@ describe('createPtySubprocess', () => { resolveUnixShellPathMock.mockReturnValue('/bin/sh') const platform = Object.getOwnPropertyDescriptor(process, 'platform') const previousShell = process.env.SHELL - const previousMarker = process.env.ORCA_SHELL_READY_MARKER + const previousFeatures = process.env.ORCA_SHELL_FEATURES const previousZdotdir = process.env.ZDOTDIR const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) delete process.env.SHELL // Why: the test runner itself can execute inside an Orca-wrapped shell // whose exported wrapper vars would leak through the process.env spread. - delete process.env.ORCA_SHELL_READY_MARKER + delete process.env.ORCA_SHELL_FEATURES delete process.env.ZDOTDIR try { @@ -257,9 +257,9 @@ describe('createPtySubprocess', () => { expect(shellPath).toBe('/bin/sh') expect(shellArgs).toEqual(['-l']) // A launch config derived from the missing preferred zsh would inject - // ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them. + // ZDOTDIR and ORCA_SHELL_FEATURES; /bin/sh must spawn without them. expect(spawnOptions.env.ZDOTDIR).toBeUndefined() - expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(spawnOptions.env.ORCA_SHELL_FEATURES).toBeUndefined() expect(spawnOptions.env.SHELL).toBe('/bin/sh') } finally { warn.mockRestore() @@ -271,10 +271,10 @@ describe('createPtySubprocess', () => { } else { process.env.SHELL = previousShell } - if (previousMarker === undefined) { - delete process.env.ORCA_SHELL_READY_MARKER + if (previousFeatures === undefined) { + delete process.env.ORCA_SHELL_FEATURES } else { - process.env.ORCA_SHELL_READY_MARKER = previousMarker + process.env.ORCA_SHELL_FEATURES = previousFeatures } if (previousZdotdir === undefined) { delete process.env.ZDOTDIR diff --git a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts index 3317ab3651b..e7ff2532c63 100644 --- a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts @@ -412,7 +412,8 @@ describe('registerPtyHandlers', () => { cwd: '/tmp', env: expect.objectContaining({ ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', - ORCA_SHELL_READY_MARKER: '0', + // No `ready`: the fallback shell carries an overlay, not a startup command. + ORCA_SHELL_FEATURES: 'overlay,history,markers', ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' }) }) diff --git a/src/main/providers/__tests__/shell-ready-framework/README.md b/src/main/providers/__tests__/shell-ready-framework/README.md index 19f4ac96c42..ddf22964fbc 100644 --- a/src/main/providers/__tests__/shell-ready-framework/README.md +++ b/src/main/providers/__tests__/shell-ready-framework/README.md @@ -39,7 +39,7 @@ EOF - Lines before the marker → setup commands - Lines after the marker → run command to test -3. **Gets Orca's wrapper config** by calling `getShellReadyLaunchConfig()` +3. **Gets Orca's wrapper config** by calling `getShellLaunchConfig()` with the features `selectShellStartupFeatures()` picks for a startup-command launch 4. **Executes setup** (if present) with bash in temp HOME, using wrapper env 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 153b9c27d80..5151a6cbe8e 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 @@ -51,7 +51,7 @@ function runInteractiveBashRcfile( env: { ...process.env, HOME: tempDir, - ORCA_SHELL_READY_MARKER: '1', + ORCA_SHELL_FEATURES: 'ready', TERM: process.env.TERM || 'xterm' }, timeout: 5000 diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index 985533d5935..058626741c9 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -72,8 +72,8 @@ function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): voi PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter), TERM: 'dumb', SHELL: '/bin/bash', - ORCA_SHELL_READY_MARKER: '0', - ORCA_SHELL_STARTUP_IDENTITY: '0', + // Why no ORCA_SHELL_FEATURES: absent means no features, so the rcfile + // emits neither the identity nor the readiness marker into stdout. ORCA_CODEX_HOME: join(fixture.root, 'codex-home'), ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue } diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 28a2d0a34a0..75aed3d2f47 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -371,8 +371,7 @@ describePosix('history-only pane in a real zsh', () => { env: { PATH: '/usr/bin:/bin', HOME: home } }) - // eslint-disable-next-line no-control-regex - expect(wrapped).not.toMatch(/]133;/) + expect(wrapped).not.toContain('\x1b]133;') expect(wrapped).toContain('PRECMD=\n') expect(wrapped).toContain('LINEINIT=none') // Startup files run in the same order with the same hooks, so the pane is diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 26e4ea53e61..91f6122b78b 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -45,6 +45,12 @@ export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity * its own. A stamped marker file (or Orca's own wrapper path shape, for * wrappers written by older builds) is that proof. Guessing at other * terminals' wrapper dirs by name never can be, so this never tries. + * + * Why every generated file redefines it instead of relying on .zshenv: one + * wrapper dir can be rewritten by two concurrently installed builds, so a shell + * can read one build's .zshenv and another's .zshrc. A .zshrc that called a + * function only the newer .zshenv defines printed `command not found` AND left + * $REPLY empty, which skipped sourcing the user's own startup file entirely. */ export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_dir() { REPLY="\${1:-}" @@ -52,8 +58,15 @@ export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_di 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 +}` + +/** + * The stricter resolver .zshenv uses for a ZDOTDIR this shell INHERITED. + * + * Why only .zshenv needs it: nothing after .zshenv re-reads an inherited value — + * the later files resolve ORCA_ORIG_ZDOTDIR, which .zshenv already vetted. + */ +export const ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK = `# Why stricter for an inherited value: Orca can be launched from a terminal that # already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh # startup file at all is not the user's config root whoever wrote it. __orca_resolve_inherited_config_dir() { diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt index 13872342401..cd85ec3af88 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt @@ -1,4 +1,11 @@ # Orca daemon zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt index 9073c69817b..5dfefd14d1c 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt @@ -1,4 +1,11 @@ # Orca daemon zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; then diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt index f341c9c6889..f35598b865e 100644 --- a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt @@ -1,4 +1,11 @@ # Orca daemon zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt index 26cb8741905..4e4aad69dbf 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt @@ -1,4 +1,11 @@ # Orca zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt index 755618d8feb..4164aa99db6 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt @@ -1,4 +1,11 @@ # Orca zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; then diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt index ed1c81933f8..84ce60c1acd 100644 --- a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt @@ -1,4 +1,11 @@ # Orca zsh shell-ready wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" _orca_home="$REPLY" if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt index 4045fd7a7e3..52a6c828ae1 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt @@ -1,4 +1,11 @@ # Orca relay zsh overlay wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" _orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt index 8071f1925db..5f5d3ef1c72 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt @@ -1,4 +1,11 @@ # Orca relay zsh overlay wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" _orca_home="$REPLY" if [[ -f "$_orca_home/.zprofile" ]]; then diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt index 52120522900..27af607ccfa 100644 --- a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ b/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt @@ -1,4 +1,11 @@ # Orca relay zsh overlay wrapper +__orca_resolve_user_config_dir() { + REPLY="${1:-}" + while [[ "$REPLY" == */ ]]; do REPLY="${REPLY%/}"; done + if [[ -z "$REPLY" || -f "$REPLY/.orca-shell-wrapper" || "$REPLY" == */shell-ready/zsh ]]; then + REPLY="$HOME" + fi +} __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" _orca_home="$REPLY" if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 4cfd68cd683..ef8a1a60e14 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -402,13 +402,21 @@ describe('terminal-history', () => { expect(env.HISTFILE).not.toContain(OTHER_WORKTREE_HASH) }) - it('preserves a HISTFILE the user set themselves', () => { + it.each([ + ['an ordinary path', ['', 'home', 'me', '.zsh_history'].join(sep)], + // Orca only ever mints absolute paths, so the same shape relative to the + // user's cwd is theirs. + [ + 'a relative path of Orca’s shape', + ['terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep) + ] + ])('preserves %s the user set as HISTFILE', (_kind, histFile) => { // Only a path Orca minted is droppable; everything else is the user's. - const env: Record = { HISTFILE: ['', 'home', 'me', '.zsh_history'].join(sep) } + const env: Record = { HISTFILE: histFile } const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - expect(env.HISTFILE).toBe(['', 'home', 'me', '.zsh_history'].join(sep)) + expect(env.HISTFILE).toBe(histFile) expect(result.histFile).toBeNull() }) diff --git a/src/main/worktree-history-file-path.ts b/src/main/worktree-history-file-path.ts index ebd5cc6aae3..137a28f7114 100644 --- a/src/main/worktree-history-file-path.ts +++ b/src/main/worktree-history-file-path.ts @@ -15,14 +15,27 @@ * the desktop (Electron userData), the daemon subprocess (no Electron), and the * relay on a remote host. Desktop and relay drop each other's shapes on purpose * — neither owns the other's worktree ids. + * + * Why the shape is enough without an Orca-specific token: two of the three + * shapes carry one already (`.orca-remote`, `terminal-history-wsl`), and the + * third needs an absolute path whose LAST TWO segments are a 16-char lowercase + * hex directory directly under `terminal-history`, holding a file named exactly + * `zsh_history`/`bash_history`. That is a machine-minted layout, not one a + * person types. The blast radius if it were ever hit is also bounded: the value + * is dropped from ONE spawn's env, so the pane gets Orca's own worktree history + * (isolation on) or the shell's default (isolation off). Nothing on disk is + * read, written, moved, or deleted. */ /** 16 hex chars: `hashWorktreeId`. */ const WORKTREE_HASH = '[0-9a-f]{16}' const HISTORY_FILE = '(?:zsh|bash)_history' +// Why a leading `/` rather than `(?:^|/)`: every minted value is absolute (or a +// Windows path normalized to forward slashes), so a relative path of the same +// shape is the user's, not Orca's. const ORCA_MINTED_HISTFILE = new RegExp( - '(?:^|/)(?:' + + '/(?:' + // Desktop: /terminal-history// `terminal-history/${WORKTREE_HASH}/${HISTORY_FILE}` + '|' + diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 7a80638eb1c..a16d41d85bd 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -7,6 +7,9 @@ * disagree on is a field on ZshStartupWrapperSpec, so the disagreements are * visible in one place instead of spread across three template literals. * + * Every generated file redefines the helpers it calls, so the epilogue is the + * only thing one file needs another to have defined — see EPILOGUE_CALL. + * * 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 @@ -26,6 +29,7 @@ import { SHELL_STARTUP_IDENTITY_MARKER_BLOCK, ZSH_FEATURE_CHANNEL_BLOCK, ZSH_HISTFILE_RESTORE_BLOCK, + ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK } from './shell-templates' @@ -180,6 +184,7 @@ function buildZshenv(spec: ZshStartupWrapperSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, + ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, spec.zshenvStrategy === 'discover-user-zdotdir' ? getZshEnvDiscoveryBody(spec.zshDir) : getZshOverlayEnvBody(spec.zshDir), @@ -188,12 +193,13 @@ function buildZshenv(spec: ZshStartupWrapperSpec): string { } /** - * Why guarded: the generated files are inter-dependent (this call, the function - * in .zshenv), and one wrapper dir can be shared by two concurrently installed - * Orca builds. Pairing this file with an older .zshenv then loses the features - * either way; degrade silently instead of printing "command not found" into the - * user's pane. Braced subscript because sh/ksh emulation (KSH_ARRAYS) rejects - * the unbraced `$+functions[...]` form outright. + * Why guarded: one wrapper dir can be shared by two concurrently installed Orca + * builds, so a shell can read one build's .zshenv and another's .zshrc/.zlogin. + * The epilogue is the only cross-file dependency left — every other function + * these files call is redefined at the top of each of them — so an older .zshenv + * costs this shell the epilogue's features and nothing else, without printing + * "command not found" into the user's pane. Braced subscript because sh/ksh + * emulation (KSH_ARRAYS) rejects the unbraced `$+functions[...]` form outright. */ const EPILOGUE_CALL = '(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue' @@ -217,6 +223,7 @@ export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshSta zshenv: buildZshenv(spec), zprofile: `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, getZshStartupFileSourceBlock({ fileName: '.zprofile', homeExpression: spec.homeExpression @@ -224,6 +231,7 @@ export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshSta ])}\n`, zshrc: `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, getZshStartupFileSourceBlock({ fileName: '.zshrc', homeExpression: spec.homeExpression, @@ -234,6 +242,7 @@ export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshSta ])}\n`, zlogin: `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, getZshStartupFileSourceBlock({ fileName: '.zlogin', homeExpression: spec.homeExpression, diff --git a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts new file mode 100644 index 00000000000..af88295c7e6 --- /dev/null +++ b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts @@ -0,0 +1,67 @@ +/** + * Real-zsh proof that a wrapper dir written by two different Orca builds still + * loads the user's own zsh config. + * + * One wrapper dir (`/shell-ready/zsh`, or `~/.orca-relay/shell-ready/ + * zsh` for a remote host) is shared by every concurrently installed build, and + * each rewrites it on spawn. A shell can therefore read one build's `.zshenv` + * and another's `.zprofile`/`.zshrc`/`.zlogin`. Only a real zsh shows the cost: + * a helper the newer files call but the older `.zshenv` never defined both + * prints `command not found` into the pane AND leaves `$REPLY` empty, which + * silently skips sourcing the user's own startup files. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' + +const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 +const ZSH_PATH = hasZsh + ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() + : '' +const itWithZsh = hasZsh ? it : it.skip + +/** + * A `.zshenv` from a build that predates everything the other three files now + * call: it exports the wrapper ZDOTDIR and nothing else. + */ +function olderBuildZshenv(zshDir: string): string { + return `# Orca zsh shell-ready wrapper +export ORCA_ORIG_ZDOTDIR="$HOME" +export ZDOTDIR=${JSON.stringify(zshDir)} +` +} + +describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => { + itWithZsh('still sources the user config when the .zshenv is from an older build', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-')) + const home = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-home-')) + try { + expect(ensureShellReadyWrappersAt(root)).toBe(true) + const zshDir = join(root, 'zsh') + writeFileSync(join(zshDir, '.zshenv'), olderBuildZshenv(zshDir)) + for (const file of ['.zprofile', '.zshrc', '.zlogin']) { + writeFileSync(join(home, file), `echo "RAN ${file}"\n`) + } + + const result = spawnSync(ZSH_PATH, ['-l', '-i', '-c', 'exit 0'], { + encoding: 'utf8', + timeout: 20_000, + env: { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: zshDir } + }) + // Why both streams: zsh prints `command not found` on stderr and the + // fixture files echo on stdout, and this asserts about each. + const output = `${result.stdout}${result.stderr}` + + expect(output).not.toContain('command not found') + expect(output).toContain('RAN .zprofile') + expect(output).toContain('RAN .zshrc') + expect(output).toContain('RAN .zlogin') + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(home, { recursive: true, force: true }) + } + }) +})