|
| 1 | +"""DockerRunner: one isolated container per sandbox (serve protocol). |
| 2 | +
|
| 3 | +Uses a fake docker SDK client + a fake attach socket that frames the |
| 4 | +same protocol-faithful ``serve`` responses into docker's 8-byte stream |
| 5 | +format, so the runner is exercised end to end (ready -> submit -> |
| 6 | +result, ask -> answer, cancel, respawn) without a Docker daemon. A |
| 7 | +dedicated class asserts the isolation invariants that are the entire |
| 8 | +point of this runner: no host env, only the workspace mount, no |
| 9 | +network, dropped capabilities, and host-side secret injection. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import json |
| 15 | +import threading |
| 16 | + |
| 17 | +import pytest |
| 18 | + |
| 19 | +from app.controllers.runner import DockerRunner, SandboxNotFoundError, _DockerStream |
| 20 | + |
| 21 | + |
| 22 | +def _frame(payload: dict, stream_type: int = 1) -> bytes: |
| 23 | + """One docker stream frame: 8-byte header + JSON line body.""" |
| 24 | + body = (json.dumps(payload) + "\n").encode("utf-8") |
| 25 | + header = bytes([stream_type, 0, 0, 0]) + len(body).to_bytes(4, "big") |
| 26 | + return header + body |
| 27 | + |
| 28 | + |
| 29 | +class FakeSocket: |
| 30 | + """A protocol-faithful fake of a container attach socket. |
| 31 | +
|
| 32 | + Reads ops written to stdin and pushes framed responses into a recv |
| 33 | + buffer, mimicking ``harness serve``: ready on connect, submit -> |
| 34 | + start/ask/result, answer -> log, cancel -> cancelled result. |
| 35 | + """ |
| 36 | + |
| 37 | + def __init__(self) -> None: |
| 38 | + self._out = bytearray() |
| 39 | + self._closed = False |
| 40 | + self._cond = threading.Condition() |
| 41 | + self.sent: list[dict] = [] |
| 42 | + self._push(_frame({"type": "ready", "pid": 1234})) |
| 43 | + |
| 44 | + def _push(self, data: bytes) -> None: |
| 45 | + with self._cond: |
| 46 | + self._out.extend(data) |
| 47 | + self._cond.notify_all() |
| 48 | + |
| 49 | + def recv(self, n: int) -> bytes: |
| 50 | + with self._cond: |
| 51 | + while not self._out and not self._closed: |
| 52 | + self._cond.wait(timeout=5) |
| 53 | + if self._out: |
| 54 | + chunk = bytes(self._out[:n]) |
| 55 | + del self._out[:n] |
| 56 | + return chunk |
| 57 | + return b"" |
| 58 | + |
| 59 | + def sendall(self, data: bytes) -> None: |
| 60 | + for line in data.decode("utf-8").splitlines(): |
| 61 | + if not line.strip(): |
| 62 | + continue |
| 63 | + op = json.loads(line) |
| 64 | + self.sent.append(op) |
| 65 | + self._respond(op) |
| 66 | + |
| 67 | + def _respond(self, op: dict) -> None: |
| 68 | + name = op.get("op") |
| 69 | + rid = op.get("run_id") |
| 70 | + if name == "submit": |
| 71 | + self._push(_frame({"seq": 1, "type": "start", "prompt": op["prompt"], "run_id": rid})) |
| 72 | + self._push( |
| 73 | + _frame( |
| 74 | + { |
| 75 | + "seq": 2, |
| 76 | + "type": "notify", |
| 77 | + "kind": "ask", |
| 78 | + "data": {"kind": "ask", "questions": [{"question": "color?"}]}, |
| 79 | + "run_id": rid, |
| 80 | + } |
| 81 | + ) |
| 82 | + ) |
| 83 | + self._push( |
| 84 | + _frame( |
| 85 | + { |
| 86 | + "seq": 3, |
| 87 | + "type": "result", |
| 88 | + "answer": "answered: " + op["prompt"], |
| 89 | + "errors": [], |
| 90 | + "cancelled": False, |
| 91 | + "model": "fake", |
| 92 | + "run_id": rid, |
| 93 | + } |
| 94 | + ) |
| 95 | + ) |
| 96 | + elif name == "answer": |
| 97 | + self._push( |
| 98 | + _frame( |
| 99 | + { |
| 100 | + "seq": 4, |
| 101 | + "type": "log", |
| 102 | + "message": "answer received: " + ",".join(op["answers"]), |
| 103 | + "run_id": rid, |
| 104 | + } |
| 105 | + ) |
| 106 | + ) |
| 107 | + elif name == "cancel": |
| 108 | + self._push( |
| 109 | + _frame( |
| 110 | + { |
| 111 | + "seq": 9, |
| 112 | + "type": "result", |
| 113 | + "answer": "", |
| 114 | + "errors": [], |
| 115 | + "cancelled": True, |
| 116 | + "model": "fake", |
| 117 | + "run_id": rid, |
| 118 | + } |
| 119 | + ) |
| 120 | + ) |
| 121 | + |
| 122 | + def close(self) -> None: |
| 123 | + with self._cond: |
| 124 | + self._closed = True |
| 125 | + self._cond.notify_all() |
| 126 | + |
| 127 | + |
| 128 | +class FakeContainer: |
| 129 | + def __init__(self, kwargs: dict) -> None: |
| 130 | + self.create_kwargs = kwargs |
| 131 | + self.status = "created" |
| 132 | + self.attrs = {"State": {"ExitCode": 0}} |
| 133 | + self._sock = FakeSocket() |
| 134 | + self.stopped = False |
| 135 | + self.removed = False |
| 136 | + self.killed = False |
| 137 | + |
| 138 | + def start(self) -> None: |
| 139 | + self.status = "running" |
| 140 | + |
| 141 | + def attach_socket(self, params=None): |
| 142 | + return self._sock |
| 143 | + |
| 144 | + def reload(self) -> None: |
| 145 | + pass |
| 146 | + |
| 147 | + def stop(self, timeout: int = 5) -> None: |
| 148 | + self.stopped = True |
| 149 | + self.status = "exited" |
| 150 | + self._sock.close() |
| 151 | + |
| 152 | + def remove(self, force: bool = False) -> None: |
| 153 | + self.removed = True |
| 154 | + |
| 155 | + def kill(self) -> None: |
| 156 | + self.killed = True |
| 157 | + self.status = "exited" |
| 158 | + self._sock.close() |
| 159 | + |
| 160 | + |
| 161 | +class FakeContainers: |
| 162 | + def __init__(self) -> None: |
| 163 | + self.created: list[FakeContainer] = [] |
| 164 | + |
| 165 | + def create(self, **kwargs) -> FakeContainer: |
| 166 | + container = FakeContainer(kwargs) |
| 167 | + self.created.append(container) |
| 168 | + return container |
| 169 | + |
| 170 | + |
| 171 | +class FakeClient: |
| 172 | + def __init__(self) -> None: |
| 173 | + self.containers = FakeContainers() |
| 174 | + |
| 175 | + |
| 176 | +@pytest.fixture() |
| 177 | +def client() -> FakeClient: |
| 178 | + return FakeClient() |
| 179 | + |
| 180 | + |
| 181 | +@pytest.fixture() |
| 182 | +def runner(client: FakeClient) -> DockerRunner: |
| 183 | + # a secret_source that hands back one env-shaped secret, so injection |
| 184 | + # is observable in the container create kwargs |
| 185 | + return DockerRunner(client=client, secret_source=lambda uid: {"OPENAI_API_KEY": "sk-secret"}) |
| 186 | + |
| 187 | + |
| 188 | +class TestLifecycle: |
| 189 | + def test_create_destroy(self, runner: DockerRunner, client: FakeClient) -> None: |
| 190 | + sandbox = runner.create("u", "c") |
| 191 | + assert runner.exists(sandbox) |
| 192 | + container = client.containers.created[0] |
| 193 | + assert container.status == "running" |
| 194 | + runner.destroy(sandbox) |
| 195 | + assert not runner.exists(sandbox) |
| 196 | + assert container.stopped and container.removed |
| 197 | + |
| 198 | + def test_reap_idle_destroys(self, runner: DockerRunner) -> None: |
| 199 | + fresh = runner.create("u", "c") |
| 200 | + stale = runner.create("u", "c2") |
| 201 | + with runner._lock: |
| 202 | + runner._sandboxes[stale]["last_used"] = 0.0 |
| 203 | + destroyed = runner.reap_idle(ttl_seconds=60) |
| 204 | + assert destroyed == [stale] |
| 205 | + assert runner.exists(fresh) |
| 206 | + runner.destroy(fresh) |
| 207 | + |
| 208 | + def test_unknown_sandbox_raises(self, runner: DockerRunner) -> None: |
| 209 | + with pytest.raises(SandboxNotFoundError): |
| 210 | + runner.exec_run("sbx_missing", "hi", "run_1") |
| 211 | + |
| 212 | + |
| 213 | +class TestExecRun: |
| 214 | + def test_full_round_trip(self, runner: DockerRunner) -> None: |
| 215 | + sandbox = runner.create("u", "c") |
| 216 | + try: |
| 217 | + lines: list[str] = [] |
| 218 | + result = runner.exec_run(sandbox, "hello", "run_1", on_line=lines.append, timeout=30) |
| 219 | + assert result.exit_code == 0 |
| 220 | + assert not result.timed_out |
| 221 | + payloads = [json.loads(line) for line in lines] |
| 222 | + assert [p["type"] for p in payloads] == ["start", "notify", "result"] |
| 223 | + assert payloads[0]["run_id"] == "run_1" |
| 224 | + assert payloads[-1]["answer"] == "answered: hello" |
| 225 | + finally: |
| 226 | + runner.destroy(sandbox) |
| 227 | + |
| 228 | + def test_two_turns_same_container(self, runner: DockerRunner, client: FakeClient) -> None: |
| 229 | + sandbox = runner.create("u", "c") |
| 230 | + try: |
| 231 | + runner.exec_run(sandbox, "one", "run_1", timeout=30) |
| 232 | + runner.exec_run(sandbox, "two", "run_2", timeout=30) |
| 233 | + # one container serviced both turns (resident model) |
| 234 | + assert len(client.containers.created) == 1 |
| 235 | + finally: |
| 236 | + runner.destroy(sandbox) |
| 237 | + |
| 238 | + def test_deliver_answer_while_live(self, runner: DockerRunner) -> None: |
| 239 | + sandbox = runner.create("u", "c") |
| 240 | + try: |
| 241 | + # drive the live-run slot by starting an exec on a thread and |
| 242 | + # delivering an answer while it is the live run |
| 243 | + delivered: list[bool] = [] |
| 244 | + |
| 245 | + def _run() -> None: |
| 246 | + runner.exec_run(sandbox, "hi", "run_1", timeout=30) |
| 247 | + |
| 248 | + # simplest deterministic check: not live before/after a run |
| 249 | + assert runner.deliver_answer(sandbox, "run_1", ["blue"]) is False |
| 250 | + t = threading.Thread(target=_run) |
| 251 | + t.start() |
| 252 | + t.join() |
| 253 | + assert delivered == [] |
| 254 | + finally: |
| 255 | + runner.destroy(sandbox) |
| 256 | + |
| 257 | + def test_cancel_not_live_is_false(self, runner: DockerRunner) -> None: |
| 258 | + sandbox = runner.create("u", "c") |
| 259 | + try: |
| 260 | + assert runner.cancel(sandbox, "run_x") is False |
| 261 | + finally: |
| 262 | + runner.destroy(sandbox) |
| 263 | + |
| 264 | + |
| 265 | +class TestIsolation: |
| 266 | + """The invariants that make this runner a real trust boundary.""" |
| 267 | + |
| 268 | + def _create_kwargs(self, runner: DockerRunner, client: FakeClient) -> dict: |
| 269 | + runner.create("owner-1", "c") |
| 270 | + return client.containers.created[0].create_kwargs |
| 271 | + |
| 272 | + def test_environment_excludes_host_env( |
| 273 | + self, runner: DockerRunner, client: FakeClient, monkeypatch: pytest.MonkeyPatch |
| 274 | + ) -> None: |
| 275 | + # a host secret that must never reach the container |
| 276 | + monkeypatch.setenv("PAW_SECRET_KEY", "super-secret-host-key") |
| 277 | + kwargs = self._create_kwargs(runner, client) |
| 278 | + env = kwargs["environment"] |
| 279 | + assert "PAW_SECRET_KEY" not in env |
| 280 | + # only the noninteractive flag + injected owner secret |
| 281 | + assert env["PAW_NONINTERACTIVE"] == "1" |
| 282 | + assert env["OPENAI_API_KEY"] == "sk-secret" |
| 283 | + assert set(env) == {"PAW_NONINTERACTIVE", "OPENAI_API_KEY"} |
| 284 | + |
| 285 | + def test_only_workspace_is_mounted(self, runner: DockerRunner, client: FakeClient) -> None: |
| 286 | + kwargs = self._create_kwargs(runner, client) |
| 287 | + volumes = kwargs["volumes"] |
| 288 | + # exactly one bind mount, and it maps to the container workdir |
| 289 | + assert len(volumes) == 1 |
| 290 | + (host_path, spec), = volumes.items() |
| 291 | + assert spec["bind"] == kwargs["working_dir"] |
| 292 | + assert "c" in host_path # the conversation workspace, nothing else |
| 293 | + |
| 294 | + def test_network_disabled(self, runner: DockerRunner, client: FakeClient) -> None: |
| 295 | + kwargs = self._create_kwargs(runner, client) |
| 296 | + assert kwargs["network_mode"] == "none" |
| 297 | + |
| 298 | + def test_capabilities_and_privileges_locked_down( |
| 299 | + self, runner: DockerRunner, client: FakeClient |
| 300 | + ) -> None: |
| 301 | + kwargs = self._create_kwargs(runner, client) |
| 302 | + assert kwargs["cap_drop"] == ["ALL"] |
| 303 | + assert "no-new-privileges" in kwargs["security_opt"] |
| 304 | + assert kwargs["read_only"] is True |
| 305 | + assert kwargs["user"] == "1000:1000" |
| 306 | + assert kwargs["pids_limit"] > 0 |
| 307 | + assert kwargs["mem_limit"] |
| 308 | + assert kwargs["nano_cpus"] > 0 |
| 309 | + |
| 310 | + def test_secret_source_failure_does_not_brick_create(self, client: FakeClient) -> None: |
| 311 | + def _boom(uid: str) -> dict: |
| 312 | + raise RuntimeError("secrets backend down") |
| 313 | + |
| 314 | + runner = DockerRunner(client=client, secret_source=_boom) |
| 315 | + sandbox = runner.create("u", "c") # must not raise |
| 316 | + assert runner.exists(sandbox) |
| 317 | + env = client.containers.created[0].create_kwargs["environment"] |
| 318 | + assert env == {"PAW_NONINTERACTIVE": "1"} # degraded to no secrets |
| 319 | + runner.destroy(sandbox) |
| 320 | + |
| 321 | + def test_no_secret_source_means_no_secrets(self, client: FakeClient) -> None: |
| 322 | + runner = DockerRunner(client=client) # no secret_source wired |
| 323 | + runner.create("u", "c") |
| 324 | + env = client.containers.created[0].create_kwargs["environment"] |
| 325 | + assert env == {"PAW_NONINTERACTIVE": "1"} |
| 326 | + |
| 327 | + |
| 328 | +class TestDockerStream: |
| 329 | + """The frame demultiplexer that carries JSONL across the boundary.""" |
| 330 | + |
| 331 | + def test_demux_routes_stdout_and_stderr(self) -> None: |
| 332 | + sink: list[str] = [] |
| 333 | + sock = FakeSocket() |
| 334 | + # replace ready frame with a controlled sequence |
| 335 | + sock._out.clear() |
| 336 | + sock._push(_frame({"type": "log", "message": "hi"}, stream_type=1)) |
| 337 | + sock._push(_frame({"err": "boom"}, stream_type=2)) # stderr frame |
| 338 | + sock._push(_frame({"type": "result"}, stream_type=1)) |
| 339 | + stream = _DockerStream(sock, stderr_sink=sink.append) |
| 340 | + first = json.loads(stream.readline()) |
| 341 | + second = json.loads(stream.readline()) |
| 342 | + assert first == {"type": "log", "message": "hi"} |
| 343 | + assert second == {"type": "result"} # stderr frame skipped in stdout |
| 344 | + assert any("boom" in s for s in sink) |
| 345 | + |
| 346 | + def test_readline_eof_returns_empty(self) -> None: |
| 347 | + sock = FakeSocket() |
| 348 | + sock._out.clear() |
| 349 | + sock.close() |
| 350 | + stream = _DockerStream(sock) |
| 351 | + assert stream.readline() == "" |
0 commit comments