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

345 lines
12 KiB
Python

"""Tests for the cross-platform process/platform primitives and Windows backend.
Covers omnigent._platform, omnigent.inner._proc, the harness IPC endpoint
abstraction, and the windows_jobobject sandbox backend. The platform-specific
assertions are gated with ``posix_only`` / ``windows_only`` markers so the
file runs on both Linux CI and a Windows box.
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import pytest
from omnigent import _platform
from omnigent.inner import _proc
def _spin_cmd() -> list[str]:
"""A short-lived child process that does nothing but sleep."""
if os.name == "nt":
return ["cmd", "/c", "ping -n 30 127.0.0.1 >NUL"]
return ["sleep", "30"]
# --------------------------------------------------------------------------
# _platform
# --------------------------------------------------------------------------
def test_platform_flags_are_mutually_consistent() -> None:
assert (os.name == "nt") == _platform.IS_WINDOWS
assert (os.name == "posix") == _platform.IS_POSIX
# Exactly one OS family is true.
assert _platform.IS_WINDOWS != _platform.IS_POSIX
def test_default_shell_argv_runs_an_echo() -> None:
argv = _platform.default_shell_argv("echo omnigent-shell-ok")
out = subprocess.run(argv, capture_output=True, text=True, check=True)
assert "omnigent-shell-ok" in out.stdout
@pytest.mark.posix_only
def test_default_interactive_shell_honors_known_shell_on_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``$SHELL`` naming a known shell that resolves on PATH is kept.
Returns the basename (not the ``$SHELL`` path) so it stays resolvable
when the terminal launches under a runner on a different host.
"""
# Patch shutil.which on the shared module so the function's local
# ``import shutil`` sees the fake; make every known shell "installed".
monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}")
for shell_path, expected in (
("/bin/zsh", "zsh"),
("/usr/local/bin/fish", "fish"),
("/bin/bash", "bash"),
):
monkeypatch.setenv("SHELL", shell_path)
assert _platform.default_interactive_shell() == expected
@pytest.mark.posix_only
def test_default_interactive_shell_falls_back_to_bash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unset / unknown / not-on-PATH ``$SHELL`` all fall back to bash."""
# Unset $SHELL → bash (known shells resolve on PATH here).
monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}")
monkeypatch.delenv("SHELL", raising=False)
assert _platform.default_interactive_shell() == "bash"
# An unknown shell name is never honored, even if on PATH.
monkeypatch.setenv("SHELL", "/opt/weird/nushell")
assert _platform.default_interactive_shell() == "bash"
# A known shell that does not resolve on PATH also falls back.
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setenv("SHELL", "/bin/zsh")
assert _platform.default_interactive_shell() == "bash"
@pytest.mark.posix_only
def test_installed_interactive_shells_lists_default_first_then_alternatives(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The default ($SHELL) leads; installed alternatives follow, deduped."""
monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}")
monkeypatch.setenv("SHELL", "/bin/zsh")
# zsh is the default → first; bash/fish follow in offer order; no dupes.
assert _platform.installed_interactive_shells() == ["zsh", "bash", "fish"]
@pytest.mark.posix_only
def test_installed_interactive_shells_skips_uninstalled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Alternatives that don't resolve on PATH are omitted."""
# Only bash and zsh installed; fish absent.
monkeypatch.setattr(
"shutil.which", lambda name: f"/usr/bin/{name}" if name in {"bash", "zsh"} else None
)
monkeypatch.setenv("SHELL", "/bin/bash")
assert _platform.installed_interactive_shells() == ["bash", "zsh"]
@pytest.mark.posix_only
def test_installed_interactive_shells_always_nonempty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With nothing resolvable the bash fallback is still offered alone."""
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.delenv("SHELL", raising=False)
assert _platform.installed_interactive_shells() == ["bash"]
def test_stable_user_id_is_stable_and_path_safe() -> None:
uid = _platform.stable_user_id()
assert uid == _platform.stable_user_id()
assert uid and not set(uid) & set("/\\: ")
@pytest.mark.windows_only
def test_resolve_repo_symlink_dereferences_git_stub(tmp_path: Path) -> None:
# Real target the Git symlink points at.
target = tmp_path / "examples" / "polly"
target.mkdir(parents=True)
(target / "config.yaml").write_text("name: polly\n", encoding="utf-8")
# Stub file Git leaves on a no-symlink Windows checkout: content is the
# relative link target, no trailing newline.
stub = tmp_path / "resources" / "polly"
stub.parent.mkdir(parents=True)
stub.write_text("../examples/polly", encoding="utf-8")
resolved = _platform.resolve_repo_symlink(stub)
assert resolved == target.resolve()
@pytest.mark.windows_only
def test_resolve_repo_symlink_leaves_real_specs_untouched(tmp_path: Path) -> None:
# A genuine single-file YAML spec must not be mistaken for a symlink stub.
spec = tmp_path / "agent.yaml"
spec.write_text("name: hello\nharness: claude-sdk\n", encoding="utf-8")
assert _platform.resolve_repo_symlink(spec) == spec
# A real directory is returned unchanged.
d = tmp_path / "bundle"
d.mkdir()
assert _platform.resolve_repo_symlink(d) == d
# --------------------------------------------------------------------------
# _proc
# --------------------------------------------------------------------------
def test_spawn_kwargs_shape_matches_platform() -> None:
kw = _proc.spawn_kwargs()
if os.name == "nt":
assert kw == {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
else:
assert kw == {"start_new_session": True}
def test_process_alive_is_a_nondestructive_probe() -> None:
proc = subprocess.Popen(_spin_cmd(), **_proc.spawn_kwargs())
try:
assert _proc.process_alive(proc.pid) is True
# Probing repeatedly must NOT kill the process (the os.kill(pid, 0)
# bug on Windows would terminate it here).
for _ in range(3):
assert _proc.process_alive(proc.pid) is True
finally:
_proc.kill_tree(proc)
proc.wait(timeout=5)
assert _proc.process_alive(proc.pid) is False
def test_process_alive_false_for_bogus_pid() -> None:
assert _proc.process_alive(2_000_000_000) is False
assert _proc.process_alive(-1) is False
def test_terminate_tree_stops_the_process() -> None:
proc = subprocess.Popen(_spin_cmd(), **_proc.spawn_kwargs())
_proc.terminate_tree(proc, grace=5)
proc.wait(timeout=5)
assert _proc.process_alive(proc.pid) is False
# --------------------------------------------------------------------------
# Harness IPC endpoint abstraction
# --------------------------------------------------------------------------
def test_endpoint_uds_variant_shape() -> None:
from omnigent.runtime.harnesses.process_manager import _HarnessEndpoint
ep = _HarnessEndpoint(socket_path=Path("/tmp/x/conv.sock"))
assert ep.is_uds is True
assert ep.spawn_args() == ["--socket", str(Path("/tmp/x/conv.sock"))]
assert ep.base_url == "http://harness.local"
def test_endpoint_tcp_variant_shape() -> None:
from omnigent.runtime.harnesses.process_manager import _HarnessEndpoint
ep = _HarnessEndpoint(host="127.0.0.1", port=54321)
assert ep.is_uds is False
assert ep.spawn_args() == ["--bind", "127.0.0.1:54321"]
assert ep.base_url == "http://127.0.0.1:54321"
def test_endpoint_create_picks_platform_transport() -> None:
from omnigent.runtime.harnesses.process_manager import _HarnessEndpoint
ep = _HarnessEndpoint.create(Path("/tmp/inst"), "conv_x")
assert ep.is_uds == (os.name != "nt")
# --------------------------------------------------------------------------
# windows_jobobject backend
# --------------------------------------------------------------------------
@pytest.mark.windows_only
def test_windows_jobobject_is_platform_default() -> None:
from omnigent.inner import sandbox
assert sandbox._default_sandbox_for_platform().type == "windows_jobobject"
@pytest.mark.windows_only
def test_windows_jobobject_kill_on_close_terminates_tree() -> None:
from omnigent.inner.sandbox import SandboxPolicy
from omnigent.inner.windows_jobobject_sandbox import WindowsJobObjectSandboxBackend
backend = WindowsJobObjectSandboxBackend()
policy = SandboxPolicy(
backend_type="windows_jobobject",
active=True,
read_roots=None,
write_roots=[],
write_files=[],
allow_network=True,
)
proc = subprocess.Popen(_spin_cmd())
handle = backend.post_spawn(policy, proc.pid)
assert handle is not None
assert _proc.process_alive(proc.pid) is True
# Closing the job handle must terminate the contained process.
handle.close()
time.sleep(0.5)
assert _proc.process_alive(proc.pid) is False
if _proc.process_alive(proc.pid):
proc.kill()
@pytest.mark.windows_only
def test_explicit_bwrap_errors_loudly_on_windows() -> None:
from omnigent.inner import sandbox
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
sandbox._ensure_builtin_backends()
backend = sandbox.get_backend("linux_bwrap")
with pytest.raises(OSError):
backend.resolve(OSEnvSpec(sandbox=OSEnvSandboxSpec(type="linux_bwrap")), Path("."))
@pytest.mark.posix_only
def test_posix_default_sandbox_is_not_jobobject() -> None:
from omnigent.inner import sandbox
assert sandbox._default_sandbox_for_platform().type in {"linux_bwrap", "darwin_seatbelt"}
@pytest.mark.windows_only
def test_helper_env_keeps_systemroot_so_child_can_import_asyncio() -> None:
# Regression: a filtered (active-sandbox) helper env that drops SYSTEMROOT
# makes any spawned `python -m omnigent...` die at `import asyncio` with
# WinError 10106 (Winsock loads providers from %SystemRoot%). The os_env
# allowlist must carry the Windows system vars.
from omnigent.inner.os_env import build_helper_env
from omnigent.inner.sandbox import SandboxPolicy
policy = SandboxPolicy(
backend_type="windows_jobobject",
active=True,
read_roots=None,
write_roots=[],
write_files=[],
allow_network=True,
)
env = build_helper_env(os.environ, policy)
assert "SYSTEMROOT" in env
result = subprocess.run(
[__import__("sys").executable, "-c", "import asyncio"],
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.windows_only
def test_parent_death_watchdog_does_not_false_fire_on_windows() -> None:
# Regression: on Windows os.getppid() does not match the spawner (the venv
# launcher breaks the parent link), so the getppid-based orphan check
# reported the runner orphaned the instant it started and tore it down
# (clean exit 0). With a live parent_pid the runner must NOT be orphaned.
from omnigent.runner._entry import _parent_is_orphaned
assert _parent_is_orphaned(os.getpid()) is False
assert _parent_is_orphaned(2_000_000_000) is True
@pytest.mark.windows_only
def test_host_runner_env_lets_child_import_asyncio_and_resolve_home() -> None:
# Regression: the host->runner env allowlist dropped SYSTEMROOT (asyncio /
# WinError 10106) and USERPROFILE (Path.home() -> "Could not determine home
# directory"). Both must pass through so a spawned runner can boot.
import sys
from omnigent.host.connect import _build_runner_env
env = _build_runner_env(
base_env=os.environ,
server_url="http://x",
runner_id="r1",
binding_token="t",
workspace=".",
parent_pid=os.getpid(),
)
assert "SYSTEMROOT" in env
assert "USERPROFILE" in env
result = subprocess.run(
[sys.executable, "-c", "import asyncio; from pathlib import Path; Path.home()"],
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr