mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Fix browser annotation sends to active Windows agents (#8309)
* fix: authorize guarded sends to active ConPTY agents * Fix stale-handle rejection using controller-less status checks and contr - Distinguish `terminal_handle_stale` (PTY rebind mid-check) from `terminal_exited` so callers get an accurate reason instead of a generic `terminal_not_writable`. - Move the PTY binding assertion to always run after the fresh foreground read, even when no controller is present, closing a gap where a rebind during a controller-less check went undetected. - Call `confirmForegroundProcess` through its owning controller instance so the method keeps its `this` receiver instead of being invoked unbound. - Update reliability gates and design doc to reflect the new test coverage and corrected error semantics. * rm design doc
This commit is contained in:
@@ -30,10 +30,7 @@ import {
|
||||
removeWorktree
|
||||
} from '../git/worktree'
|
||||
import * as gitRunner from '../git/runner'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
@@ -1179,6 +1176,67 @@ const store = {
|
||||
getProjects: () => []
|
||||
}
|
||||
|
||||
async function createExplicitAgentStatusHarness(options: {
|
||||
getForegroundProcess: (ptyId: string) => Promise<string | null>
|
||||
confirmForegroundProcess?: (ptyId: string) => Promise<string | null>
|
||||
title?: string
|
||||
}): Promise<{
|
||||
runtime: OrcaRuntimeService
|
||||
handle: string
|
||||
syncPty: (ptyId: string | null) => void
|
||||
}> {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: options.getForegroundProcess,
|
||||
confirmForegroundProcess: options.confirmForegroundProcess
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
const syncPty = (ptyId: string | null): void => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: options.title ?? 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
syncPty('pty-1')
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
return { runtime, handle: terminal.handle, syncPty }
|
||||
}
|
||||
|
||||
function makeHeadlessTerminalLayout(
|
||||
ptyIdsByLeafId: Record<string, string | undefined>
|
||||
): TerminalLayoutSnapshot {
|
||||
@@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'claude')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('calls foreground confirmation with its controller receiver', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(
|
||||
async function (this: { getForegroundProcess: typeof getForegroundProcess }) {
|
||||
return this.getForegroundProcess === getForegroundProcess ? 'codex' : null
|
||||
}
|
||||
)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['shell', async () => 'pwsh.exe'],
|
||||
['non-agent', async () => 'vim'],
|
||||
['unavailable', async () => null],
|
||||
[
|
||||
'failure',
|
||||
async () => {
|
||||
throw new Error('provider unavailable')
|
||||
}
|
||||
]
|
||||
])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => {
|
||||
const confirmForegroundProcess = vi.fn(confirm)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed on a shell conflict when the controller cannot confirm it', async () => {
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'codex')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips both foreground reads when current title evidence blocks explicit hook state', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess,
|
||||
title: 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips foreground reads for permission title and blocked wait evidence', async () => {
|
||||
for (const blocked of ['title', 'wait'] as const) {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
blocked === 'title'
|
||||
? '\x1b]0;Codex waiting for permission\x07'
|
||||
: 'Hooks need review. Press enter to confirm\n',
|
||||
Date.now() + 1000
|
||||
)
|
||||
getForegroundProcess.mockClear()
|
||||
confirmForegroundProcess.mockClear()
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => {
|
||||
const foreground = deferred<string | null>()
|
||||
const getForegroundProcess = vi.fn(() => foreground.promise)
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
syncPty('pty-2')
|
||||
foreground.resolve('zsh')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a handle rebind while a controller-less status check yields', async () => {
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
runtime.setPtyController(null)
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
syncPty('pty-2')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => {
|
||||
const confirmation = deferred<string | null>()
|
||||
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'powershell.exe',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
syncPty('pty-2')
|
||||
confirmation.resolve('codex')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('rejects confirmation evidence when the owning PTY exits', async () => {
|
||||
const confirmation = deferred<string | null>()
|
||||
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'powershell.exe',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
runtime.onPtyExit('pty-1', 0)
|
||||
confirmation.resolve('codex')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_exited')
|
||||
})
|
||||
|
||||
it('reports permission from a title-derived action-required agent state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
|
||||
@@ -1205,6 +1205,7 @@ type RuntimePtyController = {
|
||||
stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise<boolean>
|
||||
getCwd?(ptyId: string): Promise<string | null>
|
||||
getForegroundProcess(ptyId: string): Promise<string | null>
|
||||
confirmForegroundProcess?(ptyId: string): Promise<string | null>
|
||||
hasChildProcesses?(ptyId: string): Promise<boolean>
|
||||
clearBuffer?(ptyId: string): Promise<void>
|
||||
resize?(ptyId: string, cols: number, rows: number): boolean
|
||||
@@ -10226,7 +10227,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
async getTerminalAgentStatus(handle: string): Promise<RuntimeTerminalAgentStatus> {
|
||||
const terminal = this.getTerminalAgentStatusSnapshot(handle)
|
||||
const ptyId = this.getTerminalAgentStatusPtyId(handle)
|
||||
const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId)
|
||||
const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle)
|
||||
const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText)
|
||||
const liveTitleClearsBlockedText =
|
||||
@@ -10250,7 +10252,8 @@ export class OrcaRuntimeService {
|
||||
// Fresh hook state is tighter, but current shell/management evidence wins.
|
||||
const isRunningAgent =
|
||||
!terminalTitleBlocksExplicitAgentStatus(terminal.title) &&
|
||||
!(await this.terminalHasShellForegroundProcess(handle))
|
||||
!(await this.terminalHasShellForegroundProcess(handle, ptyId))
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
return {
|
||||
handle,
|
||||
isRunningAgent,
|
||||
@@ -10261,14 +10264,42 @@ export class OrcaRuntimeService {
|
||||
return { handle, isRunningAgent: true, status: terminal.titleStatus }
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
isRunningAgent: await this.isTerminalRunningAgent(handle),
|
||||
status: null
|
||||
}
|
||||
const isRunningAgent = await this.isTerminalRunningAgent(handle)
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
return { handle, isRunningAgent, status: null }
|
||||
}
|
||||
|
||||
private getTerminalAgentStatusSnapshot(handle: string): {
|
||||
private getTerminalAgentStatusPtyId(handle: string): string {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
if (!pty.pty.connected) {
|
||||
throw new Error('terminal_gone')
|
||||
}
|
||||
return pty.pty.ptyId
|
||||
}
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
if (getTerminalState(leaf) !== 'running') {
|
||||
throw new Error('terminal_exited')
|
||||
}
|
||||
if (!leaf.ptyId) {
|
||||
throw new Error('terminal_gone')
|
||||
}
|
||||
return leaf.ptyId
|
||||
}
|
||||
|
||||
private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void {
|
||||
if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) {
|
||||
return
|
||||
}
|
||||
// Why: delayed process evidence belongs only to the PTY that started the
|
||||
// read, while callers still rely on the established stale-handle contract.
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
|
||||
private getTerminalAgentStatusSnapshot(
|
||||
handle: string,
|
||||
expectedPtyId: string
|
||||
): {
|
||||
waitText: string
|
||||
waitBlockedAt: number | null
|
||||
title: string | null
|
||||
@@ -10277,8 +10308,8 @@ export class OrcaRuntimeService {
|
||||
} {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
if (!pty.pty.connected) {
|
||||
throw new Error('terminal_gone')
|
||||
if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId)
|
||||
const leafTitle = leaf
|
||||
@@ -10316,6 +10347,9 @@ export class OrcaRuntimeService {
|
||||
if (!leaf.ptyId) {
|
||||
throw new Error('terminal_gone')
|
||||
}
|
||||
if (leaf.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
const title = getLatestAgentCandidateTitleInfo(
|
||||
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
|
||||
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt },
|
||||
@@ -10330,21 +10364,36 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private async terminalHasShellForegroundProcess(handle: string): Promise<boolean> {
|
||||
private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise<boolean> {
|
||||
if (!this.ptyController) {
|
||||
return false
|
||||
}
|
||||
let foregroundProcess: string | null
|
||||
try {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
|
||||
return foregroundProcess !== null && isShellProcess(foregroundProcess)
|
||||
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
|
||||
} catch {
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
return false
|
||||
}
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
if (!foregroundProcess || !isShellProcess(foregroundProcess)) {
|
||||
return false
|
||||
}
|
||||
const confirmationController = this.ptyController
|
||||
if (!confirmationController?.confirmForegroundProcess) {
|
||||
return true
|
||||
}
|
||||
let confirmedProcess: string | null
|
||||
try {
|
||||
confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId)
|
||||
} catch {
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
return true
|
||||
}
|
||||
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
|
||||
// Why: hook identity is generic; strong provider evidence only needs to
|
||||
// prove that some recognized agent still owns this exact PTY.
|
||||
return recognizeAgentProcess(confirmedProcess) === null
|
||||
}
|
||||
|
||||
private shouldDelayPtyBackedMobileSnapshotForForegroundAgent(
|
||||
@@ -20876,7 +20925,8 @@ export class OrcaRuntimeService {
|
||||
// status without throwing on stale handles, so this returns null on any error.
|
||||
getAgentStatusForHandle(handle: string): string | null {
|
||||
try {
|
||||
return this.getTerminalAgentStatusSnapshot(handle).titleStatus
|
||||
const ptyId = this.getTerminalAgentStatusPtyId(handle)
|
||||
return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean {
|
||||
return message.includes('terminal_guard_not_writable')
|
||||
}
|
||||
|
||||
function isTerminalAgentStatusNotWritable(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return [
|
||||
'terminal_not_writable',
|
||||
'terminal_handle_stale',
|
||||
'terminal_gone',
|
||||
'terminal_exited'
|
||||
].some((code) => message.includes(code))
|
||||
}
|
||||
|
||||
function assertTerminalSendExactPtyBinding(
|
||||
runtime: OrcaRuntimeService,
|
||||
handle: string,
|
||||
expectedPtyId: string | undefined
|
||||
): void {
|
||||
try {
|
||||
if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the stable guarded-send result below.
|
||||
}
|
||||
throw new Error('terminal_guard_not_writable')
|
||||
}
|
||||
|
||||
function appendPendingMultiplexOutput(
|
||||
stream: TerminalMultiplexStream,
|
||||
data: string,
|
||||
@@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const assertSendPreconditions =
|
||||
params.requireAgentStatus === 'sendable'
|
||||
? async (ptyId?: string): Promise<void> => {
|
||||
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
|
||||
if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
|
||||
throw new Error('terminal_guard_not_writable')
|
||||
}
|
||||
const agentStatus = await runtime.getTerminalAgentStatus(params.terminal)
|
||||
let agentStatus
|
||||
try {
|
||||
agentStatus = await runtime.getTerminalAgentStatus(params.terminal)
|
||||
} catch (error) {
|
||||
if (isTerminalAgentStatusNotWritable(error)) {
|
||||
throw new Error('terminal_guard_not_writable')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Why: a send callback can race a pane reconnect; status evidence
|
||||
// must never authorize the callback's replacement PTY.
|
||||
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
|
||||
if (!agentStatus.isRunningAgent) {
|
||||
throw new Error('terminal_guard_no_agent')
|
||||
}
|
||||
|
||||
@@ -525,6 +525,84 @@ describe('terminal send RPC', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => {
|
||||
let boundPtyId = 'pty-1'
|
||||
let statusCalls = 0
|
||||
const write = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
getTerminalAgentStatus: vi.fn().mockImplementation(async () => {
|
||||
statusCalls += 1
|
||||
if (statusCalls === 2) {
|
||||
boundPtyId = 'pty-2'
|
||||
}
|
||||
return {
|
||||
handle: 'terminal-1',
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
}
|
||||
}),
|
||||
sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => {
|
||||
await options.beforeWrite('pty-1')
|
||||
write('pty-1', '\r')
|
||||
return { handle: 'terminal-1', accepted: true, bytesWritten: 1 }
|
||||
})
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
enter: true,
|
||||
requireAgentStatus: 'sendable',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { send: { accepted: false, bytesWritten: 0 } }
|
||||
})
|
||||
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => {
|
||||
const write = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
getTerminalAgentStatus: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
}),
|
||||
sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => {
|
||||
await options.beforeWrite('pty-2')
|
||||
write('pty-2', '\r')
|
||||
return { handle: 'terminal-1', accepted: true, bytesWritten: 1 }
|
||||
})
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
enter: true,
|
||||
requireAgentStatus: 'sendable',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { send: { accepted: false, bytesWritten: 0 } }
|
||||
})
|
||||
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses guarded combined text and submit sends before any PTY write', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
|
||||
Reference in New Issue
Block a user