Files
qwenpaw/tests/unit/hub/test_process_isolation.py
agent-radar 1a3a4ccde1
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:01:50 +08:00

534 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for fail-closed Hub runtime process isolation."""
import mimetypes
import subprocess
import sys
import threading
import urllib.request
from collections.abc import Mapping, Sequence
from pathlib import Path
import pytest
from qwenpaw.hub.local_provisioner import LocalProcessRuntimeProvisioner
from qwenpaw.hub.models import RuntimeRecord, RuntimeState
from qwenpaw.hub.process_isolation import (
IsolatedLaunch,
LinuxBubblewrapIsolator,
MacOSSeatbeltIsolator,
ProcessIsolationError,
ProcessIsolator,
UnsupportedProcessIsolator,
)
from tests.unit.hub.factories import runtime_record as _record
class _RecordingIsolator(ProcessIsolator):
name = "recording"
def __init__(self) -> None:
self.called = False
def prepare(
self,
record: RuntimeRecord,
command: Sequence[str],
environment: Mapping[str, str],
) -> IsolatedLaunch:
del record
self.called = True
return IsolatedLaunch(
["isolation-wrapper", *command],
dict(environment),
)
class _WindowsRecordingIsolator(_RecordingIsolator):
name = "windows-appcontainer"
def __init__(self) -> None:
super().__init__()
self.command: list[str] = []
self.process = _RuntimeProcess()
def prepare(
self,
record: RuntimeRecord,
command: Sequence[str],
environment: Mapping[str, str],
) -> IsolatedLaunch:
del record
self.called = True
self.command = list(command)
return IsolatedLaunch(list(command), dict(environment))
def launch(self, *args, **kwargs) -> "_RuntimeProcess":
del args, kwargs
return self.process
class _RuntimeProcess:
pid = 12345
def __init__(self) -> None:
self.running = True
def poll(self) -> int | None:
return None if self.running else 0
def terminate(self) -> None:
self.running = False
def kill(self) -> None:
self.running = False
def wait(self, timeout: float | None = None) -> int:
del timeout
self.running = False
return 0
class _TunnelBroker:
instances: list["_TunnelBroker"] = []
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.control_port = 9100
self.token = "tunnel-token"
self.started = False
self.closed = False
self.instances.append(self)
def start(self) -> None:
self.started = True
def close(self) -> None:
self.closed = True
def test_unsupported_platform_fails_closed(tmp_path: Path) -> None:
isolator = UnsupportedProcessIsolator("required isolation unavailable")
with pytest.raises(ProcessIsolationError, match="unavailable"):
isolator.prepare(_record(tmp_path), ["python"], {})
def test_provisioner_requires_runtime_boundary_token(tmp_path: Path) -> None:
provisioner = LocalProcessRuntimeProvisioner(isolator=_RecordingIsolator())
with pytest.raises(RuntimeError, match="boundary token"):
provisioner.start(_record(tmp_path), {})
def test_provisioner_preflight_reports_isolation_failure(
tmp_path: Path,
) -> None:
provisioner = LocalProcessRuntimeProvisioner(
isolator=UnsupportedProcessIsolator("required isolation unavailable"),
)
availability = provisioner.preflight(tmp_path / "preflight")
assert availability.available is False
assert availability.reason == "required isolation unavailable"
def test_linux_command_mounts_only_runtime_root_writable(
tmp_path: Path,
monkeypatch,
) -> None:
record = _record(tmp_path)
isolator = LinuxBubblewrapIsolator("/usr/bin/bwrap")
monkeypatch.setattr(isolator, "_probe", lambda *args: None)
launch = isolator.prepare(record, ["python", "-m", "qwenpaw"], {})
args = launch.command
bind_index = args.index("--bind")
tmp_index = next(
index
for index, value in enumerate(args)
if value == "--tmpfs" and args[index + 1] == "/tmp"
)
read_only_sources = {
args[index + 1]
for index, value in enumerate(args)
if value == "--ro-bind"
}
repository = Path(__file__).parents[3]
assert tmp_index < bind_index
assert args[bind_index + 1] == str(record.working_dir.parent)
assert str(record.working_dir.parent.parent) not in args
assert str(repository / "packages" / "qwenpawmail-mcp" / "src") in (
read_only_sources
)
assert ("/etc", "/etc") not in {
(args[index + 1], args[index + 2])
for index, value in enumerate(args)
if value == "--ro-bind"
}
assert "--unshare-pid" in args
assert "--unshare-user" in args
assert ["--cap-drop", "ALL"] == args[
args.index("--cap-drop") : args.index("--cap-drop") + 2
]
def test_linux_command_mounts_resolver_at_standard_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
resolver = Path("/etc/resolv.conf")
if not resolver.exists():
pytest.skip("resolver configuration is unavailable")
isolator = LinuxBubblewrapIsolator("/usr/bin/bwrap")
monkeypatch.setattr(isolator, "_probe", lambda *args: None)
launch = isolator.prepare(
_record(tmp_path),
["python", "-m", "qwenpaw"],
{},
)
read_only_mounts = {
(launch.command[index + 1], launch.command[index + 2])
for index, value in enumerate(launch.command)
if value == "--ro-bind"
}
assert (str(resolver), str(resolver)) in read_only_mounts
def test_linux_command_mounts_python_base_prefix(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
base_prefix = tmp_path / "base-python"
base_bin = base_prefix / "bin"
base_bin.mkdir(parents=True)
base_executable = base_bin / "python3.13"
base_executable.touch()
(base_bin / "python").symlink_to(base_executable.name)
venv = tmp_path / "venv"
venv_bin = venv / "bin"
venv_bin.mkdir(parents=True)
venv_executable = venv_bin / "python"
venv_executable.symlink_to(base_bin / "python")
monkeypatch.setattr(sys, "executable", str(venv_executable))
monkeypatch.setattr(sys, "prefix", str(venv))
monkeypatch.setattr(sys, "base_prefix", str(base_prefix))
isolator = LinuxBubblewrapIsolator("/usr/bin/bwrap")
monkeypatch.setattr(isolator, "_probe", lambda *args: None)
launch = isolator.prepare(
_record(tmp_path),
[str(venv_executable), "-m", "qwenpaw"],
{},
)
read_only_mounts = {
(launch.command[index + 1], launch.command[index + 2])
for index, value in enumerate(launch.command)
if value == "--ro-bind"
}
assert (str(base_prefix), str(base_prefix)) in read_only_mounts
def test_linux_command_never_mounts_pythonpath(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
trusted_path = tmp_path / "trusted-source"
trusted_path.mkdir()
other_tenant = tmp_path / "runtimes" / "runtime-b"
other_tenant.mkdir(parents=True)
monkeypatch.setenv("PYTHONPATH", str(trusted_path))
isolator = LinuxBubblewrapIsolator("/usr/bin/bwrap")
monkeypatch.setattr(isolator, "_probe", lambda *args: None)
launch = isolator.prepare(
_record(tmp_path),
["python", "-m", "qwenpaw"],
{"PYTHONPATH": f"{Path('/')}:{other_tenant}"},
)
read_only_sources = {
launch.command[index + 1]
for index, value in enumerate(launch.command)
if value == "--ro-bind"
}
assert str(trusted_path.resolve()) not in read_only_sources
assert str(Path("/")) not in read_only_sources
assert str(other_tenant.resolve()) not in read_only_sources
def test_macos_profile_does_not_allow_global_file_reads(
tmp_path: Path,
) -> None:
record = _record(tmp_path)
isolator = MacOSSeatbeltIsolator()
profile = isolator._profile( # pylint: disable=protected-access
record,
)
runtime_root = isolator._escape( # pylint: disable=protected-access
record.working_dir.parent,
)
assert "\n(allow file-read*)\n" not in f"\n{profile}\n"
assert f'(allow file-read* (subpath "{runtime_root}"))' in profile
def test_macos_profile_allows_system_mime_types(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mime_types = tmp_path / "system" / "mime.types"
mime_types.parent.mkdir()
mime_types.write_text("text/plain txt", encoding="utf-8")
monkeypatch.setattr(mimetypes, "knownfiles", [str(mime_types)])
isolator = MacOSSeatbeltIsolator()
profile = isolator._profile( # pylint: disable=protected-access
_record(tmp_path),
)
allowed_path = isolator._escape( # pylint: disable=protected-access
mime_types,
)
assert f'(allow file-read* (subpath "{allowed_path}"))' in profile
@pytest.mark.skipif(
sys.platform != "darwin",
reason="Seatbelt is only available on macOS.",
)
def test_macos_seatbelt_blocks_reads_outside_allowlist(
tmp_path: Path,
) -> None:
sandbox_exec = Path("/usr/bin/sandbox-exec")
if not sandbox_exec.is_file():
pytest.skip("sandbox-exec is unavailable")
record = _record(tmp_path)
allowed = record.working_dir / "allowed.txt"
allowed.write_text("allowed", encoding="utf-8")
outside = tmp_path.parent / f"{tmp_path.name}-outside.txt"
outside.write_text("outside-secret", encoding="utf-8")
profile_path = record.secret_dir / "runtime-test.sb"
profile_path.write_text(
MacOSSeatbeltIsolator()._profile( # pylint: disable=protected-access
record,
),
encoding="utf-8",
)
allowed_result = subprocess.run(
[
str(sandbox_exec),
"-f",
str(profile_path),
"/bin/cat",
str(allowed),
],
capture_output=True,
text=True,
cwd=record.working_dir,
check=False,
)
blocked_result = subprocess.run(
[str(sandbox_exec), "-f", str(profile_path), "/bin/cat", str(outside)],
capture_output=True,
text=True,
cwd=record.working_dir,
check=False,
)
assert allowed_result.returncode == 0
assert allowed_result.stdout == "allowed"
assert blocked_result.returncode != 0
@pytest.mark.skipif(
sys.platform != "darwin",
reason="Seatbelt is only available on macOS.",
)
def test_macos_seatbelt_real_preflight(tmp_path: Path) -> None:
provisioner = LocalProcessRuntimeProvisioner(
isolator=MacOSSeatbeltIsolator(),
)
availability = provisioner.preflight(tmp_path / "preflight")
provisioner.close()
assert availability.available, availability.reason
def test_provisioner_launches_through_injected_isolator(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
isolator = _RecordingIsolator()
provisioner = LocalProcessRuntimeProvisioner(isolator=isolator)
monkeypatch.setattr(
isolator,
"launch",
lambda *_: _RuntimeProcess(),
)
monkeypatch.setattr(provisioner, "_wait_until_ready", lambda *_: None)
started = provisioner.start(
_record(tmp_path),
{"QWENPAW_RUNTIME_INTERNAL_TOKEN": "secret"},
)
assert isolator.called is True
assert started.state is RuntimeState.RUNNING
provisioner.close()
def test_windows_runtime_uses_outbound_reverse_tunnel(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_TunnelBroker.instances.clear()
isolator = _WindowsRecordingIsolator()
provisioner = LocalProcessRuntimeProvisioner(isolator=isolator)
monkeypatch.setattr(
"qwenpaw.hub.local_provisioner.WindowsReverseTunnelBroker",
_TunnelBroker,
)
monkeypatch.setattr(provisioner, "_wait_until_ready", lambda *_: None)
started = provisioner.start(
_record(tmp_path),
{"QWENPAW_RUNTIME_INTERNAL_TOKEN": "secret"},
)
command = isolator.command
separator = command.index("--")
assert command[command.index("-m") + 1] == (
"qwenpaw.hub.windows_runtime_bridge"
)
assert command[command.index("--control-port") + 1] == "9100"
assert command[command.index("--token") + 1] == "tunnel-token"
assert command[separator + 1 : separator + 4] == [
sys.executable,
"-m",
"qwenpaw",
]
assert command[command.index("--host", separator) + 1] == "127.0.0.1"
assert started.port == 9001
assert _TunnelBroker.instances[0].started is True
isolator.process.running = False
provisioner.close()
assert _TunnelBroker.instances[0].closed is True
def test_local_readiness_ignores_optional_integration_health(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
provisioner = LocalProcessRuntimeProvisioner(
isolator=_RecordingIsolator(),
)
requests: list[urllib.request.Request] = []
class _Process:
@staticmethod
def poll() -> None:
return None
class _Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *args: object) -> None:
del args
def urlopen(
request: urllib.request.Request,
timeout: int,
) -> _Response:
del timeout
requests.append(request)
return _Response()
monkeypatch.setattr("urllib.request.urlopen", urlopen)
provisioner._wait_until_ready( # pylint: disable=protected-access
_record(tmp_path),
_Process(),
"runtime-token",
)
assert requests[0].full_url == "http://127.0.0.1:9001/api/version"
assert requests[0].get_header("X-qwenpaw-runtime-token") == (
"runtime-token"
)
def test_runtime_parent_thread_survives_request_worker(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
isolator = _RecordingIsolator()
provisioner = LocalProcessRuntimeProvisioner(isolator=isolator)
launcher_threads: list[threading.Thread] = []
results: list[RuntimeRecord] = []
class _Process:
pid = 12345
@staticmethod
def poll() -> None:
return None
@staticmethod
def wait(timeout: float) -> int:
del timeout
return 0
@staticmethod
def terminate() -> None:
pass
def popen(*args, **kwargs):
del args, kwargs
launcher_threads.append(threading.current_thread())
return _Process()
monkeypatch.setattr(
"qwenpaw.hub.local_provisioner.subprocess.Popen",
popen,
)
monkeypatch.setattr(
"qwenpaw.hub.local_provisioner.os.killpg",
lambda *_: None,
raising=False,
)
monkeypatch.setattr(provisioner, "_wait_until_ready", lambda *_: None)
request_worker = threading.Thread(
target=lambda: results.append(
provisioner.start(
_record(tmp_path),
{"QWENPAW_RUNTIME_INTERNAL_TOKEN": "secret"},
),
),
)
request_worker.start()
request_worker.join()
assert results[0].state is RuntimeState.RUNNING
assert launcher_threads[0] is not request_worker
assert launcher_threads[0].is_alive()
provisioner.close()
assert not launcher_threads[0].is_alive()