diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 26a1af1ba08..70348edc848 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -37,6 +37,7 @@ import { nextAutomationOccurrenceAfter } from '../shared/automation-schedules' import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' +import { isRelayAttestedPtyIncarnationId } from '../shared/pty-incarnation' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import { normalizeProxyUrl } from '../shared/network-proxy' import { normalizeKagiSessionLink } from '../shared/browser-url' @@ -1619,7 +1620,13 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { 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 } : {}) + ...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {}), + // Rebuilt field by field, so a lease is only as durable as this list — adding the field to the + // type without adding it here drops it on every load. Synthesized values are refused rather + // than carried: one written by an older build would later read as a different shell. + ...(isRelayAttestedPtyIncarnationId(raw.incarnationId) + ? { incarnationId: raw.incarnationId } + : {}) } } diff --git a/src/main/providers/ssh-pty-provider-output-state.ts b/src/main/providers/ssh-pty-provider-output-state.ts index 4d98097b9c6..a80ffc4ec4f 100644 --- a/src/main/providers/ssh-pty-provider-output-state.ts +++ b/src/main/providers/ssh-pty-provider-output-state.ts @@ -11,6 +11,7 @@ import { type SshPtyReceivingActivationLease } from './ssh-pty-notification-routing' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import { SYNTHESIZED_PTY_INCARNATION_PREFIX } from '../../shared/pty-incarnation' export class SshPtyProviderOutputState { private readonly dataListeners = new Set() @@ -132,7 +133,7 @@ export class SshPtyProviderOutputState { this.rememberPtyIncarnation(relayPtyId, incarnationId) let resolved = this.incarnationByRelayPtyId.get(relayPtyId) if (!resolved) { - resolved = `legacy:${this.providerGeneration}:${this.legacyIncarnationSerial++}:${relayPtyId}` + resolved = `${SYNTHESIZED_PTY_INCARNATION_PREFIX}${this.providerGeneration}:${this.legacyIncarnationSerial++}:${relayPtyId}` this.incarnationByRelayPtyId.set(relayPtyId, resolved) } return resolved diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index c899344c432..ee85e18cdb8 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -223,6 +223,24 @@ describe('STA-3077: existing duplicate leases are healed, not revived', () => { 2 ) }) + + // A lease is rebuilt field by field on load, so a field added to the type but not to the + // normalizer is dropped on every boot — it would look right in memory and be gone on the next + // start, which is how a fence ships silently permitting everything. A synthesized identity is + // refused outright rather than carried: it is not stable across reconnects, so one persisted by + // another build would later read as a different shell and strand a live pane. + it('keeps a host-attested incarnation across a reload and refuses a synthesized one', async () => { + const store = await createStore({ + sshRemotePtyLeases: [ + { ...leaseFor('relay-pty-a', 1), createdAt: 1, incarnationId: 'inc-host-a' }, + { ...leaseFor('relay-pty-b', 2), createdAt: 2, incarnationId: 'legacy:23:0:relay-pty-b' } + ] + }) + + const byPtyId = new Map(store.getSshRemotePtyLeases(TARGET).map((l) => [l.ptyId, l])) + expect(byPtyId.get('relay-pty-a')?.incarnationId).toBe('inc-host-a') + expect(byPtyId.get('relay-pty-b')?.incarnationId).toBeUndefined() + }) }) describe('STA-3077: reattach binds panes, it never creates them', () => { diff --git a/src/shared/pty-incarnation.ts b/src/shared/pty-incarnation.ts index a68ccd82e94..c2eb897454c 100644 --- a/src/shared/pty-incarnation.ts +++ b/src/shared/pty-incarnation.ts @@ -3,3 +3,16 @@ export type PtyIncarnationId = string export function isPtyIncarnationId(value: unknown): value is PtyIncarnationId { return typeof value === 'string' && value.length > 0 && value.length <= 128 } + +/** Stand-in minted locally when a host reported no incarnation of its own. */ +export const SYNTHESIZED_PTY_INCARNATION_PREFIX = 'legacy:' + +/** + * True only for an incarnation the HOST minted. A synthesized one is first-write-wins and is + * dropped when provider state resets, so the same live shell can present a different value on a + * later reconnect. Durable identity may only be recorded from — and compared against — a value the + * host itself attested, or a reconnect would read its own stand-in as a different shell. + */ +export function isRelayAttestedPtyIncarnationId(value: unknown): value is PtyIncarnationId { + return isPtyIncarnationId(value) && !value.startsWith(SYNTHESIZED_PTY_INCARNATION_PREFIX) +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index fd6a9a3bcb0..d0125b0e317 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -203,6 +203,14 @@ export type SshRemotePtyLease = { updatedAt: number lastAttachedAt?: number lastDetachedAt?: number + /** + * The shell this record names, as the HOST identified it. `ptyId` alone cannot: a replaced relay + * restarts its ids at `pty-1`, so the same id can name somebody else's shell. Only a host-attested + * value is stored — a locally synthesized stand-in is not stable across reconnects and would read + * as a different shell. Absent on legacy rows and on hosts that report none, which stays + * adoption-eligible and must never act as a claim that subtracts a shell. + */ + incarnationId?: string } /** Main-owned relay lease needed to reclaim PTY delivery after a desktop restart. */