From 01bab271eb788ca2963e0332ce16de8e9dcabdf9 Mon Sep 17 00:00:00 2001 From: Pim de Jong <22365764+P-de-Jong@users.noreply.github.com> Date: Mon, 11 May 2026 01:13:06 +0200 Subject: [PATCH] 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 * test(opencode): tighten overlay restore guards Co-authored-by: Orca --------- Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/main/daemon/pty-subprocess.test.ts | 108 +++++++++++--- src/main/daemon/pty-subprocess.ts | 6 +- src/main/daemon/shell-ready.test.ts | 29 ++++ src/main/daemon/shell-ready.ts | 16 +- src/main/daemon/types.ts | 13 +- src/main/ipc/pty-management.test.ts | 24 +-- src/main/ipc/pty.test.ts | 141 +++++++++++++----- src/main/ipc/pty.ts | 10 ++ src/main/providers/local-pty-provider.ts | 18 ++- .../providers/local-pty-shell-ready.test.ts | 32 ++++ src/main/providers/local-pty-shell-ready.ts | 19 ++- src/main/providers/windows-shell-args.test.ts | 19 ++- src/main/providers/windows-shell-args.ts | 15 +- 13 files changed, 361 insertions(+), 89 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 2580d98f4..c2a99aa12 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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> = {} + 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) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 36ffb359a..213a0d6ce 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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) { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 7f957072c..951c83e87 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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 diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 1bccaf632..5ae1aa58d 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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}" diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a7fba8f42..20fda41f0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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' diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index c4d5d1c19..894e06a39 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -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() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a8bcf53f5..8651d7d62 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ee4225ea0..cbbe63a4d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 8529a5d3f..e14efc549 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -155,6 +155,9 @@ export class LocalPtyProvider implements IPtyProvider { let effectiveCwd: string let validationCwd: string let shellReadyLaunch: ReturnType | null = null + let getFallbackShellReadyConfig: + | ((shell: string) => ReturnType) + | 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). diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index f80848027..b9df5792b 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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' diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index b15d8f59b..85ce95c28 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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 diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index b83c0c057..9836a7636 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -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', () => { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 556dae389..e13e2895f 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -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 }