diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 6f4f279d9f6..d4f2026e155 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -11,6 +11,7 @@ import { } from 'fs' import { dirname, join } from 'path' import { createHash, randomUUID } from 'crypto' +import { escapeRegex } from '../../shared/string-utils' // Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in // ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in @@ -365,11 +366,6 @@ function buildProjectHeaderPattern(projectPath: string): RegExp { `(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` ) } - -function escapeRegex(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - // Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines // inside multi-line strings aren't headers, so we need a stateful scanner — // a flat regex misclassifies both cases. diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 79875a82687..b4fc606696a 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -130,6 +130,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -155,6 +156,7 @@ describePosix('daemon shell-ready launch config', () => { 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') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -185,6 +187,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -210,9 +213,9 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') - expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') - expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') + expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { @@ -330,6 +333,7 @@ describePosix('daemon shell-ready launch config', () => { 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') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -410,4 +414,40 @@ describePosix('daemon shell-ready launch config', () => { } } }) + + it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { + // Why: PR #1737 sourced .zshenv inside a wrapper function, which broke + // common patterns like "typeset -U path". The fix must keep .zshenv at + // zsh top level while still capturing the ZDOTDIR it resolved. + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + 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('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('export ZDOTDIR=') + }) + + it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { + // Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper + // should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME. + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + 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:-}"') + + // 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 d107590dcec..81f7834ae2e 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -11,16 +11,13 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getZshEnvTemplate } from '../shell-templates' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007' let didEnsureShellReadyWrappers = false -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - function getShellReadyWrapperRoot(): string { const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV] // Why: older/test launchers may not seed ORCA_USER_DATA_PATH. Keep a @@ -65,6 +62,10 @@ function resolveOriginalZdotdir(): string { ) } +function resolveOriginalZshenvSourceDir(): string { + return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' +} + function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { return [ join(root, 'zsh', '.zshenv'), @@ -249,42 +250,7 @@ function ensureShellReadyWrappers(): void { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') - const zshEnv = `# Orca daemon zsh shell-ready wrapper -_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" -# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom -# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its -# preferred dir; pre-setting it (even to HOME) defeats that default. -unset ZDOTDIR -# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. -# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and -# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` -# or \`export\` to escape. -__orca_source_user_zshenv() { - # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent - # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still - # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env - # ZDOTDIR was inherited. - local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" - [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" -} -__orca_source_user_zshenv -unfunction __orca_source_user_zshenv -# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve -# the spawn-env value (an inherited resolution from a parent Orca PTY); -# else HOME. -export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" -unset _orca_spawn_orig_zdotdir -# Why: strip trailing slashes (matches Node-side normalizer) before the -# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing -# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. -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 -export ZDOTDIR=${quotePosixSingle(zshDir)} -` + const zshEnv = getZshEnvTemplate(zshDir, 'daemon') const zshProfile = `# Orca daemon zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" case "\${_orca_home%/}" in @@ -338,10 +304,25 @@ fi [join(bashDir, 'rcfile'), bashRc] ] as const - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) + 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 + didEnsureShellReadyWrappers = false } } @@ -380,6 +361,7 @@ function getWrappedShellLaunchConfig( args: ['-l'], env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), + ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: join(root, 'zsh'), ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' }, diff --git a/src/main/providers/__tests__/shell-ready-framework-example.test.ts b/src/main/providers/__tests__/shell-ready-framework-example.test.ts new file mode 100644 index 00000000000..4f91713a111 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework-example.test.ts @@ -0,0 +1,91 @@ +/** + * Example test using the shell-script-literal framework. + * + * This demonstrates the pattern for future shell-ready tests. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { shellScriptTest } from './shell-ready-framework/shell-script-test' + +const { getUserDataPathMock } = vi.hoisted(() => ({ + getUserDataPathMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return getUserDataPathMock() + } + throw new Error(`unexpected app.getPath(${name})`) + } + } +})) + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('shell-script-literal framework example', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'shell-test-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when .zshenv sources another file', async () => { + const { stdout } = await shellScriptTest( + ` + # Setup multi-file config + mkdir -p ~/.config/zsh + cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + + cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + + # Run: check discovered ZDOTDIR + zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' + `, + { userDataPath } + ) + + expect(stdout).toMatchInlineSnapshot(` + "HOME= + ZDOTDIR= + " + `) + }) + + it('handles conditional ZDOTDIR based on SSH_CONNECTION', async () => { + const { stdout } = await shellScriptTest( + ` + mkdir -p ~/.config/zsh-local ~/.config/zsh-remote + cat > ~/.zshenv <<'EOF' +if [[ -n "$SSH_CONNECTION" ]]; then + export ZDOTDIR="$HOME/.config/zsh-remote" +else + export ZDOTDIR="$HOME/.config/zsh-local" +fi +EOF + + # Run with SSH_CONNECTION set + SSH_CONNECTION='192.168.1.100 52100 192.168.1.1 22' zsh -c 'env | grep ZDOTDIR | sort' + `, + { userDataPath } + ) + + expect(stdout).toMatchInlineSnapshot(` + "ORCA_ORIG_ZDOTDIR=/.config/zsh-remote + ZDOTDIR= + " + `) + }) +}) diff --git a/src/main/providers/__tests__/shell-ready-framework/README.md b/src/main/providers/__tests__/shell-ready-framework/README.md new file mode 100644 index 00000000000..a94aa6b2340 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework/README.md @@ -0,0 +1,111 @@ +# Shell-Script-Literal Test Framework + +Framework for writing shell-ready tests as literal shell scripts that can be copy-pasted into a terminal to replicate. + +## Usage + +```typescript +import { shellScriptTest } from '../__tests__/shell-ready-framework/shell-script-test' + +it('discovers ZDOTDIR from multi-file config', async () => { + const { stdout } = await shellScriptTest(` + # Setup multi-file config + mkdir -p ~/.config/zsh + cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + + cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + + # Run: check discovered ZDOTDIR + zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' + `) + + expect(stdout).toMatchInlineSnapshot(` + "HOME= + ORCA_ORIG_ZDOTDIR=/.config/zsh + ZDOTDIR= + " + `) +}) +``` + +## How it works + +1. **Creates temp directories** for `$HOME` and Orca's `userDataPath` + +2. **Splits the script** on the `# Run:` marker: + - Lines before the marker → setup commands + - Lines after the marker → run command to test + +3. **Gets Orca's wrapper config** by calling `getShellReadyLaunchConfig()` + +4. **Executes setup** (if present) with bash in temp HOME, using wrapper env + +5. **Executes run command** with the wrapper's shell + args + env + +6. **Normalizes output** by replacing temp paths with placeholders: + - Temp HOME → `` + - Wrapper dir → `` + - Actual user HOME → `` + +7. **Cleans up** temp directories + +8. **Returns** stdout/stderr/exitCode ready for snapshot testing + +## Supported shell syntax + +**All shell syntax is supported** because the script is executed directly by bash/zsh, not parsed: + +- Heredocs (any delimiter, quoted or unquoted) +- Pipes, redirects, command substitution +- Conditionals (`if`, `[[ ]]`, `&&`, `||`) +- Loops, functions, variables +- Any valid shell script + +## Manual replication + +To manually replicate a test scenario, copy the shell commands from the test: + +```bash +# Setup commands (before # Run: marker): +mkdir -p ~/.config/zsh +cat > ~/.config/zsh/env <<'EOF' +export ZDOTDIR="$HOME/.config/zsh" +EOF + +cat > ~/.zshenv <<'EOF' +source "$HOME/.config/zsh/env" +EOF + +# Run command (after # Run: marker): +zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort' +``` + +**Note**: The test framework applies Orca's wrapper configuration (sets `ZDOTDIR` to wrapper directory, etc.). When running manually, you'll see different output unless you also configure the wrapper environment. + +## Snapshot testing + +Use `toMatchInlineSnapshot()` to keep expected output visible in the test file: + +```typescript +expect(stdout).toMatchInlineSnapshot(` + "HOME= + ORCA_ORIG_ZDOTDIR=/.config/zsh + ZDOTDIR= + " +`) +``` + +Update snapshots with `vitest -u`. + +## When to use this framework + +Use this framework for **new shell-ready tests** where: +- You want the test to be easy to replicate manually +- The setup is shell-script-based (file creation, env vars) +- You want a declarative snapshot-driven style + +**Don't migrate existing tests** - this framework is opt-in for new tests only. 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 new file mode 100644 index 00000000000..d3921cebd67 --- /dev/null +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -0,0 +1,154 @@ +import { tmpdir } from 'os' +import { join, basename } from 'path' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { spawnSync } from 'child_process' +import { getShellReadyLaunchConfig } from '../../local-pty-shell-ready' +import { escapeRegex } from '../../../../shared/string-utils' + +const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m + +/** + * Shell-script-literal test framework for shell-ready tests. + * + * Takes shell scripts as string literals that can be literally copy-pasted + * into a terminal to replicate the test scenario. + * + * Example: + * ```typescript + * const { stdout } = await shellScriptTest(` + * mkdir -p ~/.config/zsh + * cat > ~/.zshenv <<'EOF' + * export ZDOTDIR="$HOME/.config/zsh" + * EOF + * + * zsh -c 'env | grep ZDOTDIR' + * `, { userDataPath }) + * expect(stdout).toMatchInlineSnapshot(...) + * ``` + */ + +export type ShellScriptTestResult = { + stdout: string + stderr: string + exitCode: number +} + +export type ShellScriptTestOptions = { + userDataPath?: string + shell?: string +} + +function detectShellFromCommand(command: string, fallback: string): string { + const shellMatch = command.match(/(?:^|\s)((?:\/[\w/-]+\/)?(?:zsh|bash|sh))\s/) + return shellMatch ? shellMatch[1] : fallback +} + +export async function shellScriptTest( + script: string, + options: ShellScriptTestOptions = {} +): Promise { + const testHome = mkdtempSync(join(tmpdir(), 'shell-test-home-')) + const userDataPath = options.userDataPath || mkdtempSync(join(tmpdir(), 'shell-test-userdata-')) + const cleanupUserDataPath = !options.userDataPath + + try { + const parts = script.split(RUN_MARKER) + const hasRunMarker = parts.length === 2 + const setupScript = hasRunMarker ? parts[0].trim() : '' + const runScript = hasRunMarker ? parts[1].trim() : script.trim() + + const wrapperShell = detectShellFromCommand(runScript, options.shell || '/bin/zsh') + const config = getShellReadyLaunchConfig(wrapperShell) + + const env: Record = { + ...config.env, + HOME: testHome + } + + const spawnOptions = { + env: env as NodeJS.ProcessEnv, + cwd: testHome, + encoding: 'utf8' as const + } + + if (setupScript) { + const setupPath = join(testHome, '.setup.sh') + writeFileSync(setupPath, setupScript, 'utf8') + const setupResult = spawnSync('/bin/bash', [setupPath], spawnOptions) + if (setupResult.status !== 0) { + throw new Error( + `Setup script failed with exit code ${setupResult.status}\nstderr: ${setupResult.stderr}` + ) + } + } + + const runPath = join(testHome, '.run.sh') + writeFileSync(runPath, runScript, 'utf8') + const shellArgs = config.args ? [...config.args, runPath] : [runPath] + const result = spawnSync(wrapperShell, shellArgs, spawnOptions) + + const normalizationContext = { + testHome, + userDataPath, + actualUserHome: process.env.HOME || '', + shellName: basename(wrapperShell).toLowerCase() + } + + return { + stdout: normalizeOutput(result.stdout || '', normalizationContext), + stderr: normalizeOutput(result.stderr || '', normalizationContext), + exitCode: result.status ?? -1 + } + } finally { + rmSync(testHome, { recursive: true, force: true }) + if (cleanupUserDataPath) { + rmSync(userDataPath, { recursive: true, force: true }) + } + } +} + +const TEMP_PATH_PATTERN = + /\/(?:var\/folders|tmp)\/[^\s]+?\/(?:shell-test|orca|shell-ready)-[a-z]+-[a-z0-9-]+/g +const PID_PATTERN = /\bpid:\s*\d+/gi + +function normalizeOutput( + output: string, + ctx: { + testHome: string + userDataPath: string + actualUserHome: string + shellName: string + } +): string { + if (!output) { + return output + } + + const wrapperDir = join(ctx.userDataPath, 'shell-ready', ctx.shellName) + + const paths: { path: string; placeholder: string }[] = [ + { path: wrapperDir, placeholder: '' }, + { path: ctx.testHome, placeholder: '' } + ] + + if (ctx.actualUserHome) { + paths.push({ path: ctx.actualUserHome, placeholder: '' }) + } + + const replacements = paths + .sort((a, b) => b.path.length - a.path.length) + .map(({ path, placeholder }) => ({ + pattern: new RegExp(escapeRegex(path), 'g'), + placeholder + })) + + let normalized = output + for (const { pattern, placeholder } of replacements) { + normalized = normalized.replace(pattern, placeholder) + } + + normalized = normalized.replace(TEMP_PATH_PATTERN, '') + normalized = normalized.replace(PID_PATTERN, 'pid: ') + + return normalized +} diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index fdd7849135f..aa08fe36ff5 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'child_process' import { tmpdir } from 'os' -import { join } from 'path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join, dirname } from 'path' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'fs' import type * as pty from 'node-pty' import type * as LocalPtyShellReadyModule from './local-pty-shell-ready' import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready' @@ -205,6 +205,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -230,6 +231,7 @@ describePosix('local PTY shell-ready launch config', () => { 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') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -260,6 +262,7 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -285,9 +288,9 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') - expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') - expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') + expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') + expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) it('writes wrappers that restore agent config homes after user startup files', async () => { @@ -400,6 +403,7 @@ describePosix('local PTY shell-ready launch config', () => { 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') } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -470,4 +474,1127 @@ describePosix('local PTY shell-ready launch config', () => { } } }) + + it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { + // Why: PR #1737 sourced .zshenv inside a wrapper function, which broke + // common patterns like "typeset -U path". The fix must keep .zshenv at + // zsh top level while still capturing the ZDOTDIR it resolved. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + 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('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('export ZDOTDIR=') + }) + + it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { + // Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper + // should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + 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:-}"') + + // Fallback chain: discovered → normalized spawn-env path → HOME + expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + }) +}) + +// Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level +// zsh semantics. These tests spawn real zsh subprocesses, so they're gated on +// zsh availability and skipped on platforms where zsh is not found. +describePosix('live zsh subprocess tests', () => { + const hasZsh = (() => { + const result = spawnSync('which', ['zsh'], { encoding: 'utf8' }) + return result.status === 0 + })() + + const describeIfZsh = hasZsh ? describe : describe.skip + + describeIfZsh('ZDOTDIR discovery with real zsh', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('preserves typeset -U path scoping when user .zshrc uses it', async () => { + // Why: this was the breakage pattern in PR #1737. The function-wrapper + // approach made "typeset -U path" function-scoped. User rcfiles must + // still be sourced at the wrapper's top level, preserving scoping. + + // Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="$HOME/.config/zsh" +` + ) + writeFileSync( + join(xdgZshDir, '.zshrc'), + `typeset -U path +path=(/custom/bin $path) +` + ) + + // Generate the Orca wrapper + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Spawn interactive zsh with the wrapper and verify: + // 1. Wrapper discovered XDG ZDOTDIR from .zshenv + // 2. User's .zshrc was sourced from discovered ZDOTDIR + // 3. typeset -U path modification persisted (proving top-level scoping) + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + PATH: '/usr/bin:/bin' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync( + 'zsh', + [ + '-i', + '-c', + 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "PATH_HAS_CUSTOM=${PATH%%:*}"' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + const output = result.stdout + expect(output).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') + }) + + it('preserves top-level .zshenv path and function side effects', async () => { + // Why: .zshenv is the normal place for always-on zsh env/path setup. + // Dropping those side effects regresses non-Orca zsh startup semantics. + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `typeset -U path +path=(/env/bin $path) +export MY_VAR=from-zshenv +orca_zshenv_func() { echo "from-zshenv-function"; } +export ZDOTDIR="$HOME/.config/zsh" +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + PATH: '/usr/bin:/bin' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.MY_VAR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync( + 'zsh', + [ + '-c', + 'echo "PATH_HEAD=${PATH%%:*}" && echo "MY_VAR=${MY_VAR:-unset}" && orca_zshenv_func' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('PATH_HEAD=/env/bin') + expect(result.stdout).toContain('MY_VAR=from-zshenv') + expect(result.stdout).toContain('from-zshenv-function') + }) + + it('survives early return in user .zshenv without crashing', async () => { + // Why: common pattern to skip non-interactive sourcing. A direct source + // at zsh top level must keep the wrapper running, matching normal zsh. + writeFileSync( + join(testHome, '.zshenv'), + `[[ -o interactive ]] || return 0 +export ZDOTDIR="$HOME/.config/zsh" +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync( + 'zsh', + ['-c', 'echo "survived" && echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('survived') + // ZDOTDIR discovery yields nothing (early return before export), fallback to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => { + // Why: vanilla zsh users don't set ZDOTDIR. The fallback chain should + // land on HOME after preserving the rest of .zshenv behavior. + writeFileSync( + join(testHome, '.zshenv'), + `# Vanilla zsh config, no ZDOTDIR +export MY_VAR=foo +` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + }) + + describeIfZsh('high-priority edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when .zshenv sources another file that sets it', async () => { + // Multi-file sourcing pattern + const commonSh = join(testHome, '.config', 'shell', 'common.sh') + mkdirSync(dirname(commonSh), { recursive: true }) + writeFileSync(commonSh, 'export ZDOTDIR="$HOME/.config/zsh"\n') + writeFileSync(join(testHome, '.zshenv'), 'source ~/.config/shell/common.sh\n') + + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('preserves ZDOTDIR with spaces in path', async () => { + const spacePath = join(testHome, 'My Config', 'zsh') + mkdirSync(spacePath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${spacePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${spacePath}`) + }) + + it('falls back when .zshenv has syntax error', async () => { + writeFileSync(join(testHome, '.zshenv'), 'syntax error {{{\nexport ZDOTDIR=broken\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Syntax error causes discovery to fail, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles framework pattern with ${ZDOTDIR:-$HOME}', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'export ZDOTDIR="${ZDOTDIR:-$HOME}"\n# prezto-style pattern\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Framework pattern defaults to HOME when ZDOTDIR unset + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('captures last ZDOTDIR value when set multiple times', async () => { + const firstPath = join(testHome, '.config', 'zsh') + const lastPath = join(testHome, '.local', 'zsh') + mkdirSync(firstPath, { recursive: true }) + mkdirSync(lastPath, { recursive: true }) + + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="${firstPath}"\nexport ZDOTDIR="${lastPath}"\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${lastPath}`) + }) + + it('handles conditional ZDOTDIR based on environment', async () => { + const localPath = join(testHome, '.config', 'zsh') + const remotePath = join(testHome, '.config', 'zsh-remote') + mkdirSync(localPath, { recursive: true }) + mkdirSync(remotePath, { recursive: true }) + + writeFileSync( + join(testHome, '.zshenv'), + `if [[ -n "$SSH_CONNECTION" ]]; then\n export ZDOTDIR="${remotePath}"\nelse\n export ZDOTDIR="${localPath}"\nfi\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Test without SSH_CONNECTION + let cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.SSH_CONNECTION + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${localPath}`) + + // Test with SSH_CONNECTION + cleanEnv = { ...process.env, HOME: testHome, SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${remotePath}`) + }) + + it('preserves explicit ZDOTDIR="$HOME" from user .zshenv', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('falls back when discovered ZDOTDIR does not exist', async () => { + const nonexistent = join(testHome, '.config', 'zsh-missing') + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${nonexistent}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Validation rejects non-existent path, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('does not source /.zshenv when HOME is empty', async () => { + // Create /.zshenv to verify it's NOT sourced + // (can't actually create in test but we verify the wrapper logic) + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + // Verify wrapper checks the resolved source root is non-empty before sourcing + expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') + }) + + it('handles ZDOTDIR with single quote in path', async () => { + const quotePath = join(testHome, "config'zsh") + mkdirSync(quotePath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${quotePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${quotePath}`) + }) + + it('does not evaluate command substitution in ZDOTDIR', async () => { + const safePath = join(testHome, '.config', 'zsh') + mkdirSync(safePath, { recursive: true }) + // Attempt command substitution - should be treated as literal path component + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${safePath}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should contain the safe path, not any command-substituted value + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${safePath}`) + }) + + it('handles whitespace-only ZDOTDIR (tabs and newlines)', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="\t\t\n\n"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Whitespace-only should be normalized to empty, fall back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles ZDOTDIR with multiple trailing slashes', async () => { + const cleanPath = join(testHome, '.config', 'zsh') + mkdirSync(cleanPath, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${cleanPath}///"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should normalize to path without trailing slashes + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${cleanPath}`) + }) + }) + + describeIfZsh('terminal emulator edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-term-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-term-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('discovers ZDOTDIR when launched inside tmux', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + TMUX: '/tmp/tmux-501/default,12345,0', + TMUX_PANE: '%0' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('discovers ZDOTDIR when launched from SSH session', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22', + SSH_CLIENT: '10.0.0.1 12345 22', + LC_CTYPE: 'C.UTF-8' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { + const userZdotdir = join('/home', 'alice', '.config', 'zsh') + + const previousZdotdir = process.env.ZDOTDIR + const previousHome = process.env.HOME + process.env.ZDOTDIR = userZdotdir + process.env.HOME = '/root' // sudo changed HOME + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Should preserve user's ZDOTDIR from spawn env, not fall back to /root + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) + } 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 + } + } + }) + + it('re-discovers ZDOTDIR despite stale ORCA_ORIG_ZDOTDIR from previous session', async () => { + const currentZdotdir = join(testHome, '.config', 'zsh-current') + mkdirSync(currentZdotdir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${currentZdotdir}"\n`) + + const previousOrcaZdotdir = process.env.ORCA_ORIG_ZDOTDIR + process.env.ORCA_ORIG_ZDOTDIR = '/opt/orca-old/shell-ready/zsh' // stale wrapper path + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + ORCA_ORIG_ZDOTDIR: '/opt/orca-old/shell-ready/zsh' + } + delete cleanEnv.ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should discover fresh value from .zshenv, not use stale wrapper path + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${currentZdotdir}`) + } finally { + if (previousOrcaZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaZdotdir + } + } + }) + + it('prioritizes fresh discovery over inherited ORCA_ORIG_ZDOTDIR', async () => { + const freshZdotdir = join(testHome, '.config', 'zsh-updated') + mkdirSync(freshZdotdir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${freshZdotdir}"\n`) + + const previousOrcaZdotdir = process.env.ORCA_ORIG_ZDOTDIR + const oldZdotdir = join(testHome, '.config', 'zsh-old') + process.env.ORCA_ORIG_ZDOTDIR = oldZdotdir + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + ORCA_ORIG_ZDOTDIR: oldZdotdir + } + delete cleanEnv.ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should use fresh discovery (user updated .zshenv) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${freshZdotdir}`) + } finally { + if (previousOrcaZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaZdotdir + } + } + }) + + it('sources launch-time ZDOTDIR .zshenv when it is explicitly inherited', async () => { + const homeZdotdir = join(testHome, '.config', 'zsh-home') + const inheritedZdotdir = join(testHome, '.config', 'zsh-inherited') + mkdirSync(homeZdotdir, { recursive: true }) + mkdirSync(inheritedZdotdir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export SOURCE_MARKER=home\nexport ZDOTDIR="${homeZdotdir}"\n` + ) + writeFileSync( + join(inheritedZdotdir, '.zshenv'), + `export SOURCE_MARKER=inherited\nexport ZDOTDIR="${inheritedZdotdir}"\n` + ) + + const previousZdotdir = process.env.ZDOTDIR + const previousHome = process.env.HOME + process.env.ZDOTDIR = inheritedZdotdir + process.env.HOME = testHome + + try { + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(inheritedZdotdir) + + const cleanEnv: Record = { + ...process.env, + ...config.env, + HOME: testHome + } + + const result = spawnSync( + 'zsh', + [ + '-c', + 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${inheritedZdotdir}`) + expect(result.stdout).toContain('SOURCE_MARKER=inherited') + } 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 + } + } + }) + }) + + describeIfZsh('automation and edge cases', () => { + let testHome: string + let userDataPath: string + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) + getUserDataPathMock.mockReturnValue(userDataPath) + }) + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('matches normal zsh when user .zshenv calls exit', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\nexit 42\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "survived"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(42) + expect(result.stdout).not.toContain('survived') + }) + + it('survives user .zshenv with set -e and failing command', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'set -e\nfalse\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // No ZDOTDIR was reached after the failing command, so we fall back. + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('survives user .zshenv with set -u before ZDOTDIR is set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), 'set -u\nexport ZDOTDIR="$HOME/.config/zsh"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Should work because wrapper uses ${ZDOTDIR:-} which is safe with set -u + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('survives user .zshenv with nullglob set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + 'setopt nullglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('survives user .zshenv with extendedglob set', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + 'setopt extendedglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('preserves exported .zshenv environment changes in the wrapper shell', async () => { + writeFileSync( + join(testHome, '.zshenv'), + 'export MY_VAR=from-zshenv\nexport ZDOTDIR="$HOME/.config/zsh"\n' + ) + + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.MY_VAR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "MY_VAR=${MY_VAR:-unset}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('MY_VAR=from-zshenv') + }) + + it('handles empty HOME gracefully', async () => { + // When HOME is empty, wrapper should not attempt to source /.zshenv + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { HOME: '' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Empty HOME falls back to empty ORCA_ORIG_ZDOTDIR + expect(result.stdout).toContain('ORCA_ORIG_ZDOTDIR=\n') + }) + + it('handles unset HOME gracefully', async () => { + // When HOME is unset at spawn, zsh initializes it from /etc/passwd before + // running the wrapper, so the wrapper can discover ZDOTDIR normally. + // This verifies the wrapper doesn't crash when HOME is initially unset. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = {} + delete cleanEnv.HOME + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // zsh initializes HOME from passwd, wrapper discovers ZDOTDIR normally + expect(result.stdout).toMatch(/ORCA_ORIG_ZDOTDIR=.+/) + }) + + it('handles ZDOTDIR containing only "/"', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="/"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Single slash normalizes to empty after %/, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles ZDOTDIR containing only slashes "///"', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="///"\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Multiple slashes normalize to "/" then to empty after %/, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles user .zshenv that unsets HOME', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync(join(testHome, '.zshenv'), `unset HOME\nexport ZDOTDIR="${xdgZshDir}"\n`) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Subshell unsets HOME but wrapper HOME is in parent scope + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + + it('handles user .zshenv that sets ZDOTDIR to empty string', async () => { + writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR=""\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + const cleanEnv: Record = { ...process.env, HOME: testHome } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // Empty string should be normalized away, fall back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + }) + + it('handles conditional unset of ZDOTDIR', async () => { + const xdgZshDir = join(testHome, '.config', 'zsh') + mkdirSync(xdgZshDir, { recursive: true }) + writeFileSync( + join(testHome, '.zshenv'), + `export ZDOTDIR="${xdgZshDir}"\nif [[ "\${TERM}" == "dumb" ]]; then\n unset ZDOTDIR\nfi\n` + ) + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + + // Test with TERM=dumb + let cleanEnv: Record = { + ...process.env, + HOME: testHome, + TERM: 'dumb' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // ZDOTDIR unset conditionally, falls back to HOME + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) + + // Test with TERM=xterm + cleanEnv = { ...process.env, HOME: testHome, TERM: 'xterm-256color' } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + cleanEnv.ZDOTDIR = config.env.ZDOTDIR + + result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + }) + + expect(result.status).toBe(0) + // ZDOTDIR not unset, uses discovered value + expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + }) + }) }) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 76569e304a0..53f46f96e9f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -12,7 +12,7 @@ */ import { tmpdir } from 'os' import { basename, win32 as pathWin32 } from 'path' -import { mkdirSync, writeFileSync, chmodSync } from 'fs' +import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'fs' import { app } from 'electron' import type * as pty from 'node-pty' import { @@ -21,13 +21,10 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getZshEnvTemplate } from '../shell-templates' let didEnsureShellReadyWrappers = false -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready' const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' @@ -86,6 +83,20 @@ function getShellReadyWrapperRoot(): string { return `${userDataPath}/shell-ready` } +function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { + return [ + `${root}/zsh/.zshenv`, + `${root}/zsh/.zprofile`, + `${root}/zsh/.zshrc`, + `${root}/zsh/.zlogin`, + `${root}/bash/rcfile` + ] +} + +function shellReadyWrappersExist(): boolean { + return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) +} + // Why: if our own process inherited ZDOTDIR from a parent shell that was // itself an Orca PTY (e.g. the user launched `pn dev` from a terminal inside // a running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir. @@ -123,6 +134,10 @@ function resolveOriginalZdotdir(): string { ) } +function resolveOriginalZshenvSourceDir(): string { + return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || '' +} + export function getBashShellReadyRcfileContent(): string { return `# Orca bash shell-ready wrapper [[ -f /etc/profile ]] && source /etc/profile @@ -288,7 +303,10 @@ preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) } function ensureShellReadyWrappers(): void { - if (didEnsureShellReadyWrappers || process.platform === 'win32') { + if (process.platform === 'win32') { + return + } + if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { return } didEnsureShellReadyWrappers = true @@ -297,42 +315,7 @@ function ensureShellReadyWrappers(): void { const zshDir = `${root}/zsh` const bashDir = `${root}/bash` - const zshEnv = `# Orca zsh shell-ready wrapper -_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" -# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom -# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its -# preferred dir; pre-setting it (even to HOME) defeats that default. -unset ZDOTDIR -# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. -# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and -# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` -# or \`export\` to escape. -__orca_source_user_zshenv() { - # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent - # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still - # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env - # ZDOTDIR was inherited. - local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" - [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" -} -__orca_source_user_zshenv -unfunction __orca_source_user_zshenv -# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve -# the spawn-env value (an inherited resolution from a parent Orca PTY); -# else HOME. -export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" -unset _orca_spawn_orig_zdotdir -# Why: strip trailing slashes (matches Node-side normalizer) before the -# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing -# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. -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 -export ZDOTDIR=${quotePosixSingle(zshDir)} -` + const zshEnv = getZshEnvTemplate(zshDir) const zshProfile = `# Orca zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" case "\${_orca_home%/}" in @@ -386,11 +369,26 @@ fi [`${bashDir}/rcfile`, bashRc] ] as const - for (const [path, content] of files) { - const dir = path.slice(0, path.lastIndexOf('/')) - mkdirSync(dir, { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) + 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: 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(`[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 + didEnsureShellReadyWrappers = false } } @@ -414,6 +412,7 @@ function getWrappedShellLaunchConfig( args: ['-l'], env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), + ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' }, diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts new file mode 100644 index 00000000000..30a8820bf23 --- /dev/null +++ b/src/main/shell-templates.ts @@ -0,0 +1,73 @@ +// Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery; +// small drift here breaks different terminal transports in different ways. + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string { + const header = headerPrefix + ? `Orca ${headerPrefix} zsh shell-ready wrapper` + : 'Orca zsh shell-ready wrapper' + return `# ${header} +_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 + +# Why: source at wrapper top level, not in a function/subshell, so .zshenv +# exports, functions, path/fpath typesets, and zsh options keep normal scope. +unset ZDOTDIR +if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then + _orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv" +fi +if [[ -n "\${_orca_zshenv_path:-}" ]]; then + source "\${_orca_zshenv_path}" +fi + +_orca_discovered_zdotdir="\${ZDOTDIR:-}" + +while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do + _orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}" +done + +case "\${_orca_discovered_zdotdir}" in + *[![:space:]]*) ;; + *) _orca_discovered_zdotdir="" ;; +esac + +if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then + [[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2 + _orca_discovered_zdotdir="" +fi + +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 + +export ZDOTDIR=${quotePosixSingle(zshDir)} +unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir +` +} diff --git a/src/shared/string-utils.ts b/src/shared/string-utils.ts new file mode 100644 index 00000000000..f2be891ad10 --- /dev/null +++ b/src/shared/string-utils.ts @@ -0,0 +1,12 @@ +/** + * Escape special regex characters in a string for use in RegExp constructor. + * + * Why: When building a regex from user input or file paths, special regex + * characters (. * + ? ^ $ { } ( ) | [ ] \) must be escaped to match literally. + * + * @param str - String to escape + * @returns Escaped string safe for use in new RegExp() + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +}