Fix shell-ready zsh wrapper self loops (#1184)

This commit is contained in:
Jinwoo Hong 2026-04-27 12:13:45 -07:00 committed by GitHub
parent d8d2c0f033
commit ba0f263df1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 194 additions and 16 deletions

View File

@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { existsSync, mkdtempSync, rmSync } from 'fs'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import type * as ShellReadyModule from './shell-ready'
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
@ -90,6 +90,75 @@ describePosix('daemon shell-ready launch config', () => {
}
})
it('uses inherited ORCA_ORIG_ZDOTDIR when ZDOTDIR is an Orca wrapper dir', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.ORCA_ORIG_ZDOTDIR = '/Users/alice/.config/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const previousHome = process.env.HOME
delete process.env.ZDOTDIR
process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
})
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
// Why: users who run a custom zsh dotfiles directory legitimately set
// ZDOTDIR before launching Orca. We only want to reject the self-loop

View File

@ -30,21 +30,29 @@ function getShellReadyWrapperRoot(): string {
// (regardless of whether it came from this daemon's userData, a packaged
// Orca, or a different dev build). Treat it as if ZDOTDIR were unset so the
// caller falls back to HOME for the user's real config root.
function resolveOriginalZdotdir(): string {
const inherited = process.env.ZDOTDIR
if (!inherited) {
return process.env.HOME || ''
function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
if (!value) {
return null
}
// Why: tolerate trailing slashes — some shell startup scripts export
// `ZDOTDIR="$dir/"`, and without normalization the suffix check would
// miss the self-loop path and restore the recursion bug. Also collapses
// a pathological `ZDOTDIR=/` to empty so we fall back to HOME rather than
// sourcing `/.zshenv` (which is never the user's real config).
const normalized = inherited.replace(/\/+$/, '')
const normalized = value.replace(/\/+$/, '')
if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
return process.env.HOME || ''
return null
}
return inherited
return value
}
function resolveOriginalZdotdir(): string {
return (
normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) ||
normalizeOriginalZdotdirCandidate(process.env.ORCA_ORIG_ZDOTDIR) ||
process.env.HOME ||
''
)
}
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] {
@ -76,15 +84,24 @@ function ensureShellReadyWrappers(): void {
const zshEnv = `# Orca daemon zsh shell-ready wrapper
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${ORCA_ORIG_ZDOTDIR%/}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
export ZDOTDIR=${quotePosixSingle(zshDir)}
`
const zshProfile = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
`
const zshRc = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
@ -99,6 +116,9 @@ __orca_restore_attribution_path() {
`
const zshLogin = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
source "$_orca_home/.zlogin"
fi

View File

@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync } from 'fs'
import { mkdtempSync, readFileSync, rmSync } 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'
@ -165,6 +165,75 @@ describePosix('local PTY shell-ready launch config', () => {
}
})
it('uses inherited ORCA_ORIG_ZDOTDIR when ZDOTDIR is an Orca wrapper dir', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.ORCA_ORIG_ZDOTDIR = '/Users/alice/.config/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const previousHome = process.env.HOME
delete process.env.ZDOTDIR
process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
})
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/.config/zsh'

View File

@ -86,21 +86,29 @@ function getShellReadyWrapperRoot(): string {
// (regardless of whether it came from this app's userData, a packaged Orca,
// or a different dev build). Treat it as if ZDOTDIR were unset so the caller
// falls back to HOME for the user's real config root.
function resolveOriginalZdotdir(): string {
const inherited = process.env.ZDOTDIR
if (!inherited) {
return process.env.HOME || ''
function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
if (!value) {
return null
}
// Why: tolerate trailing slashes — some shell startup scripts export
// `ZDOTDIR="$dir/"`, and without normalization the suffix check would
// miss the self-loop path and restore the recursion bug. Also collapses
// a pathological `ZDOTDIR=/` to empty so we fall back to HOME rather than
// sourcing `/.zshenv` (which is never the user's real config).
const normalized = inherited.replace(/\/+$/, '')
const normalized = value.replace(/\/+$/, '')
if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
return process.env.HOME || ''
return null
}
return inherited
return value
}
function resolveOriginalZdotdir(): string {
return (
normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) ||
normalizeOriginalZdotdirCandidate(process.env.ORCA_ORIG_ZDOTDIR) ||
process.env.HOME ||
''
)
}
export function getBashShellReadyRcfileContent(): string {
@ -156,15 +164,24 @@ function ensureShellReadyWrappers(): void {
const zshEnv = `# Orca zsh shell-ready wrapper
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${ORCA_ORIG_ZDOTDIR%/}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
export ZDOTDIR=${quotePosixSingle(zshDir)}
`
const zshProfile = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
`
const zshRc = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
@ -179,6 +196,9 @@ __orca_restore_attribution_path() {
`
const zshLogin = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
source "$_orca_home/.zlogin"
fi