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:
parent
9290b25307
commit
01bab271eb
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue