Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,779 @@
|
||||
"""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 re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
__all__ = [
|
||||
"IS_WINDOWS",
|
||||
"resolve_node_command",
|
||||
"split_command_line",
|
||||
"suppress_platform_ver_console",
|
||||
"windows_detach_flags",
|
||||
"windows_detach_flags_without_breakaway",
|
||||
"windows_hide_flags",
|
||||
"windows_detach_popen_kwargs",
|
||||
"bounded_git_probe",
|
||||
"bounded_probe_run",
|
||||
"noninteractive_git_env",
|
||||
"NO_DRIVER_DIFF_FLAGS",
|
||||
"pid_is_hermes",
|
||||
]
|
||||
|
||||
# Flags that neutralize *attribute-scoped* diff drivers on any diff-rendering
|
||||
# git command (``diff``, ``log -p``, ``show``, ``blame``). A malicious repo can
|
||||
# name a driver in ``.gitattributes`` (``* diff=evil``) and point it at an
|
||||
# arbitrary program via ``[diff "evil"] command=/textconv=`` in ``.git/config``.
|
||||
# Because the attacker chooses the driver name, ``GIT_CONFIG_KEY`` overrides in
|
||||
# ``noninteractive_git_env`` cannot enumerate and disable it — only these
|
||||
# command-line flags do. ``--no-ext-diff`` kills ``command=``; ``--no-textconv``
|
||||
# kills ``textconv=``. Both are required (verified empirically: each alone
|
||||
# leaves the other live). Smudge/clean filters are neutralized by the env
|
||||
# layer's ``core.hooksPath`` + running against the index without checkout.
|
||||
NO_DRIVER_DIFF_FLAGS = ("--no-ext-diff", "--no-textconv")
|
||||
|
||||
# Subcommands that render diffs and therefore invoke ``.gitattributes``-scoped
|
||||
# diff/textconv drivers. Only these accept ``NO_DRIVER_DIFF_FLAGS`` — ``status``
|
||||
# and friends reject the flags (``unknown option``), so the helper must gate on
|
||||
# this set rather than blanket-prepending.
|
||||
_DIFF_RENDERING_SUBCOMMANDS = frozenset({"diff", "show", "log", "blame"})
|
||||
|
||||
|
||||
def harden_git_argv(args: Sequence[str]) -> list[str]:
|
||||
"""Return a copy of subcommand-first git *args* with diff-driver flags
|
||||
inserted for diff-rendering subcommands.
|
||||
|
||||
*args* is the argument list WITHOUT the leading ``"git"`` (e.g.
|
||||
``["diff", "HEAD"]`` or ``["-c", "core.quotePath=false", "diff", ...]``).
|
||||
The first non-option token is treated as the subcommand; if it is one of
|
||||
:data:`_DIFF_RENDERING_SUBCOMMANDS`, :data:`NO_DRIVER_DIFF_FLAGS` is
|
||||
inserted immediately after it. Non-diff subcommands are returned unchanged.
|
||||
|
||||
Pair with :func:`noninteractive_git_env`: the env layer disables
|
||||
fsmonitor/hooks/pager/editor/credential sinks, this closes the one class
|
||||
(attacker-named attribute drivers) env overrides cannot reach.
|
||||
"""
|
||||
out = list(args)
|
||||
# Options that consume the FOLLOWING token as their value, so that value is
|
||||
# never mistaken for the subcommand (``-C diff`` is a path; ``-c diff=x`` is
|
||||
# a config pair — neither is the diff subcommand).
|
||||
_value_opts = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}
|
||||
i = 0
|
||||
while i < len(out):
|
||||
tok = out[i]
|
||||
if tok in _value_opts:
|
||||
i += 2
|
||||
continue
|
||||
if tok.startswith("-"):
|
||||
i += 1
|
||||
continue
|
||||
if tok in _DIFF_RENDERING_SUBCOMMANDS:
|
||||
return out[: i + 1] + list(NO_DRIVER_DIFF_FLAGS) + out[i + 1 :]
|
||||
# First non-option token is the subcommand; if it isn't a diff renderer
|
||||
# there is nothing to harden.
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
# Private launcher-to-child metadata. This is diagnostic state, not user config.
|
||||
_WINDOWS_GATEWAY_BREAKAWAY_ENV = "_HERMES_GATEWAY_BREAKAWAY"
|
||||
|
||||
|
||||
def split_command_line(line: str) -> list[str]:
|
||||
"""Split a user-supplied command line into tokens, Windows-safely.
|
||||
|
||||
``shlex.split(line)`` (posix=True) treats every backslash as an escape
|
||||
character, so Windows paths are silently mangled: ``C:\\Users\\me\\out.txt``
|
||||
becomes ``C:Usersmeout.txt`` — no error, just a wrong path that then
|
||||
"succeeds" against a mangled relative filename (#83934) or makes a valid
|
||||
hook script report "not executable" (#78293).
|
||||
|
||||
On Windows this uses ``posix=False``, which preserves backslashes while
|
||||
still honoring double-quoted tokens ("path with spaces"). The trade-off
|
||||
is that posix=False keeps surrounding quotes on quoted tokens, so we
|
||||
strip one layer of matching double quotes per token — that matches how
|
||||
Windows command lines are conventionally parsed. On POSIX the behavior
|
||||
is exactly ``shlex.split``.
|
||||
|
||||
Raises ValueError for unbalanced quotes, same as ``shlex.split``.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
import shlex
|
||||
|
||||
return shlex.split(line)
|
||||
import shlex
|
||||
|
||||
tokens = shlex.split(line, posix=False)
|
||||
out: list[str] = []
|
||||
for tok in tokens:
|
||||
if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in ("'", '"'):
|
||||
tok = tok[1:-1]
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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.
|
||||
* isolated git config — inherited ``GIT_CONFIG_*`` overrides, global/system
|
||||
config, pagers, editors, fsmonitor, external diff, and hooks are disabled
|
||||
for the child process. A user's repo/global config should not be able to
|
||||
hang or mutate Hermes's internal plumbing calls.
|
||||
|
||||
``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"
|
||||
|
||||
# Do not inherit caller-supplied config injection. We rebuild the
|
||||
# GIT_CONFIG_COUNT block below so ambient -c values cannot re-enable
|
||||
# pagers, hooks, fsmonitor, editors, or credential prompts.
|
||||
for key in list(env):
|
||||
if (
|
||||
key == "GIT_CONFIG_PARAMETERS"
|
||||
or key.startswith("GIT_CONFIG_KEY_")
|
||||
or key.startswith("GIT_CONFIG_VALUE_")
|
||||
):
|
||||
env.pop(key, None)
|
||||
env.pop("GIT_CONFIG_COUNT", None)
|
||||
|
||||
devnull = os.devnull
|
||||
env["GIT_CONFIG_GLOBAL"] = devnull
|
||||
env["GIT_CONFIG_SYSTEM"] = devnull
|
||||
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
env["GIT_PAGER"] = "cat"
|
||||
env["PAGER"] = "cat"
|
||||
env["GIT_EDITOR"] = "true"
|
||||
|
||||
config_overrides = {
|
||||
"credential.helper": "",
|
||||
"core.askPass": "",
|
||||
"core.fsmonitor": "false",
|
||||
"core.untrackedCache": "false",
|
||||
"core.hooksPath": devnull,
|
||||
"core.pager": "cat",
|
||||
"core.editor": "true",
|
||||
"sequence.editor": "true",
|
||||
"diff.external": "",
|
||||
}
|
||||
env["GIT_CONFIG_COUNT"] = str(len(config_overrides))
|
||||
for idx, (key, value) in enumerate(config_overrides.items()):
|
||||
env[f"GIT_CONFIG_KEY_{idx}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{idx}"] = value
|
||||
|
||||
return env
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bounded, fail-open git probing (Windows post-kill deadlock guard)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def _process_start_time(pid: int) -> int | None:
|
||||
"""Return the repository's stable process-start fingerprint, if available."""
|
||||
try:
|
||||
from gateway.status import get_process_start_time
|
||||
|
||||
return get_process_start_time(pid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _text_names_hermes(text: str) -> bool:
|
||||
"""True when *text* names Hermes at a path-segment / token boundary.
|
||||
|
||||
A bare ``"hermes" in text`` substring test would also match unrelated
|
||||
processes whose paths merely contain the letters (``...\\shermesa\\...``),
|
||||
which is exactly the false-positive class this guard exists to prevent.
|
||||
Instead, split on path separators and whitespace and require a segment
|
||||
that *starts with* ``hermes`` (``hermes``, ``hermes.exe``, ``hermes_cli``,
|
||||
``hermes-agent``, ``hermes-runtime``) or the hidden-dir form
|
||||
``.hermes``/``.hermes-runtime``.
|
||||
"""
|
||||
for token in re.split(r"[\\/\s=,;\"']+", text.lower()):
|
||||
if token.startswith("hermes") or token.startswith(".hermes"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _process_command_is_hermes(pid: int) -> bool:
|
||||
"""Best-effort check that *pid* currently runs Hermes code."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(pid)
|
||||
command = " ".join(process.cmdline() or [])
|
||||
executable = process.exe() or ""
|
||||
return _text_names_hermes(f"{command} {executable}")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def pid_is_hermes(
|
||||
pid: int,
|
||||
*,
|
||||
expected_start_time: int | None = None,
|
||||
) -> bool:
|
||||
"""Return whether it is safe to use ``taskkill`` for *pid*.
|
||||
|
||||
The PID must be valid, currently exist, and identify a Hermes process. When
|
||||
the caller captured a start-time fingerprint before the destructive action,
|
||||
the live process must still have the same ``(pid, start_time)`` identity.
|
||||
Any ambiguity fails closed. Non-Windows callers have no ``taskkill`` path,
|
||||
so a valid PID with no (or a matching) explicit expectation is accepted
|
||||
there — but a caller-provided fingerprint that no longer matches is a
|
||||
recycled PID on every platform and is always refused.
|
||||
"""
|
||||
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
||||
return False
|
||||
if not IS_WINDOWS:
|
||||
if expected_start_time is None:
|
||||
return True
|
||||
try:
|
||||
return _process_start_time(pid) == expected_start_time
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_start_time = _process_start_time(pid)
|
||||
except Exception:
|
||||
return False
|
||||
if current_start_time is None:
|
||||
return False
|
||||
if (
|
||||
expected_start_time is not None
|
||||
and current_start_time != expected_start_time
|
||||
):
|
||||
return False
|
||||
try:
|
||||
return _process_command_is_hermes(pid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Best-effort terminate *proc* and its descendants on both platforms.
|
||||
|
||||
``proc.kill()`` alone only terminates the direct child. On Windows a
|
||||
suspended descendant (e.g. ``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.
|
||||
On POSIX the same class exists: killing the launcher leaves descendants
|
||||
(credential helpers, ``git-remote-https``, hook children) running and
|
||||
holding the pipe write ends. Callers spawn the child in its own process
|
||||
group (``process_group=0``, Python ≥3.11), so when — and only
|
||||
when — the child leads its own group (``pgid == pid``), the entire group is
|
||||
signalled with ``os.killpg``. The ownership check means a fallback spawn
|
||||
that shares our group can never cause us to kill unrelated processes.
|
||||
Ported from openai/codex#36793 ("Terminate timed-out Git process trees");
|
||||
generalized for the shell-hook runner via openai/codex#37527
|
||||
("Terminate timed-out hook process trees").
|
||||
|
||||
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.
|
||||
|
||||
Delegates the tree-kill to :func:`agent.deadline.kill_process_tree`
|
||||
(#85125 4d) — same taskkill /T /F on Windows and killpg-when-leader on
|
||||
POSIX, plus a psutil descendant sweep that also reaches descendants that
|
||||
``setsid``'d into their own sessions. On any import/delegation failure it
|
||||
falls back to the original local implementation
|
||||
(:func:`_legacy_kill_process_tree`), so the fail-open contract holds even
|
||||
in stripped environments.
|
||||
"""
|
||||
try:
|
||||
from agent.deadline import kill_process_tree as _deadline_kill_tree
|
||||
|
||||
_deadline_kill_tree(proc.pid)
|
||||
except Exception:
|
||||
_legacy_kill_process_tree(proc)
|
||||
return
|
||||
# Ensure Popen's own bookkeeping sees the exit (matches the legacy body:
|
||||
# a direct kill() so communicate()/wait() cannot hang on a stale handle).
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Pre-#85125 local tree-kill — fallback when agent.deadline is unavailable.
|
||||
|
||||
Kept verbatim so ``kill_process_tree`` can honor its swallow-everything
|
||||
contract even when the delegation path itself fails (partial install,
|
||||
import cycle during teardown).
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
# Group-kill first: verify the child actually leads its own process
|
||||
# group before signalling it, so we never blast a shared group.
|
||||
try:
|
||||
import signal as _signal
|
||||
|
||||
pgid = os.getpgid(proc.pid)
|
||||
if pgid == proc.pid:
|
||||
os.killpg(pgid, _signal.SIGKILL) # windows-footgun: ok — inside `if not IS_WINDOWS` gate
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
if IS_WINDOWS:
|
||||
# No identity guard here on purpose: *proc* is our own retained
|
||||
# ``Popen`` handle. The child cannot be reaped (and its PID cannot be
|
||||
# recycled) while we still hold the handle, so an identity check could
|
||||
# only ever false-refuse a legitimate cleanup. The fail-closed
|
||||
# ``pid_is_hermes`` guard is for BARE pids from state files or process
|
||||
# scans, where recycling is real.
|
||||
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_probe_run(
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
timeout: float,
|
||||
errors: str = "replace",
|
||||
env: "Mapping[str, str] | None" = None,
|
||||
) -> "subprocess.CompletedProcess[str] | None":
|
||||
"""Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=...)``
|
||||
for fail-open probe call sites. Returns a ``CompletedProcess`` when the
|
||||
child finished within *timeout* (any exit code), or ``None`` on spawn
|
||||
failure or timeout.
|
||||
|
||||
Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup
|
||||
calls an *unbounded* ``communicate()`` after killing the direct child.
|
||||
Killing it can leave a descendant (``git.exe`` under a launcher shim,
|
||||
``conhost.exe`` under wmic/powershell) holding duplicates of the captured
|
||||
stdout/stderr handles, so the pipes never reach EOF and the reader-thread
|
||||
join blocks forever. The wmic / ``Get-CimInstance Win32_Process`` gateway
|
||||
scan hit exactly this during ``hermes update`` on slow-WMI machines
|
||||
(#87134); the git probes hit it first (#68609 / #66037).
|
||||
|
||||
The bounded flow: an explicit ``communicate(timeout)``, then on any
|
||||
failure a tree-kill (see :func:`kill_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 spawn contract mirrors the ``run`` calls it replaces: PIPE/PIPE/DEVNULL,
|
||||
``text`` with UTF-8 decoding (*errors* configurable — the process scans use
|
||||
``"ignore"``), and the hidden-window ``creationflags`` on Windows only. On
|
||||
POSIX the child is placed in its own process group (``process_group=0``,
|
||||
Python ≥3.11) so timeout cleanup can take down descendants with the
|
||||
launcher instead of orphaning them.
|
||||
"""
|
||||
_popen_kwargs: dict = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
list(argv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors=errors,
|
||||
env=dict(env) if env is not None else None,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
stdout, stderr = 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_process_tree(proc)
|
||||
try:
|
||||
proc.communicate(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return subprocess.CompletedProcess(list(argv), proc.returncode, stdout, stderr)
|
||||
|
||||
|
||||
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``).
|
||||
|
||||
**Security (GHSA-7x36-8jrh-v4pw):** these probes run automatically against
|
||||
whatever directory the session sits in — the coding-workspace snapshot and
|
||||
the gateway project-tree build fire ``git status`` / ``git branch`` before
|
||||
any tool call, approval, or trust prompt. An index refresh executes the
|
||||
repository-configured ``core.fsmonitor`` program, and other config keys
|
||||
(hooks, pager, editor, credential helper) are execution sinks too. A repo
|
||||
delivered as files with its ``.git`` directory intact (a shared zip, sync
|
||||
folder, or USB stick — ``git clone`` never transfers ``.git/config``) would
|
||||
otherwise get host code execution as the user. Every probe now runs under
|
||||
:func:`noninteractive_git_env`, which pins those keys to inert values via
|
||||
``GIT_CONFIG_*`` and ignores global/system config. Diff-rendering callers
|
||||
additionally pass :data:`NO_DRIVER_DIFF_FLAGS` (attribute-scoped drivers
|
||||
can't be disabled through env overrides).
|
||||
|
||||
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. On POSIX the probe is
|
||||
additionally placed in its own process group (``process_group=0``,
|
||||
Python ≥3.11) so timeout cleanup can take down descendants — credential
|
||||
helpers, ``git-remote-https``, hook children — with the launcher instead of
|
||||
orphaning them (see :func:`_kill_git_process_tree`; port of
|
||||
openai/codex#36793). ``process_group`` only changes which group the child
|
||||
belongs to; it does not detach the terminal or alter the fast path.
|
||||
"""
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
|
||||
if result is None or result.returncode != 0:
|
||||
return ""
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
# Backward-compat alias — existing call sites/tests import the historical name.
|
||||
_kill_git_process_tree = kill_process_tree
|
||||
Reference in New Issue
Block a user