Files
openworker/tests/test_sandbox_selection.py
Devika Verma ab4d6dda77 Sandbox refusal: no reconnect loop; image check follows the driver (docker or podman) and stays out of remote gateways
Review of OPE-205/OPE-206 found three gaps:

- The client reconnects with backoff on ANY socket close, so a session refused for a
  missing image would be retried every few seconds, adding an error notice each time
  and showing the "reconnecting" strip forever. The server now closes a refused session
  with code 4403; the client treats that as final (no reconnect, no strip) and the
  error notice in the transcript says why.
- The image check only knew Docker, while docs promise Docker or Podman. It now maps the
  gateway's driver to its own tool (`docker`/`podman image inspect`, `... pull`), and
  the refusal and the setup hint name that tool.
- A local image store says nothing about a remote gateway's images: when the active
  gateway is remote, the check answers "cannot tell" and never refuses.
2026-09-26 21:54:54 +05:30

215 lines
11 KiB
Python

"""Which sandbox provider a session gets, and the machine commands around it
(design doc `sandbox-design.md`, rulings 17, 18, 22, 23 and 29)."""
from __future__ import annotations
import json
import pytest
from coworker import config
from coworker.sandbox import selection
from coworker.sandbox.providers.openshell import OpenShellUnavailable
@pytest.fixture(autouse=True)
def clean(monkeypatch):
monkeypatch.delenv("OPENWORKER_SANDBOX_PROVIDER", raising=False)
monkeypatch.delenv("OPENWORKER_HEADLESS", raising=False)
def _openshell(monkeypatch, problem):
monkeypatch.setattr(selection, "openshell_problem", lambda fresh=False: problem)
def test_the_desktop_and_the_terminal_app_stay_direct(monkeypatch):
_openshell(monkeypatch, None) # even with OpenShell installed and running
chosen = selection.select(None)
assert (chosen.provider, chosen.explicit, chosen.warning) == ("direct", False, "")
def test_a_headless_machine_uses_openshell_when_it_is_there(monkeypatch):
_openshell(monkeypatch, None)
monkeypatch.setenv("OPENWORKER_HEADLESS", "1")
assert selection.select(None).provider == "openshell"
def test_a_headless_machine_without_openshell_runs_direct_and_says_so_loudly(monkeypatch):
_openshell(monkeypatch, "The OpenShell gateway is not running.")
chosen = selection.select(None, headless=True)
assert chosen.provider == "direct" and not chosen.explicit
assert "WITHOUT a sandbox" in chosen.warning and "gateway is not running" in chosen.warning
assert chosen.warning.count("sandbox setup") == 1
def test_an_explicit_openshell_setting_refuses_instead_of_falling_back(monkeypatch):
_openshell(monkeypatch, "The OpenShell gateway is not running.")
with pytest.raises(OpenShellUnavailable, match="no session will start"):
selection.select("openshell")
_openshell(monkeypatch, None)
assert selection.select("openshell") == selection.Selection("openshell", explicit=True)
def test_the_environment_wins_over_the_config_and_unknown_names_are_refused(monkeypatch):
_openshell(monkeypatch, None)
monkeypatch.setenv("OPENWORKER_SANDBOX_PROVIDER", "direct")
assert selection.select("openshell", headless=True) == selection.Selection("direct", explicit=True)
monkeypatch.setenv("OPENWORKER_SANDBOX_PROVIDER", "firejail")
with pytest.raises(ValueError, match="unknown sandbox provider"):
selection.select(None)
def test_a_repository_cannot_switch_the_sandbox_off(tmp_path):
machine = tmp_path / "config.toml"
config.set_global_value("sandbox_provider", "openshell", path=machine)
repo = tmp_path / "repo"
(repo / ".coworker").mkdir(parents=True)
(repo / ".coworker" / "config.toml").write_text('sandbox_provider = "direct"\nmodel = "from-the-repo"\n')
loaded = config.load_config(repo, global_path=machine)
assert loaded.sandbox_provider == "openshell" and loaded.model == "from-the-repo"
def test_setting_a_config_value_keeps_the_rest_of_the_file(tmp_path):
path = tmp_path / "config.toml"
path.write_text('model = "x"\n\n[some.table]\nkey = 1\n')
config.set_global_value("sandbox_provider", "openshell", path=path)
config.set_global_value("sandbox_provider", "direct", path=path)
text = path.read_text()
assert text.count("sandbox_provider") == 1 and text.index("sandbox_provider") < text.index("[some.table]")
assert config.load_config(global_path=path).sandbox_provider == "direct" and 'model = "x"' in text
with pytest.raises(ValueError):
config.set_global_value("not_a_key", "x", path=path)
def test_machine_status_reports_how_sessions_will_run(tmp_path, monkeypatch, capsys):
from coworker.remote import joiner
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
_openshell(monkeypatch, "OpenShell is not installed on this machine.")
info = joiner.machine_status(tmp_path)["sandbox"]
assert info["provider"] == "direct" and "WITHOUT a sandbox" in info["warning"]
joiner._cmd_status(tmp_path)
assert "sandbox: direct" in capsys.readouterr().out
config.set_global_value("sandbox_provider", "openshell")
refused = joiner.machine_status(tmp_path)["sandbox"]
assert refused["provider"] is None and "no session will start" in refused["refused"]
joiner._cmd_status(tmp_path, as_json=True)
assert json.loads(capsys.readouterr().out)["sandbox"]["provider"] is None
def test_the_sandbox_command_is_served_but_not_advertised(tmp_path, monkeypatch, capsys):
from coworker import cli
from coworker.sandbox import setup_cmd
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
_openshell(monkeypatch, "OpenShell is not installed on this machine.")
# The same answer on any machine, with or without OpenShell installed.
monkeypatch.setattr(setup_cmd, "checks", lambda: [(f"OpenShell {setup_cmd.PINNED_VERSION} is installed", False, "found none")])
assert "sandbox" not in cli.HELP # hidden until tried on a fresh machine
with pytest.raises(SystemExit) as done:
cli.main(["machine", "sandbox", "status"])
assert done.value.code == 1 # something is missing here
out = capsys.readouterr().out
assert "[--] OpenShell" in out and "sessions on this machine run: direct" in out
def test_setup_asks_before_each_change_and_makes_none_when_refused(tmp_path, monkeypatch):
from coworker.sandbox import setup_cmd
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(setup_cmd.sys, "platform", "linux")
monkeypatch.setattr(
setup_cmd,
"checks",
lambda: [
("Docker is installed and this user can use it", True, ""),
(f"OpenShell {setup_cmd.PINNED_VERSION} is installed", True, ""),
("the gateway allows bind mounts (your folders reach a sandbox this way)", False, ""),
("the gateway is running", True, ""),
("this machine is set to use OpenShell", False, ""),
],
)
asked: list[str] = []
said: list[str] = []
code = setup_cmd.setup(ask=lambda q: asked.append(q) or False, print_fn=said.append)
assert code == 1 and asked == ["Make this change?"]
assert "enable_bind_mounts = true" in "\n".join(said) # the change was shown first
assert not (tmp_path / "xdg" / "openshell" / "gateway.toml").exists()
assert config.load_config().sandbox_provider is None
def _all_but_the_image(setup_cmd):
return [
("Docker is installed and this user can use it", True, ""),
(f"OpenShell {setup_cmd.PINNED_VERSION} is installed", True, ""),
("the gateway allows bind mounts (your folders reach a sandbox this way)", True, ""),
("the gateway keeps running after you log out (linger)", True, ""),
("the gateway is running", True, ""),
("the `grpcio` package is installed", True, ""),
("this machine is set to use OpenShell", True, ""),
(setup_cmd.IMAGE_ROW, False, "docker pull ghcr.io/example/base@sha256:abc"),
]
def test_setup_offers_the_base_image_download_and_runs_nothing_when_refused(tmp_path, monkeypatch):
# OPE-205: the download is the one slow step, so it is a row of its own, offered last,
# shown with its size and command, and run with no time limit only after a yes.
from coworker.sandbox import setup_cmd
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
monkeypatch.setattr(setup_cmd.sys, "platform", "linux")
monkeypatch.setattr(setup_cmd, "checks", lambda: _all_but_the_image(setup_cmd))
monkeypatch.setattr(setup_cmd.openshell, "sandbox_image", lambda: "ghcr.io/example/base@sha256:abc")
monkeypatch.setattr(setup_cmd.openshell, "image_tool", lambda: "docker")
ran: list[list[str]] = []
monkeypatch.setattr(setup_cmd.subprocess, "run", lambda argv, **kw: ran.append(argv) or type("Done", (), {"returncode": 0})())
asked: list[str] = []
said: list[str] = []
assert setup_cmd.setup(ask=lambda q: asked.append(q) or False, print_fn=said.append) == 1
assert asked == ["Download it now? (10 to 20 minutes on a slow connection)"]
assert "5 GB" in "\n".join(said) and "docker pull ghcr.io/example/base@sha256:abc" in "\n".join(said)
assert ran == [] # refused: nothing ran
# Accepted: the pull runs as a plain command (Docker's own progress, no capture, no timeout).
monkeypatch.setattr(setup_cmd, "status", lambda print_fn=print: 0)
assert setup_cmd.setup(yes=True, print_fn=said.append) == 0
assert ran == [["docker", "pull", "ghcr.io/example/base@sha256:abc"]]
def test_setup_on_a_mac_still_offers_the_download(tmp_path, monkeypatch):
# The rest of `setup` is Linux-only, but the image is the same on a Mac with Docker
# Desktop, and the first session hangs on it there just the same.
from coworker.sandbox import setup_cmd
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
monkeypatch.setattr(setup_cmd.sys, "platform", "darwin")
monkeypatch.setattr(setup_cmd, "checks", lambda: _all_but_the_image(setup_cmd))
monkeypatch.setattr(setup_cmd.openshell, "image_tool", lambda: "docker")
ran: list[list[str]] = []
monkeypatch.setattr(setup_cmd.subprocess, "run", lambda argv, **kw: ran.append(argv) or type("Done", (), {"returncode": 0})())
said: list[str] = []
assert setup_cmd.setup(yes=True, print_fn=said.append) == 2 # still "this configures a Linux machine"
assert ran and ran[0][:2] == ["docker", "pull"]
assert "configures a Linux machine" in "\n".join(said)
def test_status_shows_a_missing_image_as_its_own_row_not_as_a_gateway_problem(tmp_path, monkeypatch):
from coworker.sandbox import setup_cmd
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path))
monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(setup_cmd, "_run", lambda argv, timeout=600: type("Done", (), {"returncode": 0, "stdout": f"openshell {setup_cmd.PINNED_VERSION}\n"})())
monkeypatch.setattr(setup_cmd.sys, "platform", "darwin") # no linger row to fake
monkeypatch.setattr(setup_cmd, "openshell_problem", lambda fresh=False: f"{setup_cmd.openshell.IMAGE_MISSING_PREFIX} (about 5 GB, one time). Run `docker pull x`.")
monkeypatch.setattr(setup_cmd.openshell, "image_present", lambda image=None: False)
monkeypatch.setattr(setup_cmd.openshell, "sandbox_image", lambda: "x")
monkeypatch.setattr(setup_cmd.openshell, "image_tool", lambda: "podman") # the hint names the driver's own tool
rows = {what: (ok, detail) for what, ok, detail in setup_cmd.checks()}
assert rows["the gateway is running"] == (True, "") # the gateway is fine; only the image is missing
assert rows[setup_cmd.IMAGE_ROW] == (False, "podman pull x")
# Another driver: Docker cannot be asked, so no row rather than a wrong one.
monkeypatch.setattr(setup_cmd, "openshell_problem", lambda fresh=False: None)
monkeypatch.setattr(setup_cmd.openshell, "image_present", lambda image=None: None)
assert setup_cmd.IMAGE_ROW not in {what for what, _, _ in setup_cmd.checks()}