Fix Windows focus stealing from agent foreground-process scan (hide conhost window) (#8053)
* Hide console window for Windows agent foreground-process scan Agent foreground-process inspection re-forks powershell.exe (or the wmic fallback) to detect which agent runs in each terminal. Both spawns omitted windowsHide, so on Windows each fork popped a fresh conhost console window that flashed and stole keyboard focus from the foreground app — including Orca's own terminal — recurring roughly once every few tens of seconds while an agent session was open (and more often under continuous agent output). Add windowsHide: true to both probes (matching the codebase-wide convention) plus a regression test asserting the spawn options. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(windows): include scan root in foreground fixtures --------- Co-authored-by: xucongwei <xucongwei@bytedance.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
parent
c15c2174ca
commit
91f56c7255
|
|
@ -0,0 +1,99 @@
|
|||
// Regression guard: the Windows agent foreground-process scan re-forks
|
||||
// powershell.exe (or the wmic fallback) on a ~1s/pane cadence. Electron's main
|
||||
// process has no console, so a spawn without windowsHide pops a fresh conhost
|
||||
// window per scan that flashes and steals keyboard focus from the foreground app
|
||||
// (including Orca's own terminal). Both probes MUST pass windowsHide: true.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import {
|
||||
queryWindowsProcessDescendants,
|
||||
resetWindowsProcessRowsSnapshotForTests
|
||||
} from './windows-foreground-process-rows'
|
||||
|
||||
type ExecFileCallback = (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
type ExecFileCall = [string, string[], Record<string, unknown>, ExecFileCallback]
|
||||
|
||||
const POWERSHELL_ROWS_JSON = JSON.stringify([
|
||||
{
|
||||
ProcessId: 100,
|
||||
ParentProcessId: 50,
|
||||
Name: 'powershell.exe',
|
||||
CommandLine: 'powershell.exe',
|
||||
ExecutablePath: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
|
||||
},
|
||||
{
|
||||
ProcessId: 200,
|
||||
ParentProcessId: 100,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
|
||||
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
|
||||
}
|
||||
])
|
||||
|
||||
const WMIC_ROWS_VALUE =
|
||||
'CommandLine=powershell.exe\n' +
|
||||
'ExecutablePath=C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe\n' +
|
||||
'Name=powershell.exe\n' +
|
||||
'ParentProcessId=50\n' +
|
||||
'ProcessId=100\n\n' +
|
||||
'CommandLine=node C:/Users/dev/AppData/codex/bin/codex.js\n' +
|
||||
'ExecutablePath=C:/Program Files/nodejs/node.exe\n' +
|
||||
'Name=node.exe\n' +
|
||||
'ParentProcessId=100\n' +
|
||||
'ProcessId=200\n'
|
||||
|
||||
/** Returns the options object passed to the mocked execFile for a given command. */
|
||||
function optionsForCommand(command: string): Record<string, unknown> | undefined {
|
||||
const call = execFileMock.mock.calls.find((args) => (args as ExecFileCall)[0] === command) as
|
||||
| ExecFileCall
|
||||
| undefined
|
||||
return call?.[2]
|
||||
}
|
||||
|
||||
describe('windows foreground process rows spawn options', () => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
resetWindowsProcessRowsSnapshotForTests()
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides the console window for the powershell process-table scan', async () => {
|
||||
execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => {
|
||||
cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' })
|
||||
})
|
||||
|
||||
const candidates = await queryWindowsProcessDescendants(100)
|
||||
|
||||
expect(candidates?.[0]?.pid).toBe(200)
|
||||
expect(optionsForCommand('powershell.exe')).toMatchObject({ windowsHide: true })
|
||||
})
|
||||
|
||||
it('hides the console window for the wmic fallback scan', async () => {
|
||||
execFileMock.mockImplementation((cmd: string, _args, _opts, cb: ExecFileCallback) => {
|
||||
// Force the powershell probe to miss so the wmic fallback runs.
|
||||
if (cmd === 'powershell.exe') {
|
||||
cb(new Error('powershell unavailable'), { stdout: '', stderr: '' })
|
||||
return
|
||||
}
|
||||
cb(null, { stdout: WMIC_ROWS_VALUE, stderr: '' })
|
||||
})
|
||||
|
||||
const candidates = await queryWindowsProcessDescendants(100)
|
||||
|
||||
expect(candidates?.[0]?.pid).toBe(200)
|
||||
expect(optionsForCommand('wmic')).toMatchObject({ windowsHide: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -209,6 +209,7 @@ function collectDescendants<Row extends { pid: number; ppid: number }>(
|
|||
return descendants
|
||||
}
|
||||
|
||||
/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */
|
||||
async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[] | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
|
|
@ -217,7 +218,12 @@ async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[
|
|||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
// Why: this scan re-forks on a ~1s/pane cadence. Electron's main has no
|
||||
// console, so without windowsHide each fork pops a fresh conhost window
|
||||
// that flashes and steals keyboard focus from the foreground app
|
||||
// (including Orca's own terminal).
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
const rows = parseWindowsProcessJsonRows(stdout)
|
||||
|
|
@ -227,6 +233,7 @@ async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[
|
|||
}
|
||||
}
|
||||
|
||||
/** Fallback whole-process-table scan via wmic when PowerShell is unavailable. */
|
||||
async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
|
|
@ -240,7 +247,10 @@ async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | nu
|
|||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
// Why: same focus-stealing hazard as the powershell probe — hide the
|
||||
// wmic fallback's console window too.
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
const rows = parseWindowsProcessValueRows(stdout)
|
||||
|
|
|
|||
Loading…
Reference in New Issue