fix(terminal): revalidate settled pane fences and negotiate host refusal

This commit is contained in:
Jinwoo-H
2026-09-07 19:39:25 -04:00
parent c742825444
commit 2315bc2a39
13 changed files with 311 additions and 17 deletions
+4 -1
View File
@@ -25,13 +25,16 @@
"coverageNotes": "macOS hidden Electron restart exercises live and absent daemon sessions. Unit contracts cover direct SSH evidence and paired host/client refusal; other operating systems and real remote topologies remain unverified.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19358"],
"invariant": "Main reads the persisted pane resume fence and permits attachment only. Observed exit preserves the historical tab and binding; unverifiable evidence preserves ownership and permits recovery without replacement.",
"oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. Unsupported paired resolution never certifies exit.",
"oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. A fence committed during attachment or claim reconciliation prevents retirement and fresh provider spawn. Unsupported paired resolution never certifies exit; owner absence cannot create until host fenced-create semantics are negotiated.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-tab-survives-restart.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --retries=0"
],
"testFiles": [
"src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
"src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
"tests/e2e/settled-worker-tab-survives-restart.spec.ts"
],
"assertionRefs": [
@@ -105,6 +105,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
assertPtyRegistrationAllowed: vi.fn(),
preparePtyExecutionContext: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
@@ -176,6 +177,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
@@ -226,6 +229,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
}
)
@@ -285,6 +290,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('execute-refusal')
const earlyAdoption = vi.spyOn(stableAdoption, 'adoptStablePane').mockResolvedValueOnce(null)
const providerSpawn = vi.fn(async () => {
runtime.preparePtyExecutionContext.mockClear()
throw new SessionNotFoundError(spawnArgs.sessionId)
})
installDaemonTestProvider({ spawn: providerSpawn })
@@ -304,6 +310,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
@@ -318,6 +326,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
async (ensureClaim) => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-absent-worker')
const providerSpawn = vi.fn(async () => {
runtime.preparePtyExecutionContext.mockClear()
throw new SessionNotFoundError(spawnArgs.sessionId)
})
installDaemonTestProvider({ spawn: providerSpawn })
@@ -345,10 +354,129 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
})
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
}
)
it.each([false, true])(
'carries unverifiable through the runtime controller (pre-adopted: %s)',
async (preAdopted) => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-controller')
const spawn = vi.fn(async () => {
throw new Error('daemon unavailable')
})
installDaemonTestProvider({ spawn })
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]![0] as {
spawn: (args: unknown) => Promise<unknown>
}
const result = await controller.spawn({
...spawnArgs,
...(preAdopted
? {
adoptedStablePane: {
result: { id: spawnArgs.sessionId, reattachUnverifiable: true },
owner: {
ptyId: spawnArgs.sessionId,
tabId: spawnArgs.tabId,
leafId: spawnArgs.leafId
}
}
}
: {})
})
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
if (preAdopted) {
expect(spawn).not.toHaveBeenCalled()
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
}
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
}
)
it.each(['ipc', 'runtime'] as const)(
'preserves a fence committed during %s attach',
async (entry) => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-fenced-worker')
const record =
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
]
record.automaticResumeBlockedBy = ''
const spawn = vi.fn(async () => {
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
throw new SessionNotFoundError(spawnArgs.sessionId)
})
installDaemonTestProvider({ spawn })
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]![0] as {
spawn: (args: unknown) => Promise<unknown>
}
const result =
entry === 'ipc'
? await handlers.get('pty:spawn')!(null, spawnArgs)
: await controller.spawn(spawnArgs)
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
expect(spawn).toHaveBeenCalledTimes(1)
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
}
)
it('refuses a claim spawn when the fence commits during owner reconciliation', async () => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-claim-fence')
const record =
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
]
record.automaticResumeBlockedBy = ''
const spawn = vi.fn(async () => ({ id: 'replacement' }))
const listProcesses = vi.fn(async () => {
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
return []
})
installDaemonTestProvider({ spawn, listProcesses })
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]![0] as {
spawn: (args: unknown) => Promise<unknown>
}
await expect(
controller.spawn({
...spawnArgs,
agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface }
})
).resolves.toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
expect(listProcesses).toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
})
})
@@ -542,4 +542,29 @@ describe('registerPtyHandlers', () => {
vi.useRealTimers()
}
})
it.each(['renderer', 'runtime'] as const)(
'%s kill treats daemon session-not-found as completed shutdown',
async (entry) => {
const id = 'daemon-missing-session'
const shutdown = vi.fn(async () => {
throw new Error(`Session not found: ${id}`)
})
installDaemonTestProvider({ shutdown })
setPtyOwnership(id, null)
const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() }
registerPtyHandlers(mainWindow as never, runtime as never)
if (entry === 'renderer') {
await expect(handlers.get('pty:kill')!(null, { id })).resolves.toBeUndefined()
} else {
const controller = runtime.setPtyController.mock.calls[0]![0] as {
kill: (id: string) => boolean
}
expect(controller.kill(id)).toBe(true)
}
await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalled())
expect(shutdown).toHaveBeenCalledTimes(1)
const { ptyOwnership } = await import('./pty/provider/ownership-state')
expect(ptyOwnership.has(id)).toBe(false)
}
)
})
+16 -14
View File
@@ -1,6 +1,6 @@
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
import type { Store } from '../../../persistence'
import { retirePersistedStablePaneOwner } from './stable-owner-retirement'
@@ -25,7 +25,6 @@ export type StablePaneOwner = {
hasPersistedBinding?: true
persistedIncarnationId?: string
runtimeIncarnationId?: string
automaticResumeBlocked?: true
}
export type StablePaneAdoption = {
result: PtySpawnResult
@@ -116,9 +115,6 @@ export function resolveStablePaneOwner(
...(runtimeIncarnationId || persisted?.incarnationId
? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId }
: {}),
...(isStablePaneResumeBlocked(store, paneKey, worktreeId, connectionId)
? { automaticResumeBlocked: true as const }
: {}),
...(persisted ? { hasPersistedBinding: true as const } : {}),
...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}),
...(runtimeIncarnationId ? { runtimeIncarnationId } : {})
@@ -182,6 +178,13 @@ export async function attachStablePaneOwner(
args: StablePaneSpawnContext & { owner: StablePaneOwner }
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> {
const { owner, provider, runtime, spawnOptions } = args
const paneKey = makePaneKey(owner.tabId, owner.leafId)
const blockedAtAttach = isStablePaneResumeBlocked(
args.store,
paneKey,
args.worktreeId,
args.connectionId
)
let result: PtySpawnResult
try {
result = await provider.spawn({
@@ -201,12 +204,12 @@ export async function attachStablePaneOwner(
onPtySpawnCommitted: undefined
})
} catch (error) {
if (owner.automaticResumeBlocked) {
if (isStablePaneResumeBlocked(args.store, paneKey, args.worktreeId, args.connectionId)) {
return {
owner,
result: {
id: owner.ptyId,
...(isObservedPtyExitEvidence(error)
...(blockedAtAttach && isObservedPtyExitEvidence(error)
? { exitedBeforeAttach: true as const }
: { reattachUnverifiable: true as const })
}
@@ -271,8 +274,13 @@ export async function attachStablePaneOwner(
export async function spawnForStablePane(
args: StablePaneSpawnContext
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> {
if (args.owner) {
const attached = await attachStablePaneOwner({ ...args, owner: args.owner })
if (attached) {
return attached
}
}
if (
!args.owner &&
isStablePaneResumeBlocked(
args.store,
args.spawnOptions.paneKey,
@@ -285,12 +293,6 @@ export async function spawnForStablePane(
owner: null
}
}
if (args.owner) {
const attached = await attachStablePaneOwner({ ...args, owner: args.owner })
if (attached) {
return attached
}
}
const result = await args.provider.spawn(args.spawnOptions)
args.onFreshSpawn?.(result)
return { result, owner: null }
@@ -94,4 +94,33 @@ describe('stable-pane fenced attach evidence', () => {
expect(spawn).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
})
it('rechecks a fence committed while attachment is awaiting the host', async () => {
const { store, ptyId } = fixture(null)
const record = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey]
delete (record as { automaticResumeBlockedBy?: string }).automaticResumeBlockedBy
const resolveOwner = () =>
resolveStablePaneOwner(undefined, store as unknown as Store, paneKey, worktreeId, null)
const owner = resolveOwner()
let calls = 0
const spawn = vi.fn(async () => {
if (++calls === 1) {
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
throw new SessionNotFoundError(ptyId)
}
return { id: 'replacement-after-fence' }
})
const result = await spawnForStablePane({
runtime: undefined,
store: store as unknown as Store,
provider: { spawn } as unknown as IPtyProvider,
owner,
worktreeId,
connectionId: null,
resolveOwner,
spawnOptions: { cols: 80, rows: 24, paneKey }
})
expect(result.result).toEqual({ id: ptyId, reattachUnverifiable: true })
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
describe('review host-scoped fence', () => {
it('reads SSH policy when the identical local pane is not fenced', () => {
const paneKey = 'tab-worker:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
const worktreeId = 'folder-worker'
const local = { sleepingAgentSessionsByPaneKey: {} }
const remote = {
sleepingAgentSessionsByPaneKey: {
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
}
}
const store = { getWorkspaceSession: vi.fn((host) => (host === 'ssh:host' ? remote : local)) }
expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true)
})
})
@@ -17,3 +17,5 @@ export function isStablePaneResumeBlocked(
worktreeId
)
}
export class StablePaneResumeBlockedError extends Error {}
+24 -1
View File
@@ -1,3 +1,7 @@
import {
isStablePaneResumeBlocked,
StablePaneResumeBlockedError
} from '../pane/stable-pane-resume-fence'
import type { PtySpawnResult } from '../../../providers/types'
import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
@@ -62,7 +66,12 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
if (
args.agentSessionEnsure &&
!ctx.preAdoptedStablePane &&
!stablePaneOwnerCandidate?.automaticResumeBlocked
!isStablePaneResumeBlocked(
ctx.deps.store,
ctx.spawnIdentityPaneKey,
args.worktreeId,
args.connectionId
)
) {
// Why: daemon-backed claims can outlive this controller; import all
// proven owners before deciding that an identity is absent.
@@ -84,6 +93,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
surface: args.agentSessionEnsure.surface,
spawn: async () => {
assertClientStillConnected()
if (
isStablePaneResumeBlocked(
ctx.deps.store,
ctx.spawnIdentityPaneKey,
args.worktreeId,
args.connectionId
)
) {
throw new StablePaneResumeBlockedError()
}
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
ctx.rejectedRegistrationCandidate = providerResult
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
@@ -201,6 +220,10 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
: ctx.result.wslDistro
)
} catch (err) {
if (err instanceof StablePaneResumeBlockedError) {
ctx.result = { id: ctx.sessionId ?? '', reattachUnverifiable: true }
return
}
if (
(ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) &&
ctx.effectiveSessionAppId
@@ -1,3 +1,4 @@
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createRemoteRuntimeTransportMocks,
@@ -26,6 +27,9 @@ describe('paired host attach evidence', () => {
'carries host %s without subscribing or publishing a spawn',
async (outcome) => {
runtimeCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'status.get') {
return { ok: true, result: { capabilities: [TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY] } }
}
if (method === 'terminal.resolvePane') {
return {
ok: false,
@@ -142,4 +146,41 @@ describe('paired host attach evidence', () => {
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
transport.destroy?.()
})
it('an old host with resolvePane must not create a replacement for a retained pane', async () => {
runtimeCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'terminal.resolvePane') {
return { ok: false, error: { code: 'terminal_not_found', message: 'terminal_not_found' } }
}
return {
ok: true,
result: {
terminal: {
handle: 'replacement',
ptyId: 'replacement-pty',
tabId: 'tab-1',
paneKey: 'tab-1:pane:1'
}
}
}
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
const result = await transport.connect({
url: '',
sessionId: 'remote:env-1@@retained-handle',
callbacks: {}
})
expect(result).toEqual({ id: 'remote:env-1@@retained-handle', reattachUnverifiable: true })
try {
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.create' })
)
} finally {
transport.destroy?.()
}
})
})
@@ -19,7 +19,10 @@ import type {
RuntimeTerminalResolvePane,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
@@ -2234,6 +2237,15 @@ export function createRemoteRuntimePtyTransport(
}
return { id: options.sessionId, reattachUnverifiable: true }
}
const status = await callRuntime<RuntimeStatus>('status.get')
if (destroyed || lifecycleEpoch !== connectLifecycleEpoch) {
return
}
if (!status.capabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)) {
connecting = false
emitRecoveryState()
return { id: options.sessionId, reattachUnverifiable: true }
}
}
const commandToSend = options.command ?? command
const startupCommandDeliveryToSend =
+2
View File
@@ -116,6 +116,7 @@ export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'worktree.create-idempotency.v1' as const
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const
export const TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY = 'terminal.fenced-create.v1' as const
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'terminal.create-idempotency.v2' as const
@@ -250,6 +251,7 @@ export const RUNTIME_CAPABILITIES = [
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
@@ -1,4 +1,5 @@
import {
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
@@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities(
new Set([
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
@@ -0,0 +1,8 @@
import { expect, it } from 'vitest'
import { RUNTIME_CAPABILITIES, TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from './protocol-version'
import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities'
it('advertises fenced create outcomes in host status and every paired client transport', () => {
expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
expect(remoteRuntimeClientCapabilities()).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
})