Files
orca/src/shared/ssh-pty-id.ts
Neil 08c7152ab6 fix(ssh): compare a lease's relay pty id against the pane's app id (#17969)
`getRecentExpiredSshLease` compared the stored lease ptyId (relay form,
written through `toStoredPtyId` -> `toRelaySshPtyId`) raw against the
runtime's app-form `pty.ptyId`, so `'pty-3' === 'ssh:target@@pty-3'` never
held and `recoverTerminalPane` refused every real SSH pane. Normalize with
the same tolerant helper the binding reader already uses, now shared as
`toComparableRelaySshPtyId`.

Switching the path on is only safe on top of #17957 (respawn gated on the
runtime liveness verdict), #17965 (`expired` no longer withdraws bindings)
and #17966 (supersession and id recycling carry their own marks).
`recoverTerminalPane` additionally refuses a lease those marks disqualify,
so it acts only on an `expired` lease that means "reattach gave up".

The path's outcome is a reattach, not a respawn: `createTerminal` calls
`adoptStablePane` first, which attaches attach-only to the retained
binding and only falls through to a fresh shell once the host itself
answers that the PTY is absent.
2026-09-02 21:48:17 -07:00

81 lines
2.8 KiB
TypeScript

import { parseExecutionHostId } from './execution-host'
const SSH_PTY_ID_PREFIX = 'ssh:'
const SSH_PTY_ID_SEPARATOR = '@@'
// Why: reconnect/restore paths sometimes hand these routers the execution-host
// id form ("ssh:<targetId>", from a workspace `hostId`) instead of the bare SSH
// target id that app PTY ids embed. Both name the same connection, so collapse
// to the bare id before comparing/encoding — otherwise a valid reattach throws a
// spurious "belongs to SSH connection" error at the user.
function normalizeConnectionId(connectionId: string): string {
const parsed = parseExecutionHostId(connectionId)
return parsed?.kind === 'ssh' ? parsed.targetId : connectionId
}
// Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing
// needs the target id embedded so two relays cannot collide after restore.
export type ParsedSshPtyId = {
connectionId: string
relayPtyId: string
}
export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null {
if (!ptyId.startsWith(SSH_PTY_ID_PREFIX)) {
return null
}
const separatorIndex = ptyId.indexOf(SSH_PTY_ID_SEPARATOR, SSH_PTY_ID_PREFIX.length)
if (separatorIndex === -1) {
return null
}
const encodedConnectionId = ptyId.slice(SSH_PTY_ID_PREFIX.length, separatorIndex)
const relayPtyId = ptyId.slice(separatorIndex + SSH_PTY_ID_SEPARATOR.length)
if (!encodedConnectionId || !relayPtyId) {
return null
}
try {
return {
connectionId: decodeURIComponent(encodedConnectionId),
relayPtyId
}
} catch {
return null
}
}
export function toAppSshPtyId(connectionId: string, relayPtyId: string): string {
const normalizedConnectionId = normalizeConnectionId(connectionId)
const parsed = parseAppSshPtyId(relayPtyId)
if (parsed) {
if (parsed.connectionId !== normalizedConnectionId) {
throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`)
}
return relayPtyId
}
return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(normalizedConnectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}`
}
export function toRelaySshPtyId(connectionId: string, ptyId: string): string {
const parsed = parseAppSshPtyId(ptyId)
if (!parsed) {
return ptyId
}
if (parsed.connectionId !== normalizeConnectionId(connectionId)) {
throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`)
}
return parsed.relayPtyId
}
/**
* Relay form for COMPARING an id against a stored SSH lease or binding, which are written in relay
* form. Unlike `toRelaySshPtyId` this never throws: an id naming a different target is simply not
* this target's pty, and a reader asking "is this the same pty?" wants `false`, not an exception.
*/
export function toComparableRelaySshPtyId(connectionId: string, ptyId: string): string {
try {
return toRelaySshPtyId(connectionId, ptyId)
} catch {
return ptyId
}
}