diff --git a/src/main/ipc/pty-spawn-reservation-path-flavor.test.ts b/src/main/ipc/pty-spawn-reservation-path-flavor.test.ts new file mode 100644 index 00000000000..c02c8235428 --- /dev/null +++ b/src/main/ipc/pty-spawn-reservation-path-flavor.test.ts @@ -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') + }) +}) diff --git a/src/main/ipc/pty-spawn-reservation-path-flavor.ts b/src/main/ipc/pty-spawn-reservation-path-flavor.ts new file mode 100644 index 00000000000..54c201a7c5f --- /dev/null +++ b/src/main/ipc/pty-spawn-reservation-path-flavor.ts @@ -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' +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index eab2bfadc66..7f64d36b2c6 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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): 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 wslDistroByPtyId: Map earlyExitedPtyIncarnations: Map + pendingPtyRegistrationIncarnations: Map } 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 { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a067e65e5f8..7da9db7f14d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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) { - return args.result.isReattach || args.result.agentSessionEnsure?.disposition === 'adopted' - ? null - : { ...args, retired: false } +function createCreatorOnlyPtyRetirementToken( + args: Omit +): 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 { 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 +): Promise { + 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 => { + const rejectStaleWorkspaceSpawn = async ( + retirement: CreatorOnlyPtyRetirementToken + ): Promise => { 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 diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index f305f1e85b7..4a5aac71f56 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -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 + /** 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. */ diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts index 18c21bdb754..aaf4a0a3522 100644 --- a/src/main/providers/ssh-pty-provider-contract.ts +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -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: { diff --git a/src/main/providers/ssh-pty-provider-path-flavor.test.ts b/src/main/providers/ssh-pty-provider-path-flavor.test.ts new file mode 100644 index 00000000000..cc6381a4710 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-path-flavor.test.ts @@ -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') + }) +}) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index d0b29398f1f..fe249eae504 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d5dba94c80b..ba36e18c017 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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) { diff --git a/src/shared/stable-pane-id.test.ts b/src/shared/stable-pane-id.test.ts index 0336ef696a4..2221840ba46 100644 --- a/src/shared/stable-pane-id.test.ts +++ b/src/shared/stable-pane-id.test.ts @@ -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() }) diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts index 858d6c95ffb..78bc8601c51 100644 --- a/src/shared/stable-pane-id.ts +++ b/src/shared/stable-pane-id.ts @@ -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