File size: 19,663 Bytes
34c3ee8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
"""Windows subprocess compatibility helpers.



Hermes is developed on Linux / macOS and tested natively on Windows too.

Several common subprocess patterns break silently-or-loudly on Windows:



* ``["npm", "install", ...]`` β€” on Windows ``npm`` is ``npm.cmd``, a batch

  shim.  ``subprocess.Popen(["npm", ...])`` fails with WinError 193

  ("not a valid Win32 application") because CreateProcessW can't run a

  ``.cmd`` file without ``shell=True`` or PATHEXT resolution.



* ``start_new_session=True`` β€” on POSIX, this maps to ``os.setsid()`` and

  actually detaches the child.  On Windows it's silently ignored; the

  Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW``

  creationflags bundle, which Python only applies when you pass it

  explicitly.



* Console-window flashes β€” every ``subprocess.Popen`` of a ``.exe`` on

  Windows spawns a cmd window briefly unless ``CREATE_NO_WINDOW`` is

  passed.  Cosmetic but jarring for background daemons.



This module centralizes the platform-branching logic so the rest of the

codebase doesn't sprinkle ``if sys.platform == "win32":`` everywhere.



**All helpers are no-ops on non-Windows** β€” calling them in Linux/macOS

code paths is safe by design.  That's the "do no damage on POSIX"

guarantee.

"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from typing import Mapping, Sequence

__all__ = [
    "IS_WINDOWS",
    "resolve_node_command",
    "suppress_platform_ver_console",
    "windows_detach_flags",
    "windows_detach_flags_without_breakaway",
    "windows_hide_flags",
    "windows_detach_popen_kwargs",
    "bounded_git_probe",
    "noninteractive_git_env",
]


IS_WINDOWS = sys.platform == "win32"


# -----------------------------------------------------------------------------
# Node ecosystem launcher resolution
# -----------------------------------------------------------------------------


def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]:
    """Resolve a Node-ecosystem command name to an absolute-path argv.



    On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``,

    ``playwright``, ``prettier`` ship as ``.cmd`` files (batch shims).

    ``subprocess.Popen(["npm", "install"])`` fails with WinError 193

    because CreateProcessW doesn't execute batch files directly.



    ``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns

    the fully-qualified path β€” which CreateProcessW accepts because the

    extension tells Windows to route through ``cmd.exe /c``.



    On POSIX ``shutil.which`` also returns a fully-qualified path when

    found.  That's a small change from bare-name resolution (the OS does

    its own PATH search) but functionally identical and has the side

    benefit of making the argv reproducible in logs.



    Behavior when the command is not on PATH:

    - On Windows: return the bare name β€” caller can still try with

      ``shell=True`` as a last resort, OR the subsequent Popen will

      raise FileNotFoundError with a readable error we want to surface.

    - On POSIX: same.  Bare ``npm`` on a Linux box without npm installed

      fails the same way it did before this function existed.



    Args:

        name: The command name to resolve (``npm``, ``npx``, ``node`` …).

        argv: The remaining arguments.  Must NOT include ``name`` itself β€”

            this function builds the full argv list.



    Returns:

        A list suitable for passing to subprocess.Popen/run/call.

    """
    resolved = shutil.which(name)
    if resolved:
        return [resolved, *argv]
    return [name, *argv]


# -----------------------------------------------------------------------------
# Detached / hidden process creation
# -----------------------------------------------------------------------------


# Win32 CreationFlags β€” defined here rather than imported from subprocess
# because CREATE_NO_WINDOW and DETACHED_PROCESS aren't guaranteed to be
# present on stdlib subprocess on older Pythons or non-Windows builds.
_CREATE_NEW_PROCESS_GROUP = 0x00000200
# DETACHED_PROCESS is intentionally NOT part of any flag bundle here β€” do not
# re-add it.  Two reasons (the recurring console-flash bug #54220 / #56747):
#
# 1. MSDN (Process Creation Flags): CREATE_NO_WINDOW "is ignored if used with
#    either CREATE_NEW_CONSOLE or DETACHED_PROCESS".  Combining them means
#    DETACHED_PROCESS governs and the no-window bit is dead.
# 2. A DETACHED_PROCESS child has NO console at all, so every console-subsystem
#    descendant it ever spawns (git, gh, cmd, node, wmic, powershell, …) must
#    allocate its OWN console β€” a visible flash per spawn, including spawns
#    inside third-party libraries that no per-call-site CREATE_NO_WINDOW sweep
#    can reach.  A CREATE_NO_WINDOW child instead OWNS a hidden console that
#    all descendants inherit, making "no flashing windows" a property of the
#    one daemon launch.  Root cause isolated + A/B verified on Windows 11 by
#    the desktop backend fix (commit aa2ae36c3f): with per-site hide flags
#    neutered, naive git/gh/cmd spawns don't flash under a hidden-console
#    parent and do flash under a console-less one.
_DETACHED_PROCESS = 0x00000008  # kept for reference; must stay out of bundles
_CREATE_NO_WINDOW = 0x08000000
# Escape any Win32 job object the parent process belongs to. Without this,
# a detached child still inherits its parent's job object membership, and
# when that parent (Electron, Tauri, Windows Terminal, the Desktop GUI's
# bootstrap-installer) dies, the OS tears down the whole job β€” taking the
# "detached" child with it. Critical for the post-update gateway watcher:
# Electron spawns the Tauri updater inside its own job, the updater spawns
# the watcher subprocess; without BREAKAWAY the watcher dies the instant
# Electron exits, so the gateway never gets respawned after a `hermes
# update` triggered from the GUI. See fix/windows-gateway-reliability.
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000


def windows_detach_flags() -> int:
    """Return Win32 creationflags that detach a child from the parent

    console and process group without leaving it console-less.  0 on

    non-Windows.



    Pair with ``start_new_session=False`` (default) when calling

    subprocess.Popen β€” on POSIX use ``start_new_session=True`` instead,

    which maps to ``os.setsid()`` in the child.



    Rationale:

    - ``CREATE_NEW_PROCESS_GROUP`` β€” child has its own process group so

      Ctrl+C in the parent console doesn't propagate.

    - ``CREATE_NO_WINDOW`` β€” the child gets its own fresh console that is

      never shown.  This both detaches it from the parent's console

      lifetime (closing the launching terminal doesn't CTRL_CLOSE it) AND

      gives every console-subsystem descendant (git, gh, cmd, node, …) a

      console to inherit, so they don't allocate visible flashing ones.

      This deliberately replaces the old ``DETACHED_PROCESS`` approach:

      MSDN specifies CREATE_NO_WINDOW is *ignored* when combined with

      DETACHED_PROCESS, and a truly console-less daemon re-creates the

      per-descendant console-flash bug (#54220/#56747) at every spawn β€”

      see the note on ``_DETACHED_PROCESS`` above.

    - ``CREATE_BREAKAWAY_FROM_JOB`` β€” escape any job object the parent is

      in.  Electron (Desktop app) and Tauri (bootstrap installer) wrap

      their children in job objects; without breakaway, those children

      die when the parent process exits even though they have their own

      console.  This was the missing flag that made the post-update

      gateway respawn watcher silently die alongside the Tauri updater

      after the Electron Desktop's update flow finished.



    If a process is in a job that disallows breakaway (rare β€”

    JOB_OBJECT_LIMIT_BREAKAWAY_OK isn't set), CreateProcess returns

    ERROR_ACCESS_DENIED.  Python surfaces that as ``PermissionError``

    on the ``subprocess.Popen`` call.  Callers in this codebase already

    wrap detached spawns in ``try/except OSError`` and fall back to a

    cmd.exe wrapper, so the breakaway-denied case degrades gracefully

    rather than crashing.

    """
    if not IS_WINDOWS:
        return 0
    return (
        _CREATE_NEW_PROCESS_GROUP
        | _CREATE_NO_WINDOW
        | _CREATE_BREAKAWAY_FROM_JOB
    )


def windows_detach_flags_without_breakaway() -> int:
    """Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``.



    The docstring on :func:`windows_detach_flags` notes that a process in

    a job which disallows breakaway (no ``JOB_OBJECT_LIMIT_BREAKAWAY_OK``)

    will see ``ERROR_ACCESS_DENIED`` from CreateProcess, surfacing as

    ``OSError`` (``PermissionError``) on the ``subprocess.Popen`` call.

    Callers that want to recover β€” by retrying without the breakaway

    bit β€” can pair the two helpers symbolically rather than coding the

    ``& ~0x01000000`` magic at every site:



    .. code-block:: python



        try:

            subprocess.Popen(argv, creationflags=windows_detach_flags(), …)

        except OSError:

            subprocess.Popen(

                argv,

                creationflags=windows_detach_flags_without_breakaway(),

                …,

            )



    See ``gateway_windows.py::_spawn_detached`` for the canonical

    implementation of this pattern.  Returns 0 on non-Windows.

    """
    if not IS_WINDOWS:
        return 0
    return _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW


def windows_hide_flags() -> int:
    """Return Win32 creationflags that merely hide the child's console

    window without detaching the child.  0 on non-Windows.



    Use for short-lived console apps spawned as part of a larger

    operation (``taskkill``, ``where``, version probes) where we want no

    flash but also want to collect stdout/exit code synchronously.



    The difference from :func:`windows_detach_flags`: no

    ``CREATE_NEW_PROCESS_GROUP`` / ``CREATE_BREAKAWAY_FROM_JOB`` β€” the

    child stays in the parent's process group and job so Ctrl+C and job

    teardown propagate normally, as a short-lived helper wants.  Stdio

    handles are inherited either way, so ``capture_output=True`` works

    with both bundles.

    """
    if not IS_WINDOWS:
        return 0
    return _CREATE_NO_WINDOW


def suppress_platform_ver_console() -> None:
    """Stub out ``platform._syscmd_ver`` on Windows so it can never flash a

    console window.  No-op on non-Windows.



    CPython's ``platform.win32_ver()`` β€” reached by ``platform.uname()``,

    ``platform.version()``, and ``platform.platform()`` β€” unconditionally

    shells out ``cmd /c ver`` via ``subprocess.check_output(..., shell=True)``

    with no ``CREATE_NO_WINDOW``.  From a windowless parent (the pythonw

    gateway and every kanban worker it spawns) that allocates a fresh

    *visible* console: one flashing ``cmd`` window per process, triggered by

    any dependency that merely touches ``platform.uname()`` at import time.



    With ``_syscmd_ver`` stubbed to return its inputs, ``win32_ver()`` hits

    the documented ``ValueError`` fallback and reads the version from

    ``sys.getwindowsversion().platform_version`` β€” same information, queried

    in-process, no subprocess, no window.  Verified equivalent on

    CPython 3.11 (``platform()`` β†’ ``Windows-10-10.0.xxxxx-SP0`` either way).



    Call early, before heavyweight imports β€” the flash typically happens

    during a dependency's import, not from Hermes' own code.

    """
    if not IS_WINDOWS:
        return
    try:
        import platform

        if hasattr(platform, "_syscmd_ver"):
            def _quiet_syscmd_ver(system="", release="", version="",

                                  supported_platforms=("win32", "win16", "dos")):
                return system, release, version

            platform._syscmd_ver = _quiet_syscmd_ver
    except Exception:
        # Purely cosmetic hardening β€” never let it break startup.
        pass


def windows_detach_popen_kwargs() -> dict:
    """Return a dict of Popen kwargs that detach a child on Windows and

    fall back to the POSIX equivalent (``start_new_session=True``) on

    Linux/macOS.



    Usage pattern:



    .. code-block:: python



        subprocess.Popen(

            argv,

            stdout=subprocess.DEVNULL,

            stderr=subprocess.DEVNULL,

            stdin=subprocess.DEVNULL,

            close_fds=True,

            **windows_detach_popen_kwargs(),

        )



    This replaces the unsafe-on-Windows pattern:



    .. code-block:: python



        subprocess.Popen(..., start_new_session=True)



    which silently fails to detach on Windows (the flag is accepted but

    has no effect β€” the child stays attached to the parent's console

    and dies when the console closes).

    """
    if IS_WINDOWS:
        return {"creationflags": windows_detach_flags()}
    return {"start_new_session": True}


# -----------------------------------------------------------------------------
# Non-interactive git environment (credential-prompt hang guard)
# -----------------------------------------------------------------------------


def noninteractive_git_env(

    base: "Mapping[str, str] | None" = None,

) -> dict[str, str]:
    """Environment for *internal* git invocations that must never prompt.



    Hermes shells out to git from many non-interactive contexts β€” MCP catalog

    installs, plugin install/update, profile distribution staging, worktree

    base fetches, desktop review-pane fetch/push. When the remote is private,

    misconfigured, or requires auth, git's default behavior is to prompt on

    the inherited terminal (or via an askpass helper), which silently hangs

    the operation until its timeout β€” or forever at call sites without one.

    Ported from openai/codex#34540 / #34612 ("detach non-interactive

    subprocesses from stdin"): a background tool invocation must fail fast

    with a readable error, not wait for input nobody can type.



    Returns a copy of ``base`` (default ``os.environ``) with:



    * ``GIT_TERMINAL_PROMPT=0`` β€” git fails with "terminal prompts disabled"

      instead of prompting for credentials.

    * ``GCM_INTERACTIVE=Never`` β€” Git Credential Manager (the default

      credential helper on Windows installs) never pops its own dialog.



    ``GIT_ASKPASS`` / ``SSH_ASKPASS`` are deliberately left alone: when the

    user has a *working* askpass helper or ssh-agent configured, auth should

    still succeed non-interactively. The env only disables paths that block

    on a human.



    Pair with ``stdin=subprocess.DEVNULL`` so git (and any credential helper

    it spawns) also can't read the parent's inherited stdin.



    This is for internal plumbing calls only β€” the agent-facing terminal tool

    has its own policy layer and user-visible PTY, where prompting can be

    legitimate.

    """
    env = dict(base if base is not None else os.environ)
    env["GIT_TERMINAL_PROMPT"] = "0"
    env["GCM_INTERACTIVE"] = "Never"
    return env


# -----------------------------------------------------------------------------
# Bounded, fail-open git probing (Windows post-kill deadlock guard)
# -----------------------------------------------------------------------------


def _kill_git_process_tree(proc: "subprocess.Popen") -> None:
    """Best-effort terminate *proc* and, on Windows, its descendants.



    ``proc.kill()`` alone only terminates the PATH-resolved ``git`` launcher; a

    suspended descendant ``git.exe`` can survive holding duplicates of the

    captured pipe handles, which keeps the pipes from reaching EOF and leaks two

    reader threads + the process per fired timeout. ``taskkill /T /F`` takes the

    whole tree down so the bounded drain that follows can actually reach EOF.



    All failures are swallowed β€” this is cleanup on an already-failing path, and

    the caller's contract is to fail open. ``kill()`` can raise (access denied,

    already reaped); an unhandled raise here would escape the caller's ``except``

    handler and break that contract. The ``taskkill`` spawn itself cannot

    re-enter the deadlock class it fixes: it captures no pipes (DEVNULL), so its

    own timeout cleanup has no reader threads to join.

    """
    try:
        proc.kill()
    except OSError:
        pass
    if IS_WINDOWS:
        try:
            subprocess.run(
                ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
                timeout=2,
                check=False,
                creationflags=windows_hide_flags(),
            )
        except Exception:
            pass


def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
    """Run a short, throwaway ``git`` probe and return stripped stdout, or ``""``

    on ANY failure (nonzero exit, timeout, spawn error, decode error).



    This is the shared, deadlock-safe replacement for

    ``subprocess.run(["git", ...], timeout=...)`` at fail-open probe call sites

    (``tui_gateway.git_probe.run_git``, ``agent.coding_context._git``).



    Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup

    calls an *unbounded* ``communicate()`` after killing git. Killing the

    PATH-resolved launcher can leave a suspended descendant ``git.exe`` holding

    duplicates of the captured stdout/stderr handles, so the pipes never reach

    EOF and the reader-thread join blocks forever. On the Desktop agent-build

    path (``_start_agent_build β†’ _session_info β†’ branch() β†’ run_git``) that turned

    an optional branch label into ``agent initialization timed out``

    (issues #68609 / #66037).



    The bounded flow: an explicit ``communicate(timeout)``, then on any failure a

    tree-kill (see :func:`_kill_git_process_tree`) plus a bounded 1s post-kill

    drain; if the pipes are still held after that, they're abandoned (the orphaned

    reader threads are daemonic and cost nothing).



    The normal-path spawn contract mirrors the previous ``run`` call byte-for-byte:

    PIPE/PIPE/DEVNULL, ``text`` with UTF-8 ``errors="replace"`` decoding, and the

    hidden-window ``creationflags`` on Windows only.

    """
    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
    try:
        proc = subprocess.Popen(
            list(argv),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            stdin=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="replace",
            **_popen_kwargs,
        )
    except Exception:
        return ""
    try:
        stdout, _ = proc.communicate(timeout=timeout)
    except Exception:
        # Timeout OR any other communicate() failure (torn-down pipe, decode
        # error): terminate the child + descendants and drain bounded. Leaving
        # it running would leak the same suspended-descendant class this guards.
        _kill_git_process_tree(proc)
        try:
            proc.communicate(timeout=1)
        except Exception:
            pass
        return ""
    return stdout.strip() if proc.returncode == 0 else ""