fix(pty): respect host path and cleanup ownership

This commit is contained in:
Brennan Benson
2026-08-01 06:44:01 -07:00
parent a9b9dfbe99
commit b7f31677c8
11 changed files with 478 additions and 72 deletions
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { resolvePaneSpawnReservationPathFlavor } from './pty-spawn-reservation-path-flavor'
describe('pane spawn reservation path flavor', () => {
it.each([
['Linux local', 'linux' as const, 'native', 'posix'],
['macOS local', 'darwin' as const, 'native', 'posix'],
['Windows local', 'win32' as const, 'native', 'windows'],
['WSL on Windows', 'win32' as const, 'wsl:Ubuntu', 'windows'],
['WSL from a non-Windows controller', 'linux' as const, 'wsl:Ubuntu', 'windows']
])('routes %s filesystem semantics', (_label, localPlatform, executionRuntime, expected) => {
expect(resolvePaneSpawnReservationPathFlavor({ executionRuntime, localPlatform })).toBe(
expected
)
})
it.each([
['direct SSH Linux', 'posix' as const, 'posix'],
['relay macOS', 'posix' as const, 'posix'],
['direct SSH Windows', 'windows' as const, 'windows'],
['relay Windows', 'windows' as const, 'windows']
])('uses authoritative %s filesystem semantics', (_label, remotePathFlavor, expected) => {
expect(
resolvePaneSpawnReservationPathFlavor({
connectionId: 'ssh-1',
executionRuntime: 'ssh',
remotePathFlavor,
localPlatform: 'linux'
})
).toBe(expected)
})
it('preserves paths when the remote platform is unknown', () => {
expect(
resolvePaneSpawnReservationPathFlavor({
connectionId: 'ssh-unknown',
executionRuntime: 'ssh',
localPlatform: 'win32'
})
).toBe('unknown')
})
})
@@ -0,0 +1,16 @@
import type { PaneSpawnReservationPathFlavor } from '../../shared/stable-pane-id'
export function resolvePaneSpawnReservationPathFlavor(args: {
connectionId?: string | null
executionRuntime: string
remotePathFlavor?: PaneSpawnReservationPathFlavor | null
localPlatform?: NodeJS.Platform
}): PaneSpawnReservationPathFlavor {
if (args.connectionId) {
return args.remotePathFlavor ?? 'unknown'
}
if (args.executionRuntime.startsWith('wsl:')) {
return 'windows'
}
return (args.localPlatform ?? process.platform) === 'win32' ? 'windows' : 'posix'
}
+185 -4
View File
@@ -484,7 +484,10 @@ describe('registerPtyHandlers', () => {
'ssh-reattach-fail',
'ssh-reattach-ok',
'ssh-runtime-env',
'ssh-generation-replacement'
'ssh-generation-replacement',
'ssh-adopted-stale',
'ssh-posix-paths',
'ssh-windows-paths'
]) {
unregisterSshPtyProvider(leakedConnectionId)
}
@@ -7927,6 +7930,9 @@ describe('registerPtyHandlers', () => {
preAllocateHandleForPty: vi.fn(() => 'term_stale_spawn'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
retireFreshPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
releaseRejectedPtyRegistrationFence: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
@@ -7955,10 +7961,80 @@ describe('registerPtyHandlers', () => {
})
).rejects.toThrow('tab_not_found')
expect(provider.shutdown).toHaveBeenCalledOnce()
expect(provider.shutdown).toHaveBeenCalledWith('stale-workspace-spawn', { immediate: true })
expect(runtime.retireFreshPtyRegistration).toHaveBeenCalledOnce()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('does not retire an adopted relay claim without isReattach on a stale fence', async () => {
type RuntimeSpawnController = {
spawn(args: Record<string, unknown>): Promise<{ id: string }>
}
const owner = {
claim: recoveredAgentClaim,
generation: 'generation-adopted-stale',
phase: 'live' as const,
ptyId: 'ssh:ssh-adopted-stale@@pty-adopted-stale',
surface: recoveredAgentSurface
}
const provider = createAgentClaimProvider({
spawn: vi.fn(async () => ({
id: owner.ptyId,
incarnationId: 'incarnation-adopted-stale',
agentSessionEnsure: { disposition: 'adopted' as const, owner }
}))
})
registerSshPtyProvider('ssh-adopted-stale', provider as never)
const store = {
persistPtyBinding: vi.fn(),
removePtyBindingIfMatches: vi.fn(),
removeSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
releaseRejectedPtyRegistrationFence: vi.fn(),
retireFreshPtyRegistration: vi.fn(),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const acceptWorkspaceInventory = vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
await expect(
(controller as unknown as RuntimeSpawnController).spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-adopted-stale',
worktreeId: recoveredAgentSurface.worktreeId,
tabId: recoveredAgentSurface.tabId,
leafId: recoveredAgentSurface.leafId,
agentSessionEnsure: {
claim: recoveredAgentClaim,
surface: recoveredAgentSurface
},
acceptWorkspaceInventory
})
).rejects.toThrow('tab_not_found')
expect(provider.shutdown).not.toHaveBeenCalled()
expect(runtime.retireFreshPtyRegistration).not.toHaveBeenCalled()
expect(store.removePtyBindingIfMatches).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
})
it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => {
const persistPtyBinding = vi.fn()
const onPtySpawnCommitted = vi.fn()
@@ -8009,6 +8085,7 @@ describe('registerPtyHandlers', () => {
const tabId = '11111111-1111-4111-8111-111111111111'
const leafId = '22222222-2222-4222-8222-222222222222'
const preAllocatedHandle = runtime.preAllocateHandleForPty('pty-early-exit')
await expect(
controller.spawn({
cols: 80,
@@ -8016,7 +8093,7 @@ describe('registerPtyHandlers', () => {
worktreeId: 'repo::/tmp/worktree',
tabId,
leafId,
preAllocatedHandle: 'term_early_exit',
preAllocatedHandle,
persistHostSessionBinding: true,
onPtySpawnCommitted
})
@@ -8031,6 +8108,7 @@ describe('registerPtyHandlers', () => {
ptysById: Map<string, { connected: boolean }>
wslDistroByPtyId: Map<string, string>
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
expect(internals.handleByPtyId.has('pty-early-exit')).toBe(false)
expect(internals.providerSequenceInitializedPtys.has('pty-early-exit')).toBe(false)
@@ -8038,6 +8116,9 @@ describe('registerPtyHandlers', () => {
expect(internals.ptysById.get('pty-early-exit')?.connected).not.toBe(true)
expect(internals.wslDistroByPtyId.has('pty-early-exit')).toBe(false)
expect(internals.earlyExitedPtyIncarnations.has('pty-early-exit')).toBe(false)
expect(internals.pendingPtyRegistrationIncarnations.has('pty-early-exit')).toBe(false)
expect(provider.shutdown).toHaveBeenCalledOnce()
expect(provider.shutdown).toHaveBeenCalledWith('pty-early-exit', { immediate: true })
clearProviderPtyState('pty-early-exit')
})
@@ -8400,6 +8481,104 @@ describe('registerPtyHandlers', () => {
])
})
posixOnlyIt('keeps POSIX leading-double-slash workspace paths case-distinct', async () => {
const resolvers: ((result: { id: string }) => void)[] = []
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolvers.push(resolve)
})
)
installDaemonTestProvider({ spawn: providerSpawn })
registerPtyHandlers(mainWindow as never)
const base = {
cols: 80,
rows: 24,
tabId: 'tab-posix-double-slash',
leafId: '99999999-9999-4999-8999-999999999999',
sessionId: 'double-slash-session'
}
const spawns = [
handlers.get('pty:spawn')!(null, { ...base, worktreeId: 'repo-1:://Server/Share' }),
handlers.get('pty:spawn')!(null, { ...base, worktreeId: 'repo-1:://server/share' })
] as Promise<{ id: string }>[]
await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(2))
resolvers.forEach((resolve, index) => resolve({ id: `posix-double-slash-${index + 1}` }))
await expect(Promise.all(spawns)).resolves.toHaveLength(2)
})
it('routes SSH path flavor into leading-double-slash reservation identity', async () => {
const resolvers: ((result: { id: string }) => void)[] = []
const provider = createAgentClaimProvider({
spawn: vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolvers.push(resolve)
})
)
})
Object.assign(provider, { getExecutionHostPathFlavor: () => 'posix' as const })
registerSshPtyProvider('ssh-posix-paths', provider as never)
registerPtyHandlers(mainWindow as never)
const base = {
cols: 80,
rows: 24,
connectionId: 'ssh-posix-paths',
tabId: 'tab-ssh-double-slash',
leafId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
sessionId: 'double-slash-session'
}
const spawns = [
handlers.get('pty:spawn')!(null, { ...base, worktreeId: 'repo-1:://Server/Share' }),
handlers.get('pty:spawn')!(null, { ...base, worktreeId: 'repo-1:://server/share' })
] as Promise<{ id: string }>[]
await vi.waitFor(() => expect(provider.spawn).toHaveBeenCalledTimes(2))
resolvers.forEach((resolve, index) =>
resolve({ id: `ssh:ssh-posix-paths@@double-slash-${index + 1}` })
)
await expect(Promise.all(spawns)).resolves.toHaveLength(2)
})
it('coalesces equivalent UNC workspace aliases only for a Windows SSH host', async () => {
let resolveSpawn!: (result: { id: string }) => void
const provider = createAgentClaimProvider({
spawn: vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
})
Object.assign(provider, { getExecutionHostPathFlavor: () => 'windows' as const })
registerSshPtyProvider('ssh-windows-paths', provider as never)
registerPtyHandlers(mainWindow as never)
const base = {
cols: 80,
rows: 24,
connectionId: 'ssh-windows-paths',
tabId: 'tab-windows-unc',
leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
sessionId: 'windows-unc-session'
}
const creator = handlers.get('pty:spawn')!(null, {
...base,
worktreeId: 'repo-1::\\\\Server\\Share\\Project'
}) as Promise<{ id: string; spawnDisposition: string }>
const waiter = handlers.get('pty:spawn')!(null, {
...base,
worktreeId: 'repo-1:://server/share/project'
}) as Promise<{ id: string; spawnDisposition: string }>
await vi.waitFor(() => expect(provider.spawn).toHaveBeenCalledOnce())
resolveSpawn({ id: 'ssh:ssh-windows-paths@@windows-unc' })
await expect(Promise.all([creator, waiter])).resolves.toEqual([
expect.objectContaining({ spawnDisposition: 'created' }),
expect.objectContaining({ spawnDisposition: 'awaited' })
])
})
it('keeps identical pane ids independent across local and SSH providers', async () => {
const createProvider = (id: string) => {
let resolveSpawn!: (result: { id: string }) => void
@@ -8600,7 +8779,9 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn().mockImplementationOnce(() => {
throw new Error('boom: runtime registration failed')
}),
retireFreshPtyRegistration: vi.fn(),
retireFreshPtyRegistration: vi.fn(() => {
throw new Error('nested runtime cleanup failed')
}),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -9460,7 +9641,7 @@ describe('registerPtyHandlers', () => {
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true })
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).toHaveBeenCalledWith('ssh-fresh-fail', 'relay-pty')
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
const internals = runtime as unknown as {
+136 -55
View File
@@ -84,6 +84,7 @@ import {
} from '../providers/ssh-pty-errors'
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { createPtySpawnTiming } from './pty-spawn-timing'
import { resolvePaneSpawnReservationPathFlavor } from './pty-spawn-reservation-path-flavor'
import {
isSafePtySessionId,
mintPtySessionId,
@@ -544,43 +545,72 @@ async function awaitPaneSpawnReservation(
return { ...(await reservation.promise), spawnDisposition: 'awaited' }
}
type FreshPtySetupRetirement = {
type CreatorOnlyPtyRetirementToken = {
retired: boolean
creator: boolean
result: PtySpawnResult
provider: IPtyProvider
connectionId: string | null
runtime?: OrcaRuntimeService
store?: Store
binding?: { worktreeId: string; tabId: string; leafId: string }
initialRegistrationPtyId?: string
provisionalProviderPtyId?: string
}
function createFreshPtySetupRetirement(args: Omit<FreshPtySetupRetirement, 'retired'>) {
return args.result.isReattach || args.result.agentSessionEnsure?.disposition === 'adopted'
? null
: { ...args, retired: false }
function createCreatorOnlyPtyRetirementToken(
args: Omit<CreatorOnlyPtyRetirementToken, 'creator' | 'retired'>
): CreatorOnlyPtyRetirementToken {
const claimDisposition = args.result.agentSessionEnsure?.disposition
return {
...args,
creator: claimDisposition ? claimDisposition === 'created' : args.result.isReattach !== true,
retired: false
}
}
async function retireFreshPtyAfterSetupFailure(
retirement: FreshPtySetupRetirement | null
retirement: CreatorOnlyPtyRetirementToken | null
): Promise<void> {
if (!retirement || retirement.retired) {
return
}
retirement.retired = true
const { result, provider, connectionId, runtime, store, binding } = retirement
try {
await provider.shutdown(result.id, { immediate: true })
} catch (error) {
console.warn('[pty] failed to retire PTY after spawn setup failure:', error)
const {
result,
provider,
connectionId,
runtime,
store,
binding,
initialRegistrationPtyId,
provisionalProviderPtyId
} = retirement
if (retirement.creator) {
await runPtyRetirementStep('retire provider PTY', () =>
provider.shutdown(result.id, { immediate: true })
)
await runPtyRetirementStep('roll back runtime PTY registration', () =>
runtime?.retireFreshPtyRegistration?.(result.id, result.incarnationId)
)
}
try {
runtime?.retireFreshPtyRegistration?.(result.id, result.incarnationId)
} catch (error) {
console.warn('[pty] failed to roll back runtime PTY registration:', error)
await runPtyRetirementStep('clear pending PTY registration', () =>
runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId)
)
await runPtyRetirementStep('clear rejected PTY registration fence', () =>
runtime?.releaseRejectedPtyRegistrationFence?.(result.id, result.incarnationId)
)
if (initialRegistrationPtyId && initialRegistrationPtyId !== result.id) {
await runPtyRetirementStep('clear provisional PTY registration', () =>
runtime?.cancelPendingPtyRegistration?.(initialRegistrationPtyId, result.incarnationId)
)
}
try {
if (!retirement.creator) {
return
}
await runPtyRetirementStep('roll back persisted PTY binding', () => {
if (binding) {
store?.removePtyBindingIfMatches(
store?.removePtyBindingIfMatches?.(
{
...binding,
ptyId: result.id,
@@ -589,14 +619,30 @@ async function retireFreshPtyAfterSetupFailure(
connectionId ? toSshExecutionHostId(connectionId) : null
)
}
})
await runPtyRetirementStep('roll back persisted PTY lease', () => {
if (connectionId) {
store?.removeSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, result.id))
store?.removeSshRemotePtyLease?.(connectionId, getRelayPtyId(connectionId, result.id))
}
})
await runPtyRetirementStep('clear provider PTY state', () => clearProviderPtyState(result.id))
if (provisionalProviderPtyId && provisionalProviderPtyId !== result.id) {
await runPtyRetirementStep('clear provisional provider PTY state', () =>
clearProviderPtyState(provisionalProviderPtyId)
)
}
await runPtyRetirementStep('clear PTY ownership', () => deletePtyOwnership(result.id))
}
async function runPtyRetirementStep(
description: string,
step: () => void | Promise<void>
): Promise<void> {
try {
await step()
} catch (error) {
console.warn('[pty] failed to roll back persisted PTY ownership:', error)
console.warn(`[pty] failed to ${description}:`, error)
}
clearProviderPtyState(result.id)
deletePtyOwnership(result.id)
}
function settlePendingPaneSerializer(paneKey: string, gen: number): boolean {
@@ -4140,16 +4186,22 @@ export function registerPtyHandlers(
const materializedPaneKey = hostSessionBinding
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
: null
const paneSpawnExecutionRuntime = getPaneSpawnExecutionRuntimeId({
connectionId: args.connectionId,
shellOverride: daemonShellOverride,
cwd,
wslDistro: expectedWslDistro
})
const paneSpawnReservationKey = materializedPaneKey
? makePaneSpawnReservationKey({
paneKey: materializedPaneKey,
providerId: getPaneSpawnProviderId(provider),
connectionId: args.connectionId,
executionRuntime: getPaneSpawnExecutionRuntimeId({
executionRuntime: paneSpawnExecutionRuntime,
pathFlavor: resolvePaneSpawnReservationPathFlavor({
connectionId: args.connectionId,
shellOverride: daemonShellOverride,
cwd,
wslDistro: expectedWslDistro
executionRuntime: paneSpawnExecutionRuntime,
remotePathFlavor: (provider as IPtyProvider).getExecutionHostPathFlavor?.()
}),
workspaceId: hostSessionBinding?.worktreeId,
sessionId: callerRequestedSessionId
@@ -4234,7 +4286,7 @@ export function registerPtyHandlers(
let result: PtySpawnResult
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let freshPtySetupRetirement: FreshPtySetupRetirement | null = null
let freshPtySetupRetirement: CreatorOnlyPtyRetirementToken | null = null
let preparedProvisionalExecutionContext = false
let releaseWorktreeSpawn: (() => void) | undefined
try {
@@ -4268,18 +4320,13 @@ export function registerPtyHandlers(
throw new Error('tab_not_found')
}
}
const rejectStaleWorkspaceSpawn = async (candidate: PtySpawnResult): Promise<void> => {
const rejectStaleWorkspaceSpawn = async (
retirement: CreatorOnlyPtyRetirementToken
): Promise<void> => {
if (args.acceptWorkspaceInventory?.() !== false) {
return
}
if (candidate.isReattach !== true) {
try {
await provider.shutdown(candidate.id, { immediate: true })
} catch (error) {
console.warn('[pty] failed to clean up stale workspace spawn:', error)
}
clearProviderPtyState(candidate.id)
}
await retireFreshPtyAfterSetupFailure(retirement)
throw new Error('tab_not_found')
}
if (args.agentSessionEnsure) {
@@ -4305,15 +4352,21 @@ export function registerPtyHandlers(
assertClientStillConnected()
assertWorkspaceInventoryAccepted()
providerResult = await provider.spawn(spawnOptions)
await rejectStaleWorkspaceSpawn(providerResult)
freshPtySetupRetirement = createFreshPtySetupRetirement({
freshPtySetupRetirement = createCreatorOnlyPtyRetirementToken({
result: providerResult,
provider,
connectionId: args.connectionId ?? null,
runtime,
store,
...(pendingRegistrationPtyId
? { initialRegistrationPtyId: pendingRegistrationPtyId }
: {}),
...(isMintedSessionId && sessionId
? { provisionalProviderPtyId: sessionId }
: {}),
...(spawnRetirementBinding ? { binding: spawnRetirementBinding } : {})
})
await rejectStaleWorkspaceSpawn(freshPtySetupRetirement)
rejectedRegistrationCandidate = providerResult
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
reportPtySpawnCommitted()
@@ -4361,15 +4414,19 @@ export function registerPtyHandlers(
assertClientStillConnected()
assertWorkspaceInventoryAccepted()
result = await provider.spawn(spawnOptions)
await rejectStaleWorkspaceSpawn(result)
freshPtySetupRetirement = createFreshPtySetupRetirement({
freshPtySetupRetirement = createCreatorOnlyPtyRetirementToken({
result,
provider,
connectionId: args.connectionId ?? null,
runtime,
store,
...(pendingRegistrationPtyId
? { initialRegistrationPtyId: pendingRegistrationPtyId }
: {}),
...(isMintedSessionId && sessionId ? { provisionalProviderPtyId: sessionId } : {}),
...(spawnRetirementBinding ? { binding: spawnRetirementBinding } : {})
})
await rejectStaleWorkspaceSpawn(freshPtySetupRetirement)
rejectedRegistrationCandidate = result
// Why: daemon/relay returns cross the physical commit boundary before controller admission.
reportPtySpawnCommitted()
@@ -4401,19 +4458,24 @@ export function registerPtyHandlers(
: result.wslDistro
)
} catch (err) {
await retireFreshPtyAfterSetupFailure(freshPtySetupRetirement)
if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) {
runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, {
resetIncarnation: true
})
}
const rawMessage = err instanceof Error ? err.message : String(err)
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
if (
!freshPtySetupRetirement &&
rawMessage === 'agent_session_exited_during_start' &&
rejectedRegistrationCandidate
) {
runtime?.releaseRejectedPtyRegistrationFence?.(
rejectedRegistrationCandidate.id,
rejectedRegistrationCandidate.incarnationId
)
}
if (pendingRegistrationPtyId) {
if (!freshPtySetupRetirement && pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
@@ -4423,7 +4485,7 @@ export function registerPtyHandlers(
const spawnError = normalizeNodePtySpawnError(err)
const isIdentityMismatch =
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
if (effectiveSessionAppId !== undefined) {
if (!freshPtySetupRetirement && effectiveSessionAppId !== undefined) {
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
} else {
@@ -4431,6 +4493,7 @@ export function registerPtyHandlers(
}
}
if (
!freshPtySetupRetirement &&
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
@@ -4444,7 +4507,7 @@ export function registerPtyHandlers(
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
}
if (isMintedSessionId && sessionId !== undefined) {
if (!freshPtySetupRetirement && isMintedSessionId && sessionId !== undefined) {
clearProviderPtyState(sessionId)
}
throw spawnError
@@ -4628,7 +4691,7 @@ export function registerPtyHandlers(
return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response)
} catch (err) {
await retireFreshPtyAfterSetupFailure(freshPtySetupRetirement)
if (pendingRegistrationPtyId) {
if (!freshPtySetupRetirement && pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
@@ -5224,16 +5287,22 @@ export function registerPtyHandlers(
const reservationPaneKey = metadataPaneKey ?? validatedPaneKey
const validatedLeafId = verifiedLeafId ?? metadataLeafId
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
const paneSpawnExecutionRuntime = getPaneSpawnExecutionRuntimeId({
connectionId: args.connectionId,
shellOverride: effectiveShellOverride,
cwd,
wslDistro: expectedWslDistro
})
const paneSpawnReservationKey = reservationPaneKey
? makePaneSpawnReservationKey({
paneKey: reservationPaneKey,
providerId: getPaneSpawnProviderId(provider),
connectionId: args.connectionId,
executionRuntime: getPaneSpawnExecutionRuntimeId({
executionRuntime: paneSpawnExecutionRuntime,
pathFlavor: resolvePaneSpawnReservationPathFlavor({
connectionId: args.connectionId,
shellOverride: effectiveShellOverride,
cwd,
wslDistro: expectedWslDistro
executionRuntime: paneSpawnExecutionRuntime,
remotePathFlavor: (provider as IPtyProvider).getExecutionHostPathFlavor?.()
}),
workspaceId: args.worktreeId,
sessionId: args.sessionId ? getRelayPtyId(args.connectionId, args.sessionId) : null
@@ -5483,7 +5552,7 @@ export function registerPtyHandlers(
let result: PtySpawnResult
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let freshPtySetupRetirement: FreshPtySetupRetirement | null = null
let freshPtySetupRetirement: CreatorOnlyPtyRetirementToken | null = null
let preparedProvisionalExecutionContext = false
let releaseWorktreeSpawn: (() => void) | undefined
try {
@@ -5509,12 +5578,18 @@ export function registerPtyHandlers(
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
freshPtySetupRetirement = createFreshPtySetupRetirement({
freshPtySetupRetirement = createCreatorOnlyPtyRetirementToken({
result,
provider,
connectionId: args.connectionId ?? null,
runtime,
store,
...(pendingRegistrationPtyId
? { initialRegistrationPtyId: pendingRegistrationPtyId }
: {}),
...(isMintedSessionId && effectiveSessionId
? { provisionalProviderPtyId: effectiveSessionId }
: {}),
...(spawnRetirementBinding ? { binding: spawnRetirementBinding } : {})
})
rejectedRegistrationCandidate = result
@@ -5544,6 +5619,7 @@ export function registerPtyHandlers(
)
spawnTiming.mark('provider_spawn')
} catch (err) {
await retireFreshPtyAfterSetupFailure(freshPtySetupRetirement)
if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) {
runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, {
resetIncarnation: true
@@ -5554,13 +5630,17 @@ export function registerPtyHandlers(
transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false)
}
const rawMessage = err instanceof Error ? err.message : String(err)
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
if (
!freshPtySetupRetirement &&
rawMessage === 'agent_session_exited_during_start' &&
rejectedRegistrationCandidate
) {
runtime?.releaseRejectedPtyRegistrationFence?.(
rejectedRegistrationCandidate.id,
rejectedRegistrationCandidate.incarnationId
)
}
if (pendingRegistrationPtyId) {
if (!freshPtySetupRetirement && pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
@@ -5570,7 +5650,7 @@ export function registerPtyHandlers(
const spawnError = normalizeNodePtySpawnError(err)
const isIdentityMismatch =
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
if (effectiveSessionAppId !== undefined) {
if (!freshPtySetupRetirement && effectiveSessionAppId !== undefined) {
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
} else {
@@ -5578,6 +5658,7 @@ export function registerPtyHandlers(
}
}
if (
!freshPtySetupRetirement &&
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
@@ -5593,7 +5674,7 @@ export function registerPtyHandlers(
}
}
// Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed.
if (isMintedSessionId && effectiveSessionId !== undefined) {
if (!freshPtySetupRetirement && isMintedSessionId && effectiveSessionId !== undefined) {
clearProviderPtyState(effectiveSessionId)
}
// Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary.
@@ -5873,7 +5954,7 @@ export function registerPtyHandlers(
return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response)
} catch (err) {
await retireFreshPtyAfterSetupFailure(freshPtySetupRetirement)
if (pendingRegistrationPtyId) {
if (!freshPtySetupRetirement && pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
@@ -10,6 +10,7 @@ import type {
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtyProcessInfo } from './pty-process-info'
import type { PaneSpawnReservationPathFlavor } from '../../shared/stable-pane-id'
export type {
PtyBackgroundStreamEvent,
@@ -96,6 +97,8 @@ type PtyProbeOptions = { signal?: AbortSignal }
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
/** Filesystem semantics of the host that executes this provider's PTYs. */
getExecutionHostPathFlavor?: () => PaneSpawnReservationPathFlavor | null
/** Whether this spawn target can append the Git guard after its final env merge. */
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
/** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */
@@ -1,4 +1,5 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
export type RemoteCliBridgeEnv = {
binDir: string
@@ -7,6 +8,7 @@ export type RemoteCliBridgeEnv = {
sockPath: string
credentialFile?: string
pathDelimiter?: ':' | ';'
hostPlatform?: RemoteHostPlatform
}
export type SshPtyDataCallback = (payload: {
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { SshPtyProvider } from './ssh-pty-provider'
function createMultiplexer() {
return {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn(() => false)
}
}
describe('SSH PTY reservation path flavor', () => {
it('uses relay platform detection when CLI bridge env is complete', () => {
const provider = new SshPtyProvider('conn-relay', createMultiplexer() as never, {
binDir: '/home/user/.orca-relay/bin',
relayDir: '/home/user/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/user/.orca-relay/relay.sock',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(provider.getExecutionHostPathFlavor()).toBe('posix')
})
it('uses direct SSH platform detection when CLI bridge env is incomplete', () => {
const provider = new SshPtyProvider(
'conn-direct',
createMultiplexer() as never,
undefined,
1,
'windows'
)
expect(provider.getExecutionHostPathFlavor()).toBe('windows')
})
})
+5 -1
View File
@@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
import type { PtyProcessInspection } from './pty-process-inspection'
import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors'
import type { RemotePathFlavor } from '../ssh/ssh-remote-platform'
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined {
@@ -43,7 +44,9 @@ export class SshPtyProvider implements IPtyProvider {
connectionId: string,
mux: SshChannelMultiplexer,
private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv,
readonly providerGeneration = 1
readonly providerGeneration = 1,
private readonly executionHostPathFlavor: RemotePathFlavor | null = remoteCliBridgeEnv
?.hostPlatform?.pathFlavor ?? null
) {
this.connectionId = connectionId
this.mux = mux
@@ -66,6 +69,7 @@ export class SshPtyProvider implements IPtyProvider {
}
getConnectionId = (): string => this.connectionId
getExecutionHostPathFlavor = (): RemotePathFlavor | null => this.executionHostPathFlavor
private toRelayPtyId = (id: string): string => toRelaySshPtyId(this.connectionId, id)
+2 -1
View File
@@ -727,7 +727,8 @@ export class SshRelaySession {
this.targetId,
mux,
this.remoteCliBridgeEnv ?? undefined,
providerGeneration
providerGeneration,
this.getHostPlatform()?.pathFlavor ?? null
)
const consumerOwnerState = this.negotiatedPtyConsumerOwner()
if (consumerOwnerState) {
+32 -5
View File
@@ -4,6 +4,7 @@ import {
isTerminalLeafId,
makePaneKey,
makePaneSpawnReservationKey,
type PaneSpawnReservationPathFlavor,
parseLegacyNumericPaneKey,
parsePaneKey
} from './stable-pane-id'
@@ -13,13 +14,18 @@ const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
function reservationKey(
workspaceId: string,
scope: { connectionId?: string; executionRuntime?: string } = {}
scope: {
connectionId?: string
executionRuntime?: string
pathFlavor?: PaneSpawnReservationPathFlavor
} = {}
) {
return makePaneSpawnReservationKey({
paneKey: PANE_KEY,
providerId: 'provider-1',
workspaceId,
sessionId: 'session-1',
pathFlavor: 'posix',
...scope
})
}
@@ -73,13 +79,24 @@ describe('stable pane ids', () => {
it.each([
['local', { executionRuntime: 'native' }],
['SSH', { connectionId: 'ssh-1' }]
])('keeps NFC and NFD POSIX workspace paths distinct on %s', (_label, scope) => {
['SSH', { connectionId: 'ssh-1' }],
['unknown SSH', { connectionId: 'ssh-unknown', pathFlavor: 'unknown' as const }]
])('keeps NFC and NFD workspace paths distinct on %s', (_label, scope) => {
expect(reservationKey('repo-1::/workspaces/café', scope)).not.toBe(
reservationKey('repo-1::/workspaces/cafe\u0301', scope)
)
})
it.each([
['POSIX', { executionRuntime: 'native', pathFlavor: 'posix' as const }],
['SSH POSIX', { connectionId: 'ssh-1', pathFlavor: 'posix' as const }],
['unknown remote', { connectionId: 'ssh-unknown', pathFlavor: 'unknown' as const }]
])('keeps leading-double-slash path case byte-exact on %s', (_label, scope) => {
expect(reservationKey('repo-1:://Server/Share', scope)).not.toBe(
reservationKey('repo-1:://server/share', scope)
)
})
it.each([
['drive', 'repo-1::C:\\Work\\Project\\', 'repo-1::c:/work/project'],
['UNC', 'repo-1::\\\\Server\\Share\\Project\\', 'repo-1:://server/share/project'],
@@ -89,7 +106,15 @@ describe('stable pane ids', () => {
'repo-1:://wsl$/ubuntu/home/Project'
]
])('canonicalizes equivalent Windows %s paths', (_label, left, right) => {
expect(reservationKey(left)).toBe(reservationKey(right))
expect(reservationKey(left, { pathFlavor: 'windows' })).toBe(
reservationKey(right, { pathFlavor: 'windows' })
)
})
it('fails safe for Windows-looking paths on an unknown host', () => {
expect(reservationKey('repo-1::C:\\Work\\Project', { pathFlavor: 'unknown' })).not.toBe(
reservationKey('repo-1::c:/work/project', { pathFlavor: 'unknown' })
)
})
it('canonicalizes raw and worktree-scoped aliases', () => {
@@ -104,6 +129,7 @@ describe('stable pane ids', () => {
providerId: 'provider-1',
connectionId: 'ssh-1',
executionRuntime: 'wsl:Ubuntu',
pathFlavor: 'windows' as const,
workspaceId: 'worktree:repo-1::/workspaces/project',
sessionId: 'session-1'
}
@@ -125,7 +151,8 @@ describe('stable pane ids', () => {
expect(
makePaneSpawnReservationKey({
paneKey: makePaneKey('tab-1', LEAF_ID),
providerId: 'provider-1'
providerId: 'provider-1',
pathFlavor: 'unknown'
})
).toBeNull()
})
+16 -6
View File
@@ -18,6 +18,7 @@ export type PaneKey = string & { readonly [paneKeyBrand]: true }
export type PaneSpawnReservationKey = string & {
readonly [paneSpawnReservationKeyBrand]: true
}
export type PaneSpawnReservationPathFlavor = 'posix' | 'windows' | 'unknown'
export function isStablePaneId(value: string): value is StablePaneId {
return UUID_RE.test(value)
@@ -37,9 +38,12 @@ export function makePaneKey(tabId: string, stableLeafId: string): PaneKey {
return `${tabId}:${stableLeafId}` as PaneKey
}
function canonicalPaneReservationWorkspacePath(worktreePath: string): string {
// Why: POSIX and SSH paths can be byte-sensitive; fold only syntactically proven Windows paths.
if (!isWindowsAbsolutePathLike(worktreePath)) {
function canonicalPaneReservationWorkspacePath(
worktreePath: string,
pathFlavor: PaneSpawnReservationPathFlavor
): string {
// Why: leading // is valid on POSIX; syntax alone cannot prove Windows ownership.
if (pathFlavor !== 'windows' || !isWindowsAbsolutePathLike(worktreePath)) {
return worktreePath
}
const normalized = normalizeRuntimePathSeparators(worktreePath)
@@ -48,7 +52,10 @@ function canonicalPaneReservationWorkspacePath(worktreePath: string): string {
return wslUnc ? `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}` : trimmed.toLowerCase()
}
function canonicalWorkspaceIdentity(workspaceId: string): readonly string[] | null {
function canonicalWorkspaceIdentity(
workspaceId: string,
pathFlavor: PaneSpawnReservationPathFlavor
): readonly string[] | null {
if (!workspaceId.trim() || workspaceId.length > 1024) {
return null
}
@@ -62,7 +69,7 @@ function canonicalWorkspaceIdentity(workspaceId: string): readonly string[] | nu
: workspaceId
const parsed = splitWorktreeId(unscopedId)
return parsed
? [kind, parsed.repoId, canonicalPaneReservationWorkspacePath(parsed.worktreePath)]
? [kind, parsed.repoId, canonicalPaneReservationWorkspacePath(parsed.worktreePath, pathFlavor)]
: [kind, unscopedId]
}
@@ -71,10 +78,13 @@ export function makePaneSpawnReservationKey(args: {
providerId: string
connectionId?: string | null
executionRuntime?: string | null
pathFlavor: PaneSpawnReservationPathFlavor
workspaceId?: string
sessionId?: string | null
}): PaneSpawnReservationKey | null {
const workspace = args.workspaceId ? canonicalWorkspaceIdentity(args.workspaceId) : null
const workspace = args.workspaceId
? canonicalWorkspaceIdentity(args.workspaceId, args.pathFlavor)
: null
const providerId = args.providerId.trim()
const connectionId = args.connectionId?.trim() || null
const executionRuntime = args.executionRuntime?.trim() || null