Files
orca/src/main/daemon/shell-ready.test.ts
T
01bab271eb Fix PTY config overlays being overwritten by shell startup files (#1628)
* Fix OpenCode config overlay env in PTYs

* Fix Pi agent dir overlay env in PTYs

* Restore overlay env in Windows and fallback shells

Co-authored-by: Orca <help@stably.ai>

* test(opencode): tighten overlay restore guards

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
2026-05-10 16:13:06 -07:00

282 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import type * as ShellReadyModule from './shell-ready'
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
vi.resetModules()
return import('./shell-ready')
}
const describePosix = process.platform === 'win32' ? describe.skip : describe
describePosix('daemon shell-ready launch config', () => {
let previousUserDataPath: string | undefined
let previousOrcaOrigZdotdir: string | undefined
let userDataPath: string
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
delete process.env.ORCA_ORIG_ZDOTDIR
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-shell-ready-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
if (previousOrcaOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrcaOrigZdotdir
}
rmSync(userDataPath, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('stores wrapper rcfiles under durable userData instead of tmp', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/bash')
const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile')
expect(config.args).toEqual(['--rcfile', rcfile])
expect(existsSync(rcfile)).toBe(true)
})
it('rewrites wrappers when a long-lived daemon finds a missing rcfile', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile')
getShellReadyLaunchConfig('/bin/bash')
rmSync(rcfile)
expect(existsSync(rcfile)).toBe(false)
getShellReadyLaunchConfig('/bin/bash')
expect(existsSync(rcfile)).toBe(true)
})
it('points zsh launch config at durable wrapper files', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.args).toEqual(['-l'])
expect(config.env.ZDOTDIR).toBe(join(userDataPath, 'shell-ready', 'zsh'))
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
})
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
// Why: guards against the zsh recursion loop that happens when the daemon
// was forked from a shell which was itself an Orca PTY. Such a shell has
// ZDOTDIR=<some>/shell-ready/zsh; propagating that unchanged would make
// the wrapper `source "$ORCA_ORIG_ZDOTDIR/.zshenv"` source itself.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.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 (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
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('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
getShellReadyLaunchConfig('/bin/bash')
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const piRestoreLine =
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(piRestoreLine)
expect(zlogin).toContain(piRestoreLine)
expect(bashRc).toContain(piRestoreLine)
})
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
// case — any real user ZDOTDIR must round-trip so their configs load.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/.config/zsh'
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
}
}
})
it('rejects inherited ZDOTDIR ending in /shell-ready/zsh even with a trailing slash', async () => {
// Why: `endsWith('/shell-ready/zsh')` without normalization is bypassed by
// a trailing slash, which some shell startup scripts add. Pinning this case
// guards against a regression that would reintroduce the recursion loop.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.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 (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => {
// Why: a bare `/` (or `////`) normalizes to empty and is never a user's
// real zsh config root; sourcing `/.zshenv` would silently no-op. Falling
// back to HOME matches what the wrapper already assumes when ZDOTDIR is
// unset.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/'
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 (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => {
// Why: the guard must match the suffix, not a substring — a user directory
// like `/Users/alice/shell-ready/zsh-custom` should round-trip unchanged.
// Pinning this case prevents an over-eager `includes` swap in the future.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom'
try {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/shell-ready/zsh-custom')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
}
})
})