fix: persist exact terminal owner identity

This commit is contained in:
Jinwoo-H
2026-08-24 12:56:55 -04:00
parent c124ea9f2a
commit 45d2a45583
37 changed files with 801 additions and 122 deletions
+2 -1
View File
@@ -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.",
@@ -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()
@@ -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<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false,
acceptsUnroutedExit: true
}
const internals = adapter as unknown as {
activeSessionIds: Set<string>
lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string }
pendingClaimSpawnOperations: Set<typeof pendingClaim>
sessionIncarnations: Map<string, string>
}
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<void>((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', () => {
+110 -29
View File
@@ -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<string, { incarnationId?: string }[]>
ignoredExitIncarnationIds: Set<string>
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 | (() => 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<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
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<CreateOrAttachResult> => {
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<CreateOrAttachResult>(
'createOrAttach',
payload,
undefined,
opts.signal
)
: this.client.request<CreateOrAttachResult>('createOrAttach', payload)
operation.acceptsUnroutedExit = !attachOnly
try {
return await (opts.signal
? this.client.request<CreateOrAttachResult>(
'createOrAttach',
payload,
undefined,
opts.signal
)
: this.client.request<CreateOrAttachResult>('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<PtySpawnResult, 'incarnationId'> | Record<string, never> =>
result.incarnationId ? { incarnationId: result.incarnationId } : {}
const ownerIdentityResult = ():
| Pick<PtySpawnResult, 'ownerIdentity'>
| Record<string, never> => {
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<boolean | null> {
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<boolean> {
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
}
+11 -2
View File
@@ -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<boolean | null> {
return await this.ownerResolver.probe(id, expectedIncarnationId)
async probePtyLiveness(
id: string,
expectedIncarnationId?: string,
expectedOwnerIdentity?: TerminalOwnerIdentity
): Promise<boolean | null> {
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 {
+6 -2
View File
@@ -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' }
})
})
+15 -4
View File
@@ -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)
}
@@ -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<T extends IPtyProvider> = {
candidatesBySessionId: Map<string, { provider: T; process: PtyProcessInfo }[]>
complete: boolean
epoch: number
}
export function resolveDaemonSessionOwnerInventory<T extends IPtyProvider>(
inventory: OwnerInventory<T>,
sessionId: string,
expectedIncarnationId: string | undefined,
expectedIncarnationIsAuthoritative: boolean,
expectedOwnerIdentity: TerminalOwnerIdentity | undefined,
recordRoute: (sessionId: string, provider: T, incarnationId?: string) => void
): DaemonSessionOwnerResolution<T> {
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
)
}
@@ -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'
})
})
})
@@ -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<string, IPtyProvider>([['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())
@@ -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<T extends IPtyProvider> =
| { kind: 'owner'; provider: T }
| { kind: 'absent' }
| { kind: 'unknown' }
type ProviderInventory<T> = { provider: T; processes: PtyProcessInfo[] | null }
type OwnerInventory<T extends IPtyProvider> = {
candidatesBySessionId: Map<string, { provider: T; process: PtyProcessInfo }[]>
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<T extends IPtyProvider> {
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<T extends IPtyProvider> {
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<T extends IPtyProvider> {
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<T extends IPtyProvider> {
}
}
async probe(sessionId: string, expectedIncarnationId?: string): Promise<boolean | null> {
async probe(
sessionId: string,
expectedIncarnationId?: string,
expectedOwnerIdentity?: TerminalOwnerIdentity
): Promise<boolean | null> {
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<T extends IPtyProvider> {
) {
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<T extends IPtyProvider> {
async resolve(
sessionId: string,
expectedIncarnationId?: string,
expectedIncarnationIsAuthoritative = false
expectedIncarnationIsAuthoritative = false,
expectedOwnerIdentity?: TerminalOwnerIdentity
): Promise<DaemonSessionOwnerResolution<T>> {
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<T extends IPtyProvider> {
this.cachedInventory = null
}
private resolveInventory(
inventory: OwnerInventory<T>,
sessionId: string,
expectedIncarnationId?: string,
expectedIncarnationIsAuthoritative = false
): DaemonSessionOwnerResolution<T> {
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<OwnerInventory<T>> {
if (this.inventoryInFlight) {
return this.inventoryInFlight
+2 -4
View File
@@ -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
}
}
@@ -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')
+26 -15
View File
@@ -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<StablePaneOwner, 'tabId' | 'leafId' | 'ptyId' | 'incarnationId'> | null {
): Pick<StablePaneOwner, 'tabId' | 'leafId' | 'ptyId' | 'incarnationId' | 'ownerIdentity'> | 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)
@@ -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
}
@@ -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]) =>
@@ -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}`
@@ -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) {
@@ -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(
+2
View File
@@ -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. */
+7 -1
View File
@@ -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<boolean | null>
/** 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<boolean>
resize(id: string, cols: number, rows: number): void
+3
View File
@@ -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. */
@@ -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
+12 -1
View File
@@ -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 ?? '',
@@ -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)))
}
@@ -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'))
@@ -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')) {
@@ -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 = {
@@ -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'
+2 -1
View File
@@ -545,6 +545,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserUrlHistory: [],
defaultTerminalTabsAppliedByWorktreeId: {}
defaultTerminalTabsAppliedByWorktreeId: {},
terminalPtyOwnersByPaneKey: {}
}
}
+2
View File
@@ -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
@@ -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)
@@ -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)
})
})
+62
View File
@@ -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<TerminalOwnerIdentity>
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
)
}
+5 -1
View File
@@ -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')
)
}
+5
View File
@@ -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<WorkspaceSessionState> = 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())
@@ -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<string, SleepingAgentSessionRecord>
/** Host-issued process incarnation for each durable terminal surface. */
terminalPtyIncarnationsByPaneKey?: Record<string, string>
/** Authenticated exact owner identity for each durable terminal surface. */
terminalPtyOwnersByPaneKey?: Record<string, TerminalOwnerIdentity>
/** Monotonic host authority watermark for terminal membership in each repo. */
terminalTopologyRevisionByRepoId?: Record<string, number>
/** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */