"""Cross-platform OS compatibility helpers. Odysseus began as a Linux/macOS/Docker-only app. This module centralizes the small set of OS differences needed to run it *natively* on Windows so the rest of the codebase can stay platform-agnostic. Import from here instead of sprinkling ``os.name == "nt"`` checks (and POSIX-only calls) across modules. Design rules: * Stdlib + ctypes only — no new third-party deps (no psutil/pywinpty). * POSIX behaviour is unchanged; Windows gets a faithful equivalent or a safe, documented no-op. """ from __future__ import annotations import os import ntpath import shutil import subprocess from pathlib import Path from typing import List, Optional IS_WINDOWS = os.name == "nt" IS_POSIX = not IS_WINDOWS # ── File permissions ──────────────────────────────────────────────────────── def safe_chmod(path, mode: int) -> bool: """``os.chmod`` that is a harmless no-op on Windows. On POSIX we apply the mode — used to lock secret/key files down to 0o600. Windows has no POSIX permission bits; files under the user profile are already ACL-restricted to that user, so we skip rather than raise. Returns True when the mode was actually applied. """ if IS_WINDOWS: return False try: os.chmod(path, mode) return True except OSError: return False # ── Process detach / liveness / teardown ──────────────────────────────────── def detached_popen_kwargs() -> dict: """Keyword args for :class:`subprocess.Popen` that fully detach a child so it outlives the request/stream that launched it. POSIX: ``start_new_session=True`` (setsid) — new session + process group. Windows: ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` — the child gets its own process group (so it isn't killed when the parent's console closes) and is detached from any console. """ if IS_WINDOWS: flags = ( getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) ) return {"creationflags": flags} return {"start_new_session": True} def pid_alive(pid: Optional[int]) -> bool: """True if a process with ``pid`` is currently running. POSIX uses the classic ``os.kill(pid, 0)`` probe. That is **unsafe on Windows**: CPython's ``os.kill`` calls ``TerminateProcess(handle, sig)`` for any signal other than CTRL_C/CTRL_BREAK, so ``os.kill(pid, 0)`` would *kill* the process it is checking. We instead open the process and read its exit code via the Win32 API. """ if not pid: return False if IS_WINDOWS: import ctypes from ctypes import wintypes PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 STILL_ACTIVE = 259 kernel32 = ctypes.windll.kernel32 handle = kernel32.OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid) ) if not handle: return False try: code = wintypes.DWORD() if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): return code.value == STILL_ACTIVE return False finally: kernel32.CloseHandle(handle) try: os.kill(pid, 0) return True except (OSError, ProcessLookupError): return False def kill_process_tree(pid: Optional[int]) -> None: """Terminate ``pid`` and all of its descendants. POSIX: signal the whole process group (``killpg``), falling back to a plain ``kill`` if the pid isn't a group leader. Windows: ``taskkill /T /F`` walks and kills the child tree (there is no process-group signalling). """ if not pid: return if IS_WINDOWS: try: subprocess.run( ["taskkill", "/F", "/T", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except Exception: pass return import signal try: os.killpg(os.getpgid(pid), signal.SIGTERM) except Exception: try: os.kill(pid, signal.SIGTERM) except Exception: pass # ── Shell / executable resolution ─────────────────────────────────────────── _BASH_CACHE: Optional[str] = None _BASH_PROBED = False # Common Git-for-Windows install locations to probe when bash isn't on PATH. _WINDOWS_BASH_ROOT_ENV_VARS = ( "ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData", ) _WINDOWS_BASH_DEFAULT_ROOTS = ( r"C:\Program Files\Git", r"C:\Program Files (x86)\Git", ) _WINDOWS_BASH_RELATIVE_PATHS = ( ("bin", "bash.exe"), ("usr", "bin", "bash.exe"), ) def _windows_bash_fallbacks() -> List[str]: roots: List[str] = [] for env_name in _WINDOWS_BASH_ROOT_ENV_VARS: base = os.environ.get(env_name) if base: roots.append(ntpath.join(base, "Git")) roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS) paths: List[str] = [] seen = set() for root in roots: for rel in _WINDOWS_BASH_RELATIVE_PATHS: path = ntpath.join(root, *rel) key = path.lower() if key not in seen: seen.add(key) paths.append(path) return paths def find_bash() -> Optional[str]: """Locate a real ``bash`` interpreter, or None. On Windows this is typically Git Bash / WSL. Many Odysseus features (the agent ``bash`` tool, background jobs, Cookbook scripts) emit bash syntax, so when a bash is present we use it and keep full parity with POSIX. Result is cached. """ global _BASH_CACHE, _BASH_PROBED if _BASH_PROBED: return _BASH_CACHE _BASH_PROBED = True found = which_tool("bash") if not found and IS_WINDOWS: for cand in _windows_bash_fallbacks(): if os.path.exists(cand): found = cand break _BASH_CACHE = found return found def has_bash() -> bool: return find_bash() is not None def which_tool(name: str) -> Optional[str]: """``shutil.which`` that also tries Windows executable suffixes. On Windows, Node/npm shims are ``npx.cmd``/``npm.cmd`` and binaries end in ``.exe``; a bare ``which("npx")`` can miss them depending on PATHEXT. We try the bare name first, then the common suffixes. """ found = shutil.which(name) if found: return found if IS_WINDOWS: for ext in (".cmd", ".exe", ".bat"): found = shutil.which(name + ext) if found: return found return None def run_script_argv(script_path) -> List[str]: """argv to execute a shell *script file*. Prefers bash (so existing ``.sh`` wrappers work verbatim, including on Windows via Git Bash). On Windows with no bash available, falls back to ``cmd.exe /c`` — simple commands still run, but bash-specific syntax won't. Callers that need guaranteed bash should check :func:`has_bash` first and surface a clear "install Git Bash" message. """ bash = find_bash() if bash: return [bash, str(script_path)] if IS_WINDOWS: comspec = os.environ.get("ComSpec", "cmd.exe") return [comspec, "/c", str(script_path)] return ["sh", str(script_path)]