188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
"""
|
|
Shared fixtures for the cross-backend sandbox behavior suite.
|
|
|
|
The tests under :mod:`tests.inner.sandbox` exercise the *observable*
|
|
contract every active sandbox backend must uphold: cwd RO-by-default,
|
|
writable scratch tmpdir, dotfile masking, env passthrough, hard
|
|
network deny, etc. They run against whichever spawn-time backend the
|
|
current host can use — ``linux_bwrap`` on Linux + ``bwrap`` installed,
|
|
``darwin_seatbelt`` on macOS + ``sandbox-exec`` on ``PATH``. A host
|
|
that satisfies neither (e.g. a bare Linux box without ``bwrap``)
|
|
simply skips every test in the suite — the assertions stay correct,
|
|
they just don't run.
|
|
|
|
The fixtures here are the one source of truth for:
|
|
|
|
- which backend is "active" on the current host
|
|
(:func:`active_sandbox_type`),
|
|
- how to build an :class:`OSEnvSandboxSpec` for that backend with the
|
|
repo root pre-added to ``read_paths`` so helper subprocesses can
|
|
``import omnigent.*`` from a tempdir cwd
|
|
(:func:`active_sandbox_spec_factory`),
|
|
- how to materialise the PYTHONPATH env var the helper inherits
|
|
(:func:`sandbox_pythonpath_env`),
|
|
- the ``_run_async`` helper that drives a single coroutine to
|
|
completion on a fresh event loop.
|
|
|
|
A test using these fixtures runs once per supported backend; there is
|
|
no platform branching inside the tests themselves.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from omnigent.inner.datamodel import CredentialProxySpec, OSEnvSandboxSpec
|
|
|
|
_BWRAP_AVAILABLE = shutil.which("bwrap") is not None
|
|
_SANDBOX_EXEC_AVAILABLE = shutil.which("sandbox-exec") is not None
|
|
|
|
|
|
def _repo_root_for_pythonpath() -> str:
|
|
"""
|
|
Return the absolute repo-root path so a sandbox-spawned helper can
|
|
reach the ``omnigent`` package via ``PYTHONPATH``.
|
|
|
|
Helper subprocesses run with ``cwd`` set to a throwaway tempdir
|
|
in these tests, so they can't ``import omnigent`` unless the
|
|
repo root is on ``sys.path`` and visible inside the sandbox.
|
|
|
|
:returns: Absolute path to the repository root containing the
|
|
``omnigent/`` package.
|
|
"""
|
|
# tests/inner/sandbox/conftest.py → tests/inner/sandbox/ →
|
|
# tests/inner/ → tests/ → repo root.
|
|
return str(Path(__file__).resolve().parents[3])
|
|
|
|
|
|
@pytest.fixture(
|
|
params=[
|
|
pytest.param(
|
|
"linux_bwrap",
|
|
id="linux_bwrap",
|
|
marks=pytest.mark.skipif(
|
|
not (sys.platform.startswith("linux") and _BWRAP_AVAILABLE),
|
|
reason="linux_bwrap requires Linux + bwrap on PATH",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"darwin_seatbelt",
|
|
id="darwin_seatbelt",
|
|
marks=pytest.mark.skipif(
|
|
not (sys.platform == "darwin" and _SANDBOX_EXEC_AVAILABLE),
|
|
reason="darwin_seatbelt requires macOS + sandbox-exec on PATH",
|
|
),
|
|
),
|
|
]
|
|
)
|
|
def active_sandbox_type(request: pytest.FixtureRequest) -> str:
|
|
"""
|
|
Yield each spawn-time sandbox backend supported on the current host.
|
|
|
|
The parametrization runs once per backend; skip marks ensure
|
|
Linux CI only runs the ``linux_bwrap`` branch and macOS CI only
|
|
runs the ``darwin_seatbelt`` branch. A host that can't satisfy
|
|
either branch skips every parametrization.
|
|
|
|
:returns: The backend identifier ``"linux_bwrap"`` or
|
|
``"darwin_seatbelt"``.
|
|
"""
|
|
backend_type: str = request.param
|
|
return backend_type
|
|
|
|
|
|
@pytest.fixture
|
|
def active_sandbox_spec_factory(
|
|
active_sandbox_type: str,
|
|
) -> Callable[..., OSEnvSandboxSpec]:
|
|
"""
|
|
Return a factory that builds an :class:`OSEnvSandboxSpec` for the
|
|
currently parametrized backend.
|
|
|
|
The factory pre-adds the repo root to ``read_paths`` so the
|
|
helper subprocess can ``import omnigent.*`` from a tempdir
|
|
cwd. Both backends need this because they otherwise hide
|
|
everything outside cwd / the default system mounts.
|
|
|
|
Callers can override / extend the spec via keyword arguments
|
|
(``write_paths``, ``env_passthrough``, ``egress_rules``,
|
|
``allow_network``, ``cwd_allow_hidden``).
|
|
|
|
:returns: A callable that returns a fresh
|
|
:class:`OSEnvSandboxSpec` instance per invocation.
|
|
"""
|
|
repo_root = _repo_root_for_pythonpath()
|
|
|
|
def _make(
|
|
*,
|
|
write_paths: list[str] | None = None,
|
|
env_passthrough: list[str] | None = None,
|
|
egress_rules: list[str] | None = None,
|
|
allow_network: bool = True,
|
|
cwd_allow_hidden: list[str] | None = None,
|
|
extra_read_paths: list[str] | None = None,
|
|
egress_allow_private_destinations: bool = False,
|
|
credential_proxy: CredentialProxySpec | None = None,
|
|
) -> OSEnvSandboxSpec:
|
|
read_paths = [repo_root]
|
|
if extra_read_paths:
|
|
read_paths.extend(extra_read_paths)
|
|
return OSEnvSandboxSpec(
|
|
type=active_sandbox_type,
|
|
read_paths=read_paths,
|
|
write_paths=write_paths,
|
|
allow_network=allow_network,
|
|
cwd_allow_hidden=cwd_allow_hidden,
|
|
env_passthrough=env_passthrough,
|
|
egress_rules=egress_rules,
|
|
egress_allow_private_destinations=egress_allow_private_destinations,
|
|
credential_proxy=credential_proxy,
|
|
)
|
|
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def sandbox_pythonpath_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
Inject the repo root into the helper subprocess's ``PYTHONPATH``.
|
|
|
|
Helpers spawned with ``cwd`` set to a tempdir would otherwise
|
|
fail with ``ModuleNotFoundError`` because the agent's own
|
|
bootstrap runs ``import omnigent.inner.os_env`` in the helper.
|
|
The active backend's ``env_passthrough`` is filtered, but
|
|
``PYTHONPATH`` is set explicitly on the spawn env so this
|
|
fixture just preserves it for the parent process.
|
|
"""
|
|
repo = _repo_root_for_pythonpath()
|
|
existing = os.environ.get("PYTHONPATH")
|
|
new = f"{repo}{os.pathsep}{existing}" if existing else repo
|
|
monkeypatch.setenv("PYTHONPATH", new)
|
|
|
|
|
|
def run_async(coro: Any) -> Any:
|
|
"""
|
|
Drive a coroutine to completion on a fresh event loop.
|
|
|
|
Helper exposed at module level so tests can call it without a
|
|
fixture. Mirrors the ``_run_async`` helper in
|
|
``tests/inner/test_os_env.py`` so the migrated tests behave
|
|
identically.
|
|
|
|
:param coro: The coroutine to await.
|
|
:returns: Whatever the coroutine returns.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|