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>
This commit is contained in:
Pim de Jong 2026-05-11 01:13:06 +02:00 committed by GitHub
parent 9290b25307
commit 01bab271eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 361 additions and 89 deletions

View File

@ -1,5 +1,8 @@
/* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const { spawnMock, isPwshAvailableMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
@ -16,6 +19,15 @@ vi.mock('../pwsh', () => ({
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_OPENCODE_CONFIG_DIR',
'ORCA_PI_CODING_AGENT_DIR'
] as const
const POWERSHELL_PROFILE_COMMAND = expect.stringMatching(
/ORCA_OPENCODE_CONFIG_DIR[\s\S]*ORCA_PI_CODING_AGENT_DIR[\s\S]*UTF8/
)
function mockPtyProcess(pid = 12345) {
const onDataListeners: ((data: string) => void)[] = []
const onExitListeners: ((e: { exitCode: number }) => void)[] = []
@ -39,10 +51,38 @@ function mockPtyProcess(pid = 12345) {
}
describe('createPtySubprocess', () => {
const savedWrapperEnv: Partial<Record<(typeof ORCA_SHELL_WRAPPER_ENV)[number], string>> = {}
let previousUserDataPath: string | undefined
let userDataPath: string
beforeEach(() => {
spawnMock.mockReset()
isPwshAvailableMock.mockReset()
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
for (const key of ORCA_SHELL_WRAPPER_ENV) {
savedWrapperEnv[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(userDataPath, { recursive: true, force: true })
for (const key of ORCA_SHELL_WRAPPER_ENV) {
if (savedWrapperEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = savedWrapperEnv[key]
}
delete savedWrapperEnv[key]
}
})
it('spawns node-pty with correct options', () => {
const proc = mockPtyProcess()
@ -231,6 +271,48 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when OpenCode config must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay'
}
})
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toContain('shell-ready/zsh')
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when Pi config must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay'
}
})
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toContain('shell-ready/zsh')
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@ -298,11 +380,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -331,11 +409,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'pwsh.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -364,11 +438,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -397,11 +467,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})

View File

@ -159,9 +159,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
} else {
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
const shellLaunch = opts.command
? getShellReadyLaunchConfig(shellPath)
: env.ORCA_ATTRIBUTION_SHIM_DIR
: env.ORCA_ATTRIBUTION_SHIM_DIR ||
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR
? getAttributionShellLaunchConfig(shellPath)
: null
if (shellLaunch) {

View File

@ -13,10 +13,13 @@ 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
})
@ -27,6 +30,11 @@ describePosix('daemon shell-ready launch config', () => {
} 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()
})
@ -159,6 +167,27 @@ describePosix('daemon shell-ready launch config', () => {
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

View File

@ -112,7 +112,13 @@ __orca_restore_attribution_path() {
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
if [[ ! -o login ]]; then
__orca_restore_attribution_path
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
fi
`
const zshLogin = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
@ -130,6 +136,9 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
__orca_prompt_mark() {
printf "${SHELL_READY_MARKER}"
@ -158,6 +167,11 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
__orca_prompt_mark() {
printf "${SHELL_READY_MARKER}"

View File

@ -1,11 +1,10 @@
// ─── Protocol Version ────────────────────────────────────────────────
// Why: daemons can survive app updates with long-lived shell env. Bump when
// spawn-time env semantics change so stale sessions cannot bypass new behavior.
// Why: bumped from 3 → 4 for the getSnapshot RPC. A surviving v3 daemon
// would reject getSnapshot as unknown, silently failing all checkpoint
// writes. The bump forces a stale daemon to be replaced on reconnect.
export const PROTOCOL_VERSION = 4
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3] as const
// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bumped from 4 → 5 for OpenCode/Pi overlay restoration: a surviving v4
// daemon keeps emitting stale shell-ready wrappers and stale PowerShell args.
export const PROTOCOL_VERSION = 5
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4] as const
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'

View File

@ -68,7 +68,7 @@ function makeSession(
cols: 80,
rows: 24,
createdAt: 0,
protocolVersion: 4,
protocolVersion: 5,
...overrides
}
}
@ -119,7 +119,7 @@ describe('pty:management IPC handlers', () => {
describe('listSessions', () => {
it('merges sessions across current + legacy adapters with protocolVersion', async () => {
const current = makeAdapter(4, [makeSession('new-1'), makeSession('new-2')])
const current = makeAdapter(5, [makeSession('new-1'), makeSession('new-2')])
const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })])
const { registerDaemonManagementHandlers } = await importFresh()
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy]))
@ -132,8 +132,8 @@ describe('pty:management IPC handlers', () => {
expect(result.sessions).toHaveLength(3)
const byId = new Map(result.sessions.map((s) => [s.sessionId, s]))
expect(byId.get('new-1')?.protocolVersion).toBe(4)
expect(byId.get('new-2')?.protocolVersion).toBe(4)
expect(byId.get('new-1')?.protocolVersion).toBe(5)
expect(byId.get('new-2')?.protocolVersion).toBe(5)
expect(byId.get('old-1')?.protocolVersion).toBe(3)
})
@ -152,7 +152,7 @@ describe('pty:management IPC handlers', () => {
})
it('tolerates a failing adapter by skipping its sessions', async () => {
const current = makeAdapter(4, [makeSession('new-1')])
const current = makeAdapter(5, [makeSession('new-1')])
const legacy = makeAdapter(3, [])
legacy.listSessions = vi.fn(async () => {
throw new Error('legacy socket dead')
@ -205,7 +205,7 @@ describe('pty:management IPC handlers', () => {
it('fires one shutdown per initial session and polls until empty', async () => {
const currentSessions = [makeSession('new-1'), makeSession('new-2')]
const legacySessions = [makeSession('old-1', { protocolVersion: 3 })]
const current = makeAdapter(4, [])
const current = makeAdapter(5, [])
const legacy = makeAdapter(3, [])
// Why: shutdown removes the session from the adapter's backing list so
// the next poll observes the shrinking set — mirrors a daemon that
@ -246,7 +246,7 @@ describe('pty:management IPC handlers', () => {
it('reports remainingCount when sessions refuse to die after the poll window', async () => {
const sessions = [makeSession('stuck')]
const current = makeAdapter(4, [])
const current = makeAdapter(5, [])
current.listSessions = vi.fn(async () =>
sessions.map(({ protocolVersion: _pv, ...rest }) => rest)
)
@ -275,7 +275,7 @@ describe('pty:management IPC handlers', () => {
// "refused to exit" count — the user asked to kill what was alive
// when the button was pressed, not to chase new spawns.
const liveSessions = [makeSession('a'), makeSession('b')]
const current = makeAdapter(4, [])
const current = makeAdapter(5, [])
let pollCalls = 0
current.listSessions = vi.fn(async () => {
pollCalls += 1
@ -301,7 +301,7 @@ describe('pty:management IPC handlers', () => {
it('swallows per-session shutdown rejections without stopping the batch', async () => {
const sessionsList = [makeSession('a'), makeSession('b')]
const current = makeAdapter(4, [])
const current = makeAdapter(5, [])
current.listSessions = vi.fn(async () =>
sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest)
)
@ -336,7 +336,7 @@ describe('pty:management IPC handlers', () => {
describe('killOne', () => {
it('routes to the adapter whose protocolVersion owns the session', async () => {
const current = makeAdapter(4, [makeSession('new-1')])
const current = makeAdapter(5, [makeSession('new-1')])
const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })])
const { registerDaemonManagementHandlers } = await importFresh()
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy]))
@ -353,7 +353,7 @@ describe('pty:management IPC handlers', () => {
})
it('returns success=false for unknown sessionId', async () => {
const current = makeAdapter(4, [makeSession('new-1')])
const current = makeAdapter(5, [makeSession('new-1')])
const { registerDaemonManagementHandlers } = await importFresh()
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
registerDaemonManagementHandlers()
@ -368,7 +368,7 @@ describe('pty:management IPC handlers', () => {
})
it('rejects empty/missing sessionId without hitting the adapter', async () => {
const current = makeAdapter(4, [makeSession('new-1')])
const current = makeAdapter(5, [makeSession('new-1')])
const { registerDaemonManagementHandlers } = await importFresh()
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
registerDaemonManagementHandlers()

View File

@ -115,6 +115,10 @@ import {
unregisterSshPtyProvider
} from './pty'
const POWERSHELL_PROFILE_COMMAND = expect.stringMatching(
/\. \$PROFILE[\s\S]*ORCA_OPENCODE_CONFIG_DIR[\s\S]*ORCA_PI_CODING_AGENT_DIR[\s\S]*UTF8/
)
function makeDisposable() {
return { dispose: vi.fn() }
}
@ -352,12 +356,14 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_OPENCODE_HOOK_TOKEN).toBe('opencode-token')
expect(env.ORCA_OPENCODE_PTY_ID).toBe('test-pty')
expect(env.OPENCODE_CONFIG_DIR).toEqual(expect.any(String))
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR)
})
it('injects the Pi agent overlay env into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' })
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
})
it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => {
@ -524,6 +530,7 @@ describe('registerPtyHandlers', () => {
'/user/custom/opencode'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
})
it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => {
@ -533,6 +540,7 @@ describe('registerPtyHandlers', () => {
// the fixed overlay path from the shared setup.
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
})
it('injects the selected Codex home on the daemon path', async () => {
@ -630,6 +638,7 @@ describe('registerPtyHandlers', () => {
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/ambient/pi/agent')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
})
it('skips attribution shims on the daemon path when the setting is disabled', async () => {
@ -784,7 +793,9 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.CODEX_HOME).toBeUndefined()
expect(env.FOO).toBe('bar')
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
@ -939,11 +950,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -956,11 +963,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1019,11 +1022,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1045,11 +1044,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1072,11 +1067,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'pwsh.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1099,11 +1090,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1126,11 +1113,7 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND],
expect.any(Object)
)
})
@ -1280,6 +1263,78 @@ describe('registerPtyHandlers', () => {
}
})
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/zsh'
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp' })
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('uses the POSIX shell wrapper so Pi config survives shell startup files', () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/zsh'
openCodeBuildPtyEnvMock.mockImplementationOnce(() => ({
ORCA_OPENCODE_HOOK_PORT: '4567',
ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token',
ORCA_OPENCODE_PTY_ID: 'test-pty'
}))
try {
const [shell, args, options] = spawnAndGetCall({
cwd: '/tmp',
env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }
})
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('does not force ~/.bashrc after sourcing bash login files in the shell-ready rcfile', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@ -1424,7 +1479,14 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({ cwd: '/tmp' })
expect.objectContaining({
cwd: '/tmp',
env: expect.objectContaining({
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config',
ORCA_SHELL_READY_MARKER: '0',
ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh'
})
})
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Shell "/opt/homebrew/bin/bash" is not executable')
@ -1464,7 +1526,12 @@ describe('registerPtyHandlers', () => {
['-l'],
expect.objectContaining({
cwd: '/tmp',
env: expect.objectContaining({ SHELL: '/bin/zsh' })
env: expect.objectContaining({
SHELL: '/bin/zsh',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config',
ORCA_SHELL_READY_MARKER: '0',
ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh'
})
})
)
expect(warnSpy).toHaveBeenCalledWith(

View File

@ -192,6 +192,11 @@ export function buildPtyHostEnv(
// load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See
// docs/opencode-config-dir-collision.md.
Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
// wrappers restore this PTY-scoped value after user startup files run.
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
}
// Why: Claude/Codex native hooks run inside the shell process, so Orca
// must inject the loopback receiver coordinates before the agent starts.
@ -208,6 +213,11 @@ export function buildPtyHostEnv(
// back to a fresh UUID per spawn; that would discard user Pi state on
// every daemon reconnect.
Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir))
if (baseEnv.PI_CODING_AGENT_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
// wrappers restore this PTY-scoped value after user startup files run.
baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR
}
// Why: Codex account switching now materializes auth into one shared
// runtime home (~/.codex), and Codex launched inside Orca terminals must

View File

@ -155,6 +155,9 @@ export class LocalPtyProvider implements IPtyProvider {
let effectiveCwd: string
let validationCwd: string
let shellReadyLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
let getFallbackShellReadyConfig:
| ((shell: string) => ReturnType<typeof getShellReadyLaunchConfig>)
| undefined
if (wslInfo) {
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
shellPath = 'wsl.exe'
@ -249,9 +252,20 @@ export class LocalPtyProvider implements IPtyProvider {
const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv) : spawnEnv
if (!wslInfo && process.platform !== 'win32') {
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
const needsNoMarkerWrapper =
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_PI_CODING_AGENT_DIR
getFallbackShellReadyConfig = args.command
? (shell) => getShellReadyLaunchConfig(shell)
: needsNoMarkerWrapper
? (shell) => getAttributionShellLaunchConfig(shell)
: undefined
const shellLaunch = args.command
? getShellReadyLaunchConfig(shellPath)
: finalEnv.ORCA_ATTRIBUTION_SHIM_DIR
: needsNoMarkerWrapper
? getAttributionShellLaunchConfig(shellPath)
: null
if (shellLaunch) {
@ -283,7 +297,7 @@ export class LocalPtyProvider implements IPtyProvider {
cwd: effectiveCwd,
env: finalEnv,
ptySpawn: pty.spawn,
getShellReadyConfig: args.command ? (shell) => getShellReadyLaunchConfig(shell) : undefined,
getShellReadyConfig: getFallbackShellReadyConfig,
// Why: if zsh failed and bash took over, HISTFILE still points to
// zsh_history. Update it *before* spawn so the child inherits the
// correct filename (see design doc §8).

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: shell-ready wrapper coverage keeps zsh,
bash, marker scanning, and env restoration cases in one suite so the
generated wrapper contract is reviewed as a unit. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
@ -128,13 +131,21 @@ const describePosix = process.platform === 'win32' ? describe.skip : describe
describePosix('local PTY shell-ready launch config', () => {
let userDataPath: string
let previousOrcaOrigZdotdir: string | undefined
beforeEach(() => {
previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
delete process.env.ORCA_ORIG_ZDOTDIR
userDataPath = mkdtempSync(join(tmpdir(), 'local-pty-shell-ready-test-'))
getUserDataPathMock.mockReturnValue(userDataPath)
})
afterEach(() => {
if (previousOrcaOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrcaOrigZdotdir
}
rmSync(userDataPath, { recursive: true, force: true })
vi.restoreAllMocks()
})
@ -234,6 +245,27 @@ describePosix('local PTY shell-ready launch config', () => {
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 { getBashShellReadyRcfileContent, getShellReadyLaunchConfig } =
await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
const bashRc = getBashShellReadyRcfileContent()
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 () => {
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/.config/zsh'

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: this module owns both shell wrapper file
generation and the matching startup-command readiness scanner; splitting
them would make the wrapper/marker contract harder to audit. */
/**
* Shell-ready startup command support for local PTYs.
*
@ -132,6 +135,11 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
# Why: append the marker through PROMPT_COMMAND so it fires after the login
# startup files have rebuilt the prompt, without re-running user rc files.
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
@ -192,7 +200,13 @@ __orca_restore_attribution_path() {
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
if [[ ! -o login ]]; then
__orca_restore_attribution_path
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
fi
`
const zshLogin = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
@ -210,6 +224,9 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
# Why: zsh precmd runs before the prompt is drawn and before zle owns input,
# which can double-echo startup commands. line-init fires when zle is ready.
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then

View File

@ -19,8 +19,23 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(result.shellArgs[1]).toBe('-Command')
// The actual command must dot-source $PROFILE before setting encodings,
// otherwise oh-my-posh / starship / PSReadLine never load.
expect(result.shellArgs[2]).toContain('. $PROFILE')
expect(result.shellArgs[2]).toContain('UTF8')
const command = result.shellArgs[2] ?? ''
const profileIndex = command.indexOf('. $PROFILE')
const opencodeRestoreIndex = command.indexOf(
'$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR'
)
const piRestoreIndex = command.indexOf(
'$env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR'
)
const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding')
const inputEncodingIndex = command.indexOf('[Console]::InputEncoding')
expect(profileIndex).toBeGreaterThanOrEqual(0)
expect(opencodeRestoreIndex).toBeGreaterThan(profileIndex)
expect(piRestoreIndex).toBeGreaterThan(profileIndex)
expect(outputEncodingIndex).toBeGreaterThan(opencodeRestoreIndex)
expect(outputEncodingIndex).toBeGreaterThan(piRestoreIndex)
expect(inputEncodingIndex).toBeGreaterThan(outputEncodingIndex)
})
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {

View File

@ -45,12 +45,17 @@ export function resolveWindowsShellLaunchArgs(
}
if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') {
// Why: PowerShell profiles run after the spawn env is set and may re-export
// user defaults; restore Orca's PTY-scoped overlays after the profile.
const command = [
'try { . $PROFILE } catch {}',
'if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR }',
'if ($env:ORCA_PI_CODING_AGENT_DIR) { $env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR }',
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
'[Console]::InputEncoding = [System.Text.Encoding]::UTF8'
].join('; ')
return {
shellArgs: [
'-NoExit',
'-Command',
'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8'
],
shellArgs: ['-NoExit', '-Command', command],
effectiveCwd: cwd,
validationCwd: cwd
}