mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(runtime): negotiate terminal refusal responses for older clients
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
+113
@@ -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?.()
|
||||
}
|
||||
})
|
||||
Vendored
+10
@@ -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
Reference in New Issue
Block a user