Files
orca/src/main/daemon/shell-ready.test.ts
T

887 lines
36 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as ShellReadyModule from './shell-ready'
import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates'
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
vi.resetModules()
return import('./shell-ready')
}
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
const itWithZsh = hasZsh ? it : it.skip
const hasFish = process.platform !== 'win32' && spawnSync('fish', ['--version']).status === 0
const itWithFish = hasFish ? it : it.skip
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
/** Minimal xterm.js-shaped answers to the capability queries fish emits at startup
* and again around every prompt. */
const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [
['\x1b[0c', '\x1b[?6c'], // primary device attributes
['\x1b[?u', '\x1b[?0u'], // kitty keyboard flags
['\x1b[6n', '\x1b[1;1R'], // cursor position report
['\x1b]11;?', '\x1b]11;rgb:0000/0000/0000\x1b\\'], // background colour
['\x1bP+q', '\x1bP0+r\x1b\\'] // XTGETTCAP (unsupported)
]
/** Derived, not hardcoded: a shorter carry than the longest query would silently
* stop matching sequences split across two PTY chunks. */
const QUERY_CARRY_LEN = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length))
// Why: the shell-ready marker fires from zle-line-init only on a real TTY, so spawn through node-pty not spawnSync.
async function runInteractiveZshLogin(args: {
tempHome: string
wrapperZdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/*, whose insecure fpath dirs make compinit block on a [y/n] prompt before the marker fires.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: args.tempHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: args.tempHome,
TERM: 'xterm-256color',
ZDOTDIR: args.wrapperZdotdir,
ORCA_ORIG_ZDOTDIR: args.tempHome,
ORCA_ZSHENV_SOURCE_DIR: args.tempHome,
ORCA_SHELL_READY_MARKER: '1'
}
})
let output = ''
let settle = (): void => {}
const done = new Promise<void>((resolve) => {
settle = resolve
})
const deadline = setTimeout(settle, 10_000)
proc.onData((chunk) => {
output += chunk
if (args.isDone(output)) {
settle()
}
})
await done
clearTimeout(deadline)
proc.kill()
return output
}
// Why: exercise an arbitrary interactive zsh rc (own ZDOTDIR, no wrapper) so a test can source the marker block directly.
async function runInteractiveZshRc(args: {
zdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit can't block on an insecure-directory [y/n] prompt.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: args.zdotdir,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: args.zdotdir,
TERM: 'xterm-256color',
ZDOTDIR: args.zdotdir,
ORCA_SHELL_READY_MARKER: '1'
}
})
let output = ''
let settle = (): void => {}
const done = new Promise<void>((resolve) => {
settle = resolve
})
const deadline = setTimeout(settle, 10_000)
proc.onData((chunk) => {
output += chunk
if (args.isDone(output)) {
settle()
}
})
await done
clearTimeout(deadline)
proc.kill()
return output
}
function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string {
const rcfile = join(tempDir, 'bash-osc133-rcfile')
writeFileSync(rcfile, rcfileContent)
const result = spawnSync(
'bash',
['-lc', 'bash --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile],
{
input: 'true\nfalse\nexit 0\n',
encoding: 'utf8',
env: {
...process.env,
HOME: tempDir,
ORCA_SHELL_READY_MARKER: '1',
TERM: process.env.TERM || 'xterm'
},
timeout: 5000
}
)
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
return result.stdout
}
function expectBashOsc133Lifecycle(output: string): void {
const oscA = '\x1b]133;A\x07'
const oscC = '\x1b]133;C\x07'
const oscD = '\x1b]133;D;'
const firstPromptMarker = output.indexOf(oscA)
expect(firstPromptMarker).toBeGreaterThanOrEqual(0)
expect(output.slice(0, firstPromptMarker)).not.toContain(oscC)
expect(output.slice(0, firstPromptMarker)).not.toContain(oscD)
expect(output).toContain(`${oscD}0\x07${oscA}`)
expect(output).toContain(`${oscD}1\x07${oscA}`)
expect(output.split(oscC)).toHaveLength(4)
expect(output.split(oscD)).toHaveLength(3)
}
function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') {
expect(content).toContain('export ZDOTDIR="$_orca_home"')
expect(content).toContain(`source "$_orca_home/${fileName}"`)
expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"')
}
function expectFinalZdotdirRestoreContext(content: string) {
expect(content).toContain("after Orca's last wrapper file has loaded")
expect(content).toContain('export ZDOTDIR="$_orca_home"')
}
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('extends the startup barrier to fish so launch commands queue until the prompt', async () => {
const { shellPathSupportsPtyStartupBarrier, supportsPtyStartupBarrier } =
await importFreshShellReady()
expect(shellPathSupportsPtyStartupBarrier('/opt/homebrew/bin/fish')).toBe(true)
expect(supportsPtyStartupBarrier({ SHELL: '/usr/local/bin/fish' })).toBe(true)
// Why: unwrapped shells must stay off the barrier or their first command queues forever.
expect(shellPathSupportsPtyStartupBarrier('/usr/bin/tcsh')).toBe(false)
})
it('wraps fish launches with a fish_prompt shell-ready marker init command', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish')
expect(config.supportsReadyMarker).toBe(true)
expect(config.env).toEqual({ ORCA_SHELL_READY_MARKER: '1' })
expect(config.args?.slice(0, 2)).toEqual(['-l', '-C'])
const init = config.args?.[2] ?? ''
expect(init).toContain('--on-event fish_prompt')
// Why `builtin`: a user-defined printf function would swallow the marker and
// stall every launch on the ready timeout.
expect(init).toContain('builtin printf "\\033]777;orca-shell-ready\\007"')
// Why: the marker must fire once; a repeating marker would corrupt later output scans.
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps attribution-only fish spawns unwrapped', async () => {
const { getAttributionShellLaunchConfig } = await importFreshShellReady()
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
})
itWithFish(
'emits the marker at the first real fish prompt and executes a post-marker command',
async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('fish')
const tempHome = mkdtempSync(join(tmpdir(), 'fish-shell-ready-'))
const sentinel = join(tempHome, 'launched')
const erased = join(tempHome, 'marker-erased')
const stillRegistered = join(tempHome, 'marker-still-registered')
try {
mkdirSync(join(tempHome, '.config', 'fish'), { recursive: true })
// Why: mimic a slow prompt integration (Starship) — init work before the first prompt.
writeFileSync(
join(tempHome, '.config', 'fish', 'config.fish'),
'command sleep 0.2\nfunction fish_prompt\n printf "> "\nend\n'
)
const pty = await import('node-pty')
const proc = pty.spawn('fish', config.args ?? [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: tempHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: tempHome,
TERM: 'xterm-256color',
...config.env
}
})
let output = ''
let commandWritten = false
let erasureProbeWritten = false
let queryCarry = ''
let settle = (): void => {}
const done = new Promise<void>((resolve) => {
settle = resolve
})
const deadline = setTimeout(settle, 10_000)
// Why: settling on the first sentinel observes only one post-marker prompt,
// so a marker that never erased itself still looks single. Drive a second
// command and settle on its result, which also probes the erase directly.
const sentinelPoll = setInterval(() => {
if (commandWritten && !erasureProbeWritten && existsSync(sentinel)) {
erasureProbeWritten = true
proc.write(
`functions -q __orca_shell_ready_marker; and touch ${stillRegistered}; or touch ${erased}\n`
)
return
}
if (erasureProbeWritten && (existsSync(erased) || existsSync(stillRegistered))) {
settle()
}
}, 50)
proc.onData((chunk) => {
output += chunk
// Why: fish stalls its first prompt 10s waiting on these and re-queries
// each prompt, so answer every occurrence — an unanswered query makes
// fish swallow the post-marker command as its reply.
const carriedLength = queryCarry.length
const scan = queryCarry + chunk
queryCarry = scan.slice(-QUERY_CARRY_LEN)
for (const [query, reply] of TERMINAL_QUERY_REPLIES) {
for (
let at = scan.indexOf(query);
at !== -1;
at = scan.indexOf(query, at + query.length)
) {
// Why: a query wholly inside the carry was answered on the previous
// chunk; replying again would land in fish's stdin as typed input.
if (at + query.length > carriedLength) {
proc.write(reply)
}
}
}
if (!commandWritten && output.includes(SHELL_READY_MARKER_OUTPUT)) {
commandWritten = true
// Why: mirror PostReadyFlushGate — flush shortly after the post-marker prompt draw.
setTimeout(() => proc.write(`touch ${sentinel}\n`), 50)
}
})
await done
clearTimeout(deadline)
clearInterval(sentinelPoll)
proc.kill()
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
expect(existsSync(sentinel)).toBe(true)
// Why: asserts the erase directly rather than inferring it from the marker
// count, which only holds once enough prompts have been drawn to expose it.
expect(existsSync(erased)).toBe(true)
expect(existsSync(stillRegistered)).toBe(false)
} finally {
rmSync(tempHome, { recursive: true, force: true })
}
},
15_000
)
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
// Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (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')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).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')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).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('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')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).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')
const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8')
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expectZdotdirSourceContext(zprofile, '.zprofile')
expectZdotdirSourceContext(zshrc, '.zshrc')
expectZdotdirSourceContext(zlogin, '.zlogin')
expectFinalZdotdirRestoreContext(zshrc)
expectFinalZdotdirRestoreContext(zlogin)
})
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
// Why: add-zle-hook-widget aborts its chain when an earlier hook exits non-zero, so don't register the marker through it.
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
// Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source.
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
})
// Why: oh-my-zsh vi-mode's zle-line-init returns non-zero; add-zle-hook-widget then aborts the chain and the marker never fires.
itWithZsh(
'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)',
async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-vi-mode-'))
writeFileSync(
join(tempHome, '.zshrc'),
[
'function zle-line-init() {',
' [[ "${VI_MODE_SET_CURSOR:-}" = true ]] || return',
'}',
'zle -N zle-line-init',
''
].join('\n')
)
try {
const output = await runInteractiveZshLogin({
tempHome,
wrapperZdotdir: config.env.ZDOTDIR,
isDone: (current) => current.includes(SHELL_READY_MARKER_OUTPUT)
})
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
} finally {
rmSync(tempHome, { recursive: true, force: true })
}
},
15_000
)
itWithZsh(
'still runs user add-zle-hook-widget line-init hooks after the marker',
async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-azhw-'))
const userHookOutput = 'ORCA-TEST-USER-HOOK'
writeFileSync(
join(tempHome, '.zshrc'),
[
`__orca_test_line_init_hook() { printf "${userHookOutput}" }`,
'autoload -Uz add-zle-hook-widget',
'zle -N __orca_test_line_init_hook',
'add-zle-hook-widget line-init __orca_test_line_init_hook',
''
].join('\n')
)
try {
const output = await runInteractiveZshLogin({
tempHome,
wrapperZdotdir: config.env.ZDOTDIR,
isDone: (current) =>
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
})
// Why: the marker widget chains to the prior widget, so a user-registered azhw dispatcher must keep dispatching.
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output).toContain(userHookOutput)
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
output.indexOf(userHookOutput)
)
} finally {
rmSync(tempHome, { recursive: true, force: true })
}
},
15_000
)
// Why: a re-source (nested Orca, manual) must stay idempotent — keep chaining the user's original zle-line-init.
itWithZsh(
'keeps chaining the prior zle-line-init widget when the marker block is sourced twice',
async () => {
const zdotdir = mkdtempSync(join(tmpdir(), 'orca-zsh-resource-'))
const userHookOutput = 'ORCA-TEST-PRIOR-WIDGET'
const block = getZshShellReadyMarkerRegistrationBlock('\\033]777;orca-shell-ready\\007')
writeFileSync(
join(zdotdir, '.zshrc'),
[
// A user widget that mimics oh-my-zsh vi-mode owning zle-line-init.
`__orca_test_prior_widget() { printf "${userHookOutput}" }`,
'zle -N zle-line-init __orca_test_prior_widget',
block,
// Second source of the exact same block — must not drop the chain.
block,
''
].join('\n')
)
try {
const output = await runInteractiveZshRc({
zdotdir,
isDone: (current) =>
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
})
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output).toContain(userHookOutput)
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
output.indexOf(userHookOutput)
)
// Why: idempotent — the marker must fire exactly once per prompt, not duplicated by the second registration.
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
} finally {
rmSync(zdotdir, { recursive: true, force: true })
}
},
15_000
)
it('writes wrappers without restoring Pi/OMP homes 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 mimoRestoreLine =
'[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
const primeWrapperLine =
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(mimoRestoreLine)
expect(zlogin).toContain(mimoRestoreLine)
expect(bashRc).toContain(mimoRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zshrc).toContain(ompWrapperLine)
expect(zlogin).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)
expect(zshrc).toContain(primeWrapperLine)
expect(zlogin).toContain(primeWrapperLine)
expect(bashRc).toContain(primeWrapperLine)
})
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
getShellReadyLaunchConfig('/bin/bash')
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
expect(bashRc).toContain('printf "\\033]133;C\\007"')
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
expect(bashRc).toContain(
'PROMPT_COMMAND="__orca_osc133_precmd${PROMPT_COMMAND:+;${PROMPT_COMMAND}};__orca_osc133_epilogue"'
)
// DEBUG is armed after PROMPT_COMMAND setup so rcfile commands aren't seen as foreground; lastIndexOf skips the epilogue's re-arm.
expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
bashRc.indexOf('PROMPT_COMMAND="__orca_osc133_precmd')
)
expect(zshrc).toContain('printf "\\033]133;D;%s\\007"')
expect(zshrc).toContain('printf "\\033]133;C\\007"')
})
itWithBash(
'runs the daemon bash wrapper without fake C/D markers before the first prompt',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
expectBashOsc133Lifecycle(output)
}
)
itWithBash(
'preserves prompt hooks and existing DEBUG traps without fake command markers',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
writeFileSync(
join(userDataPath, '.bash_profile'),
[
'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'',
'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then\n printf "USER_DEBUG_AFTER\\n"\nfi\' DEBUG'
].join('\n')
)
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
expect(output).toContain('PROMPT_HOOK')
expect(output).toContain('USER_DEBUG_AFTER')
expectBashOsc133Lifecycle(output)
}
)
itWithBash(
'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
// Minimal bash-preexec imitation: re-arms its own DEBUG trap from PROMPT_COMMAND at first prompt, silencing Orca's trap.
writeFileSync(
join(userDataPath, '.bash_profile'),
[
'preexec_functions=()',
'__bp_preexec_invoke_exec() {',
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
' __bp_interactive_mode=""',
' local f',
' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done',
'}',
"__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
].join('\n')
)
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
expectBashOsc133Lifecycle(output)
}
)
itWithBash(
'dispatches a non-empty preexec_functions against the real command, not Orca hooks',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
// Why: the epilogue chains bash-preexec's re-armed DEBUG trap, so a real preexec callback must fire against the user's command.
writeFileSync(
join(userDataPath, '.bash_profile'),
[
'preexec_functions=(__user_preexec)',
'__user_preexec() { printf \'USER_PREEXEC:%s\\n\' "$1"; }',
'__bp_inside=0',
'__bp_last_hist=""',
'__bp_preexec_invoke_exec() {',
' (( __bp_inside > 0 )) && return',
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
' local __bp_inside=1',
' local this_command',
' this_command="$(builtin history 1)"',
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
' this_command="${this_command#* }"',
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
' [[ -n "$this_command" && "$this_command" != "$__bp_last_hist" ]] || return',
' __bp_last_hist="$this_command"',
' __bp_interactive_mode=""',
' local f',
' for f in "${preexec_functions[@]}"; do "$f" "$this_command"; done',
'}',
"__bp_arm() { set -o functrace; __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
].join('\n')
)
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
expectBashOsc133Lifecycle(output)
expect(output).toContain('USER_PREEXEC:true')
expect(output).toContain('USER_PREEXEC:false')
expect(output).not.toContain('USER_PREEXEC:__orca_osc133')
expect(output).not.toContain('USER_PREEXEC:__bp_')
}
)
itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
writeFileSync(
join(userDataPath, '.bash_profile'),
'PROMPT_COMMAND=(\'AFTER_ARRAY_PROMPT=1; printf "PROMPT_ARRAY\\n"\')\n'
)
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
expect(output).toContain('PROMPT_ARRAY')
expectBashOsc133Lifecycle(output)
})
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
// Why: only the wrapper self-loop should be rejected; a real user ZDOTDIR must round-trip so their configs load.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/.config/zsh'
try {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh')
} 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: a trailing slash bypasses `endsWith('/shell-ready/zsh')`, reintroducing the recursion loop if unguarded.
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 `/` normalizes to empty (never a real config root), so fall back to HOME as 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: guard must match suffix not substring — `/Users/alice/shell-ready/zsh-custom` must round-trip unchanged.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom'
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
}
}
})
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
// Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level.
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: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, 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}}')
})
})