mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(ssh): expose unverifiable PTY liveness
Track a PTY whose verification lost transport without classifying it live or exited. Keep provider routing ownership, but return null through the existing tri-state liveness probe until a later inventory or exit supplies evidence.
This commit is contained in:
@@ -7754,6 +7754,9 @@ export function registerPtyHandlers(
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (provider.probePtyLiveness) {
|
||||
return await provider.probePtyLiveness(args.id)
|
||||
}
|
||||
return provider.hasPty(args.id)
|
||||
} catch {
|
||||
// Why: liveness is only allowed to close panes on an authoritative false.
|
||||
|
||||
@@ -34,6 +34,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
private mux: SshChannelMultiplexer
|
||||
private connectionId: string
|
||||
private livePtyIds = new Set<string>()
|
||||
private unverifiablePtyIds = new Set<string>()
|
||||
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
|
||||
private readonly agentSessionCapabilities: SshAgentSessionCapabilities
|
||||
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
|
||||
@@ -58,6 +59,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
toAppPtyId: (id) => this.toAppPtyId(id),
|
||||
livePtyIds: this.livePtyIds,
|
||||
recordExit: (relayPtyId, incarnationId) => {
|
||||
this.unverifiablePtyIds.delete(this.toAppPtyId(relayPtyId))
|
||||
this.spawnExitRaces.recordExit(relayPtyId, incarnationId)
|
||||
}
|
||||
})
|
||||
@@ -66,6 +68,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
dispose(): void {
|
||||
this.outputState.dispose()
|
||||
this.livePtyIds.clear()
|
||||
this.unverifiablePtyIds.clear()
|
||||
}
|
||||
|
||||
getConnectionId = (): string => this.connectionId
|
||||
@@ -100,7 +103,8 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
this.outputState.installReceivingActivation(relayPtyId, activation),
|
||||
rememberPtyIncarnation: (relayPtyId, incarnationId) =>
|
||||
this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId),
|
||||
acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId)
|
||||
acceptLivePty: (relayPtyId) => this.acceptLivePty(relayPtyId),
|
||||
acceptUnverifiablePty: (relayPtyId) => this.acceptUnverifiablePty(relayPtyId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +131,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
this.outputState.installReceivingActivation(id, activation),
|
||||
rememberPtyIncarnation: (id, incarnation) =>
|
||||
this.outputState.rememberPtyIncarnation(id, incarnation),
|
||||
acceptLivePty: (id) => this.livePtyIds.add(id),
|
||||
acceptLivePty: (id) => this.acceptLivePty(id),
|
||||
toAppPtyId: this.toAppPtyId
|
||||
})
|
||||
}
|
||||
@@ -215,6 +219,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
relayTimeoutOptions(opts.deadlineMs)
|
||||
)
|
||||
this.livePtyIds.delete(id)
|
||||
this.unverifiablePtyIds.delete(id)
|
||||
}
|
||||
|
||||
async sendSignal(id: string, signal: string): Promise<void> {
|
||||
@@ -281,14 +286,28 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
)
|
||||
const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
|
||||
for (const process of processes) {
|
||||
this.livePtyIds.add(process.id)
|
||||
this.acceptLivePty(process.id)
|
||||
const relayPtyId = this.toRelayPtyId(process.id)
|
||||
this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId)
|
||||
}
|
||||
return processes
|
||||
}
|
||||
|
||||
hasPty = (id: string): boolean => this.livePtyIds.has(id)
|
||||
// `hasPty` retains routing ownership; liveness callers must use the tri-state probe.
|
||||
hasPty = (id: string): boolean => this.livePtyIds.has(id) || this.unverifiablePtyIds.has(id)
|
||||
|
||||
probePtyLiveness = async (id: string): Promise<boolean | null> =>
|
||||
this.unverifiablePtyIds.has(id) ? null : this.livePtyIds.has(id)
|
||||
|
||||
private acceptLivePty(id: string): void {
|
||||
this.unverifiablePtyIds.delete(id)
|
||||
this.livePtyIds.add(id)
|
||||
}
|
||||
|
||||
private acceptUnverifiablePty(id: string): void {
|
||||
this.livePtyIds.delete(id)
|
||||
this.unverifiablePtyIds.add(id)
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
const result = await this.mux.request('pty.getDefaultShell')
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('SSH PTY reattach when the relay requires source restoration', () => {
|
||||
expect(message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
|
||||
expect(message).toContain(SSH_PTY_RESTORE_REQUIRED_ERROR)
|
||||
expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(true)
|
||||
await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports unverifiable when the fresh delivery attach loses contact', async () => {
|
||||
@@ -84,7 +85,8 @@ describe('SSH PTY reattach when the relay requires source restoration', () => {
|
||||
|
||||
expect(message).toContain(SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR)
|
||||
expect(message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
|
||||
expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false)
|
||||
expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(true)
|
||||
await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBeNull()
|
||||
expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -99,6 +101,7 @@ describe('SSH PTY reattach when the relay requires source restoration', () => {
|
||||
|
||||
expect(message).toContain(`${SSH_SESSION_EXPIRED_ERROR}: pty-old`)
|
||||
expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false)
|
||||
await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBe(false)
|
||||
expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -261,6 +261,7 @@ export async function reattachSshPtySessionWithExitFence(
|
||||
export async function reattachSshPtySessionForSpawn(
|
||||
args: Parameters<typeof reattachSshPtySessionWithExitFence>[0] & {
|
||||
acceptLivePty: (relayPtyId: string) => void
|
||||
acceptUnverifiablePty: (relayPtyId: string) => void
|
||||
}
|
||||
): Promise<PtySpawnResult> {
|
||||
let result: SshPtyReattachResult | undefined
|
||||
@@ -284,6 +285,7 @@ export async function reattachSshPtySessionForSpawn(
|
||||
throw error
|
||||
}
|
||||
// Why: losing contact during verification is unverifiable, not live or exited.
|
||||
args.acceptUnverifiablePty(unresumable.id)
|
||||
throw new Error(
|
||||
`${SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR}: ${toRelaySshPtyId(args.connectionId, unresumable.id)}`,
|
||||
{ cause: error }
|
||||
|
||||
Reference in New Issue
Block a user