Files
2026-07-13 13:12:00 +08:00

217 lines
8.7 KiB
Python

"""Central, dependency-light platform flags and OS-portability helpers.
omnigent grew up on Linux/macOS and bakes a number of POSIX assumptions into
process management, shells, and user identity. This module is the single place
that answers "which OS are we on?" and provides the small portable primitives
that the rest of the package uses instead of branching on :data:`os.name`
ad hoc.
Keep this module import-cheap and free of heavy/optional dependencies: it is
imported very early (and on Windows it must import before any POSIX-only module
would otherwise crash), so it must never pull in ``fcntl``/``termios``/``pty``
or anything platform-specific at module top level.
"""
from __future__ import annotations
import getpass
import hashlib
import os
import sys
from pathlib import Path
#: True on native Windows (cmd/PowerShell), i.e. ``os.name == "nt"``. This is
#: *not* true under WSL, where Python reports a Linux platform.
IS_WINDOWS = os.name == "nt"
#: True on any POSIX host (Linux, macOS, BSD, WSL).
IS_POSIX = os.name == "posix"
#: True on Linux specifically (the only platform with bwrap + seccomp).
IS_LINUX = sys.platform.startswith("linux")
#: True on macOS specifically (the seatbelt sandbox platform).
IS_DARWIN = sys.platform == "darwin"
#: Non-sensitive Windows environment variables that a spawned omnigent
#: subprocess needs to function, for env-passthrough allowlists that otherwise
#: assume POSIX names. Python uppercases env keys on Windows, so these match
#: ``os.environ`` as stored; they are absent on POSIX, so including them in an
#: allowlist is a no-op there (only present vars pass through).
#:
#: - ``SYSTEMROOT`` is MANDATORY: Winsock loads its providers from
#: ``%SystemRoot%\system32\mswsock.dll``, so a child without it dies at
#: ``import asyncio`` with ``WinError 10106`` (WSAEPROVIDERFAILEDINIT).
#: - ``USERPROFILE`` / ``HOMEDRIVE`` / ``HOMEPATH`` let ``Path.home()`` /
#: ``expanduser("~")`` resolve (the Windows analog of POSIX ``HOME``).
#: - ``APPDATA`` / ``LOCALAPPDATA`` are where Windows apps (keyring, pip, npm,
#: …) keep per-user config and cache.
#: - The rest let a Windows process and shell resolve binaries normally.
#:
#: All are path/identity constants, not credentials — consistent with POSIX
#: ``HOME``/``PATH`` already being allowed.
WINDOWS_ENV_PASSTHROUGH: tuple[str, ...] = (
"SYSTEMROOT",
"SYSTEMDRIVE",
"WINDIR",
"COMSPEC",
"PATHEXT",
"NUMBER_OF_PROCESSORS",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROCESSOR_LEVEL",
"PROCESSOR_REVISION",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"APPDATA",
"LOCALAPPDATA",
)
def default_shell_argv(command: str) -> list[str]:
"""
Build the argv to run ``command`` through the host's default shell.
On POSIX this mirrors the long-standing behavior: prefer ``bash`` with
``--noprofile --norc`` (skip user rc files for a predictable environment),
falling back to ``sh -c``. On Windows there is no ``/bin/sh``; route through
``cmd.exe`` (``%COMSPEC%``) with ``/c``.
:param command: The shell command string to execute.
:returns: An argv list suitable for :func:`subprocess.Popen` (no
``shell=True`` needed).
"""
if IS_WINDOWS:
comspec = os.environ.get("COMSPEC", "cmd.exe")
return [comspec, "/c", command]
import shutil
bash = shutil.which("bash")
if bash:
return [bash, "--noprofile", "--norc", "-c", command]
sh = shutil.which("sh") or "/bin/sh"
return [sh, "-c", command]
#: Interactive shells we honor from ``$SHELL`` for a user terminal. Anything
#: outside this set (or a ``$SHELL`` that doesn't resolve on PATH) falls back to
#: bash for a predictable pane.
_KNOWN_INTERACTIVE_SHELLS = frozenset({"bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh"})
#: Mainstream interactive shells we proactively offer as launch choices (the
#: "New shell" picker), in display order. The user's ``$SHELL`` is always
#: offered first regardless (see :func:`installed_interactive_shells`); this is
#: the set of well-known alternatives we surface beyond it.
_OFFERED_INTERACTIVE_SHELLS = ("bash", "zsh", "fish")
def default_interactive_shell() -> str:
"""
Basename of the user's login shell for an interactive terminal.
Reads ``$SHELL`` and keeps its basename when it names a known shell that
resolves on PATH; otherwise falls back to ``"bash"``. Returns a basename
(not the absolute ``$SHELL`` path) so it stays PATH-resolvable when the
terminal launches under a runner on a different host than the one that read
the env.
:returns: A shell basename such as ``"zsh"``, ``"fish"``, or ``"bash"``.
"""
if IS_WINDOWS:
# Native tmux/PTY terminals are unsupported on Windows anyway.
return "bash"
import shutil
name = os.path.basename(os.environ.get("SHELL", "")).strip()
if name in _KNOWN_INTERACTIVE_SHELLS and shutil.which(name):
return name
return "bash"
def installed_interactive_shells() -> list[str]:
"""
Ordered, deduped shell basenames to offer for a new interactive terminal.
The user's login shell (:func:`default_interactive_shell`) comes first — so
the "New shell" affordance can treat entry ``[0]`` as the click default —
followed by any mainstream alternatives (bash/zsh/fish) that resolve on
PATH. Always non-empty (the default is always present, and bash is the
ultimate fallback).
:returns: Basenames such as ``["zsh", "bash", "fish"]`` — the default first.
"""
ordered = [default_interactive_shell()]
if IS_WINDOWS:
# Native tmux/PTY terminals are unsupported on Windows anyway; the lone
# bash default from above is all we can meaningfully offer.
return ordered
import shutil
for name in _OFFERED_INTERACTIVE_SHELLS:
if name not in ordered and shutil.which(name):
ordered.append(name)
return ordered
def stable_user_id() -> str:
"""
A stable, filesystem-safe token identifying the current OS user.
Used to namespace per-user scratch directories (e.g.
``omnigent-<id>`` / ``claude-<id>`` under the temp dir). On POSIX this is
the numeric uid, matching historical behavior. Windows has no ``getuid``;
derive a short hex digest from the login name so the value is stable across
runs and safe to embed in a path.
The digest is for path namespacing only — not security — so ``getuser``'s
value never needs to be recoverable or collision-proof against an
adversary; it just needs to be stable and filesystem-safe. SHA-256 with
``usedforsecurity=False`` documents that intent (and avoids flagging SHA-1).
:returns: A short string with no path separators or shell-special chars.
"""
if IS_POSIX and hasattr(os, "getuid"):
return str(os.getuid())
try:
name = getpass.getuser()
except (OSError, KeyError, ModuleNotFoundError):
name = os.environ.get("USERNAME") or os.environ.get("USER") or "user"
return hashlib.sha256(name.encode("utf-8"), usedforsecurity=False).hexdigest()[:12]
def resolve_repo_symlink(path: Path) -> Path:
"""
Follow a Git symlink that a no-symlink Windows checkout left as a text file.
On Windows with ``core.symlinks=false`` (the default when Developer Mode is
off and Git was not run elevated), Git materializes a repository symlink as a
*regular file* whose entire content is the link target — e.g. the checked-out
``omnigent/resources/examples/polly`` is a 23-byte file containing
``../../../examples/polly`` rather than a link to that directory. Code that
expects to open the linked directory then reads this stub instead (the
symptom: ``expected YAML mapping at top level, got str``).
Detect that exact shape — a small, single-line regular file whose content,
resolved relative to the stub's parent, names an existing path — and return
the real target. Everything else (real directories, real symlinks, genuine
single-file specs, multi-line or unresolvable content) is returned
unchanged. No-op off Windows, where the symlink is followed natively.
:param path: The path as resolved from packaged resources.
:returns: The dereferenced target on Windows when *path* is a Git-symlink
stub, otherwise *path* unchanged.
"""
if not IS_WINDOWS:
return path
try:
if not path.is_file() or path.stat().st_size > 4096:
return path
body = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return path
target = body.strip()
if not target or "\n" in target:
return path
candidate = path.parent / target
if candidate.exists():
return candidate.resolve()
return path