mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
fix: persist exact terminal owner identity
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -545,6 +545,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
defaultTerminalTabsAppliedByWorktreeId: {}
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
terminalPtyOwnersByPaneKey: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user