mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
* fix(ssh): replay an undelivered remote PTY stop on the next handshake A pty.shutdown that dies on the transport left the remote shell running forever: kill.ts marked liveness unverifiable and nothing retried. Record the undelivered stop on the existing durable SshRemotePtyLease and replay it against the authoritative host on the next handshake to that same target, fenced by the host-minted PTY incarnation so a replay cannot kill a later PTY that reused a recycled pty-N id. Retire the record on confirmed delivery, on the host reporting the PTY absent, and on a bounded TTL. No wire change: the fence reads incarnationId, already published on pty.listProcesses. A host that does not publish it degrades to no replay. * fix(ssh): do not leave a replayable kill order behind a reversible stop Worktree sleep stops through stopAndWait and marks those stops reversible; when one does not land the pane stays live and the user keeps using it. An order recorded there would come back on a later handshake and kill that terminal. Only killPtyFromRuntimeController — where the client gives the PTY up for good — records one, and it skips any PTY a reversible stop owns. * fix(ssh): cover the renderer kill route and harden the replay's evidence pty:kill is a separate implementation from killPtyFromRuntimeController and is the one an ordinary tab close reaches, so the record was never written on the path #12447 describes. Extracted it out of inspect.ts (which was over the line budget and was not what the file is named for) and wired both branches. Also: - finishPtyShutdown no longer retires the order. It runs on paths that asked the host and on paths that never did, so retiring there was a contract every caller had to know, and the one that forgot silently dropped a kill order. Retirement is the replay's, on inventory evidence only. - A recycled relay id now expires its lease. Declining to kill was only half: reattach fences on paneKey/tabId, never incarnation, so an untouched lease bound the user's old pane to whatever now holds the id. - Dropped isPtyAlreadyGoneError from the tombstone path. It matches message text a transport failure could wear; every tombstone now traces to a listing. - TTL is owned by a durable prune that actually deletes, not by a branch that was unreachable behind the read filter and only looked tested. - The replay re-reads the inventory per wave and re-checks the fence next to each shutdown, and can never reject into the connect path.
130 lines
5.1 KiB
TypeScript
130 lines
5.1 KiB
TypeScript
import type {
|
|
SshPtyConsumerRecovery,
|
|
SshRemotePtyLease,
|
|
SshTarget
|
|
} from '../../../shared/ssh-types'
|
|
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types'
|
|
import { normalizeSshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill'
|
|
|
|
export type LegacySshTarget = SshTarget & {
|
|
remoteWorkspaceSyncEnabled?: unknown
|
|
remoteWorkspaceSyncGracePeriodSeconds?: unknown
|
|
experimentalPtySourceCreditV1?: unknown
|
|
}
|
|
|
|
// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G.
|
|
export function normalizeSshTarget(t: SshTarget): SshTarget {
|
|
const target = { ...(t as LegacySshTarget) }
|
|
const legacySyncEnabled = target.remoteWorkspaceSyncEnabled
|
|
const currentGracePeriodSeconds = target.relayGracePeriodSeconds
|
|
const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds
|
|
const systemSshConnectionReuse = target.systemSshConnectionReuse
|
|
// Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load.
|
|
delete target.remoteWorkspaceSyncEnabled
|
|
delete target.remoteWorkspaceSyncGracePeriodSeconds
|
|
delete target.relayGracePeriodSeconds
|
|
delete target.systemSshConnectionReuse
|
|
delete target.experimentalPtySourceCreditV1
|
|
// Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration.
|
|
const relayGracePeriodSeconds =
|
|
legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'
|
|
? legacyGracePeriodSeconds
|
|
: currentGracePeriodSeconds
|
|
const normalized: SshTarget = {
|
|
...target,
|
|
configHost: target.configHost ?? target.label ?? target.host
|
|
}
|
|
// Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default.
|
|
if (
|
|
relayGracePeriodSeconds !== undefined &&
|
|
relayGracePeriodSeconds !== LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
|
) {
|
|
normalized.relayGracePeriodSeconds = relayGracePeriodSeconds
|
|
}
|
|
if (systemSshConnectionReuse === false) {
|
|
normalized.systemSshConnectionReuse = false
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// Why: strict whitelist — a record missing or mistyping a required field is dropped rather than partially trusted.
|
|
export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null
|
|
}
|
|
const raw = value as Partial<SshRemotePtyLease>
|
|
if (typeof raw.targetId !== 'string' || typeof raw.ptyId !== 'string') {
|
|
return null
|
|
}
|
|
const state = raw.state ?? 'detached'
|
|
if (!['attached', 'detached', 'terminated', 'expired'].includes(state)) {
|
|
return null
|
|
}
|
|
const now = Date.now()
|
|
const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill)
|
|
return {
|
|
targetId: raw.targetId,
|
|
ptyId: raw.ptyId,
|
|
...(pendingKill ? { pendingKill } : {}),
|
|
...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}),
|
|
...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}),
|
|
...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}),
|
|
state,
|
|
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now,
|
|
updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now,
|
|
...(typeof raw.lastAttachedAt === 'number' ? { lastAttachedAt: raw.lastAttachedAt } : {}),
|
|
...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {})
|
|
}
|
|
}
|
|
|
|
export const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512
|
|
export const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096
|
|
|
|
export function normalizeSshPtyConsumerRecovery(
|
|
value: unknown,
|
|
ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH
|
|
): SshPtyConsumerRecovery | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null
|
|
}
|
|
const raw = value as Partial<SshPtyConsumerRecovery>
|
|
const clientGeneration = raw.clientGeneration
|
|
const ownerGeneration = raw.ownerGeneration
|
|
if (
|
|
typeof raw.targetId !== 'string' ||
|
|
raw.targetId.length === 0 ||
|
|
raw.targetId.length > 512 ||
|
|
typeof raw.clientInstanceId !== 'string' ||
|
|
raw.clientInstanceId.length === 0 ||
|
|
raw.clientInstanceId.length > 512 ||
|
|
typeof raw.serverBuildId !== 'string' ||
|
|
raw.serverBuildId.length === 0 ||
|
|
raw.serverBuildId.length > 512 ||
|
|
typeof clientGeneration !== 'number' ||
|
|
!Number.isSafeInteger(clientGeneration) ||
|
|
clientGeneration <= 0 ||
|
|
typeof ownerGeneration !== 'number' ||
|
|
!Number.isSafeInteger(ownerGeneration) ||
|
|
ownerGeneration <= 0 ||
|
|
typeof raw.ownerLease !== 'string' ||
|
|
raw.ownerLease.length === 0 ||
|
|
raw.ownerLease.length > ownerLeaseMaxLength
|
|
) {
|
|
return null
|
|
}
|
|
const flow = raw.outputFlowControl
|
|
const outputFlowControl =
|
|
flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0
|
|
? { version: 1 as const, windowSu: flow.windowSu }
|
|
: undefined
|
|
return {
|
|
targetId: raw.targetId,
|
|
clientInstanceId: raw.clientInstanceId,
|
|
serverBuildId: raw.serverBuildId,
|
|
clientGeneration,
|
|
ownerGeneration,
|
|
ownerLease: raw.ownerLease,
|
|
...(outputFlowControl ? { outputFlowControl } : {})
|
|
}
|
|
}
|