From 45d2a4558310ec37fda3c308ba5b8330ef5ba399 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 24 Aug 2026 12:56:55 -0400 Subject: [PATCH] fix: persist exact terminal owner identity --- config/reliability-gates.jsonc | 3 +- ...aemon-pty-adapter-session-adoption.test.ts | 20 ++- src/main/daemon/daemon-pty-adapter.test.ts | 67 +++++++++ src/main/daemon/daemon-pty-adapter.ts | 139 ++++++++++++++---- src/main/daemon/daemon-pty-router.ts | 13 +- src/main/daemon/daemon-server.test.ts | 8 +- src/main/daemon/daemon-server.ts | 19 ++- ...emon-session-owner-resolution-inventory.ts | 61 ++++++++ ...on-owner-resolution-owner-identity.test.ts | 62 ++++++++ .../daemon-session-owner-resolution.test.ts | 25 ++++ .../daemon/daemon-session-owner-resolution.ts | 91 +++++------- src/main/daemon/types.ts | 6 +- src/main/ipc/pty-write-ipc-validation.test.ts | 19 +++ src/main/ipc/pty.ts | 41 ++++-- .../profile-project-session-state.ts | 12 ++ .../profile-project-session-transfer.ts | 6 + ...istence-pty-binding-reconciliation.test.ts | 47 ++++++ src/main/persistence/loading-store/store.ts | 15 ++ .../session-owner-removal.ts | 8 + src/main/providers/pty-process-info.ts | 2 + src/main/providers/pty-provider-contract.ts | 8 +- src/main/providers/pty-spawn-result.ts | 3 + ...session-terminal-persistence-retirement.ts | 3 + src/main/runtime/orca-runtime.ts | 13 +- ...e-session-terminal-membership-authority.ts | 15 +- .../pty-transport-spawn-errors.test.ts | 67 +++++++++ .../components/terminal-pane/pty-transport.ts | 16 +- .../remote-runtime-pty-transport.ts | 9 +- .../workspace-session-host-field-ownership.ts | 1 + src/shared/constants.ts | 3 +- src/shared/runtime-types.ts | 2 + src/shared/terminal-owner-identity-schema.ts | 13 ++ src/shared/terminal-owner-identity.test.ts | 30 ++++ src/shared/terminal-owner-identity.ts | 62 ++++++++ src/shared/terminal-pane-owner-verdict.ts | 6 +- src/shared/workspace-session-schema.ts | 5 + src/shared/workspace-session-state-types.ts | 3 + 37 files changed, 801 insertions(+), 122 deletions(-) create mode 100644 src/main/daemon/daemon-session-owner-resolution-inventory.ts create mode 100644 src/main/daemon/daemon-session-owner-resolution-owner-identity.test.ts create mode 100644 src/shared/terminal-owner-identity-schema.ts create mode 100644 src/shared/terminal-owner-identity.test.ts create mode 100644 src/shared/terminal-owner-identity.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 099554287c7..ab4301aede5 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -14297,7 +14297,8 @@ ], "knownGaps": [ "Persisted daemon owner-incarnation identity is not yet implemented; the immediate guard uses exact in-process routes plus session incarnations and fails closed when the route is missing.", - "A current client intentionally refuses authoritative reattach to a surviving pre-v37 daemon because that owner cannot fence before attach side effects; topology survives but the terminal can remain inaccessible for that PTY's lifetime.", + "A current client can reattach a persisted-incarnation session through a surviving v31-v36 daemon and validates its returned incarnation, but those owners cannot reject an incarnation mismatch before attach-side replay or subscription changes.", + "Pre-incarnation daemon events cannot authoritatively settle a saved session; anonymous exits remain unverifiable and may leave a dead legacy pane visible until the user closes it.", "A pre-fence SSH relay can ignore the optional expected incarnation and mutate delivery before a current client rejects its response; capability negotiation remains permanent-boundary work.", "The real macOS and Docker journeys did not recreate the exact A-unregistered/B-compatible-slot incident topology.", "Paired headed/headless could not start because the local runtime exposed no paired web-client URL; WSL, ConPTY, and physical Windows remain untested.", diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index 1993fc0150b..409d3ff9e68 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -206,9 +206,20 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { request.mockRestore() }) - it('does not send an incarnation-fenced attach to a pre-fence daemon', async () => { + it('reattaches an exact surviving session through a pre-fence daemon', async () => { const request = vi.spyOn(DaemonClient.prototype, 'request') + request.mockResolvedValue({ + id: 'legacy-owner-session', + isNew: false, + isReattach: true, + incarnationId: 'expected-incarnation', + snapshot: null + } as never) const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 36 }) + vi.spyOn( + (legacy as unknown as { client: DaemonClient }).client, + 'ensureConnected' + ).mockResolvedValue() try { await expect( legacy.spawn({ @@ -219,8 +230,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expectedIncarnationId: 'expected-incarnation', expectedIncarnationIsAuthoritative: true }) - ).rejects.toMatchObject({ name: 'TerminalSessionOwnerUnverifiedError' }) - expect(request).not.toHaveBeenCalled() + ).resolves.toMatchObject({ id: 'legacy-owner-session', isReattach: true }) + expect(request).toHaveBeenCalledWith( + 'createOrAttach', + expect.objectContaining({ attachOnly: true }) + ) } finally { legacy.dispose() request.mockRestore() diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 6c73892813a..8b6dd62cffc 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -283,6 +283,39 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.pendingClaimSpawnOperations.size).toBe(0) }) + it('does not let an unrelated fresh claim authorize a stale owner exit', async () => { + const sessionId = 'stale-owner-during-unrelated-claim' + const created = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const exits: string[] = [] + adapter.onExit(({ id }) => exits.push(id)) + const pendingClaim = { + exitsBySessionId: new Map(), + ignoredExitIncarnationIds: new Set(), + ignoreNextExit: false, + acceptsUnroutedExit: true + } + const internals = adapter as unknown as { + activeSessionIds: Set + lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string } + pendingClaimSpawnOperations: Set + sessionIncarnations: Map + } + internals.lastAuthenticatedIdentity = { + ...internals.lastAuthenticatedIdentity, + launchNonce: 'replacement-daemon' + } + internals.pendingClaimSpawnOperations.add(pendingClaim) + + lastSubprocess._simulateExit(0) + await waitFor(() => pendingClaim.exitsBySessionId.has(sessionId)) + + expect(created.incarnationId).toBeDefined() + expect(exits).toEqual([]) + expect(internals.activeSessionIds.has(sessionId)).toBe(true) + expect(internals.sessionIncarnations.get(sessionId)).toBe(created.incarnationId) + internals.pendingClaimSpawnOperations.delete(pendingClaim) + }) + it('does not dispatch createOrAttach when cancellation wins during preflight', async () => { let finishPreflight: (() => void) | undefined const preflight = new Promise((resolve) => { @@ -738,6 +771,40 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(exits).toEqual([]) expect(adapter.hasPty(spawned.id)).toBe(true) }) + + it('ignores an echoed expected incarnation after daemon identity replacement', async () => { + const exits: { id: string; code: number; incarnationId?: string }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const spawned = await adapter.spawn({ cols: 80, rows: 24 }) + const internals = adapter as unknown as { + client: DaemonClient + lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string } + } + internals.lastAuthenticatedIdentity = { + pid: process.pid, + startedAtMs: Date.now() + 10_000, + launchNonce: 'replacement-owner' + } + const eventListeners = ( + internals.client as unknown as { + eventListeners: { + each: (visit: (listener: (event: unknown) => void) => void) => void + } + } + ).eventListeners + + eventListeners.each((listener) => + listener({ + type: 'event', + event: 'exit', + sessionId: spawned.id, + payload: { code: -1, incarnationId: spawned.incarnationId } + }) + ) + + expect(exits).toEqual([]) + expect(adapter.hasPty(spawned.id)).toBe(true) + }) }) describe('serialize / revive', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 5f81ce11df9..dd62d8b4473 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -41,8 +41,7 @@ import { GET_SIZE_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, - SESSION_INCARNATION_ATTACH_FENCE_DAEMON_PROTOCOL_VERSION + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { isAgentSessionClaimedSpawnResult, @@ -97,11 +96,14 @@ import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility- import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' type PendingDaemonSpawnOperation = { exitsBySessionId: Map ignoredExitIncarnationIds: Set ignoreNextExit: boolean + acceptsUnroutedExit: boolean } type HistoryRecoveryContext = { @@ -150,6 +152,8 @@ export type DaemonPtyAdapterOptions = { packagedAppVersion?: string | null /** Forks a fresh daemon after endpoint death or a confirmed health replacement. */ respawn?: (reason: DaemonRespawnReason) => Promise void)> + /** Canonical execution host for persisted owner identity. */ + executionHostId?: ExecutionHostId } export type DaemonRespawnReason = @@ -246,6 +250,8 @@ export class DaemonPtyAdapter implements IPtyProvider { private respawnFn: DaemonPtyAdapterOptions['respawn'] | null private runtimeDir: string | null private packagedAppVersion: string | null + private readonly executionHostId: ExecutionHostId + private readonly ownerEndpointRef: string private pendingRespawnAdoptionRelease: (() => void) | null = null private respawnAdoptionClosed = false // Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it. @@ -372,6 +378,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.respawnFn = opts.respawn ?? null this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null this.packagedAppVersion = opts.packagedAppVersion ?? null + this.executionHostId = opts.executionHostId ?? 'local' + // Opaque and portable: never persist socket/token paths or profile dirs. + this.ownerEndpointRef = `daemon-v${this.protocolVersion}` this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 this.supportsProducerFlowControl = this.protocolVersion >= 19 @@ -405,6 +414,28 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null } + /** Returns durable identity only after the socket has authenticated. */ + private ownerIdentity( + sessionIncarnationId?: PtyIncarnationId + ): TerminalOwnerIdentity | undefined { + const daemon = this.lastAuthenticatedIdentity + if (!daemon || !sessionIncarnationId) { + return undefined + } + return { + executionHostId: this.executionHostId, + ownerKind: 'daemon', + ownerIncarnationId: daemon.launchNonce, + sessionIncarnationId, + protocolVersion: this.protocolVersion, + endpointRef: this.ownerEndpointRef + } + } + + getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null { + return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null + } + getLastAuditObservation(): DaemonAuditObservation | null { return this.lastAuditObservation } @@ -440,7 +471,8 @@ export class DaemonPtyAdapter implements IPtyProvider { const operation = { exitsBySessionId: new Map(), ignoredExitIncarnationIds: new Set(), - ignoreNextExit: false + ignoreNextExit: false, + acceptsUnroutedExit: false } const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() operations.add(operation) @@ -521,16 +553,9 @@ export class DaemonPtyAdapter implements IPtyProvider { throw new Error('agent_session_claim_unavailable') } const requestedSessionId = opts.sessionId! - // Why: v30 daemons survive upgrades; reject their accidental create result before publication. + // Why: v31+ daemons already enforce attach-only. Older daemons are handled by the + // legacy create-then-kill fence below, while v36 can still reattach surviving PTYs. const attachOnly = opts.attachOnly === true - if ( - attachOnly && - opts.expectedIncarnationIsAuthoritative === true && - opts.expectedIncarnationId !== undefined && - this.protocolVersion < SESSION_INCARNATION_ATTACH_FENCE_DAEMON_PROTOCOL_VERSION - ) { - throw new TerminalSessionOwnerUnverifiedError(requestedSessionId) - } const emulateLegacyAttachOnly = attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION let sessionId = requestedSessionId @@ -640,10 +665,10 @@ export class DaemonPtyAdapter implements IPtyProvider { ? CODEX_SHELL_READY_TIMEOUT_MS : undefined - const requestCreateOrAttach = ( + const requestCreateOrAttach = async ( historySeed: string | undefined, historySeedTransferId: string | undefined - ) => { + ): Promise => { if (opts.signal?.aborted) { throw new Error('client_disconnected') } @@ -680,14 +705,19 @@ export class DaemonPtyAdapter implements IPtyProvider { ? { agentSessionEnsure: opts.agentSessionEnsure } : {}) } - return opts.signal - ? this.client.request( - 'createOrAttach', - payload, - undefined, - opts.signal - ) - : this.client.request('createOrAttach', payload) + operation.acceptsUnroutedExit = !attachOnly + try { + return await (opts.signal + ? this.client.request( + 'createOrAttach', + payload, + undefined, + opts.signal + ) + : this.client.request('createOrAttach', payload)) + } finally { + operation.acceptsUnroutedExit = false + } } const createOrAttach = async ( @@ -799,6 +829,12 @@ export class DaemonPtyAdapter implements IPtyProvider { result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} const incarnationResult = (): Pick | Record => result.incarnationId ? { incarnationId: result.incarnationId } : {} + const ownerIdentityResult = (): + | Pick + | Record => { + const identity = this.ownerIdentity(result.incarnationId) + return identity ? { ownerIdentity: identity } : {} + } let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined @@ -924,6 +960,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...ownerIdentityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -936,6 +973,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...ownerIdentityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -978,6 +1016,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...ownerIdentityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -1005,6 +1044,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...ownerIdentityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -1153,14 +1193,22 @@ export class DaemonPtyAdapter implements IPtyProvider { async probePtyLiveness( id: string, - expectedIncarnationId?: PtyIncarnationId + expectedIncarnationId?: PtyIncarnationId, + expectedOwnerIdentity?: TerminalOwnerIdentity ): Promise { try { await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS) const ownerIdentityAtProbe = this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null - const canConfirmAbsence = this.hasExactSessionAuthority(id, expectedIncarnationId) + const canConfirmAbsence = + this.hasExactSessionAuthority(id, expectedIncarnationId) && + (expectedOwnerIdentity === undefined || + this.ownerIdentity(expectedIncarnationId ?? this.sessionIncarnations.get(id)) + ?.ownerIncarnationId === expectedOwnerIdentity.ownerIncarnationId) + if (expectedOwnerIdentity !== undefined && !canConfirmAbsence) { + return null + } if ( (expectedIncarnationId === undefined || canConfirmAbsence) && !this.getSizeUnsupported && @@ -1224,14 +1272,30 @@ export class DaemonPtyAdapter implements IPtyProvider { write(id: string, data: string): boolean { const recoverable = this.prepareWrite(id) - return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable) + return this.finishWrite( + id, + this.client.notify('write', { + sessionId: id, + data, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }), + recoverable + ) } async writeWithSettlement(id: string, data: string): Promise { const recoverable = this.prepareWrite(id) return this.finishWrite( id, - await this.client.notifyWithSettlement('write', { sessionId: id, data }), + await this.client.notifyWithSettlement('write', { + sessionId: id, + data, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }), recoverable ) } @@ -1266,7 +1330,14 @@ export class DaemonPtyAdapter implements IPtyProvider { resize(id: string, cols: number, rows: number): void { this.markSessionDirty(id) - this.client.notify('resize', { sessionId: id, cols, rows }) + this.client.notify('resize', { + sessionId: id, + cols, + rows, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }) } pauseProducer(id: string): void { @@ -1864,6 +1935,9 @@ export class DaemonPtyAdapter implements IPtyProvider { admission.admit({ id: session.sessionId, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(this.ownerIdentity(session.incarnationId) + ? { ownerIdentity: this.ownerIdentity(session.incarnationId) } + : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', title: 'shell', @@ -3050,8 +3124,10 @@ export class DaemonPtyAdapter implements IPtyProvider { fact: event.payload }) } else if (event.event === 'exit') { + const directPendingOperations = + this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? new Set() const pendingOperations = new Set([ - ...(this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? []), + ...directPendingOperations, ...this.pendingClaimSpawnOperations ]) for (const operation of pendingOperations) { @@ -3066,9 +3142,14 @@ export class DaemonPtyAdapter implements IPtyProvider { operation.exitsBySessionId.set(event.sessionId, exits) } const currentIncarnationId = this.sessionIncarnations.get(event.sessionId) + const acceptsUnroutedExit = + directPendingOperations.size > 0 && + [...directPendingOperations].every((operation) => operation.acceptsUnroutedExit) if ( !event.payload.incarnationId || - (currentIncarnationId && event.payload.incarnationId !== currentIncarnationId) + (currentIncarnationId && event.payload.incarnationId !== currentIncarnationId) || + (!acceptsUnroutedExit && + !this.hasExactSessionAuthority(event.sessionId, event.payload.incarnationId)) ) { return } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 04003d42c00..9ff09e3e520 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -13,6 +13,7 @@ import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -82,8 +83,16 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).hasPty(id) } - async probePtyLiveness(id: string, expectedIncarnationId?: string): Promise { - return await this.ownerResolver.probe(id, expectedIncarnationId) + async probePtyLiveness( + id: string, + expectedIncarnationId?: string, + expectedOwnerIdentity?: TerminalOwnerIdentity + ): Promise { + return await this.ownerResolver.probe(id, expectedIncarnationId, expectedOwnerIdentity) + } + + getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null { + return this.adapterFor(id).getTerminalOwnerIdentity?.(id) ?? null } write(id: string, data: string): boolean { diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 1eaafb78ffa..a389cd88365 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -558,13 +558,17 @@ describe('DaemonServer', () => { c.onEvent((event) => resolve(event)) }) - c.notify('write', { sessionId: 'missing-session', data: 'hi' }) + c.notify('write', { + sessionId: 'missing-session', + data: 'hi', + expectedIncarnationId: 'owner-incarnation' + }) await expect(exitEvent).resolves.toMatchObject({ type: 'event', event: 'exit', sessionId: 'missing-session', - payload: { code: -1 } + payload: { code: -1, incarnationId: 'owner-incarnation' } }) }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7568314f3e5..f7bb93d8d0e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1267,7 +1267,12 @@ export class DaemonServer { } catch (err) { this.lastInputAtBySessionId.delete(request.payload.sessionId) if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) + this.sendExitEvent( + client, + request.payload.sessionId, + -1, + request.payload.expectedIncarnationId + ) } throw err } @@ -1278,7 +1283,12 @@ export class DaemonServer { this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows) } catch (err) { if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) + this.sendExitEvent( + client, + request.payload.sessionId, + -1, + request.payload.expectedIncarnationId + ) } throw err } @@ -1502,7 +1512,8 @@ export class DaemonServer { private sendExitEvent( client: ConnectedClient | undefined, sessionId: string, - code: number + code: number, + incarnationId?: string ): void { if (!client?.streamSocket) { return @@ -1512,7 +1523,7 @@ export class DaemonServer { type: 'event', event: 'exit', sessionId, - payload: { code } + payload: { code, ...(incarnationId ? { incarnationId } : {}) } }) this.streamDataBatcher.flush(client.clientId) } diff --git a/src/main/daemon/daemon-session-owner-resolution-inventory.ts b/src/main/daemon/daemon-session-owner-resolution-inventory.ts new file mode 100644 index 00000000000..ecc2114e757 --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution-inventory.ts @@ -0,0 +1,61 @@ +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' +import type { DaemonSessionOwnerResolution } from './daemon-session-owner-resolution' + +export type OwnerInventory = { + candidatesBySessionId: Map + complete: boolean + epoch: number +} + +export function resolveDaemonSessionOwnerInventory( + inventory: OwnerInventory, + sessionId: string, + expectedIncarnationId: string | undefined, + expectedIncarnationIsAuthoritative: boolean, + expectedOwnerIdentity: TerminalOwnerIdentity | undefined, + recordRoute: (sessionId: string, provider: T, incarnationId?: string) => void +): DaemonSessionOwnerResolution { + const candidates = inventory.candidatesBySessionId.get(sessionId) ?? [] + const providers = new Set(candidates.map(({ provider }) => provider)) + const exactProviders = new Set( + candidates + .filter(({ process }) => process.incarnationId === expectedIncarnationId) + .map(({ provider }) => provider) + ) + const exactProvider = + expectedIncarnationId && exactProviders.size === 1 + ? exactProviders.values().next().value + : undefined + const ownerProviders = expectedOwnerIdentity + ? new Set( + candidates + .filter(({ process }) => matchesOwner(process, expectedOwnerIdentity)) + .map(({ provider }) => provider) + ) + : null + const soleProvider = providers.size === 1 ? providers.values().next().value : undefined + const provider = + exactProvider && + (!ownerProviders || ownerProviders.has(exactProvider)) && + (inventory.complete || expectedIncarnationIsAuthoritative) + ? exactProvider + : !expectedIncarnationIsAuthoritative && inventory.complete + ? soleProvider + : undefined + if (!provider) { + return { kind: 'unknown' } + } + const process = candidates.find((candidate) => candidate.provider === provider)?.process + recordRoute(sessionId, provider, process?.incarnationId) + return { kind: 'owner', provider } +} + +function matchesOwner(process: PtyProcessInfo, expected: TerminalOwnerIdentity): boolean { + const actual = process.ownerIdentity + return ( + actual?.ownerIncarnationId === expected.ownerIncarnationId && + actual.executionHostId === expected.executionHostId && + actual.ownerKind === expected.ownerKind + ) +} diff --git a/src/main/daemon/daemon-session-owner-resolution-owner-identity.test.ts b/src/main/daemon/daemon-session-owner-resolution-owner-identity.test.ts new file mode 100644 index 00000000000..a48e1f673bc --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution-owner-identity.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' + +const ownerA: TerminalOwnerIdentity = { + executionHostId: 'local', + ownerKind: 'daemon', + ownerIncarnationId: 'daemon-a', + sessionIncarnationId: 'session-a', + protocolVersion: 37, + endpointRef: 'local-daemon' +} + +function provider(processes: PtyProcessInfo[]): IPtyProvider { + return { + listProcesses: vi.fn(async () => processes), + spawn: vi.fn(), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + serialize: vi.fn(async () => ''), + revive: vi.fn(async () => {}), + getDefaultShell: vi.fn(async () => ''), + getProfiles: vi.fn(async () => []), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } +} + +describe('daemon owner identity resolution', () => { + it('rejects a compatible replacement with the same logical id', async () => { + const replacement = provider([ + { + id: 'pty-1', + incarnationId: 'session-b', + ownerIdentity: { + ...ownerA, + ownerIncarnationId: 'daemon-b', + sessionIncarnationId: 'session-b' + }, + cwd: '', + title: 'shell' + } + ]) + const resolver = new DaemonSessionOwnerResolver([replacement], new Map()) + await expect( + resolver.resolve('pty-1', ownerA.sessionIncarnationId, true, ownerA) + ).resolves.toEqual({ + kind: 'unknown' + }) + }) +}) diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts index c38e760d5e4..69f9154236e 100644 --- a/src/main/daemon/daemon-session-owner-resolution.test.ts +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -429,6 +429,31 @@ describe('DaemonSessionOwnerResolver', () => { expect(owner.listProcesses).not.toHaveBeenCalled() }) + it('reports exact-owner absence with multiple complete adapters', async () => { + const owner = provider( + async () => [], + async () => { + throw new SessionNotFoundError('missing') + } + ) + const other = provider(async () => []) + const routes = new Map([['missing', owner]]) + const resolver = new DaemonSessionOwnerResolver([owner, other], routes) + resolver.recordRoute('missing', owner, 'missing-incarnation') + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'missing', + expectedIncarnationId: 'missing-incarnation', + expectedIncarnationIsAuthoritative: true, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).rejects.toBeInstanceOf(TerminalSessionExitedError) + expect(other.listProcesses).toHaveBeenCalledOnce() + }) + it('keeps absence unverified when there are no possible owners', async () => { const resolver = new DaemonSessionOwnerResolver([], new Map()) diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts index 090fceac70c..0a2982718a9 100644 --- a/src/main/daemon/daemon-session-owner-resolution.ts +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -9,20 +9,18 @@ import { TerminalSessionExitedError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' +import { + resolveDaemonSessionOwnerInventory, + type OwnerInventory +} from './daemon-session-owner-resolution-inventory' export type DaemonSessionOwnerResolution = | { kind: 'owner'; provider: T } - | { kind: 'absent' } | { kind: 'unknown' } type ProviderInventory = { provider: T; processes: PtyProcessInfo[] | null } -type OwnerInventory = { - candidatesBySessionId: Map - complete: boolean - epoch: number -} - const OWNER_RESOLUTION_TIMEOUT_MS = 2_000 const OWNER_INVENTORY_CACHE_MS = 1_000 const FAILED_PROVIDER_COOLDOWN_MS = 1_000 @@ -67,11 +65,15 @@ export class DaemonSessionOwnerResolver { const routed = route && this.providerSet.has(route) ? (route as T) : undefined const direct = routed const routedIncarnation = this.routeIncarnations.get(opts.sessionId) + const routedOwnerMismatch = + opts.expectedOwnerIdentity !== undefined && + direct?.getTerminalOwnerIdentity?.(opts.sessionId)?.ownerIncarnationId !== + opts.expectedOwnerIdentity.ownerIncarnationId const routeNeedsAuthoritativeResolution = direct && routed && opts.expectedIncarnationIsAuthoritative === true && - routedIncarnation !== opts.expectedIncarnationId + (routedIncarnation !== opts.expectedIncarnationId || routedOwnerMismatch) if (direct && !routeNeedsAuthoritativeResolution) { try { const result = await direct.spawn(opts) @@ -88,13 +90,20 @@ export class DaemonSessionOwnerResolver { throw error } if ( - this.providers.length === 1 && opts.expectedIncarnationIsAuthoritative && routedIncarnation === opts.expectedIncarnationId && this.routes.get(opts.sessionId) === routed && this.routeIncarnations.get(opts.sessionId) === routedIncarnation ) { - throw new TerminalSessionExitedError(opts.sessionId) + if (this.providers.length === 1) { + throw new TerminalSessionExitedError(opts.sessionId) + } + // A complete inventory from every other adapter distinguishes an exact + // owner's absence from a replacement incarnation that moved elsewhere. + const inventory = await this.inventory() + if (inventory.complete && !inventory.candidatesBySessionId.has(opts.sessionId)) { + throw new TerminalSessionExitedError(opts.sessionId) + } } if (routed && this.routes.get(opts.sessionId) === routed) { this.forgetRoute(opts.sessionId, routed) @@ -106,15 +115,13 @@ export class DaemonSessionOwnerResolver { const resolution = await this.resolve( opts.sessionId, opts.expectedIncarnationId, - opts.expectedIncarnationIsAuthoritative + opts.expectedIncarnationIsAuthoritative, + opts.expectedOwnerIdentity ) assertClientConnected(opts.signal) if (resolution.kind === 'unknown') { throw new TerminalSessionOwnerUnverifiedError(opts.sessionId) } - if (resolution.kind === 'absent') { - throw new TerminalSessionExitedError(opts.sessionId) - } try { const result = await resolution.provider.spawn(opts) if ( @@ -140,7 +147,11 @@ export class DaemonSessionOwnerResolver { } } - async probe(sessionId: string, expectedIncarnationId?: string): Promise { + async probe( + sessionId: string, + expectedIncarnationId?: string, + expectedOwnerIdentity?: TerminalOwnerIdentity + ): Promise { const route = this.routes.get(sessionId) const routed = route && this.providerSet.has(route) ? (route as T) : undefined if (routed) { @@ -151,8 +162,15 @@ export class DaemonSessionOwnerResolver { ) { return null } + if ( + expectedOwnerIdentity && + routed.getTerminalOwnerIdentity?.(sessionId)?.ownerIncarnationId !== + expectedOwnerIdentity.ownerIncarnationId + ) { + return null + } const verdict = routed.probePtyLiveness - ? await routed.probePtyLiveness(sessionId, routedIncarnation) + ? await routed.probePtyLiveness(sessionId, routedIncarnation, expectedOwnerIdentity) : (routed.hasPty?.(sessionId) ?? null) if ( this.routes.get(sessionId) !== routed || @@ -168,17 +186,20 @@ export class DaemonSessionOwnerResolver { async resolve( sessionId: string, expectedIncarnationId?: string, - expectedIncarnationIsAuthoritative = false + expectedIncarnationIsAuthoritative = false, + expectedOwnerIdentity?: TerminalOwnerIdentity ): Promise> { const inventory = await this.inventory() if (inventory.epoch !== this.epoch) { return { kind: 'unknown' } } - const resolution = this.resolveInventory( + const resolution = resolveDaemonSessionOwnerInventory( inventory, sessionId, expectedIncarnationId, - expectedIncarnationIsAuthoritative + expectedIncarnationIsAuthoritative, + expectedOwnerIdentity, + (id, provider, incarnationId) => this.recordRoute(id, provider, incarnationId) ) if ( (!inventory.complete || resolution.kind === 'unknown') && @@ -212,38 +233,6 @@ export class DaemonSessionOwnerResolver { this.cachedInventory = null } - private resolveInventory( - inventory: OwnerInventory, - sessionId: string, - expectedIncarnationId?: string, - expectedIncarnationIsAuthoritative = false - ): DaemonSessionOwnerResolution { - const candidates = inventory.candidatesBySessionId.get(sessionId) ?? [] - const providers = new Set(candidates.map(({ provider }) => provider)) - const exactProviders = new Set( - candidates - .filter(({ process }) => process.incarnationId === expectedIncarnationId) - .map(({ provider }) => provider) - ) - const exactProvider = - expectedIncarnationId && exactProviders.size === 1 - ? exactProviders.values().next().value - : undefined - const soleProvider = providers.size === 1 ? providers.values().next().value : undefined - const provider = - exactProvider && (inventory.complete || expectedIncarnationIsAuthoritative) - ? exactProvider - : !expectedIncarnationIsAuthoritative && inventory.complete - ? soleProvider - : undefined - if (provider) { - const process = candidates.find((candidate) => candidate.provider === provider)?.process - this.recordRoute(sessionId, provider, process?.incarnationId) - return { kind: 'owner', provider } - } - return { kind: 'unknown' } - } - private inventory(): Promise> { if (this.inventoryInFlight) { return this.inventoryInFlight diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 9230d872f16..5a4a46286f9 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -115,10 +115,7 @@ export type CancelCreateOrAttachRequest = { export type WriteRequest = { id: string type: 'write' - payload: { - sessionId: string - data: string - } + payload: { sessionId: string; data: string; expectedIncarnationId?: PtyIncarnationId } } export type ResizeRequest = { @@ -128,6 +125,7 @@ export type ResizeRequest = { sessionId: string cols: number rows: number + expectedIncarnationId?: PtyIncarnationId } } diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index bf6082a2c07..c1f81b0bdba 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -90,6 +90,25 @@ describe('registerPtyHandlers', () => { ).toBe(false) expect(mockProc.proc.write).toHaveBeenCalledTimes(1) }) + it('rejects acknowledged input when owner lookup becomes unverifiable', async () => { + installDaemonTestProvider({ + hasPty: vi.fn(() => { + throw new Error('terminal_pane_owner_unverified') + }) + }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + + expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { + id: result.id, + data: '\x03' + }) + ).toBe(false) + }) it('asks the renderer to remount when the provider rejects a stale daemon write', async () => { const write = vi.fn(() => { throw new PtyWriteUnavailableError('daemon generation lost') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e9219b04b43..a96ce76717c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -9,6 +9,8 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } fro export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { PtyBindingSourceExpectation, Store } from '../persistence' +import { sameTerminalOwnerIdentity } from '../../shared/terminal-owner-identity' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' import { TERMINAL_PANE_OWNER_UNVERIFIED } from '../../shared/terminal-pane-owner-verdict' @@ -691,6 +693,7 @@ type StablePaneOwner = { hasPersistedBinding?: true persistedIncarnationId?: string runtimeIncarnationId?: string + ownerIdentity?: TerminalOwnerIdentity } type StablePaneAdoption = { result: PtySpawnResult @@ -704,7 +707,7 @@ function resolvePersistedStablePaneOwner( paneKey: string, worktreeId: string, connectionId: string | null | undefined -): Pick | null { +): Pick | null { if (!store || typeof store.getWorkspaceSession !== 'function') { return null } @@ -723,11 +726,13 @@ function resolvePersistedStablePaneOwner( return null } const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + const ownerIdentity = session.terminalPtyOwnersByPaneKey?.[paneKey] return { tabId: parsed.tabId, leafId: parsed.leafId, ptyId, - ...(incarnationId ? { incarnationId } : {}) + ...(incarnationId ? { incarnationId } : {}), + ...(ownerIdentity ? { ownerIdentity } : {}) } } @@ -781,6 +786,7 @@ function resolveStablePaneOwner( ...(runtimeIncarnationId || persisted?.incarnationId ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } : {}), + ...(persisted?.ownerIdentity ? { ownerIdentity: persisted.ownerIdentity } : {}), ...(persisted ? { hasPersistedBinding: true as const } : {}), ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) @@ -866,6 +872,7 @@ function persistAdmittedStablePaneBinding(args: { leafId: args.owner.leafId, ptyId: args.result.id, ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.result.ownerIdentity ? { ownerIdentity: args.result.ownerIdentity } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), expectedBinding }, @@ -895,6 +902,7 @@ async function attachStablePaneOwner( sessionId: owner.ptyId, attachOnly: true, expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedOwnerIdentity: owner.ownerIdentity, expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined || owner.hasPersistedBinding === true, isNewSession: undefined, @@ -957,7 +965,9 @@ async function attachStablePaneOwner( result.isReattach !== true || (owner.runtimeIncarnationId !== undefined && result.incarnationId !== owner.runtimeIncarnationId) || - (result.incarnationId === undefined && owner.incarnationId !== undefined) + (result.incarnationId === undefined && owner.incarnationId !== undefined) || + (owner.ownerIdentity !== undefined && + !sameTerminalOwnerIdentity(owner.ownerIdentity, result.ownerIdentity)) ) { throw new Error('terminal_pane_owner_changed') } @@ -5302,7 +5312,8 @@ export function registerPtyHandlers( runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { tabId: owner.surface.tabId, leafId: owner.surface.leafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.ownerIdentity ? { ownerIdentity: result.ownerIdentity } : {}) }) if (!args.connectionId) { options?.onCodexHomePtySpawned?.({ @@ -5379,6 +5390,7 @@ export function registerPtyHandlers( ptyId: result.id, hostAdmittedMembership: true, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.ownerIdentity ? { ownerIdentity: result.ownerIdentity } : {}), ...(cwd ? { startupCwd: cwd } : {}), ...(hostSessionBinding.expectedSourceBinding ? { expectedSourceBinding: hostSessionBinding.expectedSourceBinding } @@ -5429,7 +5441,8 @@ export function registerPtyHandlers( ? { tabId: args.tabId, leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.ownerIdentity ? { ownerIdentity: result.ownerIdentity } : {}) } : undefined, !args.connectionId @@ -5682,7 +5695,7 @@ export function registerPtyHandlers( err instanceof Error ? err.message : String(err) ) } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + // Transport failure is not exit evidence; preserve topology for retry. } }) return true @@ -5701,7 +5714,7 @@ export function registerPtyHandlers( err instanceof Error ? err.message : String(err) ) } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + // Startup failure is not exit evidence; preserve topology for retry. } }) return true @@ -5797,10 +5810,6 @@ export function registerPtyHandlers( // Why: the relay lease must still be tombstoned, but an absent SSH // provider is lost contact — the remote PTY is designed to survive it, // so nothing here observed an exit to report as a confirmed stop. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) } return false @@ -6989,6 +6998,7 @@ export function registerPtyHandlers( leafId: validatedLeafId, ptyId: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.ownerIdentity ? { ownerIdentity: result.ownerIdentity } : {}), ...(cwd ? { startupCwd: cwd } : {}) } if (args.connectionId) { @@ -7103,6 +7113,7 @@ export function registerPtyHandlers( tabId: args.tabId, leafId: metadataLeafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.ownerIdentity ? { ownerIdentity: result.ownerIdentity } : {}), ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) } : undefined, @@ -7389,11 +7400,11 @@ export function registerPtyHandlers( if (ptyOwnership.get(args.id) !== null) { return false } - const provider = tryGetProviderForPty(args.id) - if (!provider?.hasPty?.(args.id)) { - return false - } try { + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } const now = performance.now() lastInputAtByPty.set(args.id, now) interactiveOutputCharsByPty.set(args.id, 0) diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index acb252007ca..f54122578cb 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -89,6 +89,10 @@ export function mergeWorkspaceSessions( ...base.terminalPtyIncarnationsByPaneKey, ...incoming.terminalPtyIncarnationsByPaneKey }, + terminalPtyOwnersByPaneKey: { + ...base.terminalPtyOwnersByPaneKey, + ...incoming.terminalPtyOwnersByPaneKey + }, terminalTopologyRevisionByRepoId: mergeTerminalTopologyRevisions( base.terminalTopologyRevisionByRepoId, incoming.terminalTopologyRevisionByRepoId @@ -183,6 +187,14 @@ export function removeRepoFromWorkspaceSession( }) ) } + if (next.terminalPtyOwnersByPaneKey) { + next.terminalPtyOwnersByPaneKey = Object.fromEntries( + Object.entries(next.terminalPtyOwnersByPaneKey).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator < 1 || !removedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + } if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) { next.activeWorktreeId = null } diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index d5884096bd9..001b5fe7de7 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -136,6 +136,12 @@ export function extractSessionForTransfer( return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) }) ) + transferred.terminalPtyOwnersByPaneKey = Object.fromEntries( + Object.entries(source.terminalPtyOwnersByPaneKey ?? {}).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( ([paneKey, tombstone]) => diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index f72234b6823..50048735bad 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -310,6 +310,53 @@ describe('Store', () => { ).toBeUndefined() }) + it('persists exact owner identity atomically with a terminal binding', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [ + { + id: 'tab-owner', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + 'tab-owner': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null + } + } + }) + const ownerIdentity = { + executionHostId: 'local' as const, + ownerKind: 'daemon' as const, + ownerIncarnationId: 'daemon-a', + sessionIncarnationId: 'session-a', + protocolVersion: 37, + endpointRef: 'local-daemon' + } + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab-owner', + leafId: TEST_LEAF_1, + ptyId: 'pty-owner', + incarnationId: 'session-a', + ownerIdentity + }) + expect( + store.getWorkspaceSession().terminalPtyOwnersByPaneKey?.[`tab-owner:${TEST_LEAF_1}`] + ).toEqual(ownerIdentity) + }) + it('reconciles only the incarnation of an unchanged durable PTY binding', async () => { const store = await createStore() const paneKey = `tab1:${TEST_LEAF_1}` diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index b977f40074e..e0534e089ca 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -118,6 +118,7 @@ import { type RetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import type { TerminalOwnerIdentity } from '../../../shared/terminal-owner-identity' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' @@ -3227,6 +3228,7 @@ export class Store { leafId: string ptyId: string incarnationId?: string + ownerIdentity?: TerminalOwnerIdentity startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } expectedSourceBinding?: PtyBindingSourceExpectation @@ -3326,6 +3328,19 @@ export class Store { delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } + if (args.ownerIdentity) { + // Store the complete tuple atomically with the pane binding. A partial + // owner record is worse than legacy/unknown because it invites inference. + session.terminalPtyOwnersByPaneKey = { + ...session.terminalPtyOwnersByPaneKey, + [paneKey]: { ...args.ownerIdentity } + } + } else if (args.incarnationId) { + // A new authenticated session without owner metadata is legacy/unknown; + // never let an older owner tuple follow the rebinding. + session.terminalPtyOwnersByPaneKey = { ...session.terminalPtyOwnersByPaneKey } + delete session.terminalPtyOwnersByPaneKey[paneKey] + } const tabs = session.tabsByWorktree?.[bindingWorktreeId] const tab = tabs?.find((t) => t.id === args.tabId) if (tab) { diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts index 80a149eda6a..f1fb21c26f5 100644 --- a/src/main/persistence/restoring-sessions/session-owner-removal.ts +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -23,6 +23,14 @@ export function deleteScannedSessionFieldsForOwners( }) ) } + if (next.terminalPtyOwnersByPaneKey) { + next.terminalPtyOwnersByPaneKey = Object.fromEntries( + Object.entries(next.terminalPtyOwnersByPaneKey).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator)) + }) + ) + } if (next.terminalSurfaceTombstonesByPaneKey) { next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index a34746a6267..8a1e859cce2 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -1,9 +1,11 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' export type PtyProcessInfo = { id: string incarnationId?: PtyIncarnationId + ownerIdentity?: TerminalOwnerIdentity cwd: string title: string /** Owning worktree when the provider can report it authoritatively. */ diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 5ac62bd4c4a..c52d6b1497b 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -11,6 +11,7 @@ import type { } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' export type { PtyBackgroundStreamEvent, @@ -79,6 +80,8 @@ export type PtySpawnOptions = { expectedIncarnationId?: PtyIncarnationId /** True when runtime state makes the expected incarnation a hard attach fence. */ expectedIncarnationIsAuthoritative?: boolean + /** Persisted exact owner fence; absence means legacy/unknown. */ + expectedOwnerIdentity?: TerminalOwnerIdentity /** Why: allows the renderer to request a specific shell for a single new * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without * changing the user's persistent default shell setting. Only consulted on @@ -137,8 +140,11 @@ export type IPtyProvider = { /** Exact provider readback: false only when the provider answered that the PTY is absent. */ probePtyLiveness?: ( id: string, - expectedIncarnationId?: PtyIncarnationId + expectedIncarnationId?: PtyIncarnationId, + expectedOwnerIdentity?: TerminalOwnerIdentity ) => Promise + /** Exact authenticated owner identity for a session, if this provider can prove it. */ + getTerminalOwnerIdentity?: (id: string) => TerminalOwnerIdentity | null write(id: string, data: string): boolean | void writeWithSettlement?: (id: string, data: string) => Promise resize(id: string, cols: number, rows: number): void diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 76bb4179069..bc643a8b621 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -11,6 +12,8 @@ export type PtySpawnResult = { id: string /** Opaque provider-owned identity for this process behind a reusable PTY id. */ incarnationId?: PtyIncarnationId + /** Authenticated exact owner identity, when the provider can prove it. */ + ownerIdentity?: TerminalOwnerIdentity /** Relay source identity installed before adjacent source frames are decoded. */ sourceActivation?: PtySourceReceivingActivation /** The provider observed this exact spawn exit before its control reply settled. */ diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index e94155f342c..cc81e0ec89b 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -47,6 +47,8 @@ function recordTerminalSurfaceRetirement( ...session.terminalPtyIncarnationsByPaneKey } delete terminalPtyIncarnationsByPaneKey[paneKey] + const terminalPtyOwnersByPaneKey = { ...session.terminalPtyOwnersByPaneKey } + delete terminalPtyOwnersByPaneKey[paneKey] const terminalSurfaceTombstonesByPaneKey = { ...session.terminalSurfaceTombstonesByPaneKey } @@ -55,6 +57,7 @@ function recordTerminalSurfaceRetirement( { ...session, terminalPtyIncarnationsByPaneKey, + terminalPtyOwnersByPaneKey, terminalSurfaceTombstonesByPaneKey }, surface.worktreeId diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..c396faf68ac 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -398,6 +398,7 @@ import { resolveUnreportedExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -1469,6 +1470,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { type RuntimePtyWorktreeRecord = { ptyId: string incarnationId: PtyIncarnationId | null + ownerIdentity: TerminalOwnerIdentity | null worktreeId: string connectionId: string | null runtimeSessionOwned: boolean @@ -10809,6 +10811,7 @@ export class OrcaRuntimeService { tabId: string leafId: string incarnationId?: PtyIncarnationId + ownerIdentity?: TerminalOwnerIdentity agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } }, isWsl?: boolean @@ -10830,7 +10833,8 @@ export class OrcaRuntimeService { : {}), ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), - ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) + ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}), + ownerIdentity: binding?.ownerIdentity ?? null }) const agentLaunchAuthority = binding?.agentLaunchAuthority if ( @@ -32333,6 +32337,7 @@ export class OrcaRuntimeService { | 'isWsl' | 'wslDistro' | 'incarnationId' + | 'ownerIdentity' > > = {} ): RuntimePtyWorktreeRecord { @@ -32352,6 +32357,7 @@ export class OrcaRuntimeService { pty = { ptyId, incarnationId: state.incarnationId ?? null, + ownerIdentity: state.ownerIdentity ?? null, worktreeId, connectionId, runtimeSessionOwned: state.runtimeSessionOwned ?? false, @@ -32414,6 +32420,9 @@ export class OrcaRuntimeService { } pty.incarnationId = state.incarnationId } + if (state.ownerIdentity !== undefined) { + pty.ownerIdentity = state.ownerIdentity + } if (state.connectionId !== undefined) { pty.connectionId = state.connectionId if (state.connectionId !== null) { @@ -33023,6 +33032,7 @@ export class OrcaRuntimeService { handle: this.issueHandle(leaf), ptyId: leaf.ptyId, incarnationId: pty?.incarnationId ?? null, + ...(pty?.ownerIdentity ? { ownerIdentity: pty.ownerIdentity } : {}), orphaned: false, worktreeId: leaf.worktreeId, worktreePath: worktree?.path ?? '', @@ -35002,6 +35012,7 @@ export class OrcaRuntimeService { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, incarnationId: pty.incarnationId, + ...(pty.ownerIdentity ? { ownerIdentity: pty.ownerIdentity } : {}), orphaned, worktreeId: pty.worktreeId, worktreePath: worktree?.path ?? '', diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index c4d258d8317..2ec740a6ec5 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -287,6 +287,19 @@ export function rebaseWorkspaceSessionTerminalMembership( : {}) } return rebasedMembership - ? { ...next, terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior) } + ? { + ...next, + terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior), + terminalPtyOwnersByPaneKey: rebaseOwnerBindings(next, prior) + } : next } + +function rebaseOwnerBindings( + session: WorkspaceSessionState, + prior: WorkspaceSessionState +): WorkspaceSessionState['terminalPtyOwnersByPaneKey'] { + const allowed = new Set(Object.keys(rebaseIncarnationBindings(session, prior) ?? {})) + const merged = { ...session.terminalPtyOwnersByPaneKey, ...prior.terminalPtyOwnersByPaneKey } + return Object.fromEntries(Object.entries(merged).filter(([paneKey]) => allowed.has(paneKey))) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts index a8eb42109ce..8e1f9832423 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts @@ -88,6 +88,73 @@ describe('createIpcPtyTransport', () => { expect(onError).toHaveBeenCalledWith('ENOENT: spawn /bin/nope not found') }) + it.each(['terminal_session_exited: pty-dead', 'Terminal session exited: pty-dead'])( + 'preserves authoritative session-exited evidence during reattach: %s', + async (message) => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnMock = vi.fn().mockRejectedValue(new Error(message)) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + spawn: spawnMock, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } + } + } as unknown as typeof window + + const onError = vi.fn() + const result = await createIpcPtyTransport().connect({ + url: '', + sessionId: 'pty-dead', + callbacks: { onError } + }) + + expect(result).toEqual({ id: 'pty-dead', exitedBeforeAttach: true }) + expect(onError).not.toHaveBeenCalled() + } + ) + + it('does not reinterpret an unexpected reattach failure as an ownership verdict', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnMock = vi.fn().mockRejectedValue(new Error('unexpected provider failure')) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + spawn: spawnMock, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } + } + } as unknown as typeof window + + const onError = vi.fn() + await expect( + createIpcPtyTransport().connect({ + url: '', + sessionId: 'pty-existing', + callbacks: { onError } + }) + ).rejects.toThrow('unexpected provider failure') + expect(onError).toHaveBeenCalledWith('unexpected provider failure') + }) + it('surfaces the SSH-not-active toast for a regular SSH target with no PTY provider', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockRejectedValue(new Error('No PTY provider for connection ssh-1')) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 6072abaf4c8..e64ffbb812b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -50,7 +50,10 @@ import { type PtySideEffectGauge } from './pty-side-effect-pending-census' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalPaneOwnerUnverified } from '../../../../shared/terminal-pane-owner-verdict' +import { + isTerminalPaneOwnerUnverified, + isTerminalSessionExited +} from '../../../../shared/terminal-pane-owner-verdict' // Re-export public API so existing consumers keep working. export { @@ -951,6 +954,12 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return spawnResult.id } catch (err) { const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err)) + if (options.sessionId && isTerminalSessionExited(msg)) { + return { + id: options.sessionId, + exitedBeforeAttach: true + } satisfies PtyConnectResult + } if (options.sessionId && isTerminalPaneOwnerUnverified(msg)) { storedCallbacks.onError?.(msg) return { @@ -976,10 +985,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } if (options.sessionId) { storedCallbacks.onError?.(msg) - return { - id: options.sessionId, - ownerUnverifiable: true - } satisfies PtyConnectResult + throw err } // Why: on cold start the SSH provider isn't registered yet, so pty:spawn throws a raw IPC error; replace with a friendly message. if (connectionId && msg.includes('No PTY provider for connection')) { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index c182c96762e..c560aa3d433 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2193,7 +2193,14 @@ export function createRemoteRuntimePtyTransport( if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false const message = runtimeTerminalErrorMessage(error) - if (options.sessionId) { + if (options.sessionId && isRemoteTerminalGoneMessage(message)) { + recovery.cancel() + handleRemoteTerminalError(error) + ownerUnverifiableResult = { + id: options.sessionId, + exitedBeforeAttach: true + } + } else if (options.sessionId) { recovery.markDisconnected() surfaceErrorMessage(message) ownerUnverifiableResult = { diff --git a/src/renderer/src/lib/workspace-session-host-field-ownership.ts b/src/renderer/src/lib/workspace-session-host-field-ownership.ts index 1b154a00a1d..68c93f652dc 100644 --- a/src/renderer/src/lib/workspace-session-host-field-ownership.ts +++ b/src/renderer/src/lib/workspace-session-host-field-ownership.ts @@ -41,6 +41,7 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { markdownFrontmatterVisible: 'fileKeyed', sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed', terminalPtyIncarnationsByPaneKey: 'paneKeyed', + terminalPtyOwnersByPaneKey: 'paneKeyed', // Why: this host-issued fence must never collide while unified renderer state merges equal repo ids across hosts. terminalTopologyRevisionByRepoId: 'hostPrivate', terminalSurfaceTombstonesByPaneKey: 'surfaceTombstoneKeyed' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 811aada6144..d9c5fd86d87 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -545,6 +545,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, browserUrlHistory: [], - defaultTerminalTabsAppliedByWorktreeId: {} + defaultTerminalTabsAppliedByWorktreeId: {}, + terminalPtyOwnersByPaneKey: {} } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 7b1b224435c..e1d4046a949 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -40,6 +40,7 @@ import type { ExecutionHostId } from './execution-host' import type { PtyIncarnationId } from './pty-incarnation' import type { RasterImageDimensions } from './raster-image-dimensions' import type { TerminalExitCause } from './terminal-exit-cause' +import type { TerminalOwnerIdentity } from './terminal-owner-identity' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -461,6 +462,7 @@ export type RuntimeTerminalSummary = { handle: string ptyId: string | null incarnationId?: string | null + ownerIdentity?: TerminalOwnerIdentity orphaned?: boolean worktreeId: string worktreePath: string diff --git a/src/shared/terminal-owner-identity-schema.ts b/src/shared/terminal-owner-identity-schema.ts new file mode 100644 index 00000000000..93acfcedf1c --- /dev/null +++ b/src/shared/terminal-owner-identity-schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { isTerminalOwnerIdentity } from './terminal-owner-identity' + +export const terminalOwnerIdentitySchema = z + .object({ + executionHostId: z.string().min(1), + ownerKind: z.enum(['daemon', 'ssh', 'wsl', 'relay', 'paired-runtime', 'local-direct']), + ownerIncarnationId: z.string().min(1).max(256), + sessionIncarnationId: z.string().min(1).max(128), + protocolVersion: z.number().int().positive(), + endpointRef: z.string().max(512).optional() + }) + .refine(isTerminalOwnerIdentity) diff --git a/src/shared/terminal-owner-identity.test.ts b/src/shared/terminal-owner-identity.test.ts new file mode 100644 index 00000000000..e751cae98b6 --- /dev/null +++ b/src/shared/terminal-owner-identity.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalOwnerIdentity, + sameTerminalOwnerIdentity, + type TerminalOwnerIdentity +} from './terminal-owner-identity' + +const identity: TerminalOwnerIdentity = { + executionHostId: 'local', + ownerKind: 'daemon', + ownerIncarnationId: 'daemon-a', + sessionIncarnationId: 'session-1', + protocolVersion: 37, + endpointRef: 'local-daemon' +} + +describe('terminal owner identity', () => { + it('requires the complete owner/session tuple', () => { + expect(isTerminalOwnerIdentity(identity)).toBe(true) + expect(isTerminalOwnerIdentity({ ...identity, ownerIncarnationId: '' })).toBe(false) + expect(isTerminalOwnerIdentity({ ...identity, protocolVersion: 0 })).toBe(false) + }) + + it('does not treat a replacement owner as the same authority', () => { + expect( + sameTerminalOwnerIdentity(identity, { ...identity, ownerIncarnationId: 'daemon-b' }) + ).toBe(false) + expect(sameTerminalOwnerIdentity(identity, { ...identity })).toBe(true) + }) +}) diff --git a/src/shared/terminal-owner-identity.ts b/src/shared/terminal-owner-identity.ts new file mode 100644 index 00000000000..a040e2bf4b9 --- /dev/null +++ b/src/shared/terminal-owner-identity.ts @@ -0,0 +1,62 @@ +import type { ExecutionHostId } from './execution-host' +import { normalizeExecutionHostId } from './execution-host' +import type { PtyIncarnationId } from './pty-incarnation' + +/** The execution authority that owns a persisted terminal session. */ +export type TerminalOwnerKind = + | 'daemon' + | 'ssh' + | 'wsl' + | 'relay' + | 'paired-runtime' + | 'local-direct' + +/** + * Durable identity for a terminal owner. A logical PTY id is deliberately not + * included: it is a product handle and may be reused by a later session. + */ +export type TerminalOwnerIdentity = { + executionHostId: ExecutionHostId + ownerKind: TerminalOwnerKind + ownerIncarnationId: string + sessionIncarnationId: PtyIncarnationId + protocolVersion: number + /** Opaque endpoint reference, never an auth token or socket credential. */ + endpointRef?: string +} + +export function isTerminalOwnerIdentity(value: unknown): value is TerminalOwnerIdentity { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + typeof candidate.executionHostId === 'string' && + normalizeExecutionHostId(candidate.executionHostId) !== null && + typeof candidate.ownerKind === 'string' && + typeof candidate.ownerIncarnationId === 'string' && + candidate.ownerIncarnationId.length > 0 && + typeof candidate.sessionIncarnationId === 'string' && + candidate.sessionIncarnationId.length > 0 && + typeof candidate.protocolVersion === 'number' && + Number.isInteger(candidate.protocolVersion) && + candidate.protocolVersion > 0 && + (candidate.endpointRef === undefined || typeof candidate.endpointRef === 'string') + ) +} + +export function sameTerminalOwnerIdentity( + left: TerminalOwnerIdentity | null | undefined, + right: TerminalOwnerIdentity | null | undefined +): boolean { + return Boolean( + left && + right && + left.executionHostId === right.executionHostId && + left.ownerKind === right.ownerKind && + left.ownerIncarnationId === right.ownerIncarnationId && + left.sessionIncarnationId === right.sessionIncarnationId && + left.protocolVersion === right.protocolVersion && + left.endpointRef === right.endpointRef + ) +} diff --git a/src/shared/terminal-pane-owner-verdict.ts b/src/shared/terminal-pane-owner-verdict.ts index 2d301bf4187..8a07943ab0d 100644 --- a/src/shared/terminal-pane-owner-verdict.ts +++ b/src/shared/terminal-pane-owner-verdict.ts @@ -8,5 +8,9 @@ export function isTerminalPaneOwnerUnverified(value: unknown): boolean { export function isTerminalSessionExited(value: unknown): boolean { const message = value instanceof Error ? value.message : String(value) - return message.includes(TERMINAL_SESSION_EXITED) + return ( + message.includes(TERMINAL_SESSION_EXITED) || + message.includes('Terminal session exited:') || + (value instanceof Error && value.name === 'TerminalSessionExitedError') + ) } diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index d9aba06ac9d..ba1fbb6a161 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -27,6 +27,7 @@ import { browserWorkspaceSchema } from './workspace-session-browser-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { terminalOwnerIdentitySchema } from './terminal-owner-identity-schema' import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -310,6 +311,10 @@ export const workspaceSessionStateSchema: z.ZodType = z.o 'terminalPtyIncarnationsByPaneKey', salvagingRecord(z.string(), z.string().min(1).max(128)) ), + terminalPtyOwnersByPaneKey: salvagedOptional( + 'terminalPtyOwnersByPaneKey', + salvagingRecord(z.string(), terminalOwnerIdentitySchema) + ), terminalTopologyRevisionByRepoId: salvagedOptional( 'terminalTopologyRevisionByRepoId', salvagingRecord(z.string(), z.number().int().nonnegative()) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index bc20b7c5459..97ebee0940c 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -4,6 +4,7 @@ import type { WorkspaceKey } from './folder-workspace-types' import type { Tab, TabGroup, TabGroupLayoutNode, WorkspaceVisibleTabType } from './tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' import type { BrowserHistoryEntry, BrowserPage, BrowserWorkspace } from './browser-workspace-types' +import type { TerminalOwnerIdentity } from './terminal-owner-identity' /** Minimal subset of OpenFile persisted across restarts. * Only edit-mode files are saved — diffs, conflict reviews, and other @@ -94,6 +95,8 @@ export type WorkspaceSessionState = { sleepingAgentSessionsByPaneKey?: Record /** Host-issued process incarnation for each durable terminal surface. */ terminalPtyIncarnationsByPaneKey?: Record + /** Authenticated exact owner identity for each durable terminal surface. */ + terminalPtyOwnersByPaneKey?: Record /** Monotonic host authority watermark for terminal membership in each repo. */ terminalTopologyRevisionByRepoId?: Record /** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */