mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
* fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) Two residuals left behind by #15236 and #15258. Relay revive called resolveDefaultShell() and ignored the entry's shell override, so a restarted relay handed the user a PowerShell pane where a WSL one had been -- and with it the host default shell's history rather than the worktree-scoped one #15236 injects on spawn. The override and the requested distro are now serialized (optional fields; state from an older relay still revives the default shell) and re-resolved through the same allowlist a fresh spawn uses, so revive re-launches the same shell with the same guest-visible HISTFILE and the same WSLENV carrier. A bad override degrades that one pane instead of failing the whole batch. The wrapper's three `$(emulate)` probes each fork a zsh carrying everything the user's config has loaded, and since wrapping widened to every zsh pane every pane pays for them. Each probe now sits behind a fork-free option test that is true whenever `emulate sh`/`emulate ksh` has run; the exact probe still runs when that test passes, so no answer changes. Measured on zsh 5.9 / macOS: 9.97 ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped after -- about half the wrapper's cost. * fix(shell): harden the revive override path and silence the option probe on an exotic zsh Readiness-review follow-ups on this branch. - `[[ -o <name> ]]` prints "no such option" to stderr and returns false rather than aborting, so a zsh lacking one of the three Bourne option names would put that text in the user's pane. All three predate every supported zsh, so `2>/dev/null` is belt and braces -- but the belt costs nothing. - Bound the WSL distro name revive replays into `wsl.exe -d <name>`. It is the one field this branch newly routes from untrusted serialized state into argv, and reviveEntry's stated job is to re-apply fresh-spawn bounds. - Skip a pane whose overridden shell can no longer spawn instead of letting the throw escape the revive loop and cost every later entry its state. Skipping, not falling back to the host default shell: substituting a different shell is the defect the override exists to fix, and the args and history env are built for the shell that is gone. The worktree-removal fence throws before this, outside reviveEntry, so it stays a hard failure.
652 lines
22 KiB
TypeScript
652 lines
22 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { existsSync, rmSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { hashWorktreeId } from '../main/terminal-history-id'
|
|
|
|
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 { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler'
|
|
import type { RelayDispatcher } from './dispatcher'
|
|
import {
|
|
beginPtyHandlerTest,
|
|
createMockDispatcher,
|
|
endPtyHandlerTest
|
|
} from './pty-handler-test-harness'
|
|
import type { MockDispatcher } from './pty-handler-test-harness'
|
|
|
|
describe('PtyHandler', () => {
|
|
let dispatcher: MockDispatcher
|
|
let handler: PtyHandler
|
|
let originalPlatform: PropertyDescriptor | undefined
|
|
|
|
beforeEach(() => {
|
|
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
|
|
mockPtySpawn,
|
|
mockPtyInstance,
|
|
mockCreateShellPromptReadinessProbe
|
|
}))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await endPtyHandlerTest(handler, originalPlatform)
|
|
})
|
|
|
|
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)
|
|
// Why seeded: a revived pane must not inherit a feature selection from the
|
|
// relay process's own environment.
|
|
const oldShellFeatures = process.env.ORCA_SHELL_FEATURES
|
|
process.env.ORCA_SHELL_FEATURES = 'ready,identity,markers,overlay'
|
|
try {
|
|
await dispatcher.callRequest('pty.revive', { state })
|
|
} finally {
|
|
if (oldShellFeatures === undefined) {
|
|
delete process.env.ORCA_SHELL_FEATURES
|
|
} else {
|
|
process.env.ORCA_SHELL_FEATURES = oldShellFeatures
|
|
}
|
|
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_FEATURES).not.toContain('ready')
|
|
expect(callArgs.env.ORCA_SHELL_FEATURES).not.toContain('identity')
|
|
})
|
|
|
|
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()
|
|
|
|
await expect(
|
|
dispatcher.callRequest('pty.attach', {
|
|
id: 'pty-1',
|
|
expectedPaneKey: 'tab-other:leaf',
|
|
expectedTabId: 'tab-other'
|
|
})
|
|
).rejects.toThrow('PTY "pty-1" not found')
|
|
})
|
|
|
|
describe('a Windows relay reviving a WSL pane', () => {
|
|
const worktreeId = 'r::/remote/wsl-worktree'
|
|
const historyFile = join(
|
|
homedir(),
|
|
'.orca-remote',
|
|
'terminal-history',
|
|
`${hashWorktreeId(worktreeId)}-bash_history`
|
|
)
|
|
let previousPlatform: PropertyDescriptor | undefined
|
|
|
|
beforeEach(() => {
|
|
previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (previousPlatform) {
|
|
Object.defineProperty(process, 'platform', previousPlatform)
|
|
}
|
|
rmSync(historyFile, { force: true })
|
|
})
|
|
|
|
/** Spawn a WSL pane, serialize it, then revive it into a fresh handler. */
|
|
async function reviveWslPane(): Promise<void> {
|
|
await dispatcher.callRequest('pty.spawn', {
|
|
cols: 80,
|
|
rows: 24,
|
|
shellOverride: 'wsl.exe',
|
|
terminalWindowsWslDistro: 'Ubuntu',
|
|
worktreeId,
|
|
historyIsolationEnabled: true
|
|
})
|
|
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
|
|
|
|
await handler.dispose({ waitForPhysicalExit: false })
|
|
mockPtySpawn.mockClear()
|
|
rmSync(historyFile, { force: true })
|
|
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()
|
|
}
|
|
}
|
|
|
|
// The #15236 gap: revive called resolveDefaultShell() and ignored the
|
|
// entry's override, so a restarted relay silently handed the user a
|
|
// PowerShell pane where a WSL one had been.
|
|
it('relaunches wsl.exe in the pane\u2019s own distro instead of the host default shell', async () => {
|
|
await reviveWslPane()
|
|
|
|
const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]]
|
|
expect(shell).toBe('wsl.exe')
|
|
expect(args).toEqual(['-d', 'Ubuntu'])
|
|
})
|
|
|
|
it('scopes HISTFILE past the wsl.exe wrapper and carries it over WSLENV', async () => {
|
|
await reviveWslPane()
|
|
|
|
const spawnEnv = mockPtySpawn.mock.calls[0][2]?.env as Record<string, string>
|
|
expect(spawnEnv.HISTFILE?.endsWith(`${hashWorktreeId(worktreeId)}-bash_history`)).toBe(true)
|
|
expect(spawnEnv.WSLENV?.split(':')).toContain('HISTFILE')
|
|
// Deletion is unchanged because the file still lives on the relay host.
|
|
expect(existsSync(historyFile)).toBe(true)
|
|
})
|
|
|
|
// Why: a revived pane is serialized again on the next restart, so dropping
|
|
// the override there is the same defect one reconnect later.
|
|
it('keeps the override across a second serialize/revive round trip', async () => {
|
|
await reviveWslPane()
|
|
|
|
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
|
|
|
|
expect(JSON.parse(state)[0]).toMatchObject({
|
|
shellOverride: 'wsl.exe',
|
|
terminalWindowsWslDistro: 'Ubuntu'
|
|
})
|
|
})
|
|
|
|
// Why: this field is replayed from state the relay hands a client and takes
|
|
// back unvalidated, and it lands in argv.
|
|
it('drops an over-long distro name rather than passing it to wsl.exe', async () => {
|
|
const state = JSON.stringify([
|
|
{
|
|
id: 'pty-11',
|
|
pid: process.pid,
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: 'C:\\repo',
|
|
shellOverride: 'wsl.exe',
|
|
terminalWindowsWslDistro: 'U'.repeat(257)
|
|
}
|
|
])
|
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
try {
|
|
await dispatcher.callRequest('pty.revive', { state })
|
|
} finally {
|
|
killSpy.mockRestore()
|
|
}
|
|
|
|
const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]]
|
|
expect(shell).toBe('wsl.exe')
|
|
expect(args).toEqual([])
|
|
})
|
|
|
|
// Why: the override's whole point is that the pane keeps its own shell, so a
|
|
// shell that no longer exists must cost that pane and nothing else.
|
|
it('skips a pane whose overridden shell can no longer spawn, keeping the batch', async () => {
|
|
const state = JSON.stringify([
|
|
{
|
|
id: 'pty-12',
|
|
pid: process.pid,
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: 'C:\\repo',
|
|
shellOverride: 'wsl.exe'
|
|
},
|
|
{ id: 'pty-13', pid: process.pid, cols: 80, rows: 24, cwd: 'C:\\repo' }
|
|
])
|
|
mockPtySpawn.mockImplementationOnce(() => {
|
|
throw new Error('spawn wsl.exe ENOENT')
|
|
})
|
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
try {
|
|
await dispatcher.callRequest('pty.revive', { state })
|
|
} finally {
|
|
killSpy.mockRestore()
|
|
}
|
|
|
|
// The second entry still revived, and no other shell stood in for the first.
|
|
expect(mockPtySpawn).toHaveBeenCalledTimes(2)
|
|
const live = (await dispatcher.callRequest('pty.serialize', {
|
|
ids: ['pty-12', 'pty-13']
|
|
})) as string
|
|
expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-13'])
|
|
})
|
|
|
|
it('degrades one entry with an unsupported override without failing the batch', async () => {
|
|
const state = JSON.stringify([
|
|
{
|
|
id: 'pty-9',
|
|
pid: process.pid,
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: 'C:\\repo',
|
|
shellOverride: 'nc.exe'
|
|
},
|
|
{ id: 'pty-10', pid: process.pid, cols: 80, rows: 24, cwd: 'C:\\repo' }
|
|
])
|
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
try {
|
|
await dispatcher.callRequest('pty.revive', { state })
|
|
} finally {
|
|
killSpy.mockRestore()
|
|
}
|
|
|
|
expect(mockPtySpawn).toHaveBeenCalledTimes(2)
|
|
expect(mockPtySpawn.mock.calls.map(([shell]) => shell)).not.toContain('nc.exe')
|
|
})
|
|
})
|
|
})
|