Files
orca/src/relay/pty-handler.test.ts
T
Neil 6a0c8fa541 fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361)

Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them
reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230,
STA-4232, STA-4233 and STA-4234 against #14361.

The tab loss that motivated the revert is fixed in the commits that follow, so
this reapplication is not a straight redo.

* fix(relay): stop the fallback attach fence refusing a pane that moved tabs

The primary fence was moved to the shell's own incarnation precisely because
paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had
merely moved. The fallback that older clients fall into kept the old rule, so
the correction never reached it — the same 'the rule exists, but this path does
not ask it' leak this work has hit repeatedly.

A refusal here is not recoverable: an identity mismatch never grounds a respawn,
so the pane keeps a live shell it can no longer reach and renders blank.

Narrowed to paneKey, which is the identity; the tab is a location. Restoring the
tabId comparison reddens the new test.
2026-08-13 16:45:24 -07:00

4156 lines
141 KiB
TypeScript

/* oxlint-disable max-lines */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import * as gitBash from '../main/git-bash'
import * as ptyShellUtils from './pty-shell-utils'
import {
resolveSetupAgentSequenceLaunchCommand,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockCreateShellPromptReadinessProbe: vi.fn(),
mockPtyInstance: {
// Why: attach now proves the backing pid is alive before replaying, so the
// default managed PTY must report a live pid. Reuse the test runner's own
// pid — always alive — so unrelated attach tests are not seen as dead.
pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
clear: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}
}))
vi.mock('node-pty', () => ({
spawn: mockPtySpawn
}))
vi.mock('../main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
vi.mock('../main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
}))
import {
IMMEDIATE_PTY_EXIT_TIMEOUT_MS,
MAX_RELAY_PTY_SESSIONS,
PtyHandler,
formatNodePtyUnavailableMessage
} from './pty-handler'
import { RelayDispatcher } from './dispatcher'
import { encodeJsonRpcFrame } from './protocol'
type TestRequestContext = {
isStale: () => boolean
signal?: AbortSignal
}
function createMockDispatcher() {
const requestHandlers = new Map<
string,
(params: Record<string, unknown>, context?: TestRequestContext) => Promise<unknown>
>()
const notificationHandlers = new Map<string, (params: Record<string, unknown>) => void>()
const notifications: { method: string; params?: Record<string, unknown> }[] = []
const dispatcher = {
onRequest: vi.fn(
(
method: string,
handler: (params: Record<string, unknown>, context?: TestRequestContext) => Promise<unknown>
) => {
requestHandlers.set(method, handler)
}
),
onNotification: vi.fn((method: string, handler: (params: Record<string, unknown>) => void) => {
notificationHandlers.set(method, handler)
}),
notify: vi.fn((method: string, params?: Record<string, unknown>) => {
notifications.push({ method, params })
}),
// Helpers for tests
_requestHandlers: requestHandlers,
_notificationHandlers: notificationHandlers,
_notifications: notifications,
async callRequest(
method: string,
params: Record<string, unknown> = {},
context?: TestRequestContext
) {
const handler = requestHandlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
return handler(params, context)
},
callNotification(method: string, params: Record<string, unknown> = {}) {
const handler = notificationHandlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
handler(params)
}
}
return dispatcher
}
describe('PtyHandler', () => {
let dispatcher: ReturnType<typeof createMockDispatcher>
let handler: PtyHandler
let originalPlatform: PropertyDescriptor | undefined
async function spawnPty(
params: Record<string, unknown> = {}
): Promise<{ id: string; incarnationId: string }> {
return (await dispatcher.callRequest('pty.spawn', params)) as {
id: string
incarnationId: string
}
}
async function attachPty(
params: Record<string, unknown>
): Promise<{ incarnationId: string; replay?: string }> {
return (await dispatcher.callRequest('pty.attach', params)) as {
incarnationId: string
replay?: string
}
}
beforeEach(() => {
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
vi.useFakeTimers()
mockPtySpawn.mockReset()
mockPtyInstance.onData.mockReset()
mockPtyInstance.onExit.mockReset()
mockPtyInstance.write.mockReset()
mockPtyInstance.resize.mockReset()
mockPtyInstance.kill.mockReset()
mockPtyInstance.clear.mockReset()
mockPtyInstance.pause.mockReset()
mockPtyInstance.resume.mockReset()
mockCreateShellPromptReadinessProbe.mockReset()
mockCreateShellPromptReadinessProbe.mockReturnValue({
notifyOutput: vi.fn(),
dispose: vi.fn()
})
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
})
afterEach(async () => {
const cleanup = handler.dispose({ waitForPhysicalExit: false })
await vi.runAllTimersAsync()
await cleanup.catch(() => {})
vi.useRealTimers()
vi.restoreAllMocks()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('registers all expected handlers', () => {
const methods = Array.from(dispatcher._requestHandlers.keys())
expect(methods).toContain('pty.spawn')
expect(methods).toContain('pty.attach')
expect(methods).toContain('pty.shutdown')
expect(methods).toContain('pty.sendSignal')
expect(methods).toContain('pty.getCwd')
expect(methods).toContain('pty.getInitialCwd')
expect(methods).toContain('pty.clearBuffer')
expect(methods).toContain('pty.hasChildProcesses')
expect(methods).toContain('pty.getForegroundProcess')
expect(methods).toContain('pty.inspectProcess')
expect(methods).toContain('pty.listProcesses')
expect(methods).toContain('pty.getDefaultShell')
const notifMethods = Array.from(dispatcher._notificationHandlers.keys())
expect(notifMethods).toContain('pty.data')
expect(notifMethods).toContain('pty.resize')
expect(notifMethods).toContain('pty.ackData')
})
it('pauses native output at the producer hard water and resumes after retained writes settle', async () => {
let onData: ((data: string) => void) | undefined
const pause = vi.fn()
const resume = vi.fn()
mockPtySpawn.mockReturnValueOnce({
...mockPtyInstance,
pause,
resume,
onData: vi.fn((callback: (data: string) => void) => {
onData = callback
})
})
const writeCallbacks: (() => void)[] = []
let writableLength = 0
const boundedDispatcher = new RelayDispatcher(
(data, settle) => {
writableLength += data.length
writeCallbacks.push(() => {
writableLength -= data.length
settle({ ok: true })
})
return true
},
{
supportsWriteCallback: true,
writableLength: () => writableLength,
writableHighWaterMark: () => 4 * 1024 * 1024
}
)
const boundedHandler = new PtyHandler(boundedDispatcher)
try {
boundedDispatcher.feed(
encodeJsonRpcFrame({ jsonrpc: '2.0', id: 1, method: 'pty.spawn', params: {} }, 1, 0)
)
await vi.advanceTimersByTimeAsync(0)
expect(onData).toBeTypeOf('function')
onData?.('x'.repeat(1536 * 1024))
expect(pause).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(300)
expect(writeCallbacks.length).toBeGreaterThan(50)
expect(resume).not.toHaveBeenCalled()
for (const settle of writeCallbacks.splice(0)) {
settle()
}
await vi.advanceTimersByTimeAsync(0)
expect(resume).toHaveBeenCalledTimes(1)
} finally {
await boundedHandler.dispose({ waitForPhysicalExit: false }).catch(() => {})
boundedDispatcher.dispose()
}
})
it('rejects strict process inspection for a missing relay PTY', async () => {
await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow(
'terminal_gone'
)
})
it('allows callers to shorten a grace timer for empty startup relays', () => {
const onExpire = vi.fn()
handler.startGraceTimer(onExpire, 100)
expect(handler.graceTimerActive).toBe(true)
vi.advanceTimersByTime(99)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onExpire).toHaveBeenCalledTimes(1)
})
it('does not expire an unlimited grace timer', () => {
const onExpire = vi.fn()
handler.startGraceTimer(onExpire, 100)
expect(handler.graceTimerActive).toBe(true)
handler.startGraceTimer(onExpire, 0)
expect(handler.graceTimerActive).toBe(false)
vi.advanceTimersByTime(100)
expect(onExpire).not.toHaveBeenCalled()
})
it('uses the configured grace time for future disconnect timers', () => {
const onExpire = vi.fn()
handler.setGraceTimeMs(250)
handler.startGraceTimer(onExpire)
vi.advanceTimersByTime(249)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onExpire).toHaveBeenCalledTimes(1)
})
it('spawns a PTY and returns an id', async () => {
const result = await spawnPty({ cols: 80, rows: 24 })
expect(result).toEqual({ id: 'pty-1', incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
it("does not forward Orca's own NODE_ENV into the spawned shell", async () => {
// Why: NODE_ENV in the relay host process is a build-mode flag, not the
// user's; leaking it breaks `next build` and Vitest in the terminal.
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
} finally {
if (previous === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previous
}
}
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnOptions.env.NODE_ENV).toBeUndefined()
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH)
})
it('does not inherit legacy attribution state from the relay process', async () => {
const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
process.env.PATH = '/tmp/orca-terminal-attribution/posix:/usr/bin'
try {
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
const spawnedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as {
env: Record<string, string>
}
expect(spawnedEnv.env.PATH).toBe('/usr/bin')
expect(spawnedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(spawnedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
const state = (await dispatcher.callRequest('pty.serialize', {
ids: ['pty-1']
})) as string
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as {
env: Record<string, string>
}
expect(revivedEnv.env.PATH).toBe('/usr/bin')
expect(revivedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
})
it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => {
// Why: only the ambient value is stripped; an explicit request still wins.
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
env: { NODE_ENV: 'production' }
})
} finally {
if (previous === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previous
}
}
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnOptions.env.NODE_ENV).toBe('production')
})
it('replays an operation-owned spawn after its first response becomes stale', async () => {
const operationId = 'a'.repeat(43)
await dispatcher.callRequest(
'pty.spawn',
{ cols: 80, rows: 24, agentSessionCreateOperationId: operationId },
{ isStale: () => mockPtySpawn.mock.calls.length > 0 }
)
const replayed = await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
})
expect(replayed).toEqual({ id: 'pty-1', incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(mockPtyInstance.kill).not.toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
it('retains an operation fence when publication fails after native spawn', async () => {
const operationId = 'f'.repeat(43)
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(() => {
throw new Error('listener publication failed')
})
})
const request = {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
}
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(handler.activePtyCount).toBe(1)
})
it('releases a canceled operation before native spawn after module preflight', async () => {
let finishModuleLoad!: (value: { spawn: typeof mockPtySpawn }) => void
const moduleLoad = new Promise<{ spawn: typeof mockPtySpawn }>((resolve) => {
finishModuleLoad = resolve
})
const internals = handler as unknown as {
loadPty(): Promise<{ spawn: typeof mockPtySpawn } | null>
}
const loadPty = vi.spyOn(internals, 'loadPty').mockReturnValueOnce(moduleLoad)
const abort = new AbortController()
const operationId = 'c'.repeat(43)
const request = { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }
const spawning = dispatcher.callRequest('pty.spawn', request, {
isStale: () => abort.signal.aborted,
signal: abort.signal
})
abort.abort()
finishModuleLoad({ spawn: mockPtySpawn })
await expect(spawning).rejects.toThrow('client_disconnected')
expect(mockPtySpawn).not.toHaveBeenCalled()
loadPty.mockResolvedValue({ spawn: mockPtySpawn })
await expect(dispatcher.callRequest('pty.spawn', request)).resolves.toMatchObject({
id: expect.stringMatching(/^pty-/)
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('rejects malformed create operation ids before spawning', async () => {
await expect(
dispatcher.callRequest('pty.spawn', { agentSessionCreateOperationId: 'not-valid' })
).rejects.toThrow('agent_session_operation_invalid')
expect(mockPtySpawn).not.toHaveBeenCalled()
})
it('adopts only the exact claimed owner generation on relay retry', async () => {
const agentSessionEnsure = {
claim: {
digestVersion: 1,
keyId: 'claim-key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
surface: {
worktreeId: 'repo::/tmp/worktree',
tabId: '11111111-1111-4111-8111-111111111111',
leafId: '22222222-2222-4222-8222-222222222222',
terminalHandle: 'term_claimed'
}
}
const first = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
const second = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
expect(first).toMatchObject({
id: 'pty-1',
agentSessionEnsure: { disposition: 'created' }
})
expect(second).toMatchObject({
id: 'pty-1',
agentSessionEnsure: { disposition: 'adopted' }
})
expect(second.agentSessionEnsure).toMatchObject({
owner: (first.agentSessionEnsure as { owner: unknown }).owner
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('hedges both causes on Linux and offers the build-tools remedy nowhere else', () => {
const linux = formatNodePtyUnavailableMessage('linux')
expect(linux).toContain('Remote terminals are unavailable')
// Conditional, not asserted: a host with build-essential can still hit an ABI/Node-version flip.
expect(linux).toMatch(/If it is missing the C\/C\+\+ build tools/)
expect(linux).toContain('python3')
expect(linux).toContain('version and architecture match the installed binding')
// Windows/macOS ship node-pty prebuilds, so "install make/g++/python3" sends the user chasing nothing.
for (const platform of ['win32', 'darwin'] as const) {
const message = formatNodePtyUnavailableMessage(platform)
expect(message).toContain('Remote terminals are unavailable')
expect(message).not.toContain('build tools')
expect(message).not.toContain('python3')
expect(message).toMatch(/reconnect/i)
}
})
it('normalizes a missing native binding as degraded node-pty availability', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error(
'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64'
)
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'Remote terminals are unavailable'
)
expect(handler.activePtyCount).toBe(0)
})
it('preserves unrelated node-pty spawn failures', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error('File not found: missing-shell.exe')
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'File not found: missing-shell.exe'
)
})
it('atomically caps concurrent PTY spawn admission', async () => {
const results = await Promise.allSettled(
Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () =>
dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
)
)
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(
MAX_RELAY_PTY_SESSIONS
)
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1)
expect(results.find(({ status }) => status === 'rejected')).toMatchObject({
reason: expect.objectContaining({ message: 'Maximum number of PTY sessions reached (50)' })
})
expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS)
expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS)
})
it('spawns a PTY without post-Node-18 array copy methods', async () => {
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed')
Reflect.deleteProperty(Array.prototype, 'toReversed')
try {
await expect(dispatcher.callRequest('pty.spawn', {})).resolves.toMatchObject({ id: 'pty-1' })
expect(handler.activePtyCount).toBe(1)
} finally {
if (descriptor) {
Object.defineProperty(Array.prototype, 'toReversed', descriptor)
}
}
})
it('guards SSH agent terminals after merging the relay inherited Git config', async () => {
const gitConfigKeys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1',
'GIT_CONFIG_KEY_2',
'GIT_CONFIG_VALUE_2'
] as const
const saved = Object.fromEntries(gitConfigKeys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '3'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
process.env.GIT_CONFIG_KEY_2 = 'base.two'
process.env.GIT_CONFIG_VALUE_2 = 'two'
try {
await dispatcher.callRequest('pty.spawn', {
command: 'claude',
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
})
const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(spawnEnv.GIT_CONFIG_KEY_3).toBeUndefined()
} finally {
for (const key of gitConfigKeys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('guards a trusted SSH agent when its command uses a custom wrapper', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(spawnEnv)).toContain('credential.interactive')
expect(Object.values(spawnEnv)).toContain('credential.guiPrompt')
})
it('leaves an ordinary Windows SSH user terminal unchanged', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await dispatcher.callRequest('pty.spawn', {
env: {
GIT_TERMINAL_PROMPT: '1',
GCM_INTERACTIVE: 'auto',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false'
}
})
const userEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(userEnv.GIT_TERMINAL_PROMPT).toBe('1')
expect(userEnv.GCM_INTERACTIVE).toBe('auto')
expect(userEnv.GIT_CONFIG_COUNT).toBe('1')
expect(userEnv.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(userEnv.GIT_CONFIG_KEY_1).toBeUndefined()
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(false)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveDefaultShellSpy = vi
.spyOn(ptyShellUtils, 'resolveDefaultShell')
.mockReturnValue('/default-shell')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'powershell.exe',
expect.any(Array),
expect.any(Object)
)
mockPtySpawn.mockClear()
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
expect(mockPtySpawn).toHaveBeenCalledWith(
'/default-shell',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveDefaultShellSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('ignores Windows shell overrides on non-Windows relay hosts', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
const resolveDefaultShellSpy = vi
.spyOn(ptyShellUtils, 'resolveDefaultShell')
.mockReturnValue('/default-shell')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'/default-shell',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveDefaultShellSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('rejects unsupported shell overrides on Windows relay hosts', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
await expect(
dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'notepad.exe'
})
).rejects.toThrow('Unsupported Windows shell override')
expect(mockPtySpawn).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
// Why: both spellings classify as a POSIX startup family, so the relay must not be the one host
// that hard-fails a setting the local and daemon PTYs accept.
it.each(['bash', 'bash.exe'])(
'accepts the %s shell override and routes it through Git Bash resolution',
async (shellOverride) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveGitBashSpy = vi
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
.mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
try {
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride })
expect(resolveGitBashSpy).toHaveBeenCalledWith(shellOverride)
expect(mockPtySpawn).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveGitBashSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
}
)
it('falls back to the literal bash override when Git Bash is not installed', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveGitBashSpy = vi
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
.mockReturnValue(null)
try {
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride: 'bash' })
expect(mockPtySpawn).toHaveBeenCalledWith('bash', expect.any(Array), expect.any(Object))
} finally {
resolveGitBashSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveGitBashSpy = vi
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
.mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'git-bash'
})
expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash')
expect(mockPtySpawn).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveGitBashSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('passes the selected WSL distro to relay launches on Windows', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu-24.04'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu-24.04'],
expect.any(Object)
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })
vi.advanceTimersByTime(50)
const term = mockPtySpawn.mock.results[0]?.value
expect(term.write).not.toHaveBeenCalled()
})
it('submits provider-delivered spawn commands to the relay shell', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'echo provider-owned',
commandDelivery: 'provider'
})
vi.advanceTimersByTime(49)
const term = mockPtySpawn.mock.results[0]?.value
expect(handler.retainedStartupCommandCount).toBe(1)
expect(handler.retainedStartupCommandBytes).toBe('echo provider-owned'.length)
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
const submit = process.platform === 'win32' ? '\r' : '\n'
expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`)
expect(handler.retainedStartupCommandCount).toBe(0)
})
it.skipIf(process.platform === 'win32')(
'emits shell-ready markers for renderer-delivered startup commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-ready-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo renderer-owned',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(1)
expect(handler.retainedStartupCommandBytes).toBe(0)
vi.advanceTimersByTime(15_000)
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'emits shell-ready markers for renderer-delivered Codex native prefill commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prefill-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: "codex --prefill 'linked issue context'"
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(1)
}
)
it.skipIf(process.platform === 'win32')(
'enables shell-ready marker env for provider-delivered startup commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo provider-owned',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
}
)
it.skipIf(process.platform === 'win32')(
'uses the sequenced startup command hint for provider shell-ready detection',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: {
HOME: homeDir,
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
},
command: 'bash -lc wait-for-setup-wrapper',
commandDelivery: 'provider'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
}
)
it.skipIf(process.platform === 'win32')(
'waits for the shell-ready marker before provider-delivered startup commands',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
vi.advanceTimersByTime(1499)
expect(term.write).not.toHaveBeenCalled()
dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
vi.advanceTimersByTime(49)
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(term.write).toHaveBeenCalledWith('echo after-ready\n')
expect(handler.retainedStartupCommandCount).toBe(0)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: 'user@remote $ '
})
}
)
it.skipIf(process.platform === 'win32')(
'recovers provider delivery when startup exec replaces the relay wrapper',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-exec-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-exec',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
await vi.advanceTimersByTimeAsync(8)
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
onPromptReady: () => void
}
expect(
mockCreateShellPromptReadinessProbe.mock.results[0]?.value.notifyOutput
).toHaveBeenCalledWith('\x1b[?2004hremote $ ')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b[?2004hremote $ '
})
promptOptions.onPromptReady()
await vi.advanceTimersByTimeAsync(50)
expect(term.write).toHaveBeenCalledWith('echo after-exec\n')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'signals renderer delivery when startup exec replaces the relay wrapper',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-exec-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-exec',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
await vi.advanceTimersByTimeAsync(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b[?2004hremote $ '
})
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
onPromptReady: () => void
}
promptOptions.onPromptReady()
await vi.advanceTimersByTimeAsync(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07'
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'forwards the supported ready marker to renderer delivery',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-ready-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(
`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready\x07remote $ `
)
await vi.advanceTimersByTimeAsync(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07remote $ '
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'releases split renderer readiness through one completed path',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-split-ready-spawn-'))
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/bash'
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready`)
dataCallback?.('\x07remote $ ')
await vi.advanceTimersByTimeAsync(8)
const probe = mockCreateShellPromptReadinessProbe.mock.results[0]?.value
expect(probe.notifyOutput).not.toHaveBeenCalled()
expect(probe.dispose).toHaveBeenCalledOnce()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready\x07remote $ '
})
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'does not retain renderer readiness state for unsupported shells',
async () => {
const oldShell = process.env.SHELL
process.env.SHELL = '/bin/sh'
try {
await dispatcher.callRequest('pty.spawn', {
command: 'x'.repeat(256 * 1024),
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('0')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'flushes held shell-ready marker bytes when provider delivery falls back',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
let spawn!: { id: string; incarnationId: string }
try {
spawn = await spawnPty({
env: { HOME: homeDir },
command: 'echo fallback',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.('\x1b]777;orca-shell-ready')
vi.advanceTimersByTime(1500)
expect(term.write).toHaveBeenCalledWith('echo fallback\n')
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready'
})
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({
incarnationId: spawn.incarnationId,
replay: '\x1b]777;orca-shell-ready'
})
}
)
it('proves the exit and reaps a reattach whose backing shell is dead', async () => {
// Why: a lingering managed entry whose child died without an onExit would
// otherwise attach-succeed with an empty replay and strand the pane on a
// black shell. A provably-dead pid must surface as a PROVEN exit so the SSH
// provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh.
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
const spawned = (await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-dead:0' }
})) as { id: string; incarnationId: string }
expect(handler.activePtyCount).toBe(1)
expect(onExitCb).toBeDefined()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
// INVERTED: the reap is unchanged, but its answer is no longer the ordinary unknown. The
// relay held this shell and found its pid gone, which is first-hand proof of death — the
// one observation that separates a dead shell from one a replacement relay never knew.
await expect(
dispatcher.callRequest('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true,
exitProofSupported: true,
expectedIncarnationId: spawned.incarnationId
})
).rejects.toThrow(/SSH_PTY_EXITED/)
} finally {
aliveSpy.mockRestore()
}
// The stale entry is reaped: cache-eviction observers fire and the map slot
// is freed so a later attach also cleanly reports not-found.
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }])
expect(handler.activePtyCount).toBe(0)
// INVERTED: a LATER attach still answers, and now answers with what this process observed —
// the remembered exit outlives the reap, which is the whole point of remembering it.
await expect(
dispatcher.callRequest('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true,
exitProofSupported: true,
expectedIncarnationId: spawned.incarnationId
})
).rejects.toThrow(/SSH_PTY_EXITED/)
})
it.skipIf(process.platform === 'win32')(
'releases renderer readiness state when attach reaps a dead shell',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-dead-shell-ready-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'x'.repeat(256 * 1024),
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
expect(handler.retainedStartupCommandCount).toBe(1)
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow(
'PTY "pty-1" not found'
)
} finally {
aliveSpy.mockRestore()
}
expect(handler.retainedStartupCommandCount).toBe(0)
vi.advanceTimersByTime(15_000)
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it('settles concurrent immediate shutdown when attach proves the shell exited', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn()
})
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as {
id: string
incarnationId: string
}
let shutdown: Promise<unknown> | undefined
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockImplementation(() => {
shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
return false
})
try {
// INVERTED with the reap above: proof of exit, not an unknown id.
await expect(
dispatcher.callRequest('pty.attach', {
id: 'pty-1',
exitProofSupported: true,
expectedIncarnationId: spawned.incarnationId
})
).rejects.toThrow(/SSH_PTY_EXITED/)
await expect(shutdown).resolves.toBeUndefined()
} finally {
aliveSpy.mockRestore()
}
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(handler.activePtyCount).toBe(0)
})
// A relay that never knew an id may be a replacement whose predecessor's shells are still
// running, so "not found" proves nothing. A relay that WATCHED the shell exit knows first-hand.
// Keeping that observation is what lets the client tell a dead shell from an unknown one, and
// therefore what lets it respawn without risking a second agent on a live transcript.
// What the host answers reaches clients that predate the reply, and one of those reads an
// unrecognized attach error as neither death nor recovery — a stranded pane. So the proof is
// offered only to a client that says it can act on it.
/** `toThrow(expect.not.stringMatching(...))` passes whenever ANYTHING throws, so a negative
* claim about the wording has to read the message itself. */
async function attachRejectionMessage(params: Record<string, unknown>): Promise<string> {
const error = await dispatcher.callRequest('pty.attach', params).then(
() => null,
(thrown: unknown) => thrown as Error
)
expect(error, 'attach unexpectedly succeeded').toBeInstanceOf(Error)
return (error as Error).message
}
it('keeps the older wording for a client that has not declared it understands exit proof', async () => {
const spawned = await spawnPty({})
const onExitCb = mockPtyInstance.onExit.mock.calls.at(-1)?.[0] as
| ((evt: { exitCode: number }) => void)
| undefined
onExitCb?.({ exitCode: 3 })
await expect(dispatcher.callRequest('pty.attach', { id: spawned.id })).rejects.toThrow(
`PTY "${'pty-1'}" not found`
)
})
// The sharpest edge in the whole change. A caller that names no shell is not asking about this
// one, so a remembered exit is not an answer to it: relay A is killed leaving pty-1 alive and
// orphaned, relay B mints its own pty-1 and THAT one exits. A pane carrying no recorded identity
// must not be told its shell is gone, because the reply authorizes replacing a process that is
// still running — the double agent resume this whole change exists to prevent.
it('does not prove an exit to a caller that names no shell', async () => {
const spawned = await spawnPty({})
const onExitCb = mockPtyInstance.onExit.mock.calls.at(-1)?.[0] as
| ((evt: { exitCode: number }) => void)
| undefined
onExitCb?.({ exitCode: 3 })
expect(await attachRejectionMessage({ id: spawned.id, exitProofSupported: true })).not.toMatch(
/SSH_PTY_EXITED/
)
})
// The same rule on the other proof route. A replacement relay can hold a DIFFERENT shell under
// this id; if that one dies, its death is not evidence about the caller's, which may be running
// orphaned under the relay this one replaced. The reap still happens — a dead shell should be
// cleared whoever asked — but the answer must not authorize replacing a live process.
it('does not prove a reaped shell to a caller that named a different one', async () => {
const spawned = await spawnPty({})
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
const message = await attachRejectionMessage({
id: spawned.id,
exitProofSupported: true,
expectedIncarnationId: 'a-shell-from-a-previous-relay'
})
expect(message).not.toMatch(/SSH_PTY_EXITED/)
expect(message).toMatch(/identity mismatch/i)
} finally {
aliveSpy.mockRestore()
}
// The reap still ran: the dead entry is gone regardless of who asked.
expect(handler.activePtyCount).toBe(0)
})
it('answers a later attach with the exit it observed, not an unknown id', async () => {
const spawned = await spawnPty({})
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
// The exit listener was captured at spawn by the shared mock; drive a real exit through it.
onExitCb = mockPtyInstance.onExit.mock.calls.at(-1)?.[0] as typeof onExitCb
expect(onExitCb).toBeDefined()
onExitCb!({ exitCode: 3 })
const asking = {
id: spawned.id,
exitProofSupported: true,
expectedIncarnationId: spawned.incarnationId
}
await expect(attachPty(asking)).rejects.toThrow(/SSH_PTY_EXITED/)
await expect(attachPty(asking)).rejects.toThrow(/code=3/)
})
// The dangerous shape: a replaced relay hands pty-1 to a new shell, that one exits, and the
// pane still bound to the ORIGINAL pty-1 asks about its own. Reporting the stranger's death as
// proof would authorize a respawn onto a shell that may still be running.
it('does not report a remembered exit for a shell the caller did not ask about', async () => {
const spawned = await spawnPty({})
const onExitCb = mockPtyInstance.onExit.mock.calls.at(-1)?.[0] as
| ((evt: { exitCode: number }) => void)
| undefined
onExitCb?.({ exitCode: 3 })
expect(
await attachRejectionMessage({
id: spawned.id,
exitProofSupported: true,
expectedIncarnationId: 'a-different-shell'
})
).not.toMatch(/SSH_PTY_EXITED/)
})
// INVERTED from the deleted `attachIdentityMismatches` suite. That guard compared the paneKey and
// tabId frozen at spawn; it caught a recycled id, but it also refused a pane that had merely moved
// to another tab — and refused as "not found", which the client read as death and answered by
// resuming the agent a second time. The property worth keeping is "a recycled id must not attach";
// the property that had to go is "identity is where the pane lives". Both are pinned below, now
// against the shell's own incarnation.
it('refuses an attach whose expected incarnation names a different shell', async () => {
const first = await spawnPty({})
await expect(
dispatcher.callRequest('pty.attach', {
id: first.id,
expectedIncarnationId: 'incarnation-from-a-previous-relay'
})
).rejects.toThrow(/identity mismatch/i)
})
// The wording matters as much as the refusal: "not found" is what the client maps to an expired
// session, and expiry authorizes a respawn onto the very shell this branch proved is alive.
it('does not word an incarnation refusal as not-found', async () => {
const first = await spawnPty({})
expect(
await attachRejectionMessage({
id: first.id,
expectedIncarnationId: 'incarnation-from-a-previous-relay'
})
).not.toMatch(/not found/i)
})
// The same correction, applied to the FALLBACK fence a client too old to send an incarnation
// falls into. That path still compared the tab frozen at spawn, so it refused a moved pane and
// stranded a live shell the client could never reach again — and an identity mismatch never
// grounds a respawn, so the pane just stayed blank.
it('lets a pane that moved tabs attach when the client sends no incarnation', async () => {
const first = await spawnPty({ paneKey: 'pane-a', tabId: 'tab-1' })
await expect(
attachPty({ id: first.id, expectedPaneKey: 'pane-a', expectedTabId: 'tab-2' })
).resolves.toMatchObject({ incarnationId: first.incarnationId })
})
// The property that had to survive the narrowing: a recycled id belonging to another pane is
// still refused, even with no incarnation to check it against.
it('still refuses an incarnation-less attach naming a different pane', async () => {
const first = await spawnPty({ paneKey: 'pane-a', tabId: 'tab-1' })
await expect(
attachPty({ id: first.id, expectedPaneKey: 'pane-b', expectedTabId: 'tab-1' })
).rejects.toThrow(/identity mismatch/i)
})
it('attaches when the expected incarnation is the shell it names', async () => {
const first = await spawnPty({})
await expect(
attachPty({ id: first.id, expectedIncarnationId: first.incarnationId })
).resolves.toMatchObject({ incarnationId: first.incarnationId })
})
// An older client sends no expectation. Refusing there would strand every pane it owns.
it('stays permissive when the caller supplies no expected incarnation', async () => {
const first = await spawnPty({})
await expect(attachPty({ id: first.id })).resolves.toMatchObject({
incarnationId: first.incarnationId
})
})
// The regression the old tab-keyed guard caused. A pane carries its shell to a new tab; the
// relay still holds the paneKey/tabId frozen at spawn. That must not refuse the attach.
it('attaches a pane that has moved to another tab', async () => {
const first = await spawnPty({ paneKey: 'tab-a:0', tabId: 'tab-a' })
await expect(
attachPty({
id: first.id,
expectedIncarnationId: first.incarnationId,
expectedPaneKey: 'tab-b:0',
expectedTabId: 'tab-b'
})
).resolves.toMatchObject({ incarnationId: first.incarnationId })
expect(vi.getTimerCount()).toBe(0)
})
it('replays for a reattach whose backing shell is still alive', async () => {
// Guard the other direction: a live pid must never be reaped, so a quiet
// but live session still returns its buffered replay on reattach.
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback?.('prompt$ ')
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
try {
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'prompt$ ' })
} finally {
aliveSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(1)
})
it('terminates spawned PTY when request becomes stale before response', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const killSpy = vi.fn()
const term = {
...mockPtyInstance,
kill: killSpy,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest(
'pty.spawn',
{},
{
isStale: () => mockPtySpawn.mock.calls.length > 0
}
)
// Why: assert via the captured spy reference rather than term.kill because
// disposeManagedPty() neutralizes managed.pty.kill (replaces it with a
// no-op) on POSIX to close the UnixTerminal.destroy() → socket-close →
// SIGHUP-to-recycled-pid race. After the 5s timer fires, term.kill is the
// neutralized function, not the original spy. killSpy retains call history.
expect(killSpy).toHaveBeenCalledWith('SIGTERM')
vi.advanceTimersByTime(5000)
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
expect(handler.activePtyCount).toBe(1)
onExitCb?.({ exitCode: 137 })
expect(handler.activePtyCount).toBe(0)
})
it('does not submit provider-delivered commands for stale spawn responses', async () => {
const killSpy = vi.fn()
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest(
'pty.spawn',
{ command: 'echo stale', commandDelivery: 'provider' },
{ isStale: () => mockPtySpawn.mock.calls.length > 0 }
)
vi.advanceTimersByTime(50)
expect(term.write).not.toHaveBeenCalled()
expect(handler.retainedStartupCommandCount).toBe(0)
expect(killSpy).toHaveBeenCalledWith('SIGTERM')
})
it('releases pending provider-delivered commands on shutdown before delivery', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const killSpy = vi.fn()
const term = {
...mockPtyInstance,
kill: killSpy,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
command: 'echo stop-before-run',
commandDelivery: 'provider'
})
expect(handler.retainedStartupCommandCount).toBe(1)
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
onExitCb!({ exitCode: 137 })
await shutdown
vi.advanceTimersByTime(50)
expect(handler.retainedStartupCommandCount).toBe(0)
expect(term.write).not.toHaveBeenCalled()
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
})
it('increments PTY ids on each spawn', async () => {
const r1 = await dispatcher.callRequest('pty.spawn', {})
const r2 = await dispatcher.callRequest('pty.spawn', {})
expect((r1 as { id: string }).id).toBe('pty-1')
expect((r2 as { id: string }).id).toBe('pty-2')
})
it('accepts SIGWINCH for restored TUI repaint', async () => {
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.sendSignal', { id: 'pty-1', signal: 'SIGWINCH' })
const term = mockPtySpawn.mock.results[0].value
expect(term.kill).toHaveBeenCalledWith('SIGWINCH')
})
it('forwards data from PTY to dispatcher notifications', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
expect(dataCallback).toBeDefined()
dataCallback!('hello world')
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything())
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello world' })
})
it('consumes capable startup queries before relay replay and fanout', async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'
dataCallback!(query)
dataCallback!('prompt')
vi.advanceTimersByTime(8)
expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '',
rawLength: query.length,
seq: query.length,
transformed: true
})
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'prompt' })
await expect(
dispatcher.callRequest('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true
})
).resolves.toEqual({ replay: 'prompt', incarnationId: expect.any(String) })
})
it('does not carry transformed raw length into the next plain pending entry', async () => {
await handler.dispose({ waitForPhysicalExit: false })
const admitted: Record<string, unknown>[] = []
let hasCapacity = false
const tryNotifyPtyData = vi.fn((params: Record<string, unknown>) => {
if (hasCapacity) {
admitted.push(params)
}
return hasCapacity
})
Object.assign(dispatcher, {
onLegacyPtyCapacity: vi.fn(() => vi.fn()),
tryNotifyPtyData,
tryNotifyPtyExit: vi.fn(() => true),
legacyRetentionBelowLowWater: true
})
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((callback: (data: string) => void) => {
dataCallback = callback
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'
dataCallback?.(query)
dataCallback?.('fresh')
hasCapacity = true
await vi.runAllTimersAsync()
expect(tryNotifyPtyData).toHaveBeenCalledTimes(3)
expect(admitted).toEqual([
{
id: 'pty-1',
data: '',
rawLength: query.length,
seq: query.length,
transformed: true
},
{ id: 'pty-1', data: 'fresh' }
])
})
describe('legacy flush retry with a memoized source chunk', () => {
let capacityListener: (() => void) | undefined
let hasCapacity: boolean
let maxChars: number
let admitted: Record<string, unknown>[]
let dataCallback: ((data: string) => void) | undefined
async function setupRetryHarness(spawnParams: Record<string, unknown> = {}): Promise<void> {
await handler.dispose({ waitForPhysicalExit: false })
capacityListener = undefined
hasCapacity = false
admitted = []
Object.assign(dispatcher, {
onLegacyPtyCapacity: vi.fn((listener: () => void) => {
capacityListener = listener
return vi.fn()
}),
tryNotifyPtyData: vi.fn((params: Record<string, unknown>) => {
if (hasCapacity) {
admitted.push(params)
}
return hasCapacity
}),
tryNotifyPtyExit: vi.fn(() => true),
legacyRetentionBelowLowWater: true,
// Clamped like the real dispatcher: never more than min(data.length, limit).
maxLegacyPtyDataChars: vi.fn((_params: unknown, data: string, limit?: number) =>
Math.min(maxChars, data.length, limit ?? data.length)
)
})
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
dataCallback = undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((callback: (data: string) => void) => {
dataCallback = callback
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', spawnParams)
expect(dataCallback).toBeDefined()
}
it('resends the memo verbatim and keeps the coalesced tail when capacity grows', async () => {
maxChars = 5_000
await setupRetryHarness()
dataCallback!('a'.repeat(20_000))
await vi.advanceTimersByTimeAsync(8)
// Why before restoring capacity: the failed batch made zero writes so no flush is
// rescheduled; this enqueue re-arms the timer.
dataCallback!('b'.repeat(10))
hasCapacity = true
maxChars = 16_384
await vi.runAllTimersAsync()
expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000) + 'b'.repeat(10))
expect((admitted[0].data as string).length).toBe(5_000)
// Why: remainder frames must not leak transformed/rawLength/seq keys.
expect(admitted[1]).toStrictEqual({ id: 'pty-1', data: expect.any(String) })
})
it('keeps a tail appended after a failed flush under constant capacity', async () => {
maxChars = 16_384
await setupRetryHarness()
dataCallback!('a'.repeat(5_000))
await vi.advanceTimersByTimeAsync(8)
dataCallback!('b'.repeat(10))
hasCapacity = true
await vi.runAllTimersAsync()
expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(5_000) + 'b'.repeat(10))
expect(admitted[1].data).toBe('b'.repeat(10))
})
it('does not duplicate memoized chars when capacity shrinks before the retry', async () => {
maxChars = 16_384
await setupRetryHarness()
dataCallback!('a'.repeat(20_000))
await vi.advanceTimersByTimeAsync(8)
maxChars = 5_000
hasCapacity = true
// Why: nothing else re-arms the flush timer after a zero-write batch.
capacityListener!()
await vi.runAllTimersAsync()
expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000))
expect((admitted[0].data as string).length).toBe(16_384)
expect((admitted[1].data as string).length).toBe(3_616)
})
it('retains a coalesced transformed raw advance behind a memoized source-only chunk', async () => {
maxChars = 16_384
await setupRetryHarness({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
// First answered query: direct publish fails, entry queued without a memo.
dataCallback!('\x1b]10;?\x07')
// Capacity event while still blocked: the flush fails and memoizes the source-only chunk.
capacityListener!()
await vi.advanceTimersByTimeAsync(0)
// Second answered query coalesces into the memoized transformed head.
dataCallback!('\x1b]11;?\x07')
hasCapacity = true
capacityListener!()
await vi.runAllTimersAsync()
expect(admitted).toEqual([
{ id: 'pty-1', data: '', rawLength: 7, seq: 7, transformed: true },
{ id: 'pty-1', data: '', rawLength: 7, seq: 14, transformed: true }
])
})
})
it('leaves startup queries untouched for an unsupported relay capability version', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'
dataCallback!(query)
vi.advanceTimersByTime(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('consumes a color query at a native Windows SSH relay owner', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', { shellOverride: 'powershell.exe' })
dataCallback!('\x1b]10;?\x07')
vi.advanceTimersByTime(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '',
rawLength: '\x1b]10;?\x07'.length,
seq: '\x1b]10;?\x07'.length,
transformed: true
})
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('forwards color queries from a POSIX SSH relay owner', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' })
const query = '\x1b]10;?\x07'
dataCallback!(query)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('keeps renderer color replies for a Windows SSH relay that owns WSL', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
dataCallback!('\x1b]11;?\x07')
vi.advanceTimersByTime(8)
dispatcher.callNotification('pty.data', { id: 'pty-1', data: reply })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]11;?\x07'
})
expect(term.write).toHaveBeenCalledWith(reply)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('coalesces background PTY output before notifying the client', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
dataCallback!('hello ')
dataCallback!('world')
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything())
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: 'hello world'
})
})
it('sends recent-input redraw output immediately', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
dispatcher.callNotification('pty.data', { id: 'pty-1', data: 'a' })
dispatcher.notify.mockClear()
dataCallback!('\x1b[20;2Hredraw')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b[20;2Hredraw'
})
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledTimes(1)
})
it('drains large relay PTY output in bounded slices', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const firstChunk = 'x'.repeat(16 * 1024)
dataCallback!(`${firstChunk}tail`)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledTimes(1)
expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', {
id: 'pty-1',
data: firstChunk
})
vi.advanceTimersByTime(1)
expect(dispatcher.notify).toHaveBeenCalledTimes(2)
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.data', {
id: 'pty-1',
data: 'tail'
})
})
it('returns attach replay instead of notifying when replay notification is suppressed', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback!('buffered output')
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'buffered output' })
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.replay', expect.anything())
vi.advanceTimersByTime(8)
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything())
})
it('suppresses legacy replay after the V1 owner is already active', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback!('buffered output')
handler.setSourcePublication({
activate: vi.fn(() => 'existing'),
accepts: vi.fn(() => true),
publish: vi.fn(() => true),
dispose: vi.fn()
} as never)
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ incarnationId: spawn.incarnationId })
})
it('requires restore when the V1 pending-send recovery fence expires', async () => {
const spawn = await spawnPty()
const waitForPendingSend = vi.fn().mockResolvedValue(false)
const activate = vi
.fn()
.mockReturnValue({ status: 'restoreRequired', reason: 'checkpointUnavailable' })
handler.setSourcePublication({
activate,
accepts: vi.fn(() => true),
waitForPendingSend,
dispose: vi.fn()
} as never)
const result = await dispatcher.callRequest(
'pty.attach',
{
id: spawn.id,
sourceRecovery: {
status: 'checkpoint',
deliveryToken: 'old-token',
ptyIncarnation: spawn.incarnationId,
clientGeneration: 1,
ownerGeneration: 1,
acceptedSourceEndSu: 0
}
},
{
clientId: 2,
isStale: () => false,
onResponseSettled: vi.fn()
} as never
)
expect(waitForPendingSend).toHaveBeenCalledWith(spawn.id)
expect(activate).toHaveBeenCalledWith(
spawn.id,
spawn.incarnationId,
expect.anything(),
Object.freeze({ status: 'checkpointUnavailable' })
)
expect(result).toEqual({
incarnationId: spawn.incarnationId,
sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' }
})
})
it('notifies replay on normal attach', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback!('buffered output')
dispatcher.notify.mockClear()
const result = await attachPty({ id: 'pty-1' })
expect(result).toEqual({ incarnationId: spawn.incarnationId })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.replay', {
id: 'pty-1',
data: 'buffered output'
})
vi.advanceTimersByTime(8)
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything())
})
it('uses attach identity metadata without exporting it to the shell env', async () => {
const oldPaneKey = process.env.ORCA_PANE_KEY
const oldTabId = process.env.ORCA_TAB_ID
delete process.env.ORCA_PANE_KEY
delete process.env.ORCA_TAB_ID
let spawn!: { id: string; incarnationId: string }
try {
spawn = await spawnPty({
env: { FOO: 'bar' },
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
})
} finally {
if (oldPaneKey === undefined) {
delete process.env.ORCA_PANE_KEY
} else {
process.env.ORCA_PANE_KEY = oldPaneKey
}
if (oldTabId === undefined) {
delete process.env.ORCA_TAB_ID
} else {
process.env.ORCA_TAB_ID = oldTabId
}
}
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnOptions.env.ORCA_PANE_KEY).toBeUndefined()
expect(spawnOptions.env.ORCA_TAB_ID).toBeUndefined()
// A caller that names no incarnation is an older client, and pane identity is the only fence
// it can be given — so it still applies, and still refuses. A caller that CAN name the shell is
// fenced on that instead and attaches regardless of which tab it now lives in; that is pinned
// by "attaches a pane that has moved to another tab".
await expect(
attachPty({
id: 'pty-1',
expectedPaneKey: 'tab-b:leaf-b',
expectedTabId: 'tab-b'
})
).rejects.toThrow(/identity mismatch/i)
await expect(
attachPty({
id: 'pty-1',
expectedPaneKey: 'tab-a:leaf-a',
expectedTabId: 'tab-a'
})
).resolves.toEqual({ incarnationId: spawn.incarnationId })
})
it('notifies on PTY exit and removes from map', async () => {
let exitCallback: ((info: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCallback = cb
})
})
const spawn = await spawnPty()
expect(handler.activePtyCount).toBe(1)
exitCallback!({ exitCode: 0 })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', {
id: 'pty-1',
code: 0,
incarnationId: spawn.incarnationId
})
expect(handler.activePtyCount).toBe(0)
})
it('retains PTY exit until the ordinary writer admits it', async () => {
await handler.dispose({ waitForPhysicalExit: false })
let capacityListener: (() => void) | undefined
const tryNotifyPtyExit = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
Object.assign(dispatcher, {
onLegacyPtyCapacity: vi.fn((listener: () => void) => {
capacityListener = listener
return vi.fn()
}),
tryNotifyPtyData: vi.fn(() => true),
tryNotifyPtyExit,
legacyRetentionBelowLowWater: true
})
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
let exitCallback: ((info: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((callback: (info: { exitCode: number }) => void) => {
exitCallback = callback
})
})
const spawn = await spawnPty()
exitCallback?.({ exitCode: 0 })
expect(tryNotifyPtyExit).toHaveBeenCalledOnce()
capacityListener?.()
expect(tryNotifyPtyExit).toHaveBeenLastCalledWith({
id: 'pty-1',
code: 0,
incarnationId: spawn.incarnationId
})
expect(tryNotifyPtyExit).toHaveBeenCalledTimes(2)
})
it('flushes pending PTY output before notifying exit', async () => {
let dataCallback: ((data: string) => void) | undefined
let exitCallback: ((info: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCallback = cb
})
})
const spawn = await spawnPty()
dataCallback!('final output')
exitCallback!({ exitCode: 0 })
expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', {
id: 'pty-1',
data: 'final output'
})
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', {
id: 'pty-1',
code: 0,
incarnationId: spawn.incarnationId
})
})
it('writes data to PTY via pty.data notification', async () => {
const mockWrite = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
write: mockWrite,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
dispatcher.callNotification('pty.data', { id: 'pty-1', data: 'ls\n' })
expect(mockWrite).toHaveBeenCalledWith('ls\n')
})
it('resizes PTY via pty.resize notification', async () => {
const mockResize = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
resize: mockResize,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
dispatcher.callNotification('pty.resize', { id: 'pty-1', cols: 120, rows: 40 })
expect(mockResize).toHaveBeenCalledWith(120, 40)
})
it('reports the PTY grid actually applied by node-pty', async () => {
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
cols: 132,
rows: 43,
onData: vi.fn(),
onExit: vi.fn()
})
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({
cols: 132,
rows: 43
})
await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull()
})
it('kills PTY on shutdown with SIGTERM by default', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
expect(mockKill).toHaveBeenCalledWith('SIGTERM')
})
// Why: node-pty's Windows agent throws "Signals not supported on windows."
// for any signal argument. killPtyProcess drops the signal on win32 — cover
// every call site so a future regression cannot reintroduce signal args.
describe('kills PTY without a signal on Windows', () => {
async function withWindowsPlatform(fn: () => Promise<void>): Promise<void> {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await fn()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
}
function mockKillablePty(): ReturnType<typeof vi.fn> {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn()
})
return mockKill
}
function expectBareKills(mockKill: ReturnType<typeof vi.fn>, times: number): void {
expect(mockKill).toHaveBeenCalledTimes(times)
expect(mockKill.mock.calls.every((args) => args.length === 0)).toBe(true)
}
it('on graceful shutdown', async () => {
await withWindowsPlatform(async () => {
const mockKill = mockKillablePty()
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
expectBareKills(mockKill, 1)
})
})
it('on immediate shutdown', async () => {
await withWindowsPlatform(async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
onExitCb!({ exitCode: 137 })
await shutdown
expectBareKills(mockKill, 1)
})
})
it('does not double-kill when immediate cleanup joins graceful shutdown', async () => {
await withWindowsPlatform(async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
const destroy = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
destroy,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
const immediate = dispatcher.callRequest('pty.shutdown', {
id: 'pty-1',
immediate: true
})
expectBareKills(mockKill, 1)
onExitCb!({ exitCode: 137 })
await immediate
expectBareKills(mockKill, 1)
expect(destroy).not.toHaveBeenCalled()
})
})
it('does not retry stale-spawn cleanup after the Windows kill deadline', async () => {
await withWindowsPlatform(async () => {
const mockKill = mockKillablePty()
await dispatcher.callRequest(
'pty.spawn',
{},
{
isStale: () => mockPtySpawn.mock.calls.length > 0
}
)
expectBareKills(mockKill, 1)
vi.advanceTimersByTime(5000)
expectBareKills(mockKill, 1)
})
})
it('does not retry graceful shutdown after the Windows kill deadline', async () => {
await withWindowsPlatform(async () => {
const mockKill = mockKillablePty()
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
expectBareKills(mockKill, 1)
vi.advanceTimersByTime(5000)
expectBareKills(mockKill, 1)
})
})
it('on dispose', async () => {
await withWindowsPlatform(async () => {
const mockKill = vi.fn()
const destroy = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
destroy,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
await handler.dispose({ waitForPhysicalExit: false })
expectBareKills(mockKill, 1)
expect(destroy).not.toHaveBeenCalled()
})
})
})
it('flushes pending PTY output before immediate shutdown cleanup', async () => {
let dataCallback: ((data: string) => void) | undefined
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
dataCallback!('last words')
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
onExitCb!({ exitCode: 137 })
await shutdown
expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', {
id: 'pty-1',
data: 'last words'
})
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
})
it('notifies pty.exit when graceful shutdown falls back to SIGKILL', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
const spawn = await spawnPty({ env: { ORCA_PANE_KEY: 'tab-fallback:0' } })
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
vi.advanceTimersByTime(5000)
expect(handler.activePtyCount).toBe(1)
expect(exits).toEqual([])
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.exit', expect.anything())
onExitCb!({ exitCode: 137 })
expect(mockKill).toHaveBeenCalledWith('SIGTERM')
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', {
id: 'pty-1',
code: 137,
incarnationId: spawn.incarnationId
})
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }])
expect(handler.activePtyCount).toBe(0)
})
it('retries a rejected graceful SIGKILL fallback while retaining ownership', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
let forceAttempts = 0
const mockKill = vi.fn((signal: string) => {
if (signal === 'SIGKILL' && forceAttempts++ === 0) {
throw new Error('transient kill failure')
}
})
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
vi.advanceTimersByTime(5000)
expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
expect(handler.activePtyCount).toBe(1)
expect(vi.getTimerCount()).toBe(1)
vi.runOnlyPendingTimers()
expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']])
expect(handler.activePtyCount).toBe(1)
onExitCb!({ exitCode: 137 })
expect(handler.activePtyCount).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('kills PTY on shutdown with SIGKILL when immediate', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
onExitCb!({ exitCode: 137 })
await shutdown
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
})
it('throws for attach on nonexistent PTY', async () => {
await expect(dispatcher.callRequest('pty.attach', { id: 'pty-999' })).rejects.toThrow(
'PTY "pty-999" not found'
)
})
it('default grace timer does not expire', () => {
const onExpire = vi.fn()
handler.startGraceTimer(onExpire)
expect(handler.graceTimerActive).toBe(false)
vi.advanceTimersByTime(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000)
expect(onExpire).not.toHaveBeenCalled()
})
it('configured grace timer waits full period even when no PTYs exist', () => {
const onExpire = vi.fn()
const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
handler.setGraceTimeMs(boundedGraceMs)
handler.startGraceTimer(onExpire)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(boundedGraceMs - 1)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onExpire).toHaveBeenCalledTimes(1)
})
it('grace timer fires after configured delay when PTYs exist', async () => {
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const onExpire = vi.fn()
const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
handler.setGraceTimeMs(boundedGraceMs)
handler.startGraceTimer(onExpire)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(boundedGraceMs - 1)
expect(onExpire).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onExpire).toHaveBeenCalledTimes(1)
})
it('cancelGraceTimer prevents expiration', async () => {
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const onExpire = vi.fn()
const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
handler.setGraceTimeMs(boundedGraceMs)
handler.startGraceTimer(onExpire)
vi.advanceTimersByTime(60_000)
handler.cancelGraceTimer()
vi.advanceTimersByTime(boundedGraceMs)
expect(onExpire).not.toHaveBeenCalled()
})
it('attach preserves buffer so repeated attaches return the same data plus new output', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback!('initial output')
const r1 = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(r1).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output' })
dataCallback!(' more')
const r2 = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(r2).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output more' })
})
it('second app restart still replays full buffer', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
const spawn = await spawnPty()
dataCallback!('$ while true; do date; done\r\n')
dataCallback!('Mon Apr 28\r\n')
const firstAttach = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(firstAttach.incarnationId).toBe(spawn.incarnationId)
dataCallback!('Tue Apr 29\r\n')
const secondAttach = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(secondAttach.incarnationId).toBe(spawn.incarnationId)
dataCallback!('Wed Apr 30\r\n')
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({
incarnationId: spawn.incarnationId,
replay: '$ while true; do date; done\r\nMon Apr 28\r\nTue Apr 29\r\nWed Apr 30\r\n'
})
})
it('applies env augmenters after process.env and renderer-supplied env (augmenter wins on key conflict)', async () => {
handler.addEnvAugmenter(() => ({
ORCA_AGENT_HOOK_PORT: '12345',
ORCA_AGENT_HOOK_TOKEN: 'abc-uuid',
// Why: also override a key the renderer supplied below so the test pins
// the documented "augmenter wins on key conflict" invariant — see the
// doc-comment on addEnvAugmenter in pty-handler.ts.
ORCA_PANE_KEY: 'augmenter-wins'
}))
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_TAB_ID: 'tab-1' }
})
expect(mockPtySpawn).toHaveBeenCalled()
const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345')
expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid')
// Augmenter override beats the renderer-supplied value:
expect(callArgs.env.ORCA_PANE_KEY).toBe('augmenter-wins')
// Renderer-supplied keys not in augmenter map flow through:
expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1')
})
it('passes PTY and explicit launch identity to env augmenters', async () => {
const seenContexts: {
id: string
paneKey?: string
launchAgent?: string
env: Record<string, string>
}[] = []
handler.addEnvAugmenter((ctx) => {
seenContexts.push(ctx)
return {
OVERLAY_ID: ctx.paneKey ?? ctx.id
}
})
await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-context:0' },
launchAgent: 'pi'
})
await dispatcher.callRequest('pty.spawn', {})
const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record<string, string> }
expect(seenContexts[0]).toMatchObject({
id: 'pty-1',
paneKey: 'tab-context:0',
launchAgent: 'pi',
env: { ORCA_PANE_KEY: 'tab-context:0' }
})
expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined })
expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0')
expect(secondEnv.env.OVERLAY_ID).toBe('pty-2')
})
it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => {
const oldProcessValue = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode'
try {
handler.addEnvAugmenter((ctx) => ({
SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR,
SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR
}))
await dispatcher.callRequest('pty.spawn', {
env: {
OPENCODE_CONFIG_DIR: '/remote/renderer-opencode',
PI_CODING_AGENT_DIR: '/remote/pi'
}
})
} finally {
if (oldProcessValue === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = oldProcessValue
}
}
const spawnEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(spawnEnv.name).toBe('xterm-256color')
expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode')
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
})
it('applies identity defaults, then deletions, while preserving explicit TERM', async () => {
handler.addEnvAugmenter(() => ({
TERM: 'augmenter-term',
TERM_PROGRAM: 'augmenter-terminal',
ORCA_STALE_TEST_ENV: '/tmp/augmenter-stale'
}))
await dispatcher.callRequest('pty.spawn', {
env: {
TERM: 'screen-256color',
TERM_PROGRAM: 'renderer-terminal',
ORCA_STALE_TEST_ENV: '/tmp/renderer-stale'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
})
const spawnEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(spawnEnv.name).toBe('screen-256color')
expect(spawnEnv.env.TERM).toBe('screen-256color')
expect(spawnEnv.env.COLORTERM).toBe('truecolor')
expect(spawnEnv.env.FORCE_HYPERLINK).toBe('1')
expect(spawnEnv.env.TERM_PROGRAM).toBeUndefined()
expect(spawnEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('replaces an ambient TERM=dumb when no explicit TERM is supplied', async () => {
const previousTerm = process.env.TERM
process.env.TERM = 'dumb'
try {
await dispatcher.callRequest('pty.spawn', {})
} finally {
if (previousTerm === undefined) {
delete process.env.TERM
} else {
process.env.TERM = previousTerm
}
}
const spawnEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(spawnEnv.name).toBe('xterm-256color')
expect(spawnEnv.env.TERM).toBe('xterm-256color')
expect(spawnEnv.env.TERM_PROGRAM).toBe('Orca')
})
it('expands variables in PATH before spawning a Windows relay shell', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await dispatcher.callRequest('pty.spawn', {
env: {
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
PATH: '%orca_path_root%\\agy\\bin;C:\\Windows'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnEnv.env.PATH).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows')
})
it('uses the safe terminal default when TERM is deleted without a custom value', async () => {
await dispatcher.callRequest('pty.spawn', {
envToDelete: ['TERM']
})
const spawnEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(spawnEnv.name).toBe('xterm-256color')
expect(spawnEnv.env.TERM).toBe('xterm-256color')
})
it('admits default-cwd spawns through the worktree removal coordinator', async () => {
const finishCreation = vi.fn()
const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation)
handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn })
await dispatcher.callRequest('pty.spawn', {})
expect(beginWorktreePtySpawn).toHaveBeenCalledWith(expect.any(String))
expect(beginWorktreePtySpawn.mock.calls[0][0]).not.toBe('')
expect(finishCreation).toHaveBeenCalledTimes(1)
})
it('fences both sibling worktree identity and removing cwd with rollback', async () => {
const finishSiblingAdmission = vi.fn()
const beginWorktreePtySpawn = vi.fn((operationPath: string) => {
if (operationPath === '/repo/removing/nested') {
throw new Error('Remote worktree deletion already in progress')
}
return finishSiblingAdmission
})
handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn })
await expect(
dispatcher.callRequest('pty.spawn', {
cwd: '/repo/removing/nested',
env: { ORCA_WORKTREE_ID: 'repo-id::/repo/sibling' }
})
).rejects.toThrow('Remote worktree deletion already in progress')
expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([
'/repo/sibling',
'/repo/removing/nested'
])
expect(finishSiblingAdmission).toHaveBeenCalledOnce()
expect(mockPtySpawn).not.toHaveBeenCalled()
})
it('lets relay env augmenters resolve the original sequenced startup command hint', async () => {
handler.addEnvAugmenter((ctx) => ({
SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? ''
}))
await dispatcher.callRequest('pty.spawn', {
command: 'powershell wait-wrapper',
env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }
})
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume')
})
it.skipIf(process.platform === 'win32')(
'wraps bash spawns to restore overlay env after remote startup files',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const oldOrcaPi = process.env.ORCA_PI_CODING_AGENT_DIR
const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
delete process.env.ORCA_PI_CODING_AGENT_DIR
try {
if (!existsSync('/bin/bash')) {
return
}
handler.addEnvAugmenter(() => ({
OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
ORCA_OMP_STATUS_EXTENSION: '/remote/.omp/agent/extensions/orca-agent-status.ts'
}))
await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } })
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
if (oldOrcaPi === undefined) {
delete process.env.ORCA_PI_CODING_AGENT_DIR
} else {
process.env.ORCA_PI_CODING_AGENT_DIR = oldOrcaPi
}
}
const shellArgs = mockPtySpawn.mock.calls[0][1]
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile')
expect(shellArgs).toEqual(['--rcfile', rcfile])
expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode')
expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(readFileSync(rcfile, 'utf8')).toContain(
'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
)
expect(readFileSync(rcfile, 'utf8')).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(readFileSync(rcfile, 'utf8')).toContain('command omp --extension')
rmSync(homeDir, { recursive: true, force: true })
}
)
it('revive restores pane identity env alongside hook-server coordinates', async () => {
await dispatcher.callRequest('pty.spawn', {
cols: 90,
rows: 30,
cwd: '/tmp',
env: {
ORCA_PANE_KEY: 'tab-5:1',
ORCA_TAB_ID: 'tab-5',
ORCA_WORKTREE_ID: 'wt-5'
}
})
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
handler.addEnvAugmenter(() => ({
ORCA_AGENT_HOOK_PORT: '12345',
ORCA_AGENT_HOOK_TOKEN: 'abc-uuid'
}))
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
const oldStartupIdentity = process.env.ORCA_SHELL_STARTUP_IDENTITY
process.env.ORCA_SHELL_STARTUP_IDENTITY = '1'
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
if (oldStartupIdentity === undefined) {
delete process.env.ORCA_SHELL_STARTUP_IDENTITY
} else {
process.env.ORCA_SHELL_STARTUP_IDENTITY = oldStartupIdentity
}
killSpy.mockRestore()
}
expect(mockPtySpawn).toHaveBeenCalledTimes(1)
const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-5:1')
expect(callArgs.env.ORCA_TAB_ID).toBe('tab-5')
expect(callArgs.env.ORCA_WORKTREE_ID).toBe('wt-5')
expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345')
expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid')
expect(callArgs.env.TERM).toBe('xterm-256color')
expect(callArgs.env.TERM_PROGRAM).toBe('Orca')
expect(callArgs.env.ORCA_SHELL_READY_MARKER).toBe('0')
expect(callArgs.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('0')
})
it('fences both revived worktree identity and cwd with rollback', async () => {
const finishSiblingAdmission = vi.fn()
const beginWorktreePtySpawn = vi.fn((operationPath: string) => {
if (operationPath === '/repo/removing/nested') {
throw new Error('Remote worktree deletion already in progress')
}
return finishSiblingAdmission
})
handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn })
const state = JSON.stringify([
{
id: 'pty-7',
pid: process.pid,
cols: 80,
rows: 24,
cwd: '/repo/removing/nested',
worktreeId: 'repo-id::/repo/sibling'
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow(
'Remote worktree deletion already in progress'
)
} finally {
killSpy.mockRestore()
}
expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([
'/repo/sibling',
'/repo/removing/nested'
])
expect(finishSiblingAdmission).toHaveBeenCalledOnce()
expect(mockPtySpawn).not.toHaveBeenCalled()
})
it('applies the physical PTY cap to untrusted revive state', async () => {
const state = JSON.stringify(
Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => ({
id: `pty-${index + 1}`,
pid: process.pid,
cols: 80,
rows: 24,
cwd: '/repo',
worktreeId: 'repo-id::/repo'
}))
)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow(
'Maximum number of PTY sessions reached (50)'
)
} finally {
killSpy.mockRestore()
}
expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS)
expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS)
})
it('deduplicates concurrent revive requests for the same physical PTY id', async () => {
const state = JSON.stringify([
{
id: 'pty-7',
pid: process.pid,
cols: 80,
rows: 24,
cwd: '/repo',
worktreeId: 'repo-id::/repo'
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await Promise.all([
dispatcher.callRequest('pty.revive', { state }),
dispatcher.callRequest('pty.revive', { state })
])
} finally {
killSpy.mockRestore()
}
expect(mockPtySpawn).toHaveBeenCalledTimes(1)
expect(handler.activePtyCount).toBe(1)
})
it('revive preserves the credential guard chosen for an SSH agent terminal', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'claude'
})
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(true)
handler.dispose()
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(revivedEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(revivedEnv)).toContain('credential.interactive')
expect(Object.values(revivedEnv)).toContain('credential.guiPrompt')
})
it('revive treats legacy relay state as an ordinary unguarded terminal', async () => {
const savedTerminalPrompt = process.env.GIT_TERMINAL_PROMPT
const savedGcmInteractive = process.env.GCM_INTERACTIVE
delete process.env.GIT_TERMINAL_PROMPT
delete process.env.GCM_INTERACTIVE
const state = JSON.stringify([
{
id: 'pty-legacy',
pid: process.pid,
cols: 80,
rows: 24,
cwd: process.cwd()
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(revivedEnv.GCM_INTERACTIVE).toBeUndefined()
} finally {
killSpy.mockRestore()
if (savedTerminalPrompt === undefined) {
delete process.env.GIT_TERMINAL_PROMPT
} else {
process.env.GIT_TERMINAL_PROMPT = savedTerminalPrompt
}
if (savedGcmInteractive === undefined) {
delete process.env.GCM_INTERACTIVE
} else {
process.env.GCM_INTERACTIVE = savedGcmInteractive
}
}
})
it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => {
await dispatcher.callRequest('pty.spawn', {
env: { TERM: '' },
envToDelete: ['ORCA_STALE_TEST_ENV', '', 42]
})
const initialEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(initialEnv.name).toBe('xterm-256color')
expect(initialEnv.env.TERM).toBe('xterm-256color')
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
const [serialized] = JSON.parse(state) as {
explicitTerm?: string
envToDelete?: string[]
}[]
expect(serialized.explicitTerm).toBeUndefined()
expect(serialized.envToDelete).toEqual(['ORCA_STALE_TEST_ENV'])
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
handler.addEnvAugmenter(() => ({
ORCA_STALE_TEST_ENV: '/tmp/revived-stale'
}))
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(revivedEnv.name).toBe('xterm-256color')
expect(revivedEnv.env.TERM).toBe('xterm-256color')
expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('drops legacy empty explicit TERM metadata after revive', async () => {
const state = JSON.stringify([
{
id: 'pty-8',
pid: process.pid,
cols: 80,
rows: 24,
cwd: process.cwd(),
explicitTerm: '',
envToDelete: ['ORCA_STALE_TEST_ENV']
}
])
handler.addEnvAugmenter(() => ({
ORCA_STALE_TEST_ENV: '/tmp/legacy-empty-stale'
}))
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(revivedEnv.name).toBe('xterm-256color')
expect(revivedEnv.env.TERM).toBe('xterm-256color')
expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined()
const serializedState = (await dispatcher.callRequest('pty.serialize', {
ids: ['pty-8']
})) as string
const [serialized] = JSON.parse(serializedState) as {
explicitTerm?: string
envToDelete?: string[]
}[]
expect(serialized.explicitTerm).toBeUndefined()
expect(serialized.envToDelete).toEqual(['ORCA_STALE_TEST_ENV'])
})
it('preserves explicit TERM and env deletions through repeated revive cycles', async () => {
await dispatcher.callRequest('pty.spawn', {
env: { TERM: 'screen-256color' },
envToDelete: ['ORCA_STALE_TEST_ENV']
})
let state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
handler.addEnvAugmenter(() => ({
ORCA_STALE_TEST_ENV: '/tmp/first-revive'
}))
await dispatcher.callRequest('pty.revive', { state })
const firstRevivedEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(firstRevivedEnv.name).toBe('screen-256color')
expect(firstRevivedEnv.env.TERM).toBe('screen-256color')
expect(firstRevivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined()
state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)).toMatchObject([
{
explicitTerm: 'screen-256color',
envToDelete: ['ORCA_STALE_TEST_ENV']
}
])
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
handler.addEnvAugmenter(() => ({
ORCA_STALE_TEST_ENV: '/tmp/second-revive'
}))
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const secondRevivedEnv = mockPtySpawn.mock.calls[0][2] as {
name: string
env: Record<string, string>
}
expect(secondRevivedEnv.name).toBe('screen-256color')
expect(secondRevivedEnv.env.TERM).toBe('screen-256color')
expect(secondRevivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('revives legacy serialized entries with default TERM and no env deletions', async () => {
handler.addEnvAugmenter(() => ({
ORCA_STALE_TEST_ENV: '/tmp/legacy-stale'
}))
const state = JSON.stringify([
{
id: 'pty-7',
pid: process.pid,
cols: 80,
rows: 24,
cwd: process.cwd()
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(revivedEnv.env.TERM).toBe('xterm-256color')
expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBe('/tmp/legacy-stale')
})
it('revive preserves attach identity metadata without exporting hook identity env', async () => {
const oldPaneKey = process.env.ORCA_PANE_KEY
const oldTabId = process.env.ORCA_TAB_ID
delete process.env.ORCA_PANE_KEY
delete process.env.ORCA_TAB_ID
try {
await dispatcher.callRequest('pty.spawn', {
cols: 90,
rows: 30,
cwd: '/tmp',
env: { FOO: 'bar' },
paneKey: 'tab-5:leaf-5',
tabId: 'tab-5'
})
} finally {
if (oldPaneKey === undefined) {
delete process.env.ORCA_PANE_KEY
} else {
process.env.ORCA_PANE_KEY = oldPaneKey
}
if (oldTabId === undefined) {
delete process.env.ORCA_TAB_ID
} else {
process.env.ORCA_TAB_ID = oldTabId
}
}
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
delete process.env.ORCA_PANE_KEY
delete process.env.ORCA_TAB_ID
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
if (oldPaneKey === undefined) {
delete process.env.ORCA_PANE_KEY
} else {
process.env.ORCA_PANE_KEY = oldPaneKey
}
if (oldTabId === undefined) {
delete process.env.ORCA_TAB_ID
} else {
process.env.ORCA_TAB_ID = oldTabId
}
}
const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined()
expect(callArgs.env.ORCA_TAB_ID).toBeUndefined()
// Revive preserves the serialized pane identity, and for a caller that names no incarnation
// that identity is still the fence.
await expect(
dispatcher.callRequest('pty.attach', {
id: 'pty-1',
expectedPaneKey: 'tab-other:leaf',
expectedTabId: 'tab-other'
})
).rejects.toThrow(/identity mismatch/i)
})
it('invokes the exit listener with the spawn-time paneKey', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-2:1' }
})
expect(onExitCb).toBeDefined()
onExitCb!({ exitCode: 0 })
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }])
})
it('keeps immediate shutdown pending until onExit and invokes the exit listener once', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-shutdown:0' }
})
let settled = false
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
void shutdown.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
expect(exits).toEqual([])
expect(handler.activePtyCount).toBe(1)
onExitCb!({ exitCode: 0 })
await shutdown
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }])
expect(handler.activePtyCount).toBe(0)
})
it('physically stops only matching worktree PTYs before relay deletion', async () => {
let firstExit: ((evt: { exitCode: number }) => void) | undefined
let secondExit: ((evt: { exitCode: number }) => void) | undefined
const firstKill = vi.fn()
const secondKill = vi.fn()
mockPtySpawn
.mockReturnValueOnce({
...mockPtyInstance,
kill: firstKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
firstExit = cb
})
})
.mockReturnValueOnce({
...mockPtyInstance,
kill: secondKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
secondExit = cb
})
})
await dispatcher.callRequest('pty.spawn', {
cwd: '/repo',
env: { ORCA_WORKTREE_ID: 'repo-id::/repo' }
})
await dispatcher.callRequest('pty.spawn', {
cwd: '/sibling',
env: { ORCA_WORKTREE_ID: 'repo-id::/sibling' }
})
let settled = false
const shutdown = handler.shutdownForWorktreePath('/repo').finally(() => {
settled = true
})
await Promise.resolve()
expect(firstKill).toHaveBeenCalledWith('SIGKILL')
expect(secondKill).not.toHaveBeenCalled()
expect(settled).toBe(false)
expect(handler.activePtyCount).toBe(2)
firstExit?.({ exitCode: 137 })
await shutdown
expect(secondExit).toBeDefined()
expect(handler.activePtyCount).toBe(1)
})
it('rejects timed-out immediate shutdown while retaining the physical owner', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit')
await vi.advanceTimersByTimeAsync(IMMEDIATE_PTY_EXIT_TIMEOUT_MS)
await rejected
expect(mockKill).toHaveBeenCalledTimes(1)
expect(handler.activePtyCount).toBe(1)
const retry = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
expect(mockKill).toHaveBeenCalledTimes(1)
onExitCb!({ exitCode: 137 })
await retry
expect(handler.activePtyCount).toBe(0)
})
it('fences late creation and drains an admitted spawn before the disposal snapshot', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const admittedSpawn = dispatcher.callRequest('pty.spawn', {})
const dispose = handler.dispose()
await admittedSpawn
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(handler.activePtyCount).toBe(1)
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'PTY handler is shutting down'
)
const aliveSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await expect(
dispatcher.callRequest('pty.revive', {
state: JSON.stringify([
{ id: 'pty-late', pid: process.pid, cols: 80, rows: 24, cwd: '/tmp' }
])
})
).rejects.toThrow('PTY handler is shutting down')
} finally {
aliveSpy.mockRestore()
}
onExitCb!({ exitCode: 137 })
await dispose
expect(handler.activePtyCount).toBe(0)
expect(handler.dispose()).toBe(dispose)
})
it('retries a rejected force kill during dispose and waits for physical exit', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
let forceAttempts = 0
const mockKill = vi.fn((signal: string) => {
if (signal === 'SIGKILL' && forceAttempts++ === 0) {
throw new Error('transient dispose kill failure')
}
})
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
const dispose = handler.dispose()
await Promise.resolve()
expect(mockKill.mock.calls).toEqual([['SIGKILL']])
expect(handler.activePtyCount).toBe(1)
await vi.advanceTimersByTimeAsync(250)
expect(mockKill.mock.calls).toEqual([['SIGKILL'], ['SIGKILL']])
expect(handler.activePtyCount).toBe(1)
onExitCb!({ exitCode: 137 })
await dispose
expect(handler.activePtyCount).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('takes ownership when dispose overlaps a queued graceful force-kill retry', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
let forceAttempts = 0
const mockKill = vi.fn((signal: string) => {
if (signal === 'SIGKILL' && forceAttempts++ < 2) {
throw new Error('transient overlapping kill failure')
}
})
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
vi.advanceTimersByTime(5000)
expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
expect(vi.getTimerCount()).toBe(1)
const dispose = handler.dispose()
await Promise.resolve()
expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']])
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(250)
expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL'], ['SIGKILL']])
expect(handler.activePtyCount).toBe(1)
onExitCb!({ exitCode: 137 })
await dispose
expect(handler.activePtyCount).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => {
const mockKill = vi.fn()
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } })
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } })
expect(handler.activePtyCount).toBe(2)
const dispose = handler.dispose()
await Promise.resolve()
// Why: dispose uses SIGKILL (not SIGTERM) because the relay process is
// exiting. A SIGTERM-ignoring remote shell (editor with unsaved buffers,
// wedged process, uninterruptible sleep) would survive SIGTERM + immediate
// destroy() as an orphan on the remote host. SIGKILL is not ignorable.
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(handler.activePtyCount).toBe(2)
for (const onExit of onExitCallbacks) {
onExit({ exitCode: 137 })
}
await dispose
expect(exits).toEqual([
{ id: 'pty-1', paneKey: 'tab-dispose:0' },
{ id: 'pty-2', paneKey: 'tab-dispose:1' }
])
expect(handler.activePtyCount).toBe(0)
})
describe('onPtyPoolEmpty', () => {
it('fires once when natural exit drains the last PTY, never while one remains', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
expect(onExitCallbacks).toHaveLength(2)
onExitCallbacks[0]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when the dead-shell reap inside attach removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
const spawned = await spawnPty()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
// INVERTED with the reap above: proof of exit, not an unknown id.
await expect(
attachPty({
id: 'pty-1',
suppressReplayNotification: true,
exitProofSupported: true,
expectedIncarnationId: spawned.incarnationId
})
).rejects.toThrow(/SSH_PTY_EXITED/)
} finally {
aliveSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when dispose-for-shutdown removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
await handler.dispose({ waitForPhysicalExit: false })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when a spawn fails before the PTY ever reaches the pool', async () => {
// Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight
// would otherwise leave the relay believing it is still non-idle forever.
mockPtySpawn.mockImplementation(() => {
throw new Error('posix_spawnp failed')
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await expect(spawnPty()).rejects.toThrow()
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('stays silent when a failing creation settles while another is still admitted', async () => {
let spawnCall = 0
mockPtySpawn.mockImplementation(() => {
spawnCall += 1
if (spawnCall === 1) {
throw new Error('posix_spawnp failed')
}
return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
// Both admissions land before either creation resolves, so the failing one must not
// announce an empty pool while the surviving one still owns a shell.
const failing = spawnPty()
const succeeding = spawnPty()
expect(handler.pendingPtyCreationCount).toBe(2)
await expect(failing).rejects.toThrow()
await succeeding
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
const unsubscribe = handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
onExitCallbacks[0]({ exitCode: 0 })
expect(poolEmpty).toHaveBeenCalledTimes(1)
unsubscribe()
await spawnPty()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
})
describe('onPtyPoolActive', () => {
it('fires at admission, while the pool is still empty', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolCounts: number[] = []
handler.onPtyPoolActive(() => {
poolCounts.push(handler.activePtyCount)
})
const pending = spawnPty()
// The relay must learn it is non-idle here, not after the creation resolves.
expect(poolCounts).toEqual([0])
await pending
expect(poolCounts).toEqual([0, 1])
})
it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => {
await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' })
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
await handler.dispose({ waitForPhysicalExit: false })
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
handler.onPtyPoolActive(poolActive)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(1)
expect(poolActive).toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
const unsubscribe = handler.onPtyPoolActive(poolActive)
await spawnPty()
const callsWhileSubscribed = poolActive.mock.calls.length
expect(callsWhileSubscribed).toBeGreaterThan(0)
unsubscribe()
await spawnPty()
expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed)
})
})
it('counts a spawn admitted but not yet pooled as a pending creation', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
expect(handler.pendingPtyCreationCount).toBe(0)
const pending = spawnPty()
// The shell is already owned even though activePtyCount still reads zero.
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(1)
await pending
expect(handler.activePtyCount).toBe(1)
expect(handler.pendingPtyCreationCount).toBe(0)
})
})