mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(pty): respect host path and cleanup ownership
This commit is contained in:
@@ -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
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user