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:
Surprise233hhh 2026-07-13 14:32:17 +08:00 committed by GitHub
parent c15c2174ca
commit 91f56c7255
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 111 additions and 2 deletions

View File

@ -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 })
})
})

View File

@ -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)