fix(runtime): negotiate terminal refusal responses for older clients

This commit is contained in:
Jinwoo-H
2026-09-07 20:25:38 -04:00
parent 2315bc2a39
commit 34a70e8f25
9 changed files with 3175 additions and 41 deletions
@@ -0,0 +1,93 @@
import { expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { Store } from '../../../persistence'
import { SessionNotFoundError } from '../../../daemon/daemon-errors'
import type { IPtyProvider } from '../../../providers/types'
import { resolveStablePaneOwner, spawnForStablePane } from './stable-owner'
const tabId = 'tab-worker'
const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
const paneKey = makePaneKey(tabId, leafId)
const worktreeId = 'folder-worker'
function fixture(connectionId: string | null) {
const ptyId = connectionId ? `ssh:${connectionId}@@worker` : 'worker'
let session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, type: 'terminal' }] },
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
ptyIdsByLeafId: { [leafId]: ptyId }
}
},
sleepingAgentSessionsByPaneKey: {
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
}
}
const store = {
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
}),
flushOrThrow: vi.fn()
}
return {
ptyId,
store,
owner: resolveStablePaneOwner(
undefined,
store as unknown as Store,
paneKey,
worktreeId,
connectionId
)
}
}
it('a fence queued after retirement still stops fresh fallback', async () => {
const { store, ptyId } = fixture(null)
delete (
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey] as {
automaticResumeBlockedBy?: string
}
).automaticResumeBlockedBy
const resolveOwner = () => {
const owner = resolveStablePaneOwner(
undefined,
store as unknown as Store,
paneKey,
worktreeId,
null
)
if (!owner) {
queueMicrotask(() => {
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
paneKey
].automaticResumeBlockedBy = 'legacy-orchestration-worker'
})
}
return owner
}
const owner = resolveOwner()
const spawn = vi.fn(async (options) => {
if (options.attachOnly) {
throw new SessionNotFoundError(ptyId)
}
return { id: 'replacement-despite-new-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).toMatchObject({ reattachUnverifiable: true })
expect(spawn).toHaveBeenCalledTimes(1)
})
+21 -8
View File
@@ -1,3 +1,4 @@
import { negotiateTerminalCreateRefusal } from '../terminal-create-refusal-negotiation'
import { z } from 'zod'
import {
getAgentResumeArgv,
@@ -237,20 +238,32 @@ export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.ensureAgentSession',
params: EnsureAgentSessionParams,
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) =>
(runtime as AgentSessionRuntime).ensureAgentSession(
withExecutionHostAgentPresentation(params, clientKind),
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
handler: async (
params,
{ runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities }
) =>
negotiateTerminalCreateRefusal(
await (runtime as AgentSessionRuntime).ensureAgentSession(
withExecutionHostAgentPresentation(params, clientKind),
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
),
clientCapabilities
)
}),
defineMethod({
name: 'terminal.createAgentSession',
params: CreateAgentSessionParams,
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => {
handler: async (
params,
{ runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities }
) => {
assertOperationTimestampWithinFutureSkew(params.clientOperationId)
return (runtime as AgentSessionRuntime).createAgentSession(
withExecutionHostAgentPresentation(params, clientKind),
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
return negotiateTerminalCreateRefusal(
await (runtime as AgentSessionRuntime).createAgentSession(
withExecutionHostAgentPresentation(params, clientKind),
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
),
clientCapabilities
)
}
})
@@ -1,3 +1,4 @@
import { negotiateTerminalCreateRefusal } from '../../terminal-create-refusal-negotiation'
import { defineMethod, type RpcAnyMethod } from '../../core'
import {
navigationTargetsHost,
@@ -34,7 +35,10 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.create',
params: TerminalCreateParams,
handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => {
handler: async (
params,
{ runtime, pairedDeviceId, clientId, clientKind, clientCapabilities }
) => {
// A focused terminal create predates paired-client navigation. Keep the
// authority boundary here so a remote caller cannot activate the host
// renderer. This legacy RPC remains a background create for paired viewers;
@@ -44,38 +48,41 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
const activate = pairedViewer ? false : params.activate === true
const presentation =
pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation
return {
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus,
rendererBacked: params.rendererBacked === true,
activate,
presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
}
return negotiateTerminalCreateRefusal(
{
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus,
rendererBacked: params.rendererBacked === true,
activate,
presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
},
clientCapabilities
)
}
}),
defineMethod({
@@ -0,0 +1,64 @@
import type { RpcMethod } from './core'
import { expect, it, vi } from 'vitest'
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { terminalAttachRefusal } from '../terminal-attach-refusal'
import { AGENT_SESSION_METHODS } from './methods/agent-session'
import { TERMINAL_LIFECYCLE_METHODS } from './methods/terminal/terminal-lifecycle-methods'
import { mapRuntimeError } from './errors'
it.each(['terminal.create', 'terminal.createAgentSession', 'terminal.ensureAgentSession'])(
'%s negotiates both refusal outcomes without changing ordinary success',
async (method) => {
const definition = [...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS].find(
(entry) => entry.name === method
)! as RpcMethod
for (const outcome of ['exitedBeforeAttach', 'reattachUnverifiable', 'success'] as const) {
const owner = {
handle: 'retained',
tabId: 'tab-1',
paneKey: 'tab-1:pane',
worktreeId: 'wt-1'
}
const terminal =
outcome === 'success'
? { ...owner, ptyId: 'worker', title: null }
: terminalAttachRefusal({ id: 'worker', [outcome]: true }, owner)!
const result = { terminal, disposition: 'created' }
const runtime = {
createTerminal: vi.fn(async () => terminal),
dedupeTerminalCreate: vi.fn(async () => terminal),
createAgentSession: vi.fn(async () => result),
ensureAgentSession: vi.fn(async () => result)
}
for (const clientCapabilities of [
undefined,
[],
[TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY]
]) {
const call = definition.handler(
{
worktree: 'wt-1',
agent: 'codex',
kind: 'explicit',
providerSession: { key: 'session_id', id: 'session-1' },
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef'
},
{ runtime, clientCapabilities } as never
)
if (outcome === 'success' || clientCapabilities?.length) {
await expect(call).resolves.toMatchObject({ terminal })
} else {
await expect(call).rejects.toMatchObject({ code: 'remote_runtime_unavailable' })
try {
await call
} catch (error) {
expect(mapRuntimeError('request', { runtimeId: 'host' }, error)).toMatchObject({
ok: false,
error: { code: 'remote_runtime_unavailable' }
})
}
}
}
}
}
)
@@ -0,0 +1,19 @@
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { RuntimeTerminalCreate } from '../../../shared/runtime-terminal-contracts'
import type { RpcContext } from './core'
export function negotiateTerminalCreateRefusal<T extends { terminal: RuntimeTerminalCreate }>(
result: T,
clientCapabilities: RpcContext['clientCapabilities']
): T {
if (
(result.terminal.exitedBeforeAttach || result.terminal.reattachUnverifiable) &&
!clientCapabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
) {
// Older clients interpret every successful create as permission to publish a fresh binding.
throw Object.assign(new Error('Remote terminal attachment is temporarily unavailable.'), {
code: 'remote_runtime_unavailable'
})
}
return result
}
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { transformSync } from 'esbuild'
import type { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
export async function loadPrBasePtyTransport(): Promise<{
createRemoteRuntimePtyTransport: typeof createRemoteRuntimePtyTransport
}> {
// Frozen PR-base reader, evaluated with the same mocked dependencies as the current transport.
const source = readFileSync(
resolve('tests/fixtures/terminal/pr-base-1d1b73c40850-pty-transport.txt'),
'utf8'
)
const { code: outputText } = transformSync(source, { loader: 'ts', format: 'cjs' })
const dependencies = new Map<string, unknown>()
for (const match of outputText.matchAll(/require\("([^"]+)"\)/g)) {
const specifier = match[1]
if (!dependencies.has(specifier)) {
const target = specifier.startsWith('@/')
? resolve('src/renderer/src', specifier.slice(2))
: resolve(__dirname, specifier)
dependencies.set(specifier, await import(/* @vite-ignore */ target))
}
}
const exports = {} as { createRemoteRuntimePtyTransport: typeof createRemoteRuntimePtyTransport }
const module = { exports }
new Function('require', 'exports', 'module', outputText)(
(name: string) => dependencies.get(name),
exports,
module
)
return module.exports
}
@@ -0,0 +1,113 @@
import { resolve } from 'node:path'
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { loadPrBasePtyTransport } from './pr-base-pty-transport-test-loader'
import { beforeEach, it, expect, vi } from 'vitest'
import {
createRemoteRuntimeTransportMocks,
type MultiplexSubscriptionCallbacks
} from './remote-runtime-pty-transport-test-harness'
let callbacks: MultiplexSubscriptionCallbacks = null
let handle = 'terminal-1'
const { runtimeCall, runtimeSubscribe, resetRemoteRuntimeTransport } =
createRemoteRuntimeTransportMocks({
getCallbacks: () => callbacks,
setCallbacks: (c) => {
callbacks = c
},
getResolvedPaneHandle: () => handle,
setResolvedPaneHandle: (h) => {
handle = h
}
})
beforeEach(resetRemoteRuntimeTransport)
it.each([
['old', 'exitedBeforeAttach'],
['old', 'reattachUnverifiable'],
['current', 'exitedBeforeAttach'],
['current', 'reattachUnverifiable']
] as const)('%s client preserves host %s refusal without a fake spawn', async (reader, outcome) => {
const hostPath = resolve(__dirname, '../../../../main/runtime')
const { terminalAttachRefusal } = await import(`${hostPath}/terminal-attach-refusal`)
const { mapRuntimeError } = await import(`${hostPath}/rpc/errors`)
const { TERMINAL_LIFECYCLE_METHODS } = await import(
`${hostPath}/rpc/methods/terminal/terminal-lifecycle-methods`
)
const refused = terminalAttachRefusal(
{ id: 'old-worker-pty', [outcome]: true },
{
handle: 'unpublished-refusal-handle',
tabId: 'tab-1',
paneKey: 'tab-1:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b',
worktreeId: 'wt-1'
}
)!
runtimeCall.mockImplementation(
async ({ method, params }: { method: string; params: unknown }) => {
if (method === 'terminal.resolvePane') {
return { ok: false, error: { code: 'terminal_not_found', message: 'terminal_not_found' } }
}
if (method === 'status.get') {
return { ok: true, result: { capabilities: ['terminal.fenced-create.v1'] } }
}
if (method === 'terminal.create') {
const definition = TERMINAL_LIFECYCLE_METHODS.find(
(m: { name: string }) => m.name === 'terminal.create'
)!
try {
const result = await definition.handler(params, {
clientKind: 'runtime',
clientCapabilities: reader === 'old' ? [] : [TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY],
runtime: {
dedupeTerminalCreate: async (
_client: string,
wt: string,
_id: unknown,
_reconcile: boolean,
create: (worktree: string, handle: string) => Promise<unknown>
) => create(wt, 'unpublished-refusal-handle'),
createTerminal: async () => refused
}
} as never)
return { ok: true, result }
} catch (error) {
return mapRuntimeError('request', { runtimeId: 'host' }, error)
}
}
return { ok: true, result: {} }
}
)
const { createRemoteRuntimePtyTransport } =
reader === 'old'
? await loadPrBasePtyTransport()
: await import('./remote-runtime-pty-transport')
const onPtySpawn = vi.fn()
const onExit = vi.fn()
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b',
onPtySpawn
})
try {
const result = await transport.connect({
url: '',
sessionId: 'remote:env-1@@retained-handle',
callbacks: { onExit, onError }
})
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.create' }))
expect(onPtySpawn).not.toHaveBeenCalled()
expect(onExit).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
if (reader === 'old') {
expect(result).toBeUndefined()
expect(transport.getRecoveryState?.()).toMatchObject({ phase: 'disconnected' })
} else {
expect(result).toEqual({ id: 'remote:env-1@@retained-handle', [outcome]: true })
}
expect(runtimeSubscribe).not.toHaveBeenCalled()
} finally {
transport.destroy?.()
}
})
+10
View File
@@ -0,0 +1,10 @@
`pr-base-1d1b73c40850-pty-transport.txt` is the exact
`src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts` from PR
#19358's base commit `1d1b73c40850`, excluding only its leading max-lines lint
suppression comment. Keep this historical reader frozen.
The compatibility test compiles this data fixture as CommonJS with esbuild and
loads its dependencies through Vitest's mocked module graph. It exercises the
historical create-success/failure behavior without shipping the old transport or
adding a source line-cap exemption. It is not a full historical client binary;
transitive dependencies and stream doubles come from the current checkout.
File diff suppressed because it is too large Load Diff