The child_env fixture still reset the allowlist via the removed _allowed_env_vars_var ContextVar token, so every test using the fixture (AttributeError at setup) failed across all Python/OS lanes once #90004's process-wide set landed. Save/restore the set contents instead.
111 lines
4.9 KiB
Python
111 lines
4.9 KiB
Python
"""Isolated, real processes for the child-environment contract tests."""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import venv
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def child_env(monkeypatch, tmp_path):
|
|
# Never dump the operator's environment or source their shell startup files.
|
|
names = (
|
|
"PATH", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT",
|
|
"TEMP", "TMP", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
|
|
)
|
|
seed = {k: v for k, v in os.environ.items() if k.upper() in names}
|
|
seed.update(HOME=str(tmp_path), USERPROFILE=str(tmp_path), USER="env-test",
|
|
HERMES_HOME=str(tmp_path / "hermes"),
|
|
HERMES_RUNTIME_DIR=str(tmp_path / "runtime"),
|
|
TERMINAL_ENV="local", TERMINAL_CWD=str(tmp_path), LANG="C.UTF-8")
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
from tools import env_passthrough
|
|
from tools.environments import local
|
|
monkeypatch.setattr(local, "_read_terminal_shell_init_config", lambda: ([], False))
|
|
monkeypatch.setattr(env_passthrough, "_config_passthrough", {})
|
|
# The skill-registered allowlist is a process-wide set (see #90004): the
|
|
# registration must survive tool dispatch's copy_context() snapshots, so
|
|
# a ContextVar cannot back it. Save/restore the set contents instead of a
|
|
# ContextVar token so each test starts from an empty allowlist.
|
|
saved_allowed = set(env_passthrough._allowed_env_vars)
|
|
env_passthrough._allowed_env_vars.clear()
|
|
with patch.dict(os.environ, seed, clear=True):
|
|
try:
|
|
yield tmp_path
|
|
finally:
|
|
from tools.code_kernel import shutdown_all_kernels
|
|
shutdown_all_kernels()
|
|
env_passthrough._allowed_env_vars.clear()
|
|
env_passthrough._allowed_env_vars.update(saved_allowed)
|
|
|
|
|
|
@pytest.fixture
|
|
def project_python(child_env):
|
|
root = child_env / "project-venv"
|
|
# A symlink to the parent binary triggers realpath's same-env shortcut;
|
|
# copies=True semantics are essential to distinguish both interpreters.
|
|
venv.EnvBuilder(with_pip=False, symlinks=False).create(root)
|
|
python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
result = subprocess.run(
|
|
[str(python), "-c", "import sys,sysconfig,json; print(json.dumps([sys.prefix,sysconfig.get_path('purelib')]))"],
|
|
env=dict(os.environ), stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, check=True, timeout=30,
|
|
)
|
|
prefix, site = json.loads(result.stdout)
|
|
assert Path(prefix).resolve() == root.resolve()
|
|
assert Path(prefix).resolve() != Path(sys.prefix).resolve()
|
|
assert python.resolve() != Path(sys.executable).resolve()
|
|
Path(site, "project_only_probe.py").write_text("VALUE = 'project-only → 雪'\n", encoding="utf-8")
|
|
return python, root
|
|
|
|
|
|
def run_code(code, mode="project", enabled_tools=("read_file",), reset=True):
|
|
from tools.code_execution_tool import execute_code
|
|
with patch("tools.code_execution_tool._load_config", return_value={"mode": mode}):
|
|
result = json.loads(execute_code(code=code, task_id="child-env-test",
|
|
enabled_tools=list(enabled_tools), reset=reset))
|
|
assert result["status"] == "success", result
|
|
return json.loads(result["output"])
|
|
|
|
|
|
def observe_terminal(env, names):
|
|
code = "import json,os; print(json.dumps({k:os.environ.get(k) for k in " + repr(list(names)) + "}))"
|
|
command = shlex.join([Path(sys.executable).as_posix(), "-c", code])
|
|
result = env.execute(command)
|
|
assert result["returncode"] == 0, result
|
|
return json.loads(result["output"])
|
|
|
|
|
|
def observe_child(env, names):
|
|
code = "import json,os; print(json.dumps({k:os.environ.get(k) for k in " + repr(list(names)) + "}))"
|
|
result = subprocess.run([sys.executable, "-c", code], env=env, stdin=subprocess.DEVNULL,
|
|
capture_output=True, text=True, check=True, timeout=30)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
class RunToCompletionEnv:
|
|
"""Real shell transport for the remote file-RPC path; no remote service."""
|
|
|
|
def __init__(self, root):
|
|
import shutil
|
|
self.root = root
|
|
self.bash = shutil.which("bash")
|
|
assert self.bash, "bash is required for the POSIX file-RPC transport"
|
|
self.env = dict(os.environ)
|
|
self.env["PATH"] = os.pathsep.join([str(Path(sys.executable).parent), self.env["PATH"]])
|
|
|
|
def get_temp_dir(self):
|
|
return str(self.root)
|
|
|
|
def execute(self, command, cwd=None, timeout=30, stdin_data=None):
|
|
result = subprocess.run([self.bash, "-c", command], cwd=cwd or self.root,
|
|
env=self.env, input=stdin_data or "", timeout=timeout,
|
|
capture_output=True, text=True, encoding="utf-8")
|
|
return {"returncode": result.returncode, "output": result.stdout + result.stderr}
|