orca/src/main/ipc/pty.test.ts

5452 lines
183 KiB
TypeScript

/* eslint-disable max-lines -- Why: PTY spawn env behavior is easiest to verify in
one focused file because the registration helper is stateful and each spawn-path
assertion reuses the same mocked IPC and node-pty harness. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter, join } from 'node:path'
const {
handleMock,
onMock,
removeHandlerMock,
removeAllListenersMock,
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
readFileSyncMock,
writeFileSyncMock,
chmodSyncMock,
getPathMock,
spawnMock,
openCodeBuildPtyEnvMock,
openCodeClearPtyMock,
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
piBuildPtyEnvMock,
piClearPtyMock,
isPwshAvailableMock,
trackMock,
classifyErrorMock,
registerPtyMock,
unregisterPtyMock,
setMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtysForPaneKeyMock,
clearPaneKeyAliasesForPtyMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
removeHandlerMock: vi.fn(),
removeAllListenersMock: vi.fn(),
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
chmodSyncMock: vi.fn(),
getPathMock: vi.fn(),
spawnMock: vi.fn(),
openCodeBuildPtyEnvMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
openCodeClearPtyMock: vi.fn(),
buildAgentHookEnvMock: vi.fn(),
clearAgentHookPaneStateMock: vi.fn(),
registerPaneKeyAliasMock: vi.fn(),
piBuildPtyEnvMock: vi.fn(),
piClearPtyMock: vi.fn(),
trackMock: vi.fn(),
classifyErrorMock: vi.fn(),
registerPtyMock: vi.fn(),
unregisterPtyMock: vi.fn(),
setMigrationUnsupportedPtyMock: vi.fn(),
clearMigrationUnsupportedPtyMock: vi.fn(),
clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(),
clearPaneKeyAliasesForPtyMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
isPackaged: true,
getPath: getPathMock
},
nativeTheme: {
shouldUseDarkColors: true
},
ipcMain: {
handle: handleMock,
on: onMock,
removeHandler: removeHandlerMock,
removeAllListeners: removeAllListenersMock
}
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
readFileSync: readFileSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: chmodSyncMock,
constants: {
X_OK: 1
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
clearPty: openCodeClearPtyMock
}
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
buildPtyEnv: buildAgentHookEnvMock,
clearPaneState: clearAgentHookPaneStateMock,
registerPaneKeyAlias: registerPaneKeyAliasMock,
clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
}
}))
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: {
buildPtyEnv: piBuildPtyEnvMock,
clearPty: piClearPtyMock
}
}))
vi.mock('../pwsh', () => ({
isPwshAvailable: isPwshAvailableMock
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/classify-error', () => ({
classifyError: classifyErrorMock
}))
vi.mock('../memory/pty-registry', () => ({
registerPty: registerPtyMock,
unregisterPty: unregisterPtyMock
}))
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
setMigrationUnsupportedPty: setMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPty: clearMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtysForPaneKey: clearMigrationUnsupportedPtysForPaneKeyMock
}))
import { LocalPtyProvider } from '../providers/local-pty-provider'
import { makePaneKey } from '../../shared/stable-pane-id'
import {
registerPtyHandlers,
registerSshPtyProvider,
clearProviderPtyState,
deletePtyOwnership,
getPtyRendererDeliveryDebugSnapshot,
resetPtyRendererDeliveryDebug,
getPtyIdForPaneKey,
hasPendingRendererSerializerForPaneKey,
setPtyOwnership,
setLocalPtyProvider,
unregisterSshPtyProvider
} from './pty'
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
} from '../powershell-osc133-bootstrap'
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-provider'
const POWERSHELL_OSC133_ARGS = [
'-NoLogo',
'-NoExit',
'-EncodedCommand',
encodePowerShellCommand(getPowerShellOsc133Bootstrap())
]
const TEST_CODEX_HOME =
process.platform === 'win32'
? 'C:\\Users\\test\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
: '/tmp/orca-codex-home'
function makeDisposable() {
return { dispose: vi.fn() }
}
describe('registerPtyHandlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mainWindow = {
isDestroyed: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
const savedOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR
const savedOrcaOpenCodeSourceConfigDir = process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR
const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR
const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR
const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME
const savedOrcaOmpAgentDir = process.env.ORCA_OMP_CODING_AGENT_DIR
const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
beforeEach(() => {
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_CONFIG_DIR
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
delete process.env.PI_CODING_AGENT_DIR
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
delete process.env.ORCA_PI_CODING_AGENT_DIR
delete process.env.ORCA_CODEX_HOME
delete process.env.ORCA_OMP_SOURCE_AGENT_DIR
delete process.env.ORCA_OMP_CODING_AGENT_DIR
delete process.env.ORCA_OMP_STATUS_EXTENSION
handlers.clear()
handleMock.mockReset()
onMock.mockReset()
removeHandlerMock.mockReset()
removeAllListenersMock.mockReset()
existsSyncMock.mockReset()
statSyncMock.mockReset()
accessSyncMock.mockReset()
mkdirSyncMock.mockReset()
readFileSyncMock.mockReset()
writeFileSyncMock.mockReset()
chmodSyncMock.mockReset()
getPathMock.mockReset()
spawnMock.mockReset()
openCodeBuildPtyEnvMock.mockReset()
openCodeClearPtyMock.mockReset()
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
piBuildPtyEnvMock.mockReset()
piClearPtyMock.mockReset()
isPwshAvailableMock.mockReset()
trackMock.mockReset()
classifyErrorMock.mockReset()
registerPtyMock.mockReset()
unregisterPtyMock.mockReset()
setMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtysForPaneKeyMock.mockReset()
clearPaneKeyAliasesForPtyMock.mockReset()
mainWindow.webContents.on.mockReset()
mainWindow.webContents.send.mockReset()
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
getPathMock.mockReturnValue('/tmp/orca-user-data')
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
readFileSyncMock.mockReturnValue('')
openCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({
ORCA_OPENCODE_HOOK_PORT: '4567',
ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token',
ORCA_OPENCODE_PTY_ID: 'test-pty',
OPENCODE_CONFIG_DIR: existingConfigDir
? '/tmp/orca-opencode-overlay'
: '/tmp/orca-opencode-config'
}))
buildAgentHookEnvMock.mockReturnValue({
ORCA_AGENT_HOOK_PORT: '5678',
ORCA_AGENT_HOOK_TOKEN: 'agent-token'
})
piBuildPtyEnvMock.mockImplementation(
(_ptyId: string, existingAgentDir?: string, _kind?: string) => ({
PI_CODING_AGENT_DIR: existingAgentDir
? '/tmp/orca-pi-agent-overlay'
: '/tmp/orca-pi-agent-overlay'
})
)
isPwshAvailableMock.mockReturnValue(false)
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
process: 'zsh',
pid: 12345
})
})
afterEach(() => {
unregisterSshPtyProvider('ssh-1')
setLocalPtyProvider(new LocalPtyProvider())
if (savedOpenCodeConfigDir !== undefined) {
process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir
} else {
delete process.env.OPENCODE_CONFIG_DIR
}
if (savedOrcaOpenCodeConfigDir !== undefined) {
process.env.ORCA_OPENCODE_CONFIG_DIR = savedOrcaOpenCodeConfigDir
} else {
delete process.env.ORCA_OPENCODE_CONFIG_DIR
}
if (savedOrcaOpenCodeSourceConfigDir !== undefined) {
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = savedOrcaOpenCodeSourceConfigDir
} else {
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
if (savedPiAgentDir !== undefined) {
process.env.PI_CODING_AGENT_DIR = savedPiAgentDir
} else {
delete process.env.PI_CODING_AGENT_DIR
}
if (savedOrcaPiAgentDir !== undefined) {
process.env.ORCA_PI_CODING_AGENT_DIR = savedOrcaPiAgentDir
} else {
delete process.env.ORCA_PI_CODING_AGENT_DIR
}
if (savedOrcaPiSourceAgentDir === undefined) {
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
} else {
process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir
}
if (savedOrcaCodexHome === undefined) {
delete process.env.ORCA_CODEX_HOME
} else {
process.env.ORCA_CODEX_HOME = savedOrcaCodexHome
}
if (savedOrcaOmpAgentDir !== undefined) {
process.env.ORCA_OMP_CODING_AGENT_DIR = savedOrcaOmpAgentDir
} else {
delete process.env.ORCA_OMP_CODING_AGENT_DIR
}
if (savedOrcaOmpSourceAgentDir !== undefined) {
process.env.ORCA_OMP_SOURCE_AGENT_DIR = savedOrcaOmpSourceAgentDir
} else {
delete process.env.ORCA_OMP_SOURCE_AGENT_DIR
}
if (savedOrcaOmpStatusExtension !== undefined) {
process.env.ORCA_OMP_STATUS_EXTENSION = savedOrcaOmpStatusExtension
} else {
delete process.env.ORCA_OMP_STATUS_EXTENSION
}
if (savedOrcaClaudeAgentStatusSettings === undefined) {
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
} else {
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeAgentStatusSettings
}
})
function createMockProc() {
let dataHandler: ((data: string) => void) | null = null
let exitHandler: ((event: { exitCode: number }) => void) | null = null
return {
proc: {
onData: vi.fn((handler: (data: string) => void) => {
dataHandler = handler
return makeDisposable()
}),
onExit: vi.fn((handler: (event: { exitCode: number }) => void) => {
exitHandler = handler
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn()
},
emitData(data: string) {
dataHandler?.(data)
},
emitExit(exitCode = 0) {
exitHandler?.({ exitCode })
}
}
}
function getPtyWriteListener(): (event: unknown, args: { id: string; data: string }) => void {
const writeCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:write')
if (!writeCall) {
throw new Error('missing pty:write listener')
}
return writeCall[1] as (event: unknown, args: { id: string; data: string }) => void
}
function getPtyAckDataListener(): (
event: unknown,
args: { id: string; charCount: number }
) => void {
const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData')
if (!ackCall) {
throw new Error('missing pty:ackData listener')
}
return ackCall[1] as (event: unknown, args: { id: string; charCount: number }) => void
}
function getPtySetActiveRendererPtyListener(): (
event: unknown,
args: { id: string; active: boolean }
) => void {
const activeCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:setActiveRendererPty'
)
if (!activeCall) {
throw new Error('missing pty:setActiveRendererPty listener')
}
return activeCall[1] as (event: unknown, args: { id: string; active: boolean }) => void
}
/** Helper: trigger pty:spawn and return the env passed to node-pty. */
async function spawnAndGetEnv(
argsEnv?: Record<string, string>,
processEnvOverrides?: Record<string, string | undefined>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
agentStatusHooksEnabled?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
// Why: PR #2662 finding 2 — the threading from IPC `args.command` through
// buildPtyHostEnv to piTitlebarExtensionService.buildPtyEnv was untested
// for the OMP case because this helper never forwarded a command. Accept
// an optional `command` so callers can exercise OMP overlay resolution.
command?: string
): Promise<Record<string, string>> {
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
for (const [k, v] of Object.entries(processEnvOverrides)) {
savedEnv[k] = process.env[k]
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
try {
// Clear previously registered handlers so re-registration doesn't
// accumulate stale state across calls within one test.
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
getSelectedCodexHomePath,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...(argsEnv ? { env: argsEnv } : {}),
...(command ? { command } : {})
})
const spawnCall = spawnMock.mock.calls.at(-1)!
return spawnCall[2].env as Record<string, string>
} finally {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
}
function spawnAndGetCall(args?: {
cwd?: string
env?: Record<string, string>
command?: string
}): [string, string[], { cwd: string; env: Record<string, string> }] {
handlers.clear()
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...args
})
return spawnMock.mock.calls.at(-1) as [
string,
string[],
{ cwd: string; env: Record<string, string> }
]
}
describe('spawn environment', () => {
it('marks local Claude launches live until the PTY is killed', async () => {
const prepareClaudeAuth = vi.fn(async () => ({
configDir: '/tmp/claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'managed:account-1'
}))
registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'claude'
})) as { id: string }
expect(prepareClaudeAuth).toHaveBeenCalledTimes(1)
expect(hasLiveClaudePtys()).toBe(true)
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(hasLiveClaudePtys()).toBe(false)
})
it('clears Claude live-PTY tracking from shared provider teardown', () => {
markClaudePtySpawned('ssh-claude-pty')
expect(hasLiveClaudePtys()).toBe(true)
clearProviderPtyState('ssh-claude-pty')
expect(hasLiveClaudePtys()).toBe(false)
})
it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => {
const env = await spawnAndGetEnv(undefined, { LANG: undefined })
expect(env.LANG).toBe('en_US.UTF-8')
})
it('inherits LANG from process.env when already set', async () => {
const env = await spawnAndGetEnv(undefined, { LANG: 'ja_JP.UTF-8' })
expect(env.LANG).toBe('ja_JP.UTF-8')
})
it('lets caller-provided env override LANG', async () => {
const env = await spawnAndGetEnv({ LANG: 'fr_FR.UTF-8' })
expect(env.LANG).toBe('fr_FR.UTF-8')
})
it('always sets TERM and COLORTERM regardless of env', async () => {
const env = await spawnAndGetEnv()
expect(env.TERM).toBe('xterm-256color')
expect(env.COLORTERM).toBe('truecolor')
expect(env.TERM_PROGRAM).toBe('Orca')
})
it('advertises OSC 8 hyperlink support via FORCE_HYPERLINK', async () => {
// Why: the supports-hyperlinks npm package hard-codes a TERM_PROGRAM
// allowlist (iTerm.app / WezTerm / vscode) and reports false for
// TERM_PROGRAM=Orca, so tools like Claude Code emit plain text instead
// of ESC]8;; wrappers. Setting FORCE_HYPERLINK=1 forces the detector to
// return true; xterm.js + our linkHandler handle the sequences natively.
const env = await spawnAndGetEnv()
expect(env.FORCE_HYPERLINK).toBe('1')
})
it('surfaces ORCA_APP_VERSION as TERM_PROGRAM_VERSION for TUI feature gating', async () => {
const env = await spawnAndGetEnv(undefined, { ORCA_APP_VERSION: '1.2.3-test' })
expect(env.TERM_PROGRAM_VERSION).toBe('1.2.3-test')
})
it('falls back to a placeholder version when ORCA_APP_VERSION is unset', async () => {
const env = await spawnAndGetEnv(undefined, { ORCA_APP_VERSION: undefined })
expect(env.TERM_PROGRAM_VERSION).toBe('0.0.0-dev')
})
it('injects the selected Codex home into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('injects the OpenCode hook env into Orca terminal PTYs', async () => {
// Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used
const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined })
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(openCodeBuildPtyEnvMock.mock.calls[0]?.[0]).toEqual(expect.any(String))
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
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('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-opencode-config'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/tmp/user-opencode-config')
})
it('does not treat inherited Orca OpenCode config as user config without a source dir', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('restores user OpenCode config when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/user-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('strips inherited OpenCode overlay env when agent status hooks are disabled without a source dir', async () => {
const env = await spawnAndGetEnv(
{
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => {
// Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR;
// their interactive zsh startup later exported a company config repo.
readFileSyncMock.mockImplementation((path: string) => {
if (path.endsWith('.zshrc')) {
return [
'# Company-wide OpenCode config loaded by interactive shells',
'export OPENCODE_CONFIG_DIR="$HOME/company/opencode-config"',
''
].join('\n')
}
return ''
})
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/pim/company/opencode-config'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/home/pim/company/opencode-config')
expect(env.OPENCODE_CONFIG_DIR).not.toBe(env.ORCA_OPENCODE_SOURCE_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', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
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')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts'
)
})
it('threads command: "omp" through to piBuildPtyEnv and emits ORCA_OMP_* shadow vars', async () => {
// Why: OMP launches must emit OMP-named Orca shadow vars (ORCA_OMP_*),
// not Pi-named ones. The PI_CODING_AGENT_DIR binary var is unavoidable
// (OMP's own binary reads it — see C:\tmp\pr-workspace\oh-my-pi
// packages/utils/src/dirs.ts), but every other Orca-owned env name
// stays kind-scoped so an OMP PTY never accumulates Pi shadow state.
const env = await spawnAndGetEnv(
undefined,
{ PI_CODING_AGENT_DIR: '/tmp/user-omp-agent' },
undefined,
undefined,
'omp'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-omp-agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent')
// CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env.
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi')
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')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
})
it('does not use an inherited Pi overlay source for an OMP launch', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
},
undefined,
undefined,
undefined,
'omp'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('does not use an inherited OMP overlay source for an explicit Pi launch', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay',
ORCA_OMP_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay',
ORCA_OMP_SOURCE_AGENT_DIR: '/tmp/user-omp-agent'
},
undefined,
undefined,
undefined,
'pi'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBeUndefined()
})
it('restores user Pi config when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('mirrors Pi config exported only by shell startup files', async () => {
readFileSyncMock.mockImplementation((path: string) =>
path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : ''
)
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/tester',
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: undefined
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/tester/.config/pi-agent',
'pi'
)
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')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent')
})
it('injects the agent hook receiver env into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv()
// Why: after the daemon-parity refactor, buildAgentHookEnv runs exactly
// once for a local spawn — inside the shared buildPtyHostEnv helper,
// which LocalPtyProvider.buildSpawnEnv and the daemon-active fallback
// both route through. The handler's separate ad-hoc injection (which
// used to cause a double-call for local spawns) is gone.
expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1)
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips stale inherited hook receiver env before injecting this runtime', async () => {
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('does not leak inherited hook receiver env if the hook server is unavailable', async () => {
buildAgentHookEnvMock.mockReturnValueOnce({})
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('skips git/gh attribution shims when attribution is disabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
const daemonSpawn = vi.fn(async (options) => ({ id: 'daemon-pty', pid: 123, ...options }))
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {}
})
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
const env = await spawnAndGetEnv(
undefined,
{ CODEX_HOME: '/tmp/system-codex-home' },
() => TEST_CODEX_HOME
)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('injects explicit proxy settings into local PTY env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost,*.internal'
}))
expect(env.HTTP_PROXY).toBe('http://proxy.example:8080')
expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080')
expect(env.ALL_PROXY).toBe('http://proxy.example:8080')
expect(env.NO_PROXY).toBe('localhost,*.internal')
})
describe('daemon-active provider (parity with LocalPtyProvider)', () => {
// Why: these tests guard the regression the daemon-parity refactor was
// written to fix — under the daemon, LocalPtyProvider.buildSpawnEnv is
// never invoked, so every host-local env injection must happen inside
// the pty:spawn IPC handler instead. Before the refactor, only the
// hook server env and attribution shims were injected on this path;
// OpenCode plugin dir, Pi overlay, Codex home, and dev-mode CLI
// overrides were silently missing for daemon users (the common case).
function setupDaemonAdapter() {
const daemonSpawn = vi.fn(
async (options: {
env: Record<string, string>
sessionId?: string
isNewSession?: boolean
}) => ({
id: options.sessionId ?? 'daemon-pty'
})
)
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
return daemonSpawn
}
type DaemonSpawnCall = {
env: Record<string, string>
envToDelete?: string[]
isNewSession?: boolean
}
async function daemonSpawnAndGetOptions(
argsEnv?: Record<string, string>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
processEnvOverrides?: Record<string, string | undefined>,
// Why: daemon spawn tests need to exercise both WSL launch metadata
// from main and PR #2662 command threading for OMP overlay selection.
spawnArgs?: {
cwd?: string
shellOverride?: string
command?: string
envToDelete?: string[]
}
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter()
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
for (const [k, v] of Object.entries(processEnvOverrides)) {
savedEnv[k] = process.env[k]
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
try {
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
getSelectedCodexHomePath,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...spawnArgs,
...(argsEnv ? { env: argsEnv } : {})
})
return daemonSpawn.mock.calls.at(-1)![0] as DaemonSpawnCall
} finally {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
}
async function daemonSpawnAndGetEnv(
argsEnv?: Record<string, string>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
processEnvOverrides?: Record<string, string | undefined>,
spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }
): Promise<Record<string, string>> {
return (
await daemonSpawnAndGetOptions(
argsEnv,
getSelectedCodexHomePath,
getSettings,
processEnvOverrides,
spawnArgs
)
).env
}
it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
OPENCODE_CONFIG_DIR: undefined
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
})
it('mirrors a user-provided OPENCODE_CONFIG_DIR into a per-PTY overlay on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' })
// Why: OpenCode loads config from a single dir, so the user's path is
// mirrored into a per-PTY overlay rather than passed through literally.
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode')
})
it('uses source OpenCode config env instead of remirroring a parent overlay', async () => {
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode')
})
it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ PI_CODING_AGENT_DIR: '/user/.pi/agent' })
// Why: asserts the overlay key was passed through — the id is the
// daemon-assigned sessionId minted in pty.ts, and the mock returns
// the fixed overlay path from the shared setup.
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
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')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts'
)
})
it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP shadow vars', async () => {
// Why: mirror of the local-spawn OMP threading assertion. The
// daemon path's `command` forwarding could silently regress and
// Pi-only tests would still pass.
const env = await daemonSpawnAndGetEnv(
{ PI_CODING_AGENT_DIR: '/user/.omp/agent' },
undefined,
undefined,
undefined,
{ command: 'omp' }
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/.omp/agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/user/.omp/agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('injects the selected Codex home on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('injects explicit proxy settings on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost;*.internal'
}))
expect(env.HTTP_PROXY).toBe('http://proxy.example:8080')
expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080')
expect(env.NO_PROXY).toBe('localhost,*.internal')
})
it('skips host Codex home when a daemon-backed Windows spawn targets a WSL cwd', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
undefined,
{
CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
{ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' }
)
const { env } = spawnOptions
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('skips host Codex home when a daemon-backed Windows spawn uses a WSL shell override', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
undefined,
{
CODEX_HOME: 'C:\\Users\\test\\.codex',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
{ shellOverride: 'wsl.exe' }
)
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('injects the agent-hook receiver env on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('deletes stale Claude scoped settings env from daemon-hosted PTYs', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({}, undefined, undefined, {
ORCA_CLAUDE_AGENT_STATUS_SETTINGS:
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS =
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
} finally {
mockedApp.isPackaged = prev
}
})
it('prepends attribution shims on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
undefined,
() => ({ enableGitHubAttribution: true }),
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
)
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
// Why: the mocked `app` (see vi.mock at the top of the file) is a
// plain object, so we can flip isPackaged for the scope of the test.
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' })
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.PATH).toContain(join('/tmp/orca-user-data', 'cli', 'bin'))
} finally {
mockedApp.isPackaged = prev
}
})
it('preserves the inherited PATH when dev-mode daemon env omits PATH', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PATH: '/system/bin'
})
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.PATH).toBe(
`${join('/tmp/orca-user-data', 'cli', 'bin')}${delimiter}/system/bin`
)
} finally {
mockedApp.isPackaged = prev
}
})
it('passes the minted sessionId through to provider.spawn so the Pi overlay is keyed on a stable id', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {}
})
const spawnOpts = daemonSpawn.mock.calls.at(-1)![0]
const sessionId = spawnOpts.sessionId
expect(sessionId).toEqual(expect.any(String))
expect((sessionId ?? '').length).toBeGreaterThan(0)
expect(spawnOpts.isNewSession).toBe(true)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi')
})
it('respects a caller-provided sessionId instead of minting a new one', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: 'user-session-42'
})
expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42')
expect(daemonSpawn.mock.calls.at(-1)![0].isNewSession).toBeUndefined()
expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi')
})
it('prefixes a minted sessionId with the worktreeId when provided', async () => {
// Why: daemon reconnect keys Pi overlay and live-shell survival on the
// sessionId. Prefixing with worktreeId lets the daemon scope sessions
// by worktree while still minting a unique tail. The format contract
// is `${worktreeId}@@${8-char-hex}` and must not regress.
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
worktreeId: 'wt-alpha'
})
const sessionId = daemonSpawn.mock.calls.at(-1)![0].sessionId ?? ''
expect(sessionId).toMatch(/^wt-alpha@@[0-9a-f]{8}$/)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi')
})
it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => {
// Why: buildPtyHostEnv reads `baseEnv.X ?? process.env.X` so the
// existing-agent-dir guard stays consistent whether Pi's env was
// carried on the IPC wire or inherited by the daemon via fork. The
// fallback must reach piTitlebarExtensionService.buildPtyEnv as the
// second arg so the overlay preserves the user's existing root.
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PI_CODING_AGENT_DIR: '/ambient/pi/agent'
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/ambient/pi/agent',
'pi'
)
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 () => {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('does not mutate the caller-provided args.env on the daemon path', async () => {
// Why: the handler clones baseEnv before calling buildPtyHostEnv so
// IPC-provided env stays pristine. A regression would silently leak
// Orca host env (hook tokens, overlay paths) back into the renderer's
// copy of the object, which it may reuse for unrelated IPC calls.
const daemonSpawn = setupDaemonAdapter()
const argsEnv: Record<string, string> = { FOO: 'bar' }
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: argsEnv
})
expect(argsEnv).toEqual({ FOO: 'bar' })
// Sanity: the spawn did receive the injected env, proving the test
// isn't passing because buildPtyHostEnv never ran.
const spawnEnv = daemonSpawn.mock.calls.at(-1)![0].env
expect(spawnEnv.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnEnv).not.toBe(argsEnv)
})
it('rejects a caller-supplied sessionId that escapes userData via ..', async () => {
// Why: effectiveSessionId is used as a Pi overlay directory key under
// userData. A crafted IPC payload with a traversal sequence must be
// refused before any filesystem side-effects run.
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: '../etc/passwd'
})
).rejects.toThrow(/Invalid PTY session id/)
expect(daemonSpawn).not.toHaveBeenCalled()
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
})
it('sweeps per-PTY state when provider.spawn fails for a MINTED sessionId', async () => {
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
// materialization). If provider.spawn later fails, the overlay would
// leak. The handler should clear per-PTY state for the minted id so
// it isn't orphaned.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
).rejects.toThrow(/spawn boom/)
expect(openCodeClearPtyMock).toHaveBeenCalled()
expect(piClearPtyMock).toHaveBeenCalled()
})
it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => {
// Why: a caller-supplied sessionId may refer to an existing PTY whose
// state (OpenCode hooks, Pi overlay, agent-hook pane caches) must not
// be clobbered on a retry/attach failure. Only minted ids get swept.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: 'caller-owned-session'
})
).rejects.toThrow(/spawn boom/)
expect(openCodeClearPtyMock).not.toHaveBeenCalled()
expect(piClearPtyMock).not.toHaveBeenCalled()
})
it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
(() => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost'
})) as never,
undefined,
store as never
)
const leafId = '11111111-1111-4111-8111-111111111111'
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { FOO: 'bar', ORCA_PANE_KEY: makePaneKey('tab-1', leafId) },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId
})
const env = sshSpawn.mock.calls.at(-1)![0].env
// Why: every host-local var must be absent over SSH — the hook
// server is on the Orca host's 127.0.0.1, dev CLI / attribution /
// overlay / plugin-dir paths only exist on the local disk, so
// shipping any of them to a remote shell is at best useless and at
// worst a credential leak.
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.CODEX_HOME).toBeUndefined()
expect(env.HTTP_PROXY).toBeUndefined()
expect(env.HTTPS_PROXY).toBeUndefined()
expect(env.NO_PROXY).toBeUndefined()
expect(env.FOO).toBe('bar')
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'ssh-pty',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
ptyId: 'ssh-pty'
})
store.upsertSshRemotePtyLease.mockClear()
store.persistPtyBinding.mockClear()
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { ORCA_PANE_KEY: 'tab-1:pane:1' },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledTimes(1)
expect(sshSpawn.mock.calls.at(-1)?.[0].env.ORCA_PANE_KEY).toBeUndefined()
expect(store.upsertSshRemotePtyLease.mock.calls[0]?.[0]).not.toHaveProperty('leafId')
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('marks a caller-supplied SSH session expired when remote reattach is gone', async () => {
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: 'remote-pty'
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
})
it('marks a scoped SSH session expired using the raw relay lease id', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership(scopedPtyId, 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: scopedPtyId
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
} finally {
deletePtyOwnership(scopedPtyId)
}
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
expect(openCodeClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
})
it('does not tombstone an SSH lease when explicit kill shutdown fails transiently', async () => {
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:kill')!(null, { id: 'remote-pty', keepHistory: false })
).rejects.toThrow('Multiplexer disposed')
} finally {
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
})
it('marks an SSH lease terminated after runtime controller kill succeeds', async () => {
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
await Promise.resolve()
expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false })
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const shutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn() }
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
await Promise.resolve()
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false })
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('runtime controller kill tombstones app-scoped SSH ids when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = { markSshRemotePtyLease: vi.fn() }
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1)
})
it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => {
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await Promise.resolve()
await Promise.resolve()
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
try {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: 'tab-1:0',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
connectionId: 'ssh-1'
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.FOO).toBe('bar')
expect(env.ORCA_PANE_KEY).toBeUndefined()
expect(env.ORCA_TAB_ID).toBeUndefined()
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
// Why: the local hook server's userData-relative endpoint file path
// is meaningless on the remote box; assert it does not leak.
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH by default', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
try {
const leafId = '22222222-2222-4222-8222-222222222222'
const paneKey = makePaneKey('tab-2', leafId)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-2',
ORCA_WORKTREE_ID: 'wt-2'
},
connectionId: 'ssh-1',
tabId: 'tab-2',
leafId
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_PANE_KEY).toBe(paneKey)
expect(env.ORCA_TAB_ID).toBe('tab-2')
expect(env.ORCA_WORKTREE_ID).toBe('wt-2')
// Local hook server coords still must NOT cross the wire — the
// relay is the source of truth for those.
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
})
})
it('rethrows non-not-found local provider shutdown failures', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('daemon unavailable')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:kill')!(null, { id: 'local-pty' })).rejects.toThrow(
'daemon unavailable'
)
})
it('lists sessions from both local and SSH providers', async () => {
registerPtyHandlers(mainWindow as never)
const sshListProcesses = vi.fn(async () => [
{ id: 'remote-pty', cwd: '/remote', title: 'ssh-shell' }
])
const sshShutdown = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sshListProcesses).toHaveBeenCalled()
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ cwd: '/remote', id: 'remote-pty', title: 'ssh-shell' })
])
)
await handlers.get('pty:kill')!(null, { id: 'remote-pty' })
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: false
})
})
it('lists duplicate SSH relay session ids as distinct app sessions', async () => {
registerPtyHandlers(mainWindow as never)
const shutdownA = vi.fn(async () => undefined)
const shutdownB = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-a', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownA,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a', title: 'ssh-a' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerSshPtyProvider('ssh-b', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownB,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b', title: 'ssh-b' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a' }),
expect.objectContaining({ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b' })
])
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-a@@pty-1' })
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-b@@pty-1' })
expect(shutdownA).toHaveBeenCalledWith('ssh:ssh-a@@pty-1', {
immediate: true,
keepHistory: false
})
expect(shutdownB).toHaveBeenCalledWith('ssh:ssh-b@@pty-1', {
immediate: true,
keepHistory: false
})
})
it('kills app-scoped SSH PTY ids through the parsed provider when ownership is not rebuilt', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sshShutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn() }
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })
expect(sshShutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', {
immediate: true,
keepHistory: false
})
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('tombstones app-scoped SSH PTY ids instead of falling back local when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = { markSshRemotePtyLease: vi.fn() }
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => {
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
const provider = {
spawn: vi.fn(async () => ({ id: 'remote-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(async () => undefined),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: {}
})
unregisterSshPtyProvider('ssh-1')
const listenerFor = (channel: string): ((event: unknown, args: unknown) => void) => {
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
if (!call) {
throw new Error(`missing ${channel} listener`)
}
return call[1] as (event: unknown, args: unknown) => void
}
expect(() => listenerFor('pty:write')(null, { id: 'remote-pty', data: 'x' })).not.toThrow()
expect(() =>
listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 })
).not.toThrow()
expect(() => listenerFor('pty:ackColdRestore')(null, { id: 'remote-pty' })).not.toThrow()
expect(() =>
listenerFor('pty:signal')(null, { id: 'remote-pty', signal: 'SIGINT' })
).not.toThrow()
await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).resolves.toBeUndefined()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated')
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
registerSshPtyProvider('ssh-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: { EXISTING: '1' }
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
EXISTING: '1',
ORCA_TERMINAL_HANDLE: 'term_remote'
})
})
)
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
'remote-pty',
'term_remote'
)
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
preAllocatedHandle?: string
}): Promise<{ id: string }>
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_wrong'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
expect(controller).not.toBeNull()
const spawnController = controller as unknown as RuntimeSpawnController
await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
preAllocatedHandle: 'term_expected'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_expected')
expect(runtime.preAllocateHandleForPty).not.toHaveBeenCalled()
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
expect.any(String),
'term_expected'
)
})
it('persists runtime-owned headless session bindings when explicitly requested', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId,
env: { ORCA_PANE_KEY: makePaneKey('tab-headless', leafId) },
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId,
ptyId: expect.any(String)
})
})
it('records SSH leases for runtime-owned headless session bindings', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteSpawn = vi.fn(async () => ({ id: 'ssh:ssh-1@@relay-pty' }))
registerSshPtyProvider('ssh-1', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-1',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-1@@relay-pty',
persistHostSessionBinding: true
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'relay-pty',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty'
})
expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan(
store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]!
)
unregisterSshPtyProvider('ssh-1')
})
it('rejects runtime-owned binding persistence without complete stable identity', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const validLeafId = '11111111-1111-4111-8111-111111111111'
const baseArgs = {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId: validLeafId,
persistHostSessionBinding: true
}
for (const args of [
{ ...baseArgs, worktreeId: undefined },
{ ...baseArgs, tabId: undefined },
{ ...baseArgs, leafId: undefined },
{ ...baseArgs, leafId: 'legacy-leaf' }
]) {
await expect(spawnController.spawn(args)).rejects.toThrow(
'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId'
)
}
expect(spawnMock).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('refreshes SSH leases after successful runtime-owned reattach binding', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string; isReattach?: boolean }>
}
registerSshPtyProvider('ssh-reattach-ok', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-ok@@relay-pty', isReattach: true })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-ok',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-ok@@relay-pty',
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-reattach-ok',
ptyId: 'relay-pty',
state: 'attached',
lastAttachedAt: expect.any(Number)
})
)
} finally {
unregisterSshPtyProvider('ssh-reattach-ok')
}
})
it('strips runtime-owned SSH pane env when remote agent hooks are disabled', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
env?: Record<string, string>
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const savedRemoteHooks = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
const remoteSpawn = vi.fn(async (_opts: { env?: Record<string, string> }) => ({
id: 'ssh:ssh-runtime-env@@relay-pty'
}))
registerSshPtyProvider('ssh-runtime-env', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: makePaneKey('tab-remote', leafId),
ORCA_TAB_ID: 'tab-remote',
ORCA_WORKTREE_ID: 'wt-remote'
},
connectionId: 'ssh-runtime-env',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
persistHostSessionBinding: true
})
const env = remoteSpawn.mock.calls[0]?.[0].env
expect(env).toMatchObject({ FOO: 'bar' })
expect(env?.ORCA_PANE_KEY).toBeUndefined()
expect(env?.ORCA_TAB_ID).toBeUndefined()
expect(env?.ORCA_WORKTREE_ID).toBeUndefined()
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-runtime-env',
ptyId: 'relay-pty',
leafId,
state: 'attached'
})
)
} finally {
if (savedRemoteHooks === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = savedRemoteHooks
}
unregisterSshPtyProvider('ssh-runtime-env')
}
})
it('does not leave SSH leases when runtime-owned binding persistence fails after reattach', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteShutdown = vi.fn()
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-reattach-fail', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-fail@@relay-pty', isReattach: true })),
write: remoteWrite,
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-fail@@relay-pty',
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(remoteShutdown).not.toHaveBeenCalled()
getPtyWriteListener()(null, {
id: 'ssh:ssh-reattach-fail@@relay-pty',
data: 'echo should-not-route'
})
expect(remoteWrite).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-reattach-fail')
})
it('marks runtime-owned SSH reattach as expired and clears stale local ownership', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-expired-runtime@@relay-pty'
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-expired-runtime', {
spawn: vi.fn(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, 'ssh-expired-runtime')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-expired-runtime',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-expired-runtime',
'relay-pty',
'expired'
)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(null, { id: appPtyId, data: 'echo nope' })
expect(remoteWrite).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider('ssh-expired-runtime')
}
})
it('cleans up fresh runtime-owned SSH spawns when binding persistence fails', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-fresh-fail@@relay-pty'
const remoteShutdown = vi.fn()
registerSshPtyProvider('ssh-fresh-fail', {
spawn: vi.fn(async () => ({ id: appPtyId })),
write: vi.fn(),
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-fresh-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true })
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
} finally {
unregisterSshPtyProvider('ssh-fresh-fail')
}
})
it('maps runtime-owned spawn paneKeys for renderer serializer settlement', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
hasRendererSerializer?(ptyId: string): boolean
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const paneKey = makePaneKey('tab-cli', '11111111-1111-4111-8111-111111111111')
const gen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
const spawnController = controller as unknown as RuntimeSpawnController
const result = await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
env: { ORCA_PANE_KEY: ` ${paneKey} ` }
})
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen })
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true)
})
it('clears pending pane serializer declarations when their renderer is destroyed', async () => {
registerPtyHandlers(mainWindow as never)
const paneKey = makePaneKey('tab-crash', '22222222-2222-4222-8222-222222222222')
const destroyedListeners: (() => void)[] = []
const sender = {
id: 42,
once: vi.fn((event: string, listener: () => void) => {
if (event === 'destroyed') {
destroyedListeners.push(listener)
}
})
}
await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey })
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(true)
expect(destroyedListeners).toHaveLength(1)
destroyedListeners[0]()
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false)
})
it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => {
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' }
})
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted')
expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String))
})
it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_wsl'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
})
describe('Windows UTF-8 code page', () => {
let originalPlatform: string
let originalComspec: string | undefined
beforeEach(() => {
originalPlatform = process.platform
originalComspec = process.env.COMSPEC
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
process.env.USERPROFILE = 'C:\\Users\\test'
})
afterEach(() => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalComspec === undefined) {
delete process.env.COMSPEC
} else {
process.env.COMSPEC = originalComspec
}
delete process.env.PYTHONUTF8
})
it('passes chcp 65001 to cmd.exe for UTF-8 console output', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\system32\\cmd.exe',
['/K', 'chcp 65001 > nul'],
expect.any(Object)
)
})
it('sets Console encoding for powershell.exe', () => {
process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('sets Console encoding for pwsh.exe', () => {
process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('sets PYTHONUTF8=1 in the spawn environment on Windows', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('1')
})
it('does not override an existing PYTHONUTF8 value', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
process.env.PYTHONUTF8 = '0'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('0')
})
it('launches Git Bash from COMSPEC as an interactive login shell', () => {
process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
['--login', '-i'],
expect.objectContaining({
env: expect.objectContaining({ CHERE_INVOKING: '1' })
})
)
})
it('uses terminalWindowsShell setting over COMSPEC when provided', () => {
// Why: COMSPEC always points to cmd.exe on stock Windows, so without the
// setting the terminal would ignore the user's shell preference.
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('spawns pwsh.exe when PowerShell 7 is selected and available', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith('pwsh.exe', POWERSHELL_OSC133_ARGS, expect.any(Object))
})
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('ignores the PowerShell implementation setting for cmd.exe', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'cmd.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'cmd.exe',
['/K', 'chcp 65001 > nul'],
expect.any(Object)
)
})
it('ignores the PowerShell implementation setting for wsl.exe', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
() =>
({
terminalWindowsShell: 'wsl.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
it('keeps shellOverride priority for one-off tabs', () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
})
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
})
it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => {
const originalPlatform = process.platform
const originalUserProfile = process.env.USERPROFILE
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
process.env.USERPROFILE = 'C:\\Users\\jinwo'
existsSyncMock.mockImplementation((targetPath: string) => {
if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') {
return false
}
return true
})
try {
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing'
})
).rejects.toThrow(
'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.'
)
expect(spawnMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
}
})
it('spawns a plain POSIX login shell and queues startup commands for the live session', () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/zsh'
delete process.env.ZDOTDIR
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp', command: 'printf "hello"' })
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
if (originalZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = originalZdotdir
}
}
})
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.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
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
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/bash'
try {
spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
const { getBashShellReadyRcfileContent } = await import('./pty')
const bashRcContent = getBashShellReadyRcfileContent()
expect(bashRcContent).toContain('source "$HOME/.bash_profile"')
expect(bashRcContent).not.toContain('source "$HOME/.bashrc"')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('does not write the startup command before the shell-ready marker arrives', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: 'claude'
})
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('last login: today\r\n')
vi.runOnlyPendingTimers()
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('\x1b]133;A\x07% ')
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n')
} finally {
vi.useRealTimers()
}
})
it('falls back to a max wait when the shell emits no readiness output', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: 'codex'
})
vi.advanceTimersByTime(1500)
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('codex\n')
} finally {
vi.useRealTimers()
}
})
it('batches PTY output when it is not responding to recent input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
mockProc.emitData('background output')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(7)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'background output'
})
} finally {
vi.useRealTimers()
}
})
it('sends small PTY redraws immediately after terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
mockProc.emitData('\x1b[20;2Hredraw')
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: '\x1b[20;2Hredraw'
})
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('ignores PTY input for unknown sessions', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
const writeListener = getPtyWriteListener()
writeListener(null, {
id: 'missing-pty',
data: 'a'
})
expect(mockProc.proc.write).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('batches large PTY output even after recent terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const largeOutput = 'x'.repeat(1025)
mockProc.emitData(largeOutput)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: largeOutput
})
} finally {
vi.useRealTimers()
}
})
it('batches repeated small PTY chunks after the interactive output budget is exhausted', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const smallChunk = 'x'.repeat(512)
for (let index = 0; index < 65; index++) {
mockProc.emitData(smallChunk)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64)
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', {
id: spawnResult.id,
data: smallChunk
})
} finally {
vi.useRealTimers()
}
})
it('sends larger ANSI redraws immediately after terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(80)}`
mockProc.emitData(redraw)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: redraw
})
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('batches combined pending output that exceeds the interactive size limit', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
const pendingOutput = 'x'.repeat(1020)
mockProc.emitData(pendingOutput)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
mockProc.emitData('redraw')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: `${pendingOutput}redraw`
})
} finally {
vi.useRealTimers()
}
})
it('drains large batched PTY output in bounded slices', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
const firstChunk = 'x'.repeat(16 * 1024)
const firstRemainder = 'tail'
secondProc.emitData('second-terminal-output')
firstProc.emitData(`${firstChunk}${firstRemainder}`)
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', {
id: secondSpawn.id,
data: 'second-terminal-output'
})
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', {
id: firstSpawn.id,
data: firstChunk
})
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(3)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(3, 'pty:data', {
id: firstSpawn.id,
data: firstRemainder
})
} finally {
vi.useRealTimers()
}
})
it('waits for renderer ACKs before sending more output for a saturated PTY', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
firstProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
expect(vi.getTimerCount()).toBe(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 88 * 1024,
maxPendingCharsByPty: 88 * 1024,
rendererInFlightPtyCount: 1,
rendererInFlightChars: 512 * 1024,
maxRendererInFlightCharsByPty: 512 * 1024,
flushScheduled: false,
peakPendingChars: 600 * 1024,
peakMaxPendingCharsByPty: 600 * 1024,
peakRendererInFlightChars: 512 * 1024,
peakMaxRendererInFlightCharsByPty: 512 * 1024,
ackGatedFlushSkipCount: 1
})
secondProc.emitData('second-terminal-output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', {
id: secondSpawn.id,
data: 'second-terminal-output'
})
ackData(null, { id: firstSpawn.id, charCount: 16 * 1024 })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(34)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(34, 'pty:data', {
id: firstSpawn.id,
data: 'x'.repeat(16 * 1024)
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 72 * 1024,
rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
peakPendingChars: 600 * 1024,
peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length
})
resetPtyRendererDeliveryDebug()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 72 * 1024,
rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
peakPendingChars: 72 * 1024,
peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
ackGatedFlushSkipCount: 0
})
} finally {
vi.useRealTimers()
}
})
it('forwards only actually in-flight bytes to provider ACK backpressure', async () => {
vi.useFakeTimers()
const acknowledgeDataEvent = vi.fn()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'remote-like-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((callback) => {
mockProc.proc.onData((data: string) => callback({ id: 'remote-like-pty', data }))
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
mockProc.emitData('remote-output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: 'remote-like-pty',
data: 'remote-output'
})
// Why: stale or duplicated renderer ACKs must not over-credit SSH/relay
// flow control beyond the bytes main actually sent to the renderer.
ackData(null, { id: 'remote-like-pty', charCount: 1024 })
ackData(null, { id: 'remote-like-pty', charCount: 1024 })
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(
1,
'remote-like-pty',
'remote-output'.length
)
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'remote-like-pty', 0)
} finally {
vi.useRealTimers()
}
})
it('reserves a bounded renderer lane for interactive output when bulk output is saturated', async () => {
vi.useFakeTimers()
const bulkProcs = Array.from({ length: 16 }, () => createMockProc())
const interactiveProc = createMockProc()
for (const proc of [...bulkProcs, interactiveProc]) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
for (const _proc of bulkProcs) {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
}
const interactiveSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
for (const proc of bulkProcs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(8)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
expect(vi.getTimerCount()).toBe(0)
writeListener(null, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData('\x1b[20;2Hredraw')
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', {
id: interactiveSpawn.id,
data: '\x1b[20;2Hredraw'
})
const reservePrefix = '\x1b[20;2H'
const reserveChunk = `${reservePrefix}${'r'.repeat(16 * 1024 - reservePrefix.length)}`
for (let index = 0; index < 16; index++) {
writeListener(null, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData(reserveChunk)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529)
writeListener(null, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData(reserveChunk)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529)
} finally {
vi.useRealTimers()
}
})
it('caps total renderer in-flight output across many PTYs', async () => {
vi.useFakeTimers()
const procs = Array.from({ length: 17 }, () => createMockProc())
for (const proc of procs) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const spawns: { id: string }[] = []
for (const _proc of procs) {
spawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
for (const proc of procs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(8)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
ackData(null, { id: spawns[0].id, charCount: 16 * 1024 })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513)
} finally {
vi.useRealTimers()
}
})
it('prioritizes active PTY pending output during renderer backpressure', async () => {
vi.useFakeTimers()
const procs = Array.from({ length: 18 }, () => createMockProc())
for (const proc of procs) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const spawns: { id: string }[] = []
for (const _proc of procs) {
spawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
const ackData = getPtyAckDataListener()
const setActiveRendererPty = getPtySetActiveRendererPtyListener()
mainWindow.webContents.send.mockClear()
for (let index = 0; index < procs.length - 1; index++) {
procs[index]!.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(8)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
const activeIndex = procs.length - 1
procs[activeIndex]!.emitData('active-output')
setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true })
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', {
id: spawns[activeIndex]!.id,
data: 'active-output'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
activeRendererPtyCount: 1,
pendingPtyCount: procs.length - 1,
rendererInFlightChars: 8 * 1024 * 1024 + 'active-output'.length
})
ackData(null, { id: spawns[0]!.id, charCount: 16 * 1024 })
} finally {
vi.useRealTimers()
}
})
it('lets active PTY output exceed its old background in-flight cap', async () => {
vi.useFakeTimers()
const activeProc = createMockProc()
spawnMock.mockReturnValue(activeProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const activeSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setActiveRendererPty = getPtySetActiveRendererPtyListener()
mainWindow.webContents.send.mockClear()
activeProc.emitData('x'.repeat(768 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 256 * 1024,
rendererInFlightChars: 512 * 1024,
maxRendererInFlightCharsByPty: 512 * 1024
})
setActiveRendererPty(null, { id: activeSpawn.id, active: true })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', {
id: activeSpawn.id,
data: 'x'.repeat(16 * 1024)
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingChars: 240 * 1024,
rendererInFlightChars: 528 * 1024,
maxRendererInFlightCharsByPty: 528 * 1024
})
} finally {
vi.useRealTimers()
}
})
it('batches stale PTY output after the interactive window expires', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
id: spawnResult.id,
data: 'a'
})
vi.advanceTimersByTime(101)
mainWindow.webContents.send.mockClear()
mockProc.emitData('stale redraw')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'stale redraw'
})
} finally {
vi.useRealTimers()
}
})
it('falls back to a system shell when SHELL points to a missing binary', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
existsSyncMock.mockImplementation(
(targetPath: string) => targetPath !== '/opt/homebrew/bin/bash'
)
try {
process.env.SHELL = '/opt/homebrew/bin/bash'
registerPtyHandlers(mainWindow as never)
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
expect(result).toEqual({ id: expect.any(String), pid: 12345 })
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({ cwd: '/tmp' })
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('falls back when SHELL points to a non-executable binary', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
accessSyncMock.mockImplementation((targetPath: string) => {
if (targetPath === '/opt/homebrew/bin/bash') {
throw new Error('permission denied')
}
})
try {
process.env.SHELL = '/opt/homebrew/bin/bash'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
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')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('acknowledges pty writes only for owned PTYs', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x03')
expect(
handlers.get('pty:writeAccepted')!(null, {
id: 'missing-pty-for-write-ack',
data: '\x03'
})
).toBe(false)
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
})
it('seeds headless terminal state with cold-restore cwd metadata', async () => {
const coldRestore = { scrollback: 'restored history\r\n', cwd: '/projects/restored' }
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-cold-restore', coldRestore })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'handle-cold-restore'),
registerPreAllocatedHandleForPty: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith(
'pty-cold-restore',
'restored history\r\n',
undefined,
{ cwd: '/projects/restored' }
)
})
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: 'tab-1:0' }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: stablePaneKey
})
)
expect(registerPaneKeyAliasMock).toHaveBeenCalledWith(
'tab-1:0',
stablePaneKey,
expect.any(String)
)
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled()
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: stablePaneKey
})
)
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: makePaneKey('tab-2', leafId) }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: null
})
)
})
it('does not let an old PTY teardown clear a newer pane-key owner', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
const first = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
const second = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id)
clearAgentHookPaneStateMock.mockClear()
clearProviderPtyState(first.id)
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id)
expect(clearAgentHookPaneStateMock).not.toHaveBeenCalledWith(stablePaneKey)
clearProviderPtyState(second.id)
expect(getPtyIdForPaneKey(stablePaneKey)).toBeUndefined()
expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey)
})
it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
const current = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id)
clearPaneKeyAliasesForPtyMock.mockClear()
clearProviderPtyState('old-pty-without-forward-pane-key')
const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find(
([ptyId]) => ptyId === 'old-pty-without-forward-pane-key'
)?.[1]
expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false)
})
it('prefers args.env.SHELL and normalizes the child env after fallback', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
existsSyncMock.mockImplementation(
(targetPath: string) => targetPath !== '/opt/homebrew/bin/bash'
)
try {
process.env.SHELL = '/bin/bash'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
env: { SHELL: '/opt/homebrew/bin/bash' }
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({
cwd: '/tmp',
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(
expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('cleans up provider-specific PTY overlays when a PTY is killed', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
// Simulate node-pty behavior: kill triggers onExit callback
exitCb?.({ exitCode: -1 })
}),
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
})
it('disposes PTY listeners before manual kill IPC', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
// Why: hold a stable reference to the kill spy. On POSIX, destroyPtyProcess
// in local-pty-provider reassigns proc.kill to a no-op to defuse the
// SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). Reading
// proc.kill.mock after that runs would yield a non-mock and crash.
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
})
it('disposes PTY listeners before runtime controller kill', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const runtimeController = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(runtimeController.kill(spawnResult.id)).toBe(true)
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
})
it('disposes PTY listeners before did-finish-load orphan cleanup', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never, runtime as never)
const didFinishLoad = mainWindow.webContents.on.mock.calls.find(
([eventName]) => eventName === 'did-finish-load'
)?.[1] as (() => void) | undefined
expect(didFinishLoad).toBeTypeOf('function')
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
// The first load after spawn only advances generation. The second one sees
// this PTY as belonging to a prior page load and kills it as orphaned.
didFinishLoad?.()
didFinishLoad?.()
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
})
it('removes the previous orphan-cleanup listener from its original webContents', () => {
const firstWindow = {
isDestroyed: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
const secondWindow = {
isDestroyed: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
registerPtyHandlers(firstWindow as never)
const didFinishLoad = firstWindow.webContents.on.mock.calls.find(
([eventName]) => eventName === 'did-finish-load'
)?.[1] as (() => void) | undefined
expect(didFinishLoad).toBeTypeOf('function')
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
registerPtyHandlers(secondWindow as never)
expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith(
'did-finish-load',
didFinishLoad
)
expect(
secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load')
).toBe(false)
})
it('clears PTY state even when kill reports the process is already gone', async () => {
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
throw new Error('already dead')
}),
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(await handlers.get('pty:hasChildProcesses')!(null, { id: spawnResult.id })).toBe(false)
expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
})
describe('agent_started telemetry', () => {
// Why: telemetry-plan.md§Agent launch semantics — agent_started must
// fire only after provider.spawn resolves. The renderer threads
// launch metadata through `pty:spawn`; a missing or malformed
// payload must not produce a silently-malformed event.
it('emits agent_started after a successful spawn when telemetry is supplied', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
expect(trackMock).toHaveBeenCalledWith('agent_started', {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
})
})
it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(trackMock).not.toHaveBeenCalled()
})
it('drops the event when any telemetry field is outside its closed enum', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'not_a_real_surface',
request_kind: 'new'
}
})
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
it('does not emit agent_started when provider.spawn throws', async () => {
// Why: telemetry-plan contract is that agent_started fires only on
// confirmed launch. Inject a provider whose spawn throws so we hit
// the catch path with no race against the real LocalPtyProvider.
setLocalPtyProvider({
spawn: vi.fn(async () => {
throw new Error('spawn boom')
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
classifyErrorMock.mockReturnValue({ error_class: 'unknown' })
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'claude',
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
).rejects.toThrow(/spawn boom/)
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
})
describe('serializeBuffer dispatch', () => {
type SerializeListener = (
_event: unknown,
args: {
requestId?: string
snapshot?: { data?: unknown; cols?: unknown; rows?: unknown; lastTitle?: unknown } | null
}
) => void
type SerializeController = {
serializeBuffer: (
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
}
function setup(): { listener: SerializeListener; controller: SerializeController } {
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const onCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
if (!onCall) {
throw new Error('expected pty:serializeBuffer:response listener registration')
}
const listener = onCall[1] as SerializeListener
const controller = runtime.setPtyController.mock.calls[0]?.[0] as SerializeController
return { listener, controller }
}
function getSentRequestIds(): string[] {
return mainWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'pty:serializeBuffer:request')
.map((call: unknown[]) => (call[1] as { requestId: string }).requestId)
}
it('registers exactly one persistent listener regardless of concurrent in-flight requests', async () => {
const { listener, controller } = setup()
const inflight = [
controller.serializeBuffer('pty-1'),
controller.serializeBuffer('pty-2'),
controller.serializeBuffer('pty-3'),
controller.serializeBuffer('pty-4'),
controller.serializeBuffer('pty-5'),
controller.serializeBuffer('pty-6'),
controller.serializeBuffer('pty-7'),
controller.serializeBuffer('pty-8'),
controller.serializeBuffer('pty-9'),
controller.serializeBuffer('pty-10'),
controller.serializeBuffer('pty-11'),
controller.serializeBuffer('pty-12')
]
// Why: the bug being fixed registered one listener per request, so 12
// concurrent calls would register 12 listeners and trip Node's MaxListeners.
const responseChannelRegistrations = onMock.mock.calls.filter(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
expect(responseChannelRegistrations.length).toBe(1)
// Drain the in-flight requests so the test doesn't leak timers.
for (const requestId of getSentRequestIds()) {
listener(null, { requestId, snapshot: null })
}
await Promise.all(inflight)
})
it('routes each response to the originating request via requestId', async () => {
const { listener, controller } = setup()
const a = controller.serializeBuffer('pty-a')
const b = controller.serializeBuffer('pty-b')
const ids = getSentRequestIds()
const requestIdA = ids[0]
const requestIdB = ids[1]
listener(null, {
requestId: requestIdB,
snapshot: { data: 'B-data', cols: 80, rows: 24 }
})
listener(null, {
requestId: requestIdA,
snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' }
})
await expect(b).resolves.toEqual({ data: 'B-data', cols: 80, rows: 24 })
await expect(a).resolves.toEqual({
data: 'A-data',
cols: 100,
rows: 30,
lastTitle: 'A-title'
})
})
it('ignores responses with unknown requestId without affecting pending requests', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-1')
const realRequestId = getSentRequestIds()[0]
listener(null, {
requestId: 'not-a-real-id',
snapshot: { data: 'irrelevant', cols: 1, rows: 1 }
})
listener(null, { requestId: undefined, snapshot: null })
let resolved = false
void pending.then(() => {
resolved = true
})
await new Promise((r) => setTimeout(r, 0))
expect(resolved).toBe(false)
listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } })
await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 })
})
it('resolves to null and removes the entry when the 750ms timeout fires', async () => {
vi.useFakeTimers()
try {
const { controller } = setup()
const pending = controller.serializeBuffer('pty-stuck')
vi.advanceTimersByTime(750)
await expect(pending).resolves.toBeNull()
} finally {
vi.useRealTimers()
}
})
it('resolves to null when the response snapshot is malformed', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-bad')
const requestId = getSentRequestIds()[0]
listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } })
await expect(pending).resolves.toBeNull()
})
})
describe('main buffer snapshot dispatch', () => {
it('returns a sequenced main-owned terminal snapshot with clamped scrollback', async () => {
const runtime = {
setPtyController: vi.fn(),
serializeMainTerminalBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'pty-1',
opts: { scrollbackRows: 999_999 }
})
expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 50_000
})
expect(result).toEqual({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
source: 'headless'
})
})
})
})