fix windows desktop terminal popups (#1199)
This commit is contained in:
@@ -231,9 +231,6 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
const bundledPython = isWin
|
const bundledPython = isWin
|
||||||
? join(pythonDir(), 'python.exe')
|
? join(pythonDir(), 'python.exe')
|
||||||
: join(pythonDir(), 'bin', 'python3')
|
: join(pythonDir(), 'bin', 'python3')
|
||||||
const bundledPythonNoWindow = isWin
|
|
||||||
? join(pythonDir(), 'pythonw.exe')
|
|
||||||
: bundledPython
|
|
||||||
const bridgePort = await getFreeTcpPort()
|
const bridgePort = await getFreeTcpPort()
|
||||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||||
const loginShellPath = await getLoginShellPath()
|
const loginShellPath = await getLoginShellPath()
|
||||||
@@ -257,7 +254,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
// ready handshakes. Use python.exe on Windows and hide windows at the
|
// ready handshakes. Use python.exe on Windows and hide windows at the
|
||||||
// process creation layer instead of switching the bridge to pythonw.exe.
|
// process creation layer instead of switching the bridge to pythonw.exe.
|
||||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_CLI_PYTHON: existsSync(bundledPythonNoWindow) ? bundledPythonNoWindow : bundledPython,
|
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_ROOT: pythonDir(),
|
HERMES_AGENT_ROOT: pythonDir(),
|
||||||
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ delimited JSON request/response protocol over a local socket.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import copy
|
import copy
|
||||||
import errno
|
import errno
|
||||||
@@ -71,6 +72,14 @@ def _hidden_subprocess_kwargs() -> dict[str, int]:
|
|||||||
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_hidden_creationflags(kwargs: dict[str, Any], create_no_window: int) -> None:
|
||||||
|
flags = kwargs.get("creationflags", 0) or 0
|
||||||
|
try:
|
||||||
|
kwargs["creationflags"] = int(flags) | create_no_window
|
||||||
|
except Exception:
|
||||||
|
kwargs["creationflags"] = create_no_window
|
||||||
|
|
||||||
|
|
||||||
def _install_windows_hidden_subprocess_defaults() -> None:
|
def _install_windows_hidden_subprocess_defaults() -> None:
|
||||||
"""Hide console windows for subprocesses launched inside desktop bridge runs.
|
"""Hide console windows for subprocesses launched inside desktop bridge runs.
|
||||||
|
|
||||||
@@ -87,18 +96,26 @@ def _install_windows_hidden_subprocess_defaults() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
original_popen = subprocess.Popen
|
original_popen = subprocess.Popen
|
||||||
|
original_create_subprocess_exec = asyncio.create_subprocess_exec
|
||||||
|
original_create_subprocess_shell = asyncio.create_subprocess_shell
|
||||||
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000
|
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000
|
||||||
|
|
||||||
class HiddenPopen(original_popen): # type: ignore[misc, valid-type]
|
class HiddenPopen(original_popen): # type: ignore[misc, valid-type]
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
flags = kwargs.get("creationflags", 0) or 0
|
_add_hidden_creationflags(kwargs, create_no_window)
|
||||||
try:
|
|
||||||
kwargs["creationflags"] = int(flags) | create_no_window
|
|
||||||
except Exception:
|
|
||||||
kwargs["creationflags"] = create_no_window
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
async def hidden_create_subprocess_exec(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
_add_hidden_creationflags(kwargs, create_no_window)
|
||||||
|
return await original_create_subprocess_exec(*args, **kwargs)
|
||||||
|
|
||||||
|
async def hidden_create_subprocess_shell(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
_add_hidden_creationflags(kwargs, create_no_window)
|
||||||
|
return await original_create_subprocess_shell(*args, **kwargs)
|
||||||
|
|
||||||
subprocess.Popen = HiddenPopen # type: ignore[assignment]
|
subprocess.Popen = HiddenPopen # type: ignore[assignment]
|
||||||
|
asyncio.create_subprocess_exec = hidden_create_subprocess_exec # type: ignore[assignment]
|
||||||
|
asyncio.create_subprocess_shell = hidden_create_subprocess_shell # type: ignore[assignment]
|
||||||
subprocess._hermes_hidden_defaults_installed = True # type: ignore[attr-defined]
|
subprocess._hermes_hidden_defaults_installed = True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ function bundledCliPythonForWindows(hermesBin: string): string | null {
|
|||||||
if (envPython) return envPython
|
if (envPython) return envPython
|
||||||
|
|
||||||
if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null
|
if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null
|
||||||
const pythonw = resolve(dirname(hermesBin), '..', 'pythonw.exe')
|
|
||||||
if (existsSync(pythonw)) return pythonw
|
|
||||||
const python = resolve(dirname(hermesBin), '..', 'python.exe')
|
const python = resolve(dirname(hermesBin), '..', 'python.exe')
|
||||||
return existsSync(python) ? python : null
|
return existsSync(python) ? python : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withWindowsHide<T extends ExecFileOptions | SpawnOptions>(options?: T): T {
|
||||||
|
if (process.platform !== 'win32') return (options || {}) as T
|
||||||
|
return { windowsHide: true, ...(options || {}) } as T
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation {
|
export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const python = bundledCliPythonForWindows(hermesBin)
|
const python = bundledCliPythonForWindows(hermesBin)
|
||||||
@@ -47,7 +50,7 @@ export function execHermesWithBin(
|
|||||||
execFile(
|
execFile(
|
||||||
invocation.command,
|
invocation.command,
|
||||||
[...invocation.argsPrefix, ...args],
|
[...invocation.argsPrefix, ...args],
|
||||||
{ ...options, encoding: 'utf8' },
|
{ ...withWindowsHide(options), encoding: 'utf8' },
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
rejectExec(Object.assign(error, { stdout, stderr }))
|
rejectExec(Object.assign(error, { stdout, stderr }))
|
||||||
@@ -69,7 +72,7 @@ export function spawnHermesWithBin(
|
|||||||
options?: SpawnOptions,
|
options?: SpawnOptions,
|
||||||
): ChildProcess {
|
): ChildProcess {
|
||||||
const invocation = resolveHermesInvocation(hermesBin)
|
const invocation = resolveHermesInvocation(hermesBin)
|
||||||
return spawn(invocation.command, [...invocation.argsPrefix, ...args], options || {})
|
return spawn(invocation.command, [...invocation.argsPrefix, ...args], withWindowsHide(options))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnHermes(args: readonly string[], options?: SpawnOptions): ChildProcess {
|
export function spawnHermes(args: readonly string[], options?: SpawnOptions): ChildProcess {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ print(json.dumps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('agent bridge Windows desktop subprocess defaults', () => {
|
describe('agent bridge Windows desktop subprocess defaults', () => {
|
||||||
it('adds CREATE_NO_WINDOW to nested subprocesses without replacing existing flags', async () => {
|
it('adds CREATE_NO_WINDOW to sync and async nested subprocesses without replacing existing flags', async () => {
|
||||||
const result = await runBridgeProbe(String.raw`
|
const result = await runBridgeProbe(String.raw`
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
@@ -121,6 +121,8 @@ spec.loader.exec_module(bridge)
|
|||||||
|
|
||||||
original_os_name = bridge.os.name
|
original_os_name = bridge.os.name
|
||||||
original_popen = bridge.subprocess.Popen
|
original_popen = bridge.subprocess.Popen
|
||||||
|
original_async_exec = bridge.asyncio.create_subprocess_exec
|
||||||
|
original_async_shell = bridge.asyncio.create_subprocess_shell
|
||||||
original_create_no_window = getattr(bridge.subprocess, "CREATE_NO_WINDOW", None)
|
original_create_no_window = getattr(bridge.subprocess, "CREATE_NO_WINDOW", None)
|
||||||
original_installed = getattr(bridge.subprocess, "_hermes_hidden_defaults_installed", None)
|
original_installed = getattr(bridge.subprocess, "_hermes_hidden_defaults_installed", None)
|
||||||
|
|
||||||
@@ -130,10 +132,22 @@ class FakePopen:
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
FakePopen.calls.append({"args": args, "kwargs": kwargs})
|
FakePopen.calls.append({"args": args, "kwargs": kwargs})
|
||||||
|
|
||||||
|
async_calls = []
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
async_calls.append({"kind": "exec", "args": args, "kwargs": kwargs})
|
||||||
|
return {"kind": "exec"}
|
||||||
|
|
||||||
|
async def fake_create_subprocess_shell(*args, **kwargs):
|
||||||
|
async_calls.append({"kind": "shell", "args": args, "kwargs": kwargs})
|
||||||
|
return {"kind": "shell"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bridge.os.name = "nt"
|
bridge.os.name = "nt"
|
||||||
bridge.os.environ["HERMES_DESKTOP"] = "true"
|
bridge.os.environ["HERMES_DESKTOP"] = "true"
|
||||||
bridge.subprocess.Popen = FakePopen
|
bridge.subprocess.Popen = FakePopen
|
||||||
|
bridge.asyncio.create_subprocess_exec = fake_create_subprocess_exec
|
||||||
|
bridge.asyncio.create_subprocess_shell = fake_create_subprocess_shell
|
||||||
bridge.subprocess.CREATE_NO_WINDOW = 0x08000000
|
bridge.subprocess.CREATE_NO_WINDOW = 0x08000000
|
||||||
if hasattr(bridge.subprocess, "_hermes_hidden_defaults_installed"):
|
if hasattr(bridge.subprocess, "_hermes_hidden_defaults_installed"):
|
||||||
delattr(bridge.subprocess, "_hermes_hidden_defaults_installed")
|
delattr(bridge.subprocess, "_hermes_hidden_defaults_installed")
|
||||||
@@ -141,9 +155,15 @@ try:
|
|||||||
bridge._install_windows_hidden_subprocess_defaults()
|
bridge._install_windows_hidden_subprocess_defaults()
|
||||||
bridge.subprocess.Popen(["git", "status"], creationflags=0x00000200)
|
bridge.subprocess.Popen(["git", "status"], creationflags=0x00000200)
|
||||||
flags = FakePopen.calls[0]["kwargs"]["creationflags"]
|
flags = FakePopen.calls[0]["kwargs"]["creationflags"]
|
||||||
|
bridge.asyncio.run(bridge.asyncio.create_subprocess_exec("git", "status", creationflags=0x00000400))
|
||||||
|
bridge.asyncio.run(bridge.asyncio.create_subprocess_shell("git status"))
|
||||||
|
async_exec_flags = async_calls[0]["kwargs"]["creationflags"]
|
||||||
|
async_shell_flags = async_calls[1]["kwargs"]["creationflags"]
|
||||||
finally:
|
finally:
|
||||||
bridge.os.name = original_os_name
|
bridge.os.name = original_os_name
|
||||||
bridge.subprocess.Popen = original_popen
|
bridge.subprocess.Popen = original_popen
|
||||||
|
bridge.asyncio.create_subprocess_exec = original_async_exec
|
||||||
|
bridge.asyncio.create_subprocess_shell = original_async_shell
|
||||||
if original_create_no_window is None:
|
if original_create_no_window is None:
|
||||||
try:
|
try:
|
||||||
delattr(bridge.subprocess, "CREATE_NO_WINDOW")
|
delattr(bridge.subprocess, "CREATE_NO_WINDOW")
|
||||||
@@ -163,6 +183,11 @@ print(json.dumps({
|
|||||||
"flags": flags,
|
"flags": flags,
|
||||||
"has_create_no_window": bool(flags & 0x08000000),
|
"has_create_no_window": bool(flags & 0x08000000),
|
||||||
"kept_existing_flag": bool(flags & 0x00000200),
|
"kept_existing_flag": bool(flags & 0x00000200),
|
||||||
|
"async_exec_flags": async_exec_flags,
|
||||||
|
"async_exec_has_create_no_window": bool(async_exec_flags & 0x08000000),
|
||||||
|
"async_exec_kept_existing_flag": bool(async_exec_flags & 0x00000400),
|
||||||
|
"async_shell_flags": async_shell_flags,
|
||||||
|
"async_shell_has_create_no_window": bool(async_shell_flags & 0x08000000),
|
||||||
}))
|
}))
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -170,6 +195,11 @@ print(json.dumps({
|
|||||||
flags: 0x08000200,
|
flags: 0x08000200,
|
||||||
has_create_no_window: true,
|
has_create_no_window: true,
|
||||||
kept_existing_flag: true,
|
kept_existing_flag: true,
|
||||||
|
async_exec_flags: 0x08000400,
|
||||||
|
async_exec_has_create_no_window: true,
|
||||||
|
async_exec_kept_existing_flag: true,
|
||||||
|
async_shell_flags: 0x08000000,
|
||||||
|
async_shell_has_create_no_window: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { tmpdir } from 'os'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||||
|
const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||||
|
|
||||||
vi.mock('child_process', () => ({
|
vi.mock('child_process', () => ({
|
||||||
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||||
execFileCalls.push({ command, args, options })
|
execFileCalls.push({ command, args, options })
|
||||||
callback(null, 'ok\n', '')
|
callback(null, 'ok\n', '')
|
||||||
}),
|
}),
|
||||||
spawn: vi.fn(),
|
spawn: vi.fn((command: string, args: string[], options: any) => {
|
||||||
|
spawnCalls.push({ command, args, options })
|
||||||
|
return {} as any
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||||
@@ -21,6 +25,7 @@ function setPlatform(platform: NodeJS.Platform): void {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
execFileCalls.length = 0
|
execFileCalls.length = 0
|
||||||
|
spawnCalls.length = 0
|
||||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||||
delete process.env.HERMES_AGENT_CLI_PYTHON
|
delete process.env.HERMES_AGENT_CLI_PYTHON
|
||||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||||
@@ -30,7 +35,7 @@ afterEach(() => {
|
|||||||
describe('Hermes process invocation', () => {
|
describe('Hermes process invocation', () => {
|
||||||
it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => {
|
it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => {
|
||||||
setPlatform('win32')
|
setPlatform('win32')
|
||||||
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\pythonw.exe'
|
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe'
|
||||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||||
|
|
||||||
const result = await execHermesWithBin(
|
const result = await execHermesWithBin(
|
||||||
@@ -43,25 +48,26 @@ describe('Hermes process invocation', () => {
|
|||||||
expect(execFileCalls[0]).toMatchObject({
|
expect(execFileCalls[0]).toMatchObject({
|
||||||
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
||||||
args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'],
|
args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'],
|
||||||
|
options: expect.objectContaining({ windowsHide: true }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers sibling pythonw.exe for a Windows hermes.exe launcher', async () => {
|
it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => {
|
||||||
setPlatform('win32')
|
setPlatform('win32')
|
||||||
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
|
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
|
||||||
try {
|
try {
|
||||||
const scripts = join(root, 'Scripts')
|
const scripts = join(root, 'Scripts')
|
||||||
mkdirSync(scripts)
|
mkdirSync(scripts)
|
||||||
writeFileSync(join(root, 'python.exe'), '')
|
writeFileSync(join(root, 'python.exe'), '')
|
||||||
writeFileSync(join(root, 'pythonw.exe'), '')
|
|
||||||
writeFileSync(join(scripts, 'hermes.exe'), '')
|
writeFileSync(join(scripts, 'hermes.exe'), '')
|
||||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||||
|
|
||||||
await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'], { windowsHide: true })
|
await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'])
|
||||||
|
|
||||||
expect(execFileCalls[0]).toMatchObject({
|
expect(execFileCalls[0]).toMatchObject({
|
||||||
command: join(root, 'pythonw.exe'),
|
command: join(root, 'python.exe'),
|
||||||
args: ['-m', 'hermes_cli.main', '--version'],
|
args: ['-m', 'hermes_cli.main', '--version'],
|
||||||
|
options: expect.objectContaining({ windowsHide: true }),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true })
|
rmSync(root, { recursive: true, force: true })
|
||||||
@@ -79,4 +85,18 @@ describe('Hermes process invocation', () => {
|
|||||||
args: ['--version'],
|
args: ['--version'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaults spawned Windows Hermes processes to hidden windows', async () => {
|
||||||
|
setPlatform('win32')
|
||||||
|
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Hermes Studio\\resources\\python\\python.exe'
|
||||||
|
const { spawnHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||||
|
|
||||||
|
spawnHermesWithBin('C:\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe', ['gateway', 'run'])
|
||||||
|
|
||||||
|
expect(spawnCalls[0]).toMatchObject({
|
||||||
|
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
||||||
|
args: ['-m', 'hermes_cli.main', 'gateway', 'run'],
|
||||||
|
options: expect.objectContaining({ windowsHide: true }),
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user