From 35e7b1fa17d02ee8ae0ac2c71589d466f6c809f3 Mon Sep 17 00:00:00 2001 From: Bram Van Rompuy Date: Thu, 3 Sep 2026 17:29:11 +0200 Subject: [PATCH 1/4] test(setup-owner): pin Windows ownership contract --- tests/test_cli.py | 67 +++++ tests/test_setup_owner.py | 389 ++++++++++++++++++++++++++++ tests/test_windows_setup_process.py | 139 ++++++++++ 3 files changed, 595 insertions(+) create mode 100644 tests/test_setup_owner.py create mode 100644 tests/test_windows_setup_process.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ab798b..7366fea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -48,6 +48,73 @@ def test_capabilities_accepts_required_typed_call_error_reason(capsys) -> None: assert json.loads(capsys.readouterr().out)["status"] == "compatible" +def test_setup_owner_cli_routes_fixed_purpose_commands(monkeypatch, capsys) -> None: + captured: list[tuple[str, dict[str, object]]] = [] + + def fake_launch(**kwargs): + captured.append(("launch", kwargs)) + return {"schema_version": 1, "status": "owned"} + + monkeypatch.setattr(cli, "launch_setup", fake_launch) + + exit_code = cli.main( + [ + "setup-owner", + "launch", + "--state-root", + "C:\\BlenderBox", + "--attempt-id", + "a" * 32, + "--script-sha256", + "b" * 64, + "--deadline-unix-ms", + "2000000000000", + "--json", + ] + ) + + assert exit_code == 0 + assert captured == [ + ( + "launch", + { + "state_root": Path("C:\\BlenderBox"), + "attempt_id": "a" * 32, + "script_sha256": "b" * 64, + "deadline_unix_ms": 2_000_000_000_000, + }, + ) + ] + assert json.loads(capsys.readouterr().out)["status"] == "owned" + + +def test_setup_owner_cli_reports_machine_readable_failure(monkeypatch, capsys) -> None: + def fail_status(**_kwargs): + raise cli.SetupOwnerError("owner unavailable") + + monkeypatch.setattr(cli, "status_setup", fail_status) + + exit_code = cli.main( + [ + "setup-owner", + "status", + "--state-root", + "C:\\BlenderBox", + "--attempt-id", + "a" * 32, + "--json", + ] + ) + + assert exit_code == 1 + assert json.loads(capsys.readouterr().out) == { + "schema_version": 1, + "status": "error", + "command": "setup-owner status", + "message": "owner unavailable", + } + + def test_capabilities_rejects_unknown_contract() -> None: with pytest.raises(SystemExit) as error: cli.main(["capabilities", "--require", "unknown-v1"]) diff --git a/tests/test_setup_owner.py b/tests/test_setup_owner.py new file mode 100644 index 0000000..21ef497 --- /dev/null +++ b/tests/test_setup_owner.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from blendersessiond import setup_owner + +ATTEMPT_ID = "a" * 32 +SCRIPT = b'Write-Output \'{"status":"configured"}\'\n' +SCRIPT_SHA256 = hashlib.sha256(SCRIPT).hexdigest() + + +def stage_script(root: Path, content: bytes = SCRIPT) -> Path: + path = root / "setup-attempts" / ATTEMPT_ID / "setup.ps1" + path.parent.mkdir(parents=True) + path.write_bytes(content) + return path + + +def test_launch_publishes_request_before_spawning_keeper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_script(tmp_path) + observed: list[dict[str, object]] = [] + + def fake_spawn(attempt_dir: Path) -> tuple[int, str]: + observed.append(json.loads((attempt_dir / "request.json").read_text())) + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": observed[0]["request_sha256"], + "launch_id": "launch_test", + "keeper_pid": 10, + "keeper_creation_time": "windows:keeper", + "root_pid": 11, + "root_creation_time": "windows:root", + "owned_at": "2026-09-03T00:00:00Z", + }, + ) + return 10, "windows:keeper" + + monkeypatch.setattr(setup_owner, "_spawn_keeper", fake_spawn) + + result = setup_owner.launch_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + script_sha256=SCRIPT_SHA256, + deadline_unix_ms=2_000_000_000_000, + platform_name="Windows", + ) + + assert result["status"] == "owned" + assert observed[0]["script_size"] == len(SCRIPT) + assert observed[0]["script_sha256"] == SCRIPT_SHA256 + + +def test_launch_is_byte_identical_replay(tmp_path: Path, monkeypatch) -> None: + stage_script(tmp_path) + calls = 0 + + def fake_spawn(attempt_dir: Path) -> tuple[int, str]: + nonlocal calls + calls += 1 + request = json.loads((attempt_dir / "request.json").read_text()) + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_test", + "keeper_pid": 10, + "keeper_creation_time": "windows:keeper", + "root_pid": 11, + "root_creation_time": "windows:root", + "owned_at": "2026-09-03T00:00:00Z", + }, + ) + return 10, "windows:keeper" + + monkeypatch.setattr(setup_owner, "_spawn_keeper", fake_spawn) + arguments = { + "state_root": tmp_path, + "attempt_id": ATTEMPT_ID, + "script_sha256": SCRIPT_SHA256, + "deadline_unix_ms": 2_000_000_000_000, + "platform_name": "Windows", + } + + first = setup_owner.launch_setup(**arguments) + second = setup_owner.launch_setup(**arguments) + + assert first == second + assert calls == 1 + + +def test_lost_launch_response_reconciles_the_published_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_script(tmp_path) + calls = 0 + + def ambiguous_spawn(attempt_dir: Path) -> tuple[int, str]: + nonlocal calls + calls += 1 + request = json.loads((attempt_dir / "request.json").read_text()) + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_test", + "keeper_pid": 10, + "keeper_creation_time": "windows:keeper", + "root_pid": 11, + "root_creation_time": "windows:root", + "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", + "owned_at": "2026-09-03T00:00:00Z", + }, + ) + raise OSError("launch response was lost") + + monkeypatch.setattr(setup_owner, "_spawn_keeper", ambiguous_spawn) + arguments = { + "state_root": tmp_path, + "attempt_id": ATTEMPT_ID, + "script_sha256": SCRIPT_SHA256, + "deadline_unix_ms": 2_000_000_000_000, + "platform_name": "Windows", + } + + with pytest.raises(OSError, match="response was lost"): + setup_owner.launch_setup(**arguments) + reconciled = setup_owner.launch_setup(**arguments) + + assert reconciled["status"] == "owned" + assert reconciled["receipt"]["launch_id"] == "launch_test" + assert calls == 1 + + +def test_abandoned_pre_receipt_claim_becomes_an_immutable_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_script(tmp_path) + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + setup_owner._write_create_once( + attempt_dir / "launch-claim.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_abandoned", + "claimed_at": "2026-09-03T00:00:00Z", + }, + ) + monkeypatch.setattr(setup_owner, "_LAUNCH_WAIT_SECONDS", 0) + monkeypatch.setattr( + setup_owner, + "_spawn_keeper", + lambda _attempt_dir: (_ for _ in ()).throw(AssertionError("replacement")), + ) + + first = setup_owner.launch_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + script_sha256=SCRIPT_SHA256, + deadline_unix_ms=2_000_000_000_000, + platform_name="Windows", + ) + second = setup_owner.status_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + + assert first == second + assert first["status"] == "launch_failed" + assert first["cleanup"] == "cleanup_unverified" + assert json.loads((attempt_dir / "terminal.json").read_text()) == first + + +def test_launch_rejects_request_replacement(tmp_path: Path, monkeypatch) -> None: + stage_script(tmp_path) + monkeypatch.setattr( + setup_owner, + "_spawn_keeper", + lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), + ) + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + setup_owner._write_create_once( + attempt_dir / "request.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": "b" * 64, + "script_sha256": "c" * 64, + "script_size": 1, + "deadline_unix_ms": 2_000_000_000_000, + "operation": "blender-box-windows-setup-v1", + }, + ) + + with pytest.raises(setup_owner.SetupOwnerError, match="different request"): + setup_owner.launch_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + script_sha256=SCRIPT_SHA256, + deadline_unix_ms=2_000_000_000_000, + platform_name="Windows", + ) + + +def test_stop_before_ownership_is_immutable_and_prevents_launch( + tmp_path: Path, monkeypatch +) -> None: + stage_script(tmp_path) + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + monkeypatch.setattr( + setup_owner, + "_spawn_keeper", + lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), + ) + + first = setup_owner.stop_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + second = setup_owner.stop_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + replay = setup_owner.launch_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + script_sha256=SCRIPT_SHA256, + deadline_unix_ms=2_000_000_000_000, + platform_name="Windows", + ) + + assert first == second == replay + assert first["status"] == "stopped_before_ownership" + assert first["cleanup"] == "tree_gone" + + +def test_status_does_not_dispatch_owner(tmp_path: Path, monkeypatch) -> None: + stage_script(tmp_path) + monkeypatch.setattr( + setup_owner, + "_spawn_keeper", + lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), + ) + + result = setup_owner.status_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + + assert result["status"] == "not_found" + + +def test_owner_loss_publishes_one_stable_tree_gone_terminal( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_script(tmp_path) + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_lost", + "keeper_pid": 10, + "keeper_creation_time": "windows:keeper", + "root_pid": 11, + "root_creation_time": "windows:root", + "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", + "owned_at": "2026-09-03T00:00:00Z", + }, + ) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) + monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: False) + + first = setup_owner.status_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + second = setup_owner.stop_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + + assert first == second + assert first["status"] == "owner_lost" + assert first["cleanup"] == "tree_gone" + assert json.loads((attempt_dir / "terminal.json").read_text()) == first + + +def test_pid_reuse_is_never_terminated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_script(tmp_path) + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_stale_pid", + "keeper_pid": 10, + "keeper_creation_time": "windows:old-keeper", + "root_pid": 11, + "root_creation_time": "windows:old-root", + "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", + "owned_at": "2026-09-03T00:00:00Z", + }, + ) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) + monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: True) + monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) + terminated: list[tuple[int, str]] = [] + monkeypatch.setattr( + setup_owner, + "_terminate_exact_keeper", + lambda pid, identity: terminated.append((pid, identity)), + ) + + first = setup_owner.stop_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + second = setup_owner.stop_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Windows", + ) + + assert first == second + assert first["status"] == "cleanup_unverified" + assert first["cleanup"] == "cleanup_unverified" + assert terminated == [] + + +@pytest.mark.parametrize("attempt_id", ["", "A" * 32, "../escape", "a" * 31]) +def test_attempt_identity_is_strict(attempt_id: str, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="Attempt ID"): + setup_owner.status_setup( + state_root=tmp_path, + attempt_id=attempt_id, + platform_name="Windows", + ) + + +def test_non_windows_fails_closed(tmp_path: Path) -> None: + with pytest.raises(setup_owner.SetupOwnerError, match="Windows"): + setup_owner.status_setup( + state_root=tmp_path, + attempt_id=ATTEMPT_ID, + platform_name="Darwin", + ) diff --git a/tests/test_windows_setup_process.py b/tests/test_windows_setup_process.py new file mode 100644 index 0000000..b48bf1e --- /dev/null +++ b/tests/test_windows_setup_process.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from blendersessiond import setup_owner, windows_setup_process + +ATTEMPT_ID = "a" * 32 +SCRIPT = b"Write-Output 'owned bytes'\n" +SCRIPT_SHA256 = hashlib.sha256(SCRIPT).hexdigest() + + +def test_keeper_uses_validated_bytes_and_publishes_receipt_before_resume( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + attempt_dir.mkdir(parents=True) + (attempt_dir / "setup.ps1").write_bytes(SCRIPT) + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + setup_owner._write_create_once( + attempt_dir / "launch-claim.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_test", + "claimed_at": "2026-09-03T00:00:00Z", + }, + ) + child = windows_setup_process._OwnedProcess( + process_handle=101, + thread_handle=102, + process_id=103, + stdin_write=104, + stdout_read=105, + stderr_read=106, + child_handles=(), + attribute_list=None, + attribute_buffer=None, + ) + created: list[tuple[int, str]] = [] + started: list[bytes] = [] + + def create(script_size: int, script_sha256: str, job: int, *, cwd: Path): + created.append((script_size, script_sha256)) + assert job == 100 + assert cwd == attempt_dir + return child + + def start_io(owned, content: bytes, output, threads) -> None: + assert owned is child + assert json.loads((attempt_dir / "launch-receipt.json").read_text())[ + "root_pid" + ] == 103 + started.append(content) + + monkeypatch.setattr(windows_setup_process, "_create_kill_on_close_job", lambda _: 100) + monkeypatch.setattr(windows_setup_process, "_create_suspended_setup_process", create) + monkeypatch.setattr(windows_setup_process, "_creation_time_from_handle", lambda _: "windows:root") + monkeypatch.setattr(windows_setup_process, "process_start_time", lambda _: "windows:keeper") + monkeypatch.setattr(windows_setup_process, "_is_process_in_job", lambda *_: True) + monkeypatch.setattr(windows_setup_process, "_start_process_io", start_io) + monkeypatch.setattr(windows_setup_process, "_resume_process", lambda _: None) + monkeypatch.setattr(windows_setup_process, "_wait_for_process", lambda *_: "exited") + monkeypatch.setattr(windows_setup_process, "_process_exit_code", lambda _: 0) + monkeypatch.setattr(windows_setup_process, "_active_processes", lambda _: 0) + monkeypatch.setattr(windows_setup_process, "_wait_for_empty_job", lambda _: None) + monkeypatch.setattr(windows_setup_process, "_finish_process_io", lambda *_: None) + monkeypatch.setattr(windows_setup_process, "_close_owned_process", lambda _: None) + monkeypatch.setattr(windows_setup_process, "_close_handle", lambda _: None) + + assert windows_setup_process.run_keeper(attempt_dir) == 0 + + terminal = json.loads((attempt_dir / "terminal.json").read_text()) + assert created == [(len(SCRIPT), SCRIPT_SHA256)] + assert started == [SCRIPT] + assert terminal["status"] == "completed" + assert terminal["process"] == "exited" + assert terminal["cleanup"] == "tree_gone" + + +def test_keeper_rechecks_pre_ownership_stop_before_process_creation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID + attempt_dir.mkdir(parents=True) + (attempt_dir / "setup.ps1").write_bytes(SCRIPT) + request = setup_owner._request_payload( + ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 + ) + setup_owner._write_create_once(attempt_dir / "request.json", request) + setup_owner._write_create_once( + attempt_dir / "launch-claim.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "launch_id": "launch_test", + "claimed_at": "2026-09-03T00:00:00Z", + }, + ) + setup_owner._write_create_once( + attempt_dir / "terminal.json", + { + "schema_version": 1, + "attempt_id": ATTEMPT_ID, + "request_sha256": request["request_sha256"], + "status": "stopped_before_ownership", + "process": "not_started", + "cleanup": "tree_gone", + "finished_at": "2026-09-03T00:00:01Z", + }, + ) + monkeypatch.setattr( + windows_setup_process, + "_create_kill_on_close_job", + lambda _: (_ for _ in ()).throw(AssertionError("created Job after stop")), + ) + + assert windows_setup_process.run_keeper(attempt_dir) == 0 + + +def test_powershell_bootstrap_reads_exact_bytes_and_rejects_trailing_input() -> None: + encoded = windows_setup_process._powershell_bootstrap( + len(SCRIPT), SCRIPT_SHA256 + ) + + assert "OpenStandardInput" in encoded + assert "UTF8Encoding($false, $true)" in encoded + assert "SHA256" in encoded + assert "ReadByte() -ne -1" in encoded + assert str(len(SCRIPT)) in encoded + assert SCRIPT_SHA256 in encoded From d0dd8c87081dbc3220ab949a44271fff4c50906c Mon Sep 17 00:00:00 2001 From: Bram Van Rompuy Date: Thu, 3 Sep 2026 17:44:23 +0200 Subject: [PATCH 2/4] test(setup-owner): require fenced request envelope --- tests/test_cli.py | 119 +++---- tests/test_setup_owner.py | 460 ++++++++-------------------- tests/test_windows_setup_process.py | 192 ++++-------- 3 files changed, 256 insertions(+), 515 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7366fea..a34182d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,11 @@ from __future__ import annotations import json +import io import os import subprocess import sys -from pathlib import Path +from types import SimpleNamespace import pytest @@ -48,71 +49,79 @@ def test_capabilities_accepts_required_typed_call_error_reason(capsys) -> None: assert json.loads(capsys.readouterr().out)["status"] == "compatible" -def test_setup_owner_cli_routes_fixed_purpose_commands(monkeypatch, capsys) -> None: - captured: list[tuple[str, dict[str, object]]] = [] +def test_setup_owner_launch_reads_exact_raw_json_from_stdin(monkeypatch, capsys) -> None: + raw = b'{"schema_version":1,"attempt_id":"exact bytes"}\n' - def fake_launch(**kwargs): - captured.append(("launch", kwargs)) - return {"schema_version": 1, "status": "owned"} + def fake_launch(raw_request: bytes): + assert raw_request == raw + return SimpleNamespace(to_dict=lambda: {"schema_version": 1, "status": "accepted"}) monkeypatch.setattr(cli, "launch_setup", fake_launch) + monkeypatch.setattr(cli.sys, "stdin", SimpleNamespace(buffer=io.BytesIO(raw))) - exit_code = cli.main( - [ - "setup-owner", - "launch", - "--state-root", - "C:\\BlenderBox", - "--attempt-id", - "a" * 32, - "--script-sha256", - "b" * 64, - "--deadline-unix-ms", - "2000000000000", - "--json", - ] - ) + assert cli.main(["setup-owner", "launch", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "accepted" - assert exit_code == 0 - assert captured == [ - ( - "launch", - { - "state_root": Path("C:\\BlenderBox"), - "attempt_id": "a" * 32, - "script_sha256": "b" * 64, - "deadline_unix_ms": 2_000_000_000_000, - }, - ) - ] - assert json.loads(capsys.readouterr().out)["status"] == "owned" +def test_setup_owner_stop_routes_complete_fence(monkeypatch, capsys) -> None: + captured: dict[str, object] = {} -def test_setup_owner_cli_reports_machine_readable_failure(monkeypatch, capsys) -> None: - def fail_status(**_kwargs): - raise cli.SetupOwnerError("owner unavailable") + def fake_stop(attempt_id: str, **kwargs): + captured["attempt_id"] = attempt_id + captured.update(kwargs) + return SimpleNamespace(to_dict=lambda: {"schema_version": 1, "status": "terminal"}) + + monkeypatch.setattr(cli, "stop_setup", fake_stop) + attempt_id = "bbsa_" + "A" * 43 + launch_id = "bbsl_" + "B" * 43 + + assert cli.main([ + "setup-owner", "stop", "--attempt-id", attempt_id, + "--expect-request-sha256", "c" * 64, + "--expect-launch-id", launch_id, "--json", + ]) == 0 + assert captured == { + "attempt_id": attempt_id, + "expected_request_sha256": "c" * 64, + "expected_launch_id": launch_id, + } + assert json.loads(capsys.readouterr().out)["status"] == "terminal" - monkeypatch.setattr(cli, "status_setup", fail_status) - exit_code = cli.main( - [ - "setup-owner", - "status", - "--state-root", - "C:\\BlenderBox", - "--attempt-id", - "a" * 32, - "--json", - ] - ) +def test_setup_owner_parser_rejects_generic_process_inputs() -> None: + parser = cli.build_parser() + for option in ("--state-root", "--executable", "--args", "--env", "--cwd"): + with pytest.raises(SystemExit) as error: + parser.parse_args(["setup-owner", "launch", option, "value", "--json"]) + assert error.value.code == 2 + +def test_windows_setup_owner_capability_fails_closed_off_windows(monkeypatch, capsys) -> None: + monkeypatch.setattr(cli, "_platform_system", lambda: "Linux") + exit_code = cli.main([ + "capabilities", "--require", "blender-box-v1", + "--require-capability", "windows-setup-owner-v1", + ]) assert exit_code == 1 - assert json.loads(capsys.readouterr().out) == { - "schema_version": 1, - "status": "error", - "command": "setup-owner status", - "message": "owner unavailable", - } + assert json.loads(capsys.readouterr().out)["status"] == "incompatible" + + +def test_windows_setup_owner_capability_runs_runtime_self_test(monkeypatch, capsys) -> None: + calls = 0 + + def pass_self_test() -> None: + nonlocal calls + calls += 1 + + monkeypatch.setattr(cli, "_platform_system", lambda: "Windows") + monkeypatch.setattr(cli, "windows_setup_owner_self_test", pass_self_test) + exit_code = cli.main([ + "capabilities", "--require", "blender-box-v1", + "--require-capability", "windows-setup-owner-v1", + ]) + assert exit_code == 0 + assert calls == 1 + assert "windows-setup-owner-v1" in json.loads(capsys.readouterr().out)["capabilities"] def test_capabilities_rejects_unknown_contract() -> None: diff --git a/tests/test_setup_owner.py b/tests/test_setup_owner.py index 21ef497..549cac9 100644 --- a/tests/test_setup_owner.py +++ b/tests/test_setup_owner.py @@ -2,388 +2,182 @@ import hashlib import json +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from blendersessiond import setup_owner -ATTEMPT_ID = "a" * 32 -SCRIPT = b'Write-Output \'{"status":"configured"}\'\n' +ATTEMPT_ID = "bbsa_" + "A" * 43 +LAUNCH_ID = "bbsl_" + "B" * 43 +SCRIPT = b"Write-Output 'owned bytes'\n" SCRIPT_SHA256 = hashlib.sha256(SCRIPT).hexdigest() -def stage_script(root: Path, content: bytes = SCRIPT) -> Path: - path = root / "setup-attempts" / ATTEMPT_ID / "setup.ps1" - path.parent.mkdir(parents=True) - path.write_bytes(content) - return path - - -def test_launch_publishes_request_before_spawning_keeper( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - stage_script(tmp_path) - observed: list[dict[str, object]] = [] - - def fake_spawn(attempt_dir: Path) -> tuple[int, str]: - observed.append(json.loads((attempt_dir / "request.json").read_text())) - setup_owner._write_create_once( - attempt_dir / "launch-receipt.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": observed[0]["request_sha256"], - "launch_id": "launch_test", - "keeper_pid": 10, - "keeper_creation_time": "windows:keeper", - "root_pid": 11, - "root_creation_time": "windows:root", - "owned_at": "2026-09-03T00:00:00Z", - }, - ) - return 10, "windows:keeper" - - monkeypatch.setattr(setup_owner, "_spawn_keeper", fake_spawn) - - result = setup_owner.launch_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, - script_sha256=SCRIPT_SHA256, - deadline_unix_ms=2_000_000_000_000, - platform_name="Windows", - ) - - assert result["status"] == "owned" - assert observed[0]["script_size"] == len(SCRIPT) - assert observed[0]["script_sha256"] == SCRIPT_SHA256 - - -def test_launch_is_byte_identical_replay(tmp_path: Path, monkeypatch) -> None: - stage_script(tmp_path) - calls = 0 - - def fake_spawn(attempt_dir: Path) -> tuple[int, str]: - nonlocal calls - calls += 1 - request = json.loads((attempt_dir / "request.json").read_text()) - setup_owner._write_create_once( - attempt_dir / "launch-receipt.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_test", - "keeper_pid": 10, - "keeper_creation_time": "windows:keeper", - "root_pid": 11, - "root_creation_time": "windows:root", - "owned_at": "2026-09-03T00:00:00Z", - }, - ) - return 10, "windows:keeper" - - monkeypatch.setattr(setup_owner, "_spawn_keeper", fake_spawn) - arguments = { - "state_root": tmp_path, - "attempt_id": ATTEMPT_ID, - "script_sha256": SCRIPT_SHA256, - "deadline_unix_ms": 2_000_000_000_000, - "platform_name": "Windows", - } - - first = setup_owner.launch_setup(**arguments) - second = setup_owner.launch_setup(**arguments) - - assert first == second - assert calls == 1 - - -def test_lost_launch_response_reconciles_the_published_receipt( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - stage_script(tmp_path) - calls = 0 - - def ambiguous_spawn(attempt_dir: Path) -> tuple[int, str]: - nonlocal calls - calls += 1 - request = json.loads((attempt_dir / "request.json").read_text()) - setup_owner._write_create_once( - attempt_dir / "launch-receipt.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_test", - "keeper_pid": 10, - "keeper_creation_time": "windows:keeper", - "root_pid": 11, - "root_creation_time": "windows:root", - "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", - "owned_at": "2026-09-03T00:00:00Z", - }, - ) - raise OSError("launch response was lost") - - monkeypatch.setattr(setup_owner, "_spawn_keeper", ambiguous_spawn) - arguments = { - "state_root": tmp_path, - "attempt_id": ATTEMPT_ID, - "script_sha256": SCRIPT_SHA256, - "deadline_unix_ms": 2_000_000_000_000, - "platform_name": "Windows", - } - - with pytest.raises(OSError, match="response was lost"): - setup_owner.launch_setup(**arguments) - reconciled = setup_owner.launch_setup(**arguments) - - assert reconciled["status"] == "owned" - assert reconciled["receipt"]["launch_id"] == "launch_test" - assert calls == 1 - - -def test_abandoned_pre_receipt_claim_becomes_an_immutable_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - stage_script(tmp_path) - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - setup_owner._write_create_once( - attempt_dir / "launch-claim.json", +def raw_launch(now: datetime) -> bytes: + return json.dumps( { "schema_version": 1, "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_abandoned", - "claimed_at": "2026-09-03T00:00:00Z", + "launch_id": LAUNCH_ID, + "deadline_utc": (now + timedelta(minutes=4)).isoformat().replace("+00:00", "Z"), + "operation_revision": "windows-setup-owner-v1", + "script": { + "artifact_id": f"{ATTEMPT_ID}.ps1", + "size": len(SCRIPT), + "sha256": SCRIPT_SHA256, + }, }, - ) - monkeypatch.setattr(setup_owner, "_LAUNCH_WAIT_SECONDS", 0) - monkeypatch.setattr( - setup_owner, - "_spawn_keeper", - lambda _attempt_dir: (_ for _ in ()).throw(AssertionError("replacement")), - ) - - first = setup_owner.launch_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, - script_sha256=SCRIPT_SHA256, - deadline_unix_ms=2_000_000_000_000, - platform_name="Windows", - ) - second = setup_owner.status_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, - platform_name="Windows", - ) + separators=(",", ":"), + ).encode() - assert first == second - assert first["status"] == "launch_failed" - assert first["cleanup"] == "cleanup_unverified" - assert json.loads((attempt_dir / "terminal.json").read_text()) == first +def stage_script(root: Path) -> Path: + path = setup_owner.script_path_for_attempt(root, ATTEMPT_ID) + path.parent.mkdir(parents=True) + path.write_bytes(SCRIPT) + return path -def test_launch_rejects_request_replacement(tmp_path: Path, monkeypatch) -> None: - stage_script(tmp_path) - monkeypatch.setattr( - setup_owner, - "_spawn_keeper", - lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), - ) - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - setup_owner._write_create_once( - attempt_dir / "request.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": "b" * 64, - "script_sha256": "c" * 64, - "script_size": 1, - "deadline_unix_ms": 2_000_000_000_000, - "operation": "blender-box-windows-setup-v1", - }, - ) - with pytest.raises(setup_owner.SetupOwnerError, match="different request"): - setup_owner.launch_setup( +def test_request_records_exact_raw_hash_and_both_identities(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + raw = raw_launch(now) + request = setup_owner.accept_launch_request(raw, state_root=tmp_path, now=now) + + assert request.attempt_id == ATTEMPT_ID + assert request.launch_id == LAUNCH_ID + assert request.request_sha256 == hashlib.sha256(raw).hexdigest() + assert request.script.artifact_id == f"{ATTEMPT_ID}.ps1" + persisted = json.loads( + (tmp_path / "setup-attempts" / ATTEMPT_ID / "request.json").read_text() + ) + assert persisted["request_sha256"] == hashlib.sha256(raw).hexdigest() + + +def test_replay_requires_byte_identical_raw_json(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + raw = raw_launch(now) + first = setup_owner.accept_launch_request(raw, state_root=tmp_path, now=now) + assert first == setup_owner.accept_launch_request(raw, state_root=tmp_path, now=now) + with pytest.raises(setup_owner.AttemptConflictError, match="different request"): + setup_owner.accept_launch_request(raw + b"\n", state_root=tmp_path, now=now) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda value: value | {"schema_version": 2}, "schema_version"), + (lambda value: value | {"attempt_id": "../escape"}, "Attempt ID"), + (lambda value: value | {"launch_id": "bbsl_short"}, "Launch ID"), + (lambda value: value | {"operation_revision": "generic-command-v1"}, "operation_revision"), + (lambda value: value | {"unknown": True}, "unknown"), + (lambda value: value | {"deadline_utc": "2026-09-03T12:05:00.001Z"}, "five minutes"), + ( + lambda value: value | {"script": value["script"] | {"artifact_id": "chosen.ps1"}}, + "artifact_id", + ), + ], +) +def test_request_is_strictly_bounded(tmp_path: Path, mutate, message: str) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + payload = mutate(json.loads(raw_launch(now))) + with pytest.raises(setup_owner.SetupOwnerError, match=message): + setup_owner.accept_launch_request( + json.dumps(payload, separators=(",", ":")).encode(), state_root=tmp_path, - attempt_id=ATTEMPT_ID, - script_sha256=SCRIPT_SHA256, - deadline_unix_ms=2_000_000_000_000, - platform_name="Windows", + now=now, ) -def test_stop_before_ownership_is_immutable_and_prevents_launch( - tmp_path: Path, monkeypatch -) -> None: - stage_script(tmp_path) - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - monkeypatch.setattr( - setup_owner, - "_spawn_keeper", - lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), - ) - +def test_stop_before_dispatch_requires_full_fence_and_is_immutable(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request(raw_launch(now), state_root=tmp_path, now=now) + with pytest.raises(setup_owner.SetupOwnerError, match="request SHA-256"): + setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256="0" * 64, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + now=now, + platform_name="Windows", + ) first = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now, platform_name="Windows", ) second = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, state_root=tmp_path, - attempt_id=ATTEMPT_ID, - platform_name="Windows", - ) - replay = setup_owner.launch_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, - script_sha256=SCRIPT_SHA256, - deadline_unix_ms=2_000_000_000_000, - platform_name="Windows", - ) - - assert first == second == replay - assert first["status"] == "stopped_before_ownership" - assert first["cleanup"] == "tree_gone" - - -def test_status_does_not_dispatch_owner(tmp_path: Path, monkeypatch) -> None: - stage_script(tmp_path) - monkeypatch.setattr( - setup_owner, - "_spawn_keeper", - lambda _attempt_dir: (_ for _ in ()).throw(RuntimeError("not reached")), - ) - - result = setup_owner.status_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now + timedelta(seconds=1), platform_name="Windows", ) - - assert result["status"] == "not_found" + assert first == second + assert first.terminal.outcome == "stopped_before_ownership" + assert first.terminal.cleanup == "tree_gone" -def test_owner_loss_publishes_one_stable_tree_gone_terminal( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_inflight_pre_receipt_stop_never_claims_tree_gone(tmp_path: Path, monkeypatch) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) stage_script(tmp_path) - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - setup_owner._write_create_once( - attempt_dir / "launch-receipt.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_lost", - "keeper_pid": 10, - "keeper_creation_time": "windows:keeper", - "root_pid": 11, - "root_creation_time": "windows:root", - "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", - "owned_at": "2026-09-03T00:00:00Z", - }, - ) - monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) - monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: False) - - first = setup_owner.status_setup( + view = setup_owner.launch_setup( + raw_launch(now), state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now, platform_name="Windows", + dispatch_keeper=lambda _directory: None, ) - second = setup_owner.stop_setup( + monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) + stopped = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=view.request.request_sha256, + expected_launch_id=LAUNCH_ID, state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now, platform_name="Windows", ) + assert stopped.terminal.outcome == "launch_failed" + assert stopped.terminal.cleanup == "cleanup_unverified" - assert first == second - assert first["status"] == "owner_lost" - assert first["cleanup"] == "tree_gone" - assert json.loads((attempt_dir / "terminal.json").read_text()) == first - - -def test_pid_reuse_is_never_terminated( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - stage_script(tmp_path) - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - setup_owner._write_create_once( - attempt_dir / "launch-receipt.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_stale_pid", - "keeper_pid": 10, - "keeper_creation_time": "windows:old-keeper", - "root_pid": 11, - "root_creation_time": "windows:old-root", - "job_name": f"Local\\blendersessiond-setup-{ATTEMPT_ID}", - "owned_at": "2026-09-03T00:00:00Z", - }, - ) - monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) - monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: True) - monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) - terminated: list[tuple[int, str]] = [] - monkeypatch.setattr( - setup_owner, - "_terminate_exact_keeper", - lambda pid, identity: terminated.append((pid, identity)), - ) - first = setup_owner.stop_setup( +def test_existing_terminal_prevents_late_dispatch(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request(raw_launch(now), state_root=tmp_path, now=now) + terminal = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now, platform_name="Windows", ) - second = setup_owner.stop_setup( + replay = setup_owner.launch_setup( + raw_launch(now), state_root=tmp_path, - attempt_id=ATTEMPT_ID, + now=now, platform_name="Windows", + dispatch_keeper=lambda _directory: (_ for _ in ()).throw(AssertionError("dispatch")), ) - - assert first == second - assert first["status"] == "cleanup_unverified" - assert first["cleanup"] == "cleanup_unverified" - assert terminated == [] + assert replay == terminal -@pytest.mark.parametrize("attempt_id", ["", "A" * 32, "../escape", "a" * 31]) -def test_attempt_identity_is_strict(attempt_id: str, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="Attempt ID"): - setup_owner.status_setup( - state_root=tmp_path, - attempt_id=attempt_id, - platform_name="Windows", - ) +def test_only_three_json_records_define_attempt_state(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + stage_script(tmp_path) + setup_owner.launch_setup( + raw_launch(now), state_root=tmp_path, now=now, platform_name="Windows", + dispatch_keeper=lambda _directory: None, + ) + names = {path.name for path in (tmp_path / "setup-attempts" / ATTEMPT_ID).glob("*.json")} + assert names <= {"request.json", "launch-receipt.json", "terminal.json"} def test_non_windows_fails_closed(tmp_path: Path) -> None: with pytest.raises(setup_owner.SetupOwnerError, match="Windows"): - setup_owner.status_setup( - state_root=tmp_path, - attempt_id=ATTEMPT_ID, - platform_name="Darwin", - ) + setup_owner.status_setup(ATTEMPT_ID, state_root=tmp_path, platform_name="Darwin") diff --git a/tests/test_windows_setup_process.py b/tests/test_windows_setup_process.py index b48bf1e..c929932 100644 --- a/tests/test_windows_setup_process.py +++ b/tests/test_windows_setup_process.py @@ -1,139 +1,77 @@ from __future__ import annotations -import hashlib -import json from pathlib import Path -import pytest +from blendersessiond import windows_setup_process -from blendersessiond import setup_owner, windows_setup_process -ATTEMPT_ID = "a" * 32 -SCRIPT = b"Write-Output 'owned bytes'\n" -SCRIPT_SHA256 = hashlib.sha256(SCRIPT).hexdigest() +class FakeWin32: + def __init__(self) -> None: + self.calls: list[object] = [] + def create_job(self, name: str): + self.calls.append(("create_job", name)) + return "job" -def test_keeper_uses_validated_bytes_and_publishes_receipt_before_resume( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - attempt_dir.mkdir(parents=True) - (attempt_dir / "setup.ps1").write_bytes(SCRIPT) - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - setup_owner._write_create_once( - attempt_dir / "launch-claim.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_test", - "claimed_at": "2026-09-03T00:00:00Z", - }, - ) - child = windows_setup_process._OwnedProcess( - process_handle=101, - thread_handle=102, - process_id=103, - stdin_write=104, - stdout_read=105, - stderr_read=106, - child_handles=(), - attribute_list=None, - attribute_buffer=None, - ) - created: list[tuple[int, str]] = [] - started: list[bytes] = [] - - def create(script_size: int, script_sha256: str, job: int, *, cwd: Path): - created.append((script_size, script_sha256)) - assert job == 100 - assert cwd == attempt_dir - return child - - def start_io(owned, content: bytes, output, threads) -> None: - assert owned is child - assert json.loads((attempt_dir / "launch-receipt.json").read_text())[ - "root_pid" - ] == 103 - started.append(content) - - monkeypatch.setattr(windows_setup_process, "_create_kill_on_close_job", lambda _: 100) - monkeypatch.setattr(windows_setup_process, "_create_suspended_setup_process", create) - monkeypatch.setattr(windows_setup_process, "_creation_time_from_handle", lambda _: "windows:root") - monkeypatch.setattr(windows_setup_process, "process_start_time", lambda _: "windows:keeper") - monkeypatch.setattr(windows_setup_process, "_is_process_in_job", lambda *_: True) - monkeypatch.setattr(windows_setup_process, "_start_process_io", start_io) - monkeypatch.setattr(windows_setup_process, "_resume_process", lambda _: None) - monkeypatch.setattr(windows_setup_process, "_wait_for_process", lambda *_: "exited") - monkeypatch.setattr(windows_setup_process, "_process_exit_code", lambda _: 0) - monkeypatch.setattr(windows_setup_process, "_active_processes", lambda _: 0) - monkeypatch.setattr(windows_setup_process, "_wait_for_empty_job", lambda _: None) - monkeypatch.setattr(windows_setup_process, "_finish_process_io", lambda *_: None) - monkeypatch.setattr(windows_setup_process, "_close_owned_process", lambda _: None) - monkeypatch.setattr(windows_setup_process, "_close_handle", lambda _: None) - - assert windows_setup_process.run_keeper(attempt_dir) == 0 - - terminal = json.loads((attempt_dir / "terminal.json").read_text()) - assert created == [(len(SCRIPT), SCRIPT_SHA256)] - assert started == [SCRIPT] - assert terminal["status"] == "completed" - assert terminal["process"] == "exited" - assert terminal["cleanup"] == "tree_gone" - - -def test_keeper_rechecks_pre_ownership_stop_before_process_creation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - attempt_dir = tmp_path / "setup-attempts" / ATTEMPT_ID - attempt_dir.mkdir(parents=True) - (attempt_dir / "setup.ps1").write_bytes(SCRIPT) - request = setup_owner._request_payload( - ATTEMPT_ID, SCRIPT_SHA256, len(SCRIPT), 2_000_000_000_000 - ) - setup_owner._write_create_once(attempt_dir / "request.json", request) - setup_owner._write_create_once( - attempt_dir / "launch-claim.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "launch_id": "launch_test", - "claimed_at": "2026-09-03T00:00:00Z", - }, - ) - setup_owner._write_create_once( - attempt_dir / "terminal.json", - { - "schema_version": 1, - "attempt_id": ATTEMPT_ID, - "request_sha256": request["request_sha256"], - "status": "stopped_before_ownership", - "process": "not_started", - "cleanup": "tree_gone", - "finished_at": "2026-09-03T00:00:01Z", - }, - ) - monkeypatch.setattr( - windows_setup_process, - "_create_kill_on_close_job", - lambda _: (_ for _ in ()).throw(AssertionError("created Job after stop")), - ) + def set_kill_on_close(self, job) -> None: + self.calls.append(("set_kill_on_close", job)) - assert windows_setup_process.run_keeper(attempt_dir) == 0 + def create_standard_pipes(self): + self.calls.append("create_standard_pipes") + return ("child-in", "child-out", "child-err"), ("parent-in", "parent-out", "parent-err") + def create_process(self, **kwargs): + self.calls.append(("create_process", kwargs)) + return "process", "thread", 123 -def test_powershell_bootstrap_reads_exact_bytes_and_rejects_trailing_input() -> None: - encoded = windows_setup_process._powershell_bootstrap( - len(SCRIPT), SCRIPT_SHA256 - ) + def close_handles(self, handles) -> None: + self.calls.append(("close_handles", tuple(handles))) + + def process_creation_filetime(self, process) -> int: + self.calls.append(("process_creation_filetime", process)) + return 456 - assert "OpenStandardInput" in encoded - assert "UTF8Encoding($false, $true)" in encoded - assert "SHA256" in encoded - assert "ReadByte() -ne -1" in encoded - assert str(len(SCRIPT)) in encoded - assert SCRIPT_SHA256 in encoded + def is_process_in_job(self, process, job) -> bool: + self.calls.append(("is_process_in_job", process, job)) + return True + + +def test_process_is_suspended_and_atomically_assigned_before_receipt(tmp_path: Path) -> None: + api = FakeWin32() + owned = windows_setup_process.WindowsSetupProcess.create_suspended( + api=api, + powershell_path=tmp_path / "powershell.exe", + job_name="Local\\BlenderSessiond.Setup.bbsl_test", + ) + assert [call if isinstance(call, str) else call[0] for call in api.calls] == [ + "create_job", "set_kill_on_close", "create_standard_pipes", "create_process", + "close_handles", "process_creation_filetime", "is_process_in_job", + ] + create = api.calls[3][1] + assert create["creation_flags"] & windows_setup_process.CREATE_SUSPENDED + assert create["creation_flags"] & windows_setup_process.EXTENDED_STARTUPINFO_PRESENT + assert create["job_list"] == ("job",) + assert create["handle_list"] == ("child-in", "child-out", "child-err") + assert create["inherit_handles"] is True + assert "job" not in create["handle_list"] + assert owned.root_pid == 123 + assert owned.root_creation_filetime == 456 + + +def test_receipt_flush_precedes_resume() -> None: + events: list[str] = [] + owned = object.__new__(windows_setup_process.WindowsSetupProcess) + owned._api = type("API", (), {"resume_thread": lambda _self, _thread: events.append("resume")})() + owned.thread_handle = "thread" + owned.publish_then_resume(lambda: events.append("receipt_flushed")) + assert events == ["receipt_flushed", "resume"] + + +def test_bootstrap_reads_exact_bytes_and_decodes_strict_utf8() -> None: + bootstrap = windows_setup_process.powershell_bootstrap(27, "a" * 64) + assert "OpenStandardInput" in bootstrap + assert "UTF8Encoding($false, $true)" in bootstrap + assert "SHA256" in bootstrap + assert "ReadByte() -ne -1" in bootstrap + assert "27" in bootstrap + assert "a" * 64 in bootstrap From 8cede65e82e4277d411088821c74a5b84a844379 Mon Sep 17 00:00:00 2001 From: Bram Van Rompuy Date: Thu, 3 Sep 2026 19:00:45 +0200 Subject: [PATCH 3/4] feat(setup-owner): fence Windows setup trees --- CHANGELOG.md | 1 + README.md | 19 + docs/adr/0005-fenced-windows-setup-owner.md | 66 ++ src/blendersessiond/cli.py | 86 +- src/blendersessiond/setup_owner.py | 1015 ++++++++++++++++++ src/blendersessiond/windows_setup_process.py | 944 ++++++++++++++++ tests/test_cli.py | 74 +- tests/test_setup_owner.py | 581 +++++++++- tests/test_windows_setup_process.py | 351 +++++- 9 files changed, 3096 insertions(+), 41 deletions(-) create mode 100644 docs/adr/0005-fenced-windows-setup-owner.md create mode 100644 src/blendersessiond/setup_owner.py create mode 100644 src/blendersessiond/windows_setup_process.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6247e00..92fdfb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A Windows-only `setup-owner` protocol gives Blender Box a fixed-purpose, identity-fenced process owner before setup code can run. Its runtime capability probe fails closed when atomic Job Object assignment is unavailable. - `capabilities --require blender-box-v1` reports the exact identity-fencing and bounded-call contract for remote wrappers without touching Session state; wrappers can require named capabilities before use. - Machine-readable `call` failures include a typed wire-error reason so orchestrators can distinguish a bounded read timeout from other failures. diff --git a/README.md b/README.md index 7caf603..c186b41 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ factory-empty file. - `call`: Send one raw command and optional JSON parameters to a healthy Session's addon. - `stop`: Terminate an owned Session process tree without saving. - `mcp-serve`: Connect the validated `blender-mcp` stdio server to one healthy Session. +- `setup-owner`: Own one fenced Blender Box setup process tree on Windows. This integration-only command is not a general remote command runner. Run `blendersessiond COMMAND --help` for each command's flags. `doctor`, `start`, `status`, and `stop` support versioned JSON output with `--json`; @@ -129,6 +130,18 @@ Wrappers that persist timeout state should also pass `--require-capability typed-call-error-reason`. With `call --json`, wire failures include a stable `reason`; a read-timeout expiration uses `timeout`. +Blender Box setup also requires the runtime-gated Windows owner contract: + +```console +blendersessiond capabilities --require blender-box-v1 --require-capability windows-setup-owner-v1 +``` + +That probe creates an inert suspended Windows process inside a kill-on-close +Job Object, verifies membership, and terminates it without resuming it. The +capability fails closed when the current host context cannot provide atomic +Job assignment. Blender Box alone drives the private `setup-owner` launch, +status, and stop protocol with immutable Attempt and Launch identities. + Raw calls wait up to 180 seconds by default. Use `call --read-timeout SECONDS` for a known long render or script, up to the hard 3,600-second maximum. Keep an outer workflow deadline and choose a read timeout no later than its remaining @@ -245,6 +258,12 @@ Set `BLENDERSESSIOND_STATE_DIR` to an absolute path to override this location. `blendersessiond status` prints each Session's log paths; use `--json` when a script needs the complete state record. +On Windows, Blender Box setup attempts use the reserved `setup-attempts` +subdirectory below this state directory. Their request, launch receipt, and +terminal records are create-once. The staged script name is derived from the +Attempt ID, and the owner validates its size, SHA-256, and strict UTF-8 bytes +again before it can resume PowerShell. + Session MCP ports are allocated upward from `9876` on loopback. Set `BLENDERSESSIOND_BASE_MCP_PORT` to start allocation from a different port — the test suite uses this to keep fake-Blender Sessions off the ports a real diff --git a/docs/adr/0005-fenced-windows-setup-owner.md b/docs/adr/0005-fenced-windows-setup-owner.md new file mode 100644 index 0000000..4754a41 --- /dev/null +++ b/docs/adr/0005-fenced-windows-setup-owner.md @@ -0,0 +1,66 @@ +--- +summary: Blender Box setup uses an atomic, identity-fenced Windows Job owner. +read_when: + - Changing setup-owner launch, status, stop, state, or Windows process code. + - Changing the Blender Box capability contract. +--- + +# Fenced Windows setup owner + +## Context + +Blender Box setup crosses Windows OpenSSH before its PowerShell program can +record a process identity. Cancelling the SSH client in that interval can +leave setup work running. A later cleanup cannot safely find that work by +name, command text, or PID because each can identify an unrelated replacement. + +The existing Blender Session owner already contains Windows Job Object code, +but setup must remain a narrower operation. It must not become a remote shell. + +## Decision + +`blendersessiond setup-owner` accepts only one versioned Blender Box setup +operation. The client creates a random Attempt ID and Launch ID before launch. +It sends a bounded raw JSON request on standard input. The request names only a +script artifact derived from the Attempt ID, plus its size, SHA-256, revision, +and a deadline no more than five minutes away. Executable, argument, +environment, working-directory, and output-path choices are not part of the +contract. + +Each attempt has three create-once JSON records under `setup-attempts`: + +- `request.json` records both identities and the SHA-256 of the exact raw request. +- `launch-receipt.json` records the detached keeper and suspended root process identities before resume. +- `terminal.json` records process outcome, bounded output, and whether cleanup is proven. + +The detached keeper creates a kill-on-close Job Object, then creates system +PowerShell suspended with `PROC_THREAD_ATTRIBUTE_JOB_LIST`. The child inherits +only its three standard-stream pipe handles. The keeper durably publishes the +launch receipt before it resumes the root process. It sends the already +validated script bytes through standard input to a fixed bootstrap. That +bootstrap checks the exact byte count and SHA-256, decodes strict UTF-8 in +memory, and only then invokes the script block. + +Status and stop require the complete Attempt, request-hash, and Launch fence. +Normal stop asks the exact keeper to terminate its Job. Fallback termination +opens the recorded keeper once and verifies its creation FILETIME through that +handle. PID lookup alone never authorizes termination. Owner-loss recovery +claims `tree_gone` only after the exact keeper and root are absent and the +named Job no longer exists. Otherwise the immutable terminal is +`cleanup_unverified`. + +`windows-setup-owner-v1` is a runtime capability. Its probe creates an inert +suspended child with the same atomic Job-list mechanism, verifies membership, +terminates the Job before resume, and proves that it has no members. Missing +API support or an incompatible outer Job policy fails closed. + +## Consequences + +Blender Box can reconcile lost launch responses and repeat status or stop +without broad process discovery. A stale caller cannot act on a replacement +attempt. The keeper is the only process that retains the Job handle, so its +loss terminates the setup tree. + +The protocol is Windows-only and private to Blender Box setup. Staging and its +ACL handoff remain Blender Box responsibilities. The owner rejects a staged +artifact that does not match the immutable request. diff --git a/src/blendersessiond/cli.py b/src/blendersessiond/cli.py index 784de5d..f8172dc 100644 --- a/src/blendersessiond/cli.py +++ b/src/blendersessiond/cli.py @@ -4,6 +4,7 @@ import argparse import json +import platform import sys from collections.abc import Sequence from pathlib import Path @@ -24,6 +25,16 @@ validate_session_id, validate_session_name, ) +from blendersessiond.setup_owner import ( + MAX_REQUEST_BYTES, + SetupOwnerError, + launch_setup, + status_setup, + stop_setup, +) +from blendersessiond.windows_setup_process import ( + runtime_self_test as windows_setup_owner_self_test, +) from blendersessiond.wire import ( DEFAULT_READ_TIMEOUT_SECONDS, MAX_READ_TIMEOUT_SECONDS, @@ -38,6 +49,8 @@ "bounded-call-read-timeout", "typed-call-error-reason", ] +WINDOWS_SETUP_OWNER_CAPABILITY = "windows-setup-owner-v1" +_platform_system = platform.system def build_parser() -> argparse.ArgumentParser: @@ -61,7 +74,7 @@ def build_parser() -> argparse.ArgumentParser: capabilities.add_argument( "--require-capability", action="append", - choices=BLENDER_BOX_CAPABILITIES, + choices=[*BLENDER_BOX_CAPABILITIES, WINDOWS_SETUP_OWNER_CAPABILITY], metavar="CAPABILITY", help="Require a named capability within the integration contract.", ) @@ -145,6 +158,24 @@ def build_parser() -> argparse.ArgumentParser: help="Serve MCP stdio for one healthy Session.", ) _add_name_argument(mcp_serve) + + setup_owner = commands.add_parser( + "setup-owner", + help="Own one fixed-purpose Blender Box Windows setup attempt.", + ) + setup_commands = setup_owner.add_subparsers( + dest="setup_owner_command", required=True + ) + setup_launch = setup_commands.add_parser( + "launch", help="Launch the exact setup request read from standard input." + ) + _add_json_argument(setup_launch) + for command_name in ("status", "stop"): + setup_command = setup_commands.add_parser(command_name) + setup_command.add_argument("--attempt-id", required=True) + setup_command.add_argument("--expect-request-sha256", required=True) + setup_command.add_argument("--expect-launch-id", required=True) + _add_json_argument(setup_command) return parser @@ -152,13 +183,36 @@ def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.command == "capabilities": + capabilities = list(BLENDER_BOX_CAPABILITIES) + if WINDOWS_SETUP_OWNER_CAPABILITY in (args.require_capability or []): + try: + if _platform_system() != "Windows": + raise RuntimeError( + "Windows setup ownership is unavailable on this platform." + ) + windows_setup_owner_self_test() + except (OSError, RuntimeError, ValueError) as error: + print( + json.dumps( + { + "schema_version": 1, + "status": "incompatible", + "contract": args.require, + "capability": WINDOWS_SETUP_OWNER_CAPABILITY, + "message": str(error), + }, + indent=2, + ) + ) + return 1 + capabilities.append(WINDOWS_SETUP_OWNER_CAPABILITY) print( json.dumps( { "schema_version": 1, "status": "compatible", "contract": args.require, - "capabilities": BLENDER_BOX_CAPABILITIES, + "capabilities": capabilities, }, indent=2, ) @@ -299,6 +353,34 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"ERROR: {error}", file=sys.stderr) return 1 + if args.command == "setup-owner": + try: + if args.setup_owner_command == "launch": + result = launch_setup(sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1)) + elif args.setup_owner_command == "status": + result = status_setup( + args.attempt_id, + expected_request_sha256=args.expect_request_sha256, + expected_launch_id=args.expect_launch_id, + ) + elif args.setup_owner_command == "stop": + result = stop_setup( + args.attempt_id, + expected_request_sha256=args.expect_request_sha256, + expected_launch_id=args.expect_launch_id, + ) + else: + raise AssertionError(args.setup_owner_command) + except (SetupOwnerError, OSError, RuntimeError, ValueError) as error: + _print_failure( + command=f"setup-owner {args.setup_owner_command}", + message=str(error), + as_json=args.json, + ) + return 1 + _print_result(result.to_dict(), as_json=args.json) + return 0 + raise AssertionError(f"unhandled command: {args.command}") diff --git a/src/blendersessiond/setup_owner.py b/src/blendersessiond/setup_owner.py new file mode 100644 index 0000000..331b38d --- /dev/null +++ b/src/blendersessiond/setup_owner.py @@ -0,0 +1,1015 @@ +from __future__ import annotations + +import hashlib +import json +import os +import platform +import re +import secrets +import stat +import subprocess +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from blendersessiond.locking import file_lock +from blendersessiond.processes import process_matches, wait_for_process_start_time +from blendersessiond.state import resolve_state_directory + +SCHEMA_VERSION = 1 +OPERATION_REVISION = "windows-setup-owner-v1" +MAX_REQUEST_BYTES = 16 * 1024 +MAX_SCRIPT_BYTES = 128 * 1024 +MAX_RECORD_BYTES = 256 * 1024 +MAX_OUTPUT_BYTES = 24 * 1024 +MAX_DEADLINE = timedelta(minutes=5) +_LAUNCH_WAIT_SECONDS = 15.0 +_STOP_WAIT_SECONDS = 10.0 +_OWNER_LOSS_WAIT_SECONDS = 2.0 +_ATTEMPT_ID = re.compile(r"^bbsa_[A-Za-z0-9_-]{43}$") +_LAUNCH_ID = re.compile(r"^bbsl_[A-Za-z0-9_-]{43}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_TERMINAL_OUTCOMES = { + "cancelled", + "cleanup_unverified", + "launch_failed", + "owner_lost", + "process_failed", + "process_succeeded", + "stopped", + "stopped_before_ownership", + "timed_out", +} +_TERMINAL_PROCESSES = { + "cancelled", + "cancelled_before_resume", + "exited", + "failed", + "not_resumed", + "not_started", + "owner_lost", + "timed_out", +} +_TERMINAL_CLEANUP = {"cleanup_unverified", "tree_gone"} + + +class SetupOwnerError(RuntimeError): + """A Setup Attempt cannot proceed without weakening ownership.""" + + +class AttemptConflictError(SetupOwnerError): + """An immutable Setup Attempt identity was reused for different bytes.""" + + +@dataclass(frozen=True) +class ScriptArtifact: + artifact_id: str + size: int + sha256: str + + def to_dict(self) -> dict[str, Any]: + return { + "artifact_id": self.artifact_id, + "size": self.size, + "sha256": self.sha256, + } + + +@dataclass(frozen=True) +class SetupRequest: + attempt_id: str + launch_id: str + deadline_utc: datetime + operation_revision: str + script: ScriptArtifact + request_sha256: str + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "attempt_id": self.attempt_id, + "launch_id": self.launch_id, + "deadline_utc": _format_time(self.deadline_utc), + "operation_revision": self.operation_revision, + "script": self.script.to_dict(), + "request_sha256": self.request_sha256, + } + + +@dataclass(frozen=True) +class LaunchReceipt: + attempt_id: str + launch_id: str + request_sha256: str + keeper_pid: int + keeper_creation_time: str + root_pid: int + root_creation_time: str + job_name: str + owned_at: datetime + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "attempt_id": self.attempt_id, + "launch_id": self.launch_id, + "request_sha256": self.request_sha256, + "keeper_pid": self.keeper_pid, + "keeper_creation_time": self.keeper_creation_time, + "root_pid": self.root_pid, + "root_creation_time": self.root_creation_time, + "job_name": self.job_name, + "owned_at": _format_time(self.owned_at), + } + + +@dataclass(frozen=True) +class SetupTerminal: + attempt_id: str + launch_id: str + request_sha256: str + outcome: str + process: str + cleanup: str + exit_code: int | None + stdout: bytes + stderr: bytes + stdout_truncated: bool + stderr_truncated: bool + finished_at: datetime + message: str | None = None + + @property + def succeeded(self) -> bool: + return ( + self.outcome == "process_succeeded" + and self.process == "exited" + and self.cleanup == "tree_gone" + and self.exit_code == 0 + and not self.stdout_truncated + and not self.stderr_truncated + ) + + @classmethod + def owned( + cls, + *, + request: SetupRequest, + outcome: str, + process: str, + cleanup: str, + stdout: bytes, + stderr: bytes, + finished_at: datetime, + exit_code: int | None = None, + stdout_truncated: bool = False, + stderr_truncated: bool = False, + message: str | None = None, + ) -> SetupTerminal: + return cls( + attempt_id=request.attempt_id, + launch_id=request.launch_id, + request_sha256=request.request_sha256, + outcome=outcome, + process=process, + cleanup=cleanup, + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + stdout_truncated=stdout_truncated, + stderr_truncated=stderr_truncated, + finished_at=finished_at, + message=message, + ) + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "attempt_id": self.attempt_id, + "launch_id": self.launch_id, + "request_sha256": self.request_sha256, + "status": "terminal", + "outcome": self.outcome, + "process": self.process, + "cleanup": self.cleanup, + "exit_code": self.exit_code, + "stdout": self.stdout.decode("utf-8", errors="strict"), + "stderr": self.stderr.decode("utf-8", errors="strict"), + "stdout_truncated": self.stdout_truncated, + "stderr_truncated": self.stderr_truncated, + "finished_at": _format_time(self.finished_at), + } + if self.message is not None: + payload["message"] = self.message + return payload + + +@dataclass(frozen=True) +class SetupView: + request: SetupRequest + receipt: LaunchReceipt | None = None + terminal: SetupTerminal | None = None + + def to_dict(self) -> dict[str, Any]: + if self.terminal is not None: + return self.terminal.to_dict() + payload: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "attempt_id": self.request.attempt_id, + "launch_id": self.request.launch_id, + "request_sha256": self.request.request_sha256, + "status": "owned" if self.receipt is not None else "accepted", + } + if self.receipt is not None: + payload["receipt"] = self.receipt.to_dict() + return payload + + +def accept_launch_request( + raw_request: bytes, + *, + state_root: Path, + now: datetime | None = None, +) -> SetupRequest: + current = datetime.now(UTC) if now is None else now + request = _parse_request(raw_request) + directory = _attempt_directory(state_root, request.attempt_id) + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with file_lock(directory / ".lock"): + existing = _read_optional_record(directory / "request.json") + if existing is None: + if ( + request.deadline_utc <= current + or request.deadline_utc - current > MAX_DEADLINE + ): + raise SetupOwnerError( + "Setup launch deadline must be within five minutes." + ) + _write_create_once(directory / "request.json", request.to_dict()) + elif existing != request.to_dict(): + raise AttemptConflictError( + "Setup Attempt already contains a different request." + ) + return request + + +def launch_setup( + raw_request: bytes, + *, + state_root: Path | None = None, + now: datetime | None = None, + platform_name: str | None = None, + dispatch_keeper: Callable[[Path], None] | None = None, +) -> SetupView: + _require_windows(platform_name) + root = resolve_state_directory() if state_root is None else Path(state_root) + request = accept_launch_request(raw_request, state_root=root, now=now) + directory = _attempt_directory(root, request.attempt_id) + should_dispatch = False + with file_lock(directory / ".lock"): + view = _read_view(directory, request) + if view.terminal is not None or view.receipt is not None: + return view + should_dispatch = _touch_create_once(directory / ".dispatch") + if should_dispatch: + if dispatch_keeper is not None: + dispatch_keeper(directory) + return SetupView(request=request) + _spawn_keeper(directory) + deadline = time.monotonic() + _LAUNCH_WAIT_SECONDS + while time.monotonic() < deadline: + view = _read_view(directory, request) + if view.receipt is not None or view.terminal is not None: + return view + time.sleep(0.02) + with file_lock(directory / ".lock"): + view = _read_view(directory, request) + if view.receipt is not None or view.terminal is not None: + return view + _touch_create_once(directory / ".stop") + terminal = SetupTerminal.owned( + request=request, + outcome="launch_failed", + process="not_started", + cleanup="cleanup_unverified", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + message="The keeper did not acknowledge ownership before the launch bound.", + ) + _write_create_once(directory / "terminal.json", terminal.to_dict()) + return SetupView(request=request, terminal=terminal) + + +def status_setup( + attempt_id: str, + *, + expected_request_sha256: str | None = None, + expected_launch_id: str | None = None, + state_root: Path | None = None, + platform_name: str | None = None, +) -> SetupView: + _require_windows(platform_name) + root = resolve_state_directory() if state_root is None else Path(state_root) + directory = _attempt_directory(root, attempt_id) + request = _load_request(directory) + _check_fence(request, expected_request_sha256, expected_launch_id) + view = _read_view(directory, request) + if view.terminal is not None: + return view + if view.receipt is None: + if ( + not (directory / ".dispatch").exists() + or request.deadline_utc > datetime.now(UTC) + ): + return view + with file_lock(directory / ".lock"): + view = _read_view(directory, request) + if view.terminal is not None or view.receipt is not None: + return view + _touch_create_once(directory / ".stop") + terminal = SetupTerminal.owned( + request=request, + outcome="launch_failed", + process="not_started", + cleanup="cleanup_unverified", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + message="The dispatched keeper did not acknowledge ownership.", + ) + _write_create_once(directory / "terminal.json", terminal.to_dict()) + return SetupView(request=request, terminal=terminal) + receipt = view.receipt + if process_matches(receipt.keeper_pid, receipt.keeper_creation_time): + return view + return _reconcile_owner_loss(directory, request, receipt) + + +def stop_setup( + attempt_id: str, + *, + expected_request_sha256: str, + expected_launch_id: str, + state_root: Path | None = None, + now: datetime | None = None, + platform_name: str | None = None, +) -> SetupView: + _require_windows(platform_name) + root = resolve_state_directory() if state_root is None else Path(state_root) + directory = _attempt_directory(root, attempt_id) + request = _load_request(directory) + _check_fence(request, expected_request_sha256, expected_launch_id) + with file_lock(directory / ".lock"): + view = _read_view(directory, request) + if view.terminal is not None: + return view + if view.receipt is None and not (directory / ".dispatch").exists(): + terminal = SetupTerminal.owned( + request=request, + outcome="stopped_before_ownership", + process="not_started", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC) if now is None else now, + ) + _write_create_once(directory / "terminal.json", terminal.to_dict()) + return SetupView(request=request, terminal=terminal) + _touch_create_once(directory / ".stop") + deadline = time.monotonic() + _STOP_WAIT_SECONDS + while time.monotonic() < deadline: + view = _read_view(directory, request) + if view.terminal is not None: + return view + if view.receipt is not None: + break + time.sleep(0.02) + view = _read_view(directory, request) + if view.terminal is not None: + return view + if view.receipt is None: + return _publish_terminal( + directory, + request, + SetupTerminal.owned( + request=request, + outcome="launch_failed", + process="not_started", + cleanup="cleanup_unverified", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + message="Stop could not obtain a keeper ownership acknowledgement.", + ), + ) + receipt = view.receipt + deadline = time.monotonic() + _STOP_WAIT_SECONDS + while time.monotonic() < deadline: + current = _read_view(directory, request) + if current.terminal is not None: + return current + if not process_matches(receipt.keeper_pid, receipt.keeper_creation_time): + break + time.sleep(0.05) + if process_matches(receipt.keeper_pid, receipt.keeper_creation_time): + from blendersessiond.windows_setup_process import terminate_exact_process + + try: + terminate_exact_process(receipt.keeper_pid, receipt.keeper_creation_time) + except (OSError, RuntimeError): + return _publish_terminal( + directory, + request, + SetupTerminal.owned( + request=request, + outcome="cleanup_unverified", + process="cancelled", + cleanup="cleanup_unverified", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ), + ) + return _reconcile_after_stop(directory, request, receipt) + + +def script_path_for_attempt(state_root: Path, attempt_id: str) -> Path: + return _attempt_directory(state_root, attempt_id) / f"{attempt_id}.ps1" + + +def read_staged_script(state_root: Path, request: SetupRequest) -> bytes: + path = script_path_for_attempt(state_root, request.attempt_id) + try: + path_metadata = path.lstat() + if stat.S_ISLNK(path_metadata.st_mode) or not stat.S_ISREG( + path_metadata.st_mode + ): + raise SetupOwnerError("The staged setup script must be a regular file.") + with path.open("rb") as stream: + opened_metadata = os.fstat(stream.fileno()) + if ( + not stat.S_ISREG(opened_metadata.st_mode) + or (path_metadata.st_dev, path_metadata.st_ino) + != (opened_metadata.st_dev, opened_metadata.st_ino) + ): + raise SetupOwnerError( + "The staged setup script changed while it was opened." + ) + content = stream.read(request.script.size + 1) + except FileNotFoundError as error: + raise SetupOwnerError("The staged setup script is missing.") from error + if ( + opened_metadata.st_size != request.script.size + or len(content) != request.script.size + ): + raise SetupOwnerError("The staged setup script size does not match.") + try: + content.decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise SetupOwnerError("The staged setup script is not strict UTF-8.") from error + if hashlib.sha256(content).hexdigest() != request.script.sha256: + raise SetupOwnerError("The staged setup script SHA-256 does not match.") + return content + + +def _parse_request(raw: bytes) -> SetupRequest: + if not isinstance(raw, bytes) or not raw or len(raw) > MAX_REQUEST_BYTES: + raise SetupOwnerError("Setup launch request must be 1-16 KiB.") + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise SetupOwnerError( + "Setup launch request is not valid UTF-8 JSON." + ) from error + if not isinstance(payload, dict): + raise SetupOwnerError("Setup launch request must be a JSON object.") + expected = { + "schema_version", + "attempt_id", + "launch_id", + "deadline_utc", + "operation_revision", + "script", + } + unknown = set(payload) - expected + missing = expected - set(payload) + if unknown: + raise SetupOwnerError( + f"Setup launch request contains unknown keys: {sorted(unknown)}." + ) + if missing: + raise SetupOwnerError( + f"Setup launch request is missing keys: {sorted(missing)}." + ) + if ( + type(payload["schema_version"]) is not int + or payload["schema_version"] != SCHEMA_VERSION + ): + raise SetupOwnerError("Setup launch request schema_version must be 1.") + attempt_id = _validate_attempt_id(payload["attempt_id"]) + launch_id = _validate_launch_id(payload["launch_id"]) + if payload["operation_revision"] != OPERATION_REVISION: + raise SetupOwnerError( + f"Setup launch operation_revision must be {OPERATION_REVISION}." + ) + deadline = _parse_time(payload["deadline_utc"], "deadline_utc") + script = payload["script"] + if not isinstance(script, dict) or set(script) != { + "artifact_id", + "size", + "sha256", + }: + raise SetupOwnerError("Setup launch script must contain exact artifact fields.") + if script["artifact_id"] != f"{attempt_id}.ps1": + raise SetupOwnerError( + "Setup script artifact_id must derive from the Attempt ID." + ) + size = script["size"] + if ( + not isinstance(size, int) + or isinstance(size, bool) + or not 0 < size <= MAX_SCRIPT_BYTES + ): + raise SetupOwnerError("Setup script size must be 1-128 KiB.") + return SetupRequest( + attempt_id=attempt_id, + launch_id=launch_id, + deadline_utc=deadline, + operation_revision=OPERATION_REVISION, + script=ScriptArtifact( + artifact_id=script["artifact_id"], + size=size, + sha256=_validate_sha256(script["sha256"]), + ), + request_sha256=hashlib.sha256(raw).hexdigest(), + ) + + +def _spawn_keeper(directory: Path) -> None: + flags = 0 + if os.name == "nt": + flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + process = subprocess.Popen( + [ + sys.executable, + "-m", + "blendersessiond.windows_setup_process", + "--keeper", + str(directory), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + creationflags=flags, + ) + if wait_for_process_start_time(process.pid) is None: + raise SetupOwnerError( + "The setup keeper exited before ownership acknowledgement." + ) + + +def _read_view(directory: Path, request: SetupRequest) -> SetupView: + terminal = _read_optional_record(directory / "terminal.json") + if terminal is not None: + return SetupView( + request=request, + terminal=_terminal_from_record(terminal, request), + ) + receipt = _read_optional_record(directory / "launch-receipt.json") + if receipt is not None: + return SetupView( + request=request, + receipt=_receipt_from_record(receipt, request), + ) + return SetupView(request=request) + + +def _load_request(directory: Path) -> SetupRequest: + payload = _read_optional_record(directory / "request.json") + if payload is None: + raise SetupOwnerError("The Setup Attempt was not found.") + return _request_from_record(payload) + + +def _check_fence( + request: SetupRequest, + request_sha256: str | None, + launch_id: str | None, +) -> None: + if request_sha256 is not None and request_sha256 != request.request_sha256: + raise SetupOwnerError("The expected request SHA-256 does not match.") + if launch_id is not None and launch_id != request.launch_id: + raise SetupOwnerError("The expected Launch ID does not match.") + + +def _reconcile_after_stop( + directory: Path, + request: SetupRequest, + receipt: LaunchReceipt, +) -> SetupView: + deadline = time.monotonic() + _STOP_WAIT_SECONDS + while time.monotonic() < deadline: + current = _read_view(directory, request) + if current.terminal is not None: + return current + keeper = process_matches(receipt.keeper_pid, receipt.keeper_creation_time) + root = process_matches(receipt.root_pid, receipt.root_creation_time) + if not keeper and not root and _job_is_absent(receipt.job_name): + return _publish_terminal( + directory, + request, + SetupTerminal.owned( + request=request, + outcome="stopped", + process="cancelled", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ), + ) + time.sleep(0.05) + return _publish_terminal( + directory, + request, + SetupTerminal.owned( + request=request, + outcome="cleanup_unverified", + process="cancelled", + cleanup="cleanup_unverified", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ), + ) + + +def _reconcile_owner_loss( + directory: Path, + request: SetupRequest, + receipt: LaunchReceipt, +) -> SetupView: + deadline = time.monotonic() + _OWNER_LOSS_WAIT_SECONDS + while time.monotonic() < deadline: + if process_matches(receipt.keeper_pid, receipt.keeper_creation_time): + return SetupView(request=request, receipt=receipt) + if not process_matches(receipt.root_pid, receipt.root_creation_time): + break + time.sleep(0.02) + root = process_matches(receipt.root_pid, receipt.root_creation_time) + cleanup = ( + "tree_gone" + if not root and _job_is_absent(receipt.job_name) + else "cleanup_unverified" + ) + return _publish_terminal( + directory, + request, + SetupTerminal.owned( + request=request, + outcome="owner_lost" if cleanup == "tree_gone" else cleanup, + process="owner_lost", + cleanup=cleanup, + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ), + ) + + +def _publish_terminal( + directory: Path, + request: SetupRequest, + terminal: SetupTerminal, +) -> SetupView: + with file_lock(directory / ".lock"): + existing = _read_optional_record(directory / "terminal.json") + if existing is not None: + return SetupView( + request=request, + terminal=_terminal_from_record(existing, request), + ) + _write_create_once(directory / "terminal.json", terminal.to_dict()) + return SetupView(request=request, terminal=terminal) + + +def _write_create_once(path: Path, payload: dict[str, Any]) -> None: + encoded = _encode_record(payload) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{secrets.token_hex(16)}.tmp" + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "wb", closefd=False) as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + finally: + os.close(descriptor) + try: + _publish_no_replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _publish_no_replace(source: Path, destination: Path) -> None: + if os.name == "nt": + import ctypes + + move_file = ctypes.WinDLL("kernel32", use_last_error=True).MoveFileExW + move_file.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32] + move_file.restype = ctypes.c_int + if not move_file(str(source), str(destination), 0x00000008): + error = ctypes.get_last_error() + if error in {80, 183}: + raise FileExistsError(destination) + raise ctypes.WinError(error) + return + os.link(source, destination) + + +def _touch_create_once(path: Path) -> bool: + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return False + os.close(descriptor) + return True + + +def _read_optional_record(path: Path) -> dict[str, Any] | None: + try: + with path.open("rb") as stream: + encoded = stream.read(MAX_RECORD_BYTES + 1) + except FileNotFoundError: + return None + if len(encoded) > MAX_RECORD_BYTES: + raise SetupOwnerError(f"Setup record is too large: {path.name}.") + try: + payload = json.loads(encoded) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise SetupOwnerError(f"Setup record is invalid: {path.name}.") from error + if ( + not isinstance(payload, dict) + or type(payload.get("schema_version")) is not int + or payload["schema_version"] != 1 + ): + raise SetupOwnerError(f"Setup record is incompatible: {path.name}.") + return payload + + +def _encode_record(payload: dict[str, Any]) -> bytes: + encoded = ( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + "\n" + ).encode("ascii") + if len(encoded) > MAX_RECORD_BYTES: + raise SetupOwnerError("Setup record exceeds its size limit.") + return encoded + + +def _request_from_record(payload: dict[str, Any]) -> SetupRequest: + try: + if type(payload["schema_version"]) is not int or payload["schema_version"] != 1: + raise SetupOwnerError("Setup request record schema is invalid.") + attempt_id = _validate_attempt_id(payload["attempt_id"]) + script_payload = payload["script"] + if not isinstance(script_payload, dict) or set(script_payload) != { + "artifact_id", + "size", + "sha256", + }: + raise SetupOwnerError("Setup request record script is invalid.") + size = script_payload["size"] + if type(size) is not int or not 0 < size <= MAX_SCRIPT_BYTES: + raise SetupOwnerError("Setup request record script size is invalid.") + if script_payload["artifact_id"] != f"{attempt_id}.ps1": + raise SetupOwnerError("Setup request record artifact is invalid.") + if payload["operation_revision"] != OPERATION_REVISION: + raise SetupOwnerError("Setup request record revision is invalid.") + request = SetupRequest( + attempt_id=attempt_id, + launch_id=_validate_launch_id(payload["launch_id"]), + deadline_utc=_parse_time(payload["deadline_utc"], "deadline_utc"), + operation_revision=OPERATION_REVISION, + script=ScriptArtifact( + artifact_id=script_payload["artifact_id"], + size=size, + sha256=_validate_sha256(script_payload["sha256"]), + ), + request_sha256=_validate_sha256(payload["request_sha256"]), + ) + except (KeyError, SetupOwnerError, TypeError) as error: + raise SetupOwnerError("Setup request record is invalid.") from error + if payload != request.to_dict(): + raise SetupOwnerError("Setup request record contains unexpected fields.") + return request + + +def _receipt_from_record( + payload: dict[str, Any], request: SetupRequest +) -> LaunchReceipt: + try: + receipt = LaunchReceipt( + attempt_id=payload["attempt_id"], + launch_id=payload["launch_id"], + request_sha256=payload["request_sha256"], + keeper_pid=payload["keeper_pid"], + keeper_creation_time=payload["keeper_creation_time"], + root_pid=payload["root_pid"], + root_creation_time=payload["root_creation_time"], + job_name=payload["job_name"], + owned_at=_parse_time(payload["owned_at"], "owned_at"), + ) + except (KeyError, TypeError) as error: + raise SetupOwnerError("Setup launch receipt is invalid.") from error + valid = ( + payload == receipt.to_dict() + and receipt.attempt_id == request.attempt_id + and receipt.launch_id == request.launch_id + and receipt.request_sha256 == request.request_sha256 + and isinstance(receipt.keeper_pid, int) + and not isinstance(receipt.keeper_pid, bool) + and receipt.keeper_pid > 0 + and isinstance(receipt.keeper_creation_time, str) + and isinstance(receipt.root_pid, int) + and not isinstance(receipt.root_pid, bool) + and receipt.root_pid > 0 + and isinstance(receipt.root_creation_time, str) + and receipt.job_name == _job_name(request.launch_id) + ) + if not valid: + raise SetupOwnerError("Setup launch receipt is invalid or stale.") + return receipt + + +def _terminal_from_record( + payload: dict[str, Any], request: SetupRequest +) -> SetupTerminal: + try: + terminal = SetupTerminal( + attempt_id=payload["attempt_id"], + launch_id=payload["launch_id"], + request_sha256=payload["request_sha256"], + outcome=payload["outcome"], + process=payload["process"], + cleanup=payload["cleanup"], + exit_code=payload["exit_code"], + stdout=payload["stdout"].encode("utf-8"), + stderr=payload["stderr"].encode("utf-8"), + stdout_truncated=payload["stdout_truncated"], + stderr_truncated=payload["stderr_truncated"], + finished_at=_parse_time(payload["finished_at"], "finished_at"), + message=payload.get("message"), + ) + except (AttributeError, KeyError, TypeError) as error: + raise SetupOwnerError("Setup terminal record is invalid.") from error + valid = ( + payload == terminal.to_dict() + and ( + terminal.attempt_id, + terminal.launch_id, + terminal.request_sha256, + ) + == (request.attempt_id, request.launch_id, request.request_sha256) + and terminal.outcome in _TERMINAL_OUTCOMES + and terminal.process in _TERMINAL_PROCESSES + and terminal.cleanup in _TERMINAL_CLEANUP + and ( + terminal.exit_code is None + or ( + isinstance(terminal.exit_code, int) + and not isinstance(terminal.exit_code, bool) + and 0 <= terminal.exit_code <= 0xFFFFFFFF + ) + ) + and isinstance(terminal.stdout_truncated, bool) + and isinstance(terminal.stderr_truncated, bool) + and len(terminal.stdout) <= MAX_OUTPUT_BYTES + and len(terminal.stderr) <= MAX_OUTPUT_BYTES + and ( + terminal.message is None + or (isinstance(terminal.message, str) and len(terminal.message) <= 4096) + ) + and _terminal_semantics_are_valid(terminal) + ) + if not valid: + raise SetupOwnerError("Setup terminal record is invalid or stale.") + return terminal + + +def _terminal_semantics_are_valid(terminal: SetupTerminal) -> bool: + if terminal.outcome == "process_succeeded": + return terminal.succeeded + if terminal.outcome == "process_failed": + if terminal.process == "failed": + return terminal.exit_code is None + return terminal.process == "exited" and not ( + terminal.exit_code == 0 + and not terminal.stdout_truncated + and not terminal.stderr_truncated + ) + if terminal.outcome == "cancelled": + return terminal.process == "cancelled" and terminal.cleanup == "tree_gone" + if terminal.outcome == "timed_out": + return ( + terminal.process in {"timed_out", "not_resumed"} + and terminal.cleanup == "tree_gone" + ) + if terminal.outcome == "stopped": + return ( + terminal.process in {"cancelled", "cancelled_before_resume"} + and terminal.cleanup == "tree_gone" + and terminal.exit_code is None + ) + if terminal.outcome == "stopped_before_ownership": + return ( + terminal.process == "not_started" + and terminal.cleanup == "tree_gone" + and terminal.exit_code is None + ) + if terminal.outcome == "launch_failed": + return ( + terminal.process in {"not_started", "not_resumed"} + and terminal.exit_code is None + ) + if terminal.outcome == "owner_lost": + return ( + terminal.process == "owner_lost" + and terminal.cleanup == "tree_gone" + and terminal.exit_code is None + ) + return ( + terminal.outcome == "cleanup_unverified" + and terminal.process in {"cancelled", "owner_lost"} + and terminal.cleanup == "cleanup_unverified" + and terminal.exit_code is None + ) + + +def _validate_attempt_id(value: Any) -> str: + if not isinstance(value, str) or not _ATTEMPT_ID.fullmatch(value): + raise SetupOwnerError("Attempt ID is invalid.") + return value + + +def _validate_launch_id(value: Any) -> str: + if not isinstance(value, str) or not _LAUNCH_ID.fullmatch(value): + raise SetupOwnerError("Launch ID is invalid.") + return value + + +def _validate_sha256(value: Any) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + raise SetupOwnerError("SHA-256 is invalid.") + return value + + +def _attempt_directory(state_root: Path, attempt_id: str) -> Path: + return Path(state_root) / "setup-attempts" / _validate_attempt_id(attempt_id) + + +def _parse_time(value: Any, field: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise SetupOwnerError(f"Setup {field} must be a UTC timestamp.") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as error: + raise SetupOwnerError(f"Setup {field} is invalid.") from error + return parsed.astimezone(UTC) + + +def _format_time(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _require_windows(value: str | None) -> None: + if (platform.system() if value is None else value) != "Windows": + raise SetupOwnerError( + "Windows setup ownership is unavailable on this platform." + ) + + +def _job_name(launch_id: str) -> str: + return f"Local\\BlenderSessiond.Setup.{launch_id}" + + +def _job_exists(name: str) -> bool: + from blendersessiond.windows_setup_process import job_exists + + return job_exists(name) + + +def _job_is_absent(name: str) -> bool: + try: + return not _job_exists(name) + except (OSError, RuntimeError): + return False diff --git a/src/blendersessiond/windows_setup_process.py b/src/blendersessiond/windows_setup_process.py new file mode 100644 index 0000000..27f6d8b --- /dev/null +++ b/src/blendersessiond/windows_setup_process.py @@ -0,0 +1,944 @@ +from __future__ import annotations + +import base64 +import ctypes +import os +import subprocess +import sys +import threading +import time +from collections.abc import Callable +from ctypes import wintypes +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from blendersessiond.locking import file_lock +from blendersessiond.processes import process_start_time + +CREATE_SUSPENDED = 0x00000004 +EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +_CREATE_NO_WINDOW = 0x08000000 +_STARTF_USESTDHANDLES = 0x00000100 +_HANDLE_FLAG_INHERIT = 0x00000001 +_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 +_PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1 +_WAIT_OBJECT_0 = 0 +_WAIT_TIMEOUT = 258 +_ERROR_BROKEN_PIPE = 109 +_ERROR_ALREADY_EXISTS = 183 +_ERROR_FILE_NOT_FOUND = 2 +_STILL_ACTIVE = 259 +_MAX_STREAM_BYTES = 24 * 1024 + + +class _SecurityAttributes(ctypes.Structure): + _fields_ = [ + ("length", wintypes.DWORD), + ("security_descriptor", wintypes.LPVOID), + ("inherit_handle", wintypes.BOOL), + ] + + +class _StartupInfo(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("reserved", wintypes.LPWSTR), + ("desktop", wintypes.LPWSTR), + ("title", wintypes.LPWSTR), + ("x", wintypes.DWORD), + ("y", wintypes.DWORD), + ("x_size", wintypes.DWORD), + ("y_size", wintypes.DWORD), + ("x_count_chars", wintypes.DWORD), + ("y_count_chars", wintypes.DWORD), + ("fill_attribute", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("show_window", wintypes.WORD), + ("reserved_size", wintypes.WORD), + ("reserved_bytes", ctypes.POINTER(ctypes.c_ubyte)), + ("stdin", wintypes.HANDLE), + ("stdout", wintypes.HANDLE), + ("stderr", wintypes.HANDLE), + ] + + +class _StartupInfoEx(ctypes.Structure): + _fields_ = [ + ("startup_info", _StartupInfo), + ("attribute_list", wintypes.LPVOID), + ] + + +class _ProcessInformation(ctypes.Structure): + _fields_ = [ + ("process", wintypes.HANDLE), + ("thread", wintypes.HANDLE), + ("process_id", wintypes.DWORD), + ("thread_id", wintypes.DWORD), + ] + + +class _BasicLimitInformation(ctypes.Structure): + _fields_ = [ + ("per_process_time", ctypes.c_longlong), + ("per_job_time", ctypes.c_longlong), + ("flags", wintypes.DWORD), + ("minimum_working_set", ctypes.c_size_t), + ("maximum_working_set", ctypes.c_size_t), + ("active_process_limit", wintypes.DWORD), + ("affinity", ctypes.c_size_t), + ("priority_class", wintypes.DWORD), + ("scheduling_class", wintypes.DWORD), + ] + + +class _IoCounters(ctypes.Structure): + _fields_ = [ + (name, ctypes.c_uint64) + for name in ( + "read_operations", + "write_operations", + "other_operations", + "read_bytes", + "write_bytes", + "other_bytes", + ) + ] + + +class _ExtendedLimitInformation(ctypes.Structure): + _fields_ = [ + ("basic", _BasicLimitInformation), + ("io", _IoCounters), + ("process_memory", ctypes.c_size_t), + ("job_memory", ctypes.c_size_t), + ("peak_process_memory", ctypes.c_size_t), + ("peak_job_memory", ctypes.c_size_t), + ] + + +class _BasicAccountingInformation(ctypes.Structure): + _fields_ = [ + ("total_user_time", ctypes.c_longlong), + ("total_kernel_time", ctypes.c_longlong), + ("period_user_time", ctypes.c_longlong), + ("period_kernel_time", ctypes.c_longlong), + ("page_faults", wintypes.DWORD), + ("total_processes", wintypes.DWORD), + ("active_processes", wintypes.DWORD), + ("terminated_processes", wintypes.DWORD), + ] + + +class _NativeWin32: + def __init__(self) -> None: + self.kernel = _kernel32() + + def create_job(self, name: str | None) -> int: + job = self.kernel.CreateJobObjectW(None, name) + error = ctypes.get_last_error() + if not job: + raise ctypes.WinError(error) + if name is not None and error == _ERROR_ALREADY_EXISTS: + self.close_handles((job,)) + raise RuntimeError("Setup Job Object name is already in use.") + return job + + def set_kill_on_close(self, job: int) -> None: + information = _ExtendedLimitInformation() + information.basic.flags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not self.kernel.SetInformationJobObject( + job, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + ): + raise ctypes.WinError(ctypes.get_last_error()) + + def create_standard_pipes(self) -> tuple[tuple[int, ...], tuple[int, ...]]: + security = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), None, True) + values: list[tuple[int, int]] = [] + try: + for _ in range(3): + read = wintypes.HANDLE() + write = wintypes.HANDLE() + if not self.kernel.CreatePipe( + ctypes.byref(read), + ctypes.byref(write), + ctypes.byref(security), + 0, + ): + raise ctypes.WinError(ctypes.get_last_error()) + values.append((read.value, write.value)) + child = (values[0][0], values[1][1], values[2][1]) + parent = (values[0][1], values[1][0], values[2][0]) + for handle in parent: + if not self.kernel.SetHandleInformation( + handle, _HANDLE_FLAG_INHERIT, 0 + ): + raise ctypes.WinError(ctypes.get_last_error()) + return child, parent + except BaseException: + self.close_handles(handle for pair in values for handle in pair) + raise + + def create_process(self, **values: Any) -> tuple[int, int, int]: + child_handles = values["handle_list"] + jobs = values["job_list"] + attribute_size = ctypes.c_size_t() + self.kernel.InitializeProcThreadAttributeList( + None, 2, 0, ctypes.byref(attribute_size) + ) + buffer = ctypes.create_string_buffer(attribute_size.value) + attributes = ctypes.cast(buffer, wintypes.LPVOID) + if not self.kernel.InitializeProcThreadAttributeList( + attributes, 2, 0, ctypes.byref(attribute_size) + ): + raise ctypes.WinError(ctypes.get_last_error()) + inherited = (wintypes.HANDLE * len(child_handles))(*child_handles) + job_list = (wintypes.HANDLE * len(jobs))(*jobs) + try: + if not self.kernel.UpdateProcThreadAttribute( + attributes, + 0, + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + ctypes.byref(inherited), + ctypes.sizeof(inherited), + None, + None, + ): + raise ctypes.WinError(ctypes.get_last_error()) + if not self.kernel.UpdateProcThreadAttribute( + attributes, + 0, + _PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.byref(job_list), + ctypes.sizeof(job_list), + None, + None, + ): + raise ctypes.WinError(ctypes.get_last_error()) + startup = _StartupInfoEx() + startup.startup_info.cb = ctypes.sizeof(startup) + startup.startup_info.flags = _STARTF_USESTDHANDLES + startup.startup_info.stdin = values["standard_handles"][0] + startup.startup_info.stdout = values["standard_handles"][1] + startup.startup_info.stderr = values["standard_handles"][2] + startup.attribute_list = attributes + information = _ProcessInformation() + command = ctypes.create_unicode_buffer( + subprocess.list2cmdline( + [values["application_name"], *values["command_line"]] + ) + ) + if not self.kernel.CreateProcessW( + values["application_name"], + command, + None, + None, + values["inherit_handles"], + values["creation_flags"] | _CREATE_NO_WINDOW, + None, + values["cwd"], + ctypes.byref(startup), + ctypes.byref(information), + ): + raise ctypes.WinError(ctypes.get_last_error()) + return information.process, information.thread, information.process_id + finally: + self.kernel.DeleteProcThreadAttributeList(attributes) + + def close_handles(self, handles: Any) -> None: + for handle in handles: + if handle: + self.kernel.CloseHandle(handle) + + def process_creation_filetime(self, process: int) -> int: + creation = wintypes.FILETIME() + exit_time = wintypes.FILETIME() + kernel = wintypes.FILETIME() + user = wintypes.FILETIME() + if not self.kernel.GetProcessTimes( + process, + ctypes.byref(creation), + ctypes.byref(exit_time), + ctypes.byref(kernel), + ctypes.byref(user), + ): + raise ctypes.WinError(ctypes.get_last_error()) + return (creation.dwHighDateTime << 32) | creation.dwLowDateTime + + def is_process_in_job(self, process: int, job: int) -> bool: + result = wintypes.BOOL() + if not self.kernel.IsProcessInJob(process, job, ctypes.byref(result)): + raise ctypes.WinError(ctypes.get_last_error()) + return bool(result.value) + + def resume_thread(self, thread: int) -> None: + if self.kernel.ResumeThread(thread) == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + + def terminate_job(self, job: int) -> None: + if not self.kernel.TerminateJobObject(job, 1): + raise ctypes.WinError(ctypes.get_last_error()) + + def terminate_process(self, process: int) -> None: + if not self.kernel.TerminateProcess(process, 1): + raise ctypes.WinError(ctypes.get_last_error()) + + def active_processes(self, job: int) -> int: + information = _BasicAccountingInformation() + if not self.kernel.QueryInformationJobObject( + job, + _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + None, + ): + raise ctypes.WinError(ctypes.get_last_error()) + return information.active_processes + + def wait_process(self, process: int, milliseconds: int) -> int: + return self.kernel.WaitForSingleObject(process, milliseconds) + + def exit_code(self, process: int) -> int: + value = wintypes.DWORD() + if not self.kernel.GetExitCodeProcess(process, ctypes.byref(value)): + raise ctypes.WinError(ctypes.get_last_error()) + return value.value + + def write(self, handle: int, content: bytes) -> None: + offset = 0 + while offset < len(content): + chunk = content[offset : offset + 16 * 1024] + buffer = ctypes.create_string_buffer(chunk) + written = wintypes.DWORD() + if not self.kernel.WriteFile( + handle, buffer, len(chunk), ctypes.byref(written), None + ): + if ctypes.get_last_error() == _ERROR_BROKEN_PIPE: + return + raise ctypes.WinError(ctypes.get_last_error()) + if written.value == 0: + raise RuntimeError("Setup stdin stopped accepting bytes.") + offset += written.value + + def read(self, handle: int) -> bytes | None: + buffer = ctypes.create_string_buffer(16 * 1024) + read = wintypes.DWORD() + if not self.kernel.ReadFile( + handle, buffer, len(buffer), ctypes.byref(read), None + ): + if ctypes.get_last_error() == _ERROR_BROKEN_PIPE: + return None + raise ctypes.WinError(ctypes.get_last_error()) + return bytes(buffer.raw[: read.value]) if read.value else None + + +@dataclass +class WindowsSetupProcess: + _api: Any + job_handle: Any + process_handle: Any + thread_handle: Any + root_pid: int + root_creation_filetime: Any + parent_stdin: Any + parent_stdout: Any + parent_stderr: Any + + @classmethod + def create_suspended( + cls, + *, + api: Any | None = None, + powershell_path: Path | None = None, + job_name: str | None, + script_size: int = 1, + script_sha256: str = "0" * 64, + cwd: Path | None = None, + ) -> WindowsSetupProcess: + owner = _NativeWin32() if api is None else api + powershell = ( + _system_powershell() if powershell_path is None else str(powershell_path) + ) + job = owner.create_job(job_name) + try: + owner.set_kill_on_close(job) + child, parent = owner.create_standard_pipes() + try: + process, thread, pid = owner.create_process( + application_name=powershell, + command_line=_powershell_arguments(script_size, script_sha256), + creation_flags=CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, + job_list=(job,), + handle_list=tuple(child), + standard_handles=tuple(child), + inherit_handles=True, + cwd=str(Path.cwd() if cwd is None else cwd), + ) + except BaseException: + owner.close_handles((*child, *parent)) + raise + owner.close_handles(child) + try: + creation = owner.process_creation_filetime(process) + if not owner.is_process_in_job(process, job): + raise RuntimeError("Setup process is not inside its Job Object.") + except BaseException: + try: + owner.terminate_process(process) + owner.wait_process(process, 5000) + finally: + owner.close_handles((thread, process, *parent)) + raise + return cls( + _api=owner, + job_handle=job, + process_handle=process, + thread_handle=thread, + root_pid=pid, + root_creation_filetime=creation, + parent_stdin=parent[0], + parent_stdout=parent[1], + parent_stderr=parent[2], + ) + except BaseException: + owner.close_handles((job,)) + raise + + def publish_then_resume(self, publish: Callable[[], None]) -> None: + publish() + self.resume() + + def resume(self) -> None: + self._api.resume_thread(self.thread_handle) + self._api.close_handles((self.thread_handle,)) + self.thread_handle = 0 + + def start_io( + self, + script: bytes, + output: dict[str, tuple[bytes, bool]], + threads: list[threading.Thread], + ) -> None: + stdin_handle = self.parent_stdin + + def write() -> None: + try: + self._api.write(stdin_handle, script) + finally: + self._api.close_handles((stdin_handle,)) + + writer = threading.Thread(target=write, daemon=True) + writer.start() + threads.append(writer) + self.parent_stdin = 0 + for name, handle in ( + ("stdout", self.parent_stdout), + ("stderr", self.parent_stderr), + ): + thread = threading.Thread( + target=self._drain, + args=(handle, output, name), + daemon=True, + ) + thread.start() + threads.append(thread) + + def _drain( + self, + handle: Any, + output: dict[str, tuple[bytes, bool]], + name: str, + ) -> None: + collected = bytearray() + truncated = False + try: + while True: + chunk = self._api.read(handle) + if chunk is None: + break + remaining = _MAX_STREAM_BYTES - len(collected) + if remaining > 0: + collected.extend(chunk[:remaining]) + if len(chunk) > remaining: + truncated = True + except OSError: + truncated = True + output[name] = (bytes(collected), truncated) + + def finish_io(self, threads: list[threading.Thread]) -> None: + for thread in threads: + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError("Setup process stream did not close.") + self._api.close_handles((self.parent_stdout, self.parent_stderr)) + self.parent_stdout = 0 + self.parent_stderr = 0 + + def terminate(self) -> None: + self._api.terminate_job(self.job_handle) + + def wait_empty(self) -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if self._api.active_processes(self.job_handle) == 0: + return + time.sleep(0.02) + raise RuntimeError("Setup Job Object did not become empty.") + + def empty(self) -> bool: + return self._api.active_processes(self.job_handle) == 0 + + def active_count(self) -> int: + return self._api.active_processes(self.job_handle) + + def close(self) -> None: + self._api.close_handles( + ( + self.thread_handle, + self.process_handle, + self.parent_stdin, + self.parent_stdout, + self.parent_stderr, + self.job_handle, + ) + ) + self.thread_handle = 0 + self.process_handle = 0 + self.job_handle = 0 + + +def powershell_bootstrap(script_size: int, script_sha256: str) -> str: + return f"""$ErrorActionPreference = 'Stop' +$stream = [Console]::OpenStandardInput() +$bytes = [byte[]]::new({script_size}) +$offset = 0 +while ($offset -lt $bytes.Length) {{ + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) {{ throw 'Setup input ended before the declared byte count.' }} + $offset += $read +}} +if ($stream.ReadByte() -ne -1) {{ + throw 'Setup input exceeds the declared byte count.' +}} +$sha = [Security.Cryptography.SHA256]::Create() +try {{ $digest = $sha.ComputeHash($bytes) }} finally {{ $sha.Dispose() }} +$actual = ([BitConverter]::ToString($digest)).Replace('-', '').ToLowerInvariant() +if ($actual -cne '{script_sha256}') {{ + throw 'Setup input SHA256 does not match the accepted request.' +}} +$text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) +$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$block = [ScriptBlock]::Create($text) +& $block +""" + + +def runtime_self_test() -> None: + owned = WindowsSetupProcess.create_suspended( + job_name=None, + script_size=1, + script_sha256="0" * 64, + ) + try: + if owned.active_count() != 1: + raise RuntimeError("Setup owner self-test found unexpected Job members.") + owned.terminate() + owned.wait_empty() + if not owned.empty(): + raise RuntimeError("Setup owner self-test did not empty the Job Object.") + finally: + owned.close() + + +def terminate_exact_process(pid: int, expected_creation_time: str) -> None: + api = _NativeWin32() + process = api.kernel.OpenProcess(0x00100000 | 0x1000 | 0x0001, False, pid) + if not process: + return + try: + actual = f"windows:{api.process_creation_filetime(process)}" + if actual != expected_creation_time: + return + if not api.kernel.TerminateProcess(process, 1): + raise ctypes.WinError(ctypes.get_last_error()) + api.kernel.WaitForSingleObject(process, 5000) + finally: + api.close_handles((process,)) + + +def job_exists(name: str) -> bool: + kernel = _kernel32() + job = kernel.OpenJobObjectW(0x0004, False, name) + if not job: + error = ctypes.get_last_error() + if error == _ERROR_FILE_NOT_FOUND: + return False + raise ctypes.WinError(error) + kernel.CloseHandle(job) + return True + + +def run_keeper(attempt_dir: Path) -> int: + from blendersessiond import setup_owner + + try: + request = setup_owner._load_request(attempt_dir) + except BaseException: + return 2 + try: + script = setup_owner.read_staged_script(attempt_dir.parent.parent, request) + except BaseException as error: + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="launch_failed", + process="not_started", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + message=f"{type(error).__name__}: {error}"[:4096], + ) + setup_owner._publish_terminal(attempt_dir, request, terminal) + return 1 + owned: WindowsSetupProcess | None = None + output: dict[str, tuple[bytes, bool]] = {} + threads: list[threading.Thread] = [] + resumed = False + try: + with file_lock(attempt_dir / ".lock"): + if (attempt_dir / "terminal.json").exists(): + return 0 + if (attempt_dir / ".stop").exists(): + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="stopped_before_ownership", + process="not_started", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ) + setup_owner._write_create_once( + attempt_dir / "terminal.json", terminal.to_dict() + ) + return 0 + owned = WindowsSetupProcess.create_suspended( + job_name=setup_owner._job_name(request.launch_id), + script_size=request.script.size, + script_sha256=request.script.sha256, + cwd=attempt_dir, + ) + keeper_time = process_start_time(os.getpid()) + if keeper_time is None: + raise RuntimeError("Setup keeper identity is unavailable.") + receipt = setup_owner.LaunchReceipt( + attempt_id=request.attempt_id, + launch_id=request.launch_id, + request_sha256=request.request_sha256, + keeper_pid=os.getpid(), + keeper_creation_time=keeper_time, + root_pid=owned.root_pid, + root_creation_time=f"windows:{owned.root_creation_filetime}", + job_name=setup_owner._job_name(request.launch_id), + owned_at=datetime.now(UTC), + ) + with file_lock(attempt_dir / ".lock"): + if (attempt_dir / "terminal.json").exists(): + owned.terminate() + owned.wait_empty() + return 0 + setup_owner._write_create_once( + attempt_dir / "launch-receipt.json", receipt.to_dict() + ) + if (attempt_dir / ".stop").exists(): + owned.terminate() + owned.wait_empty() + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="stopped", + process="cancelled_before_resume", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ) + setup_owner._write_create_once( + attempt_dir / "terminal.json", terminal.to_dict() + ) + return 0 + owned.start_io(script, output, threads) + if request.deadline_utc <= datetime.now(UTC): + owned.terminate() + owned.wait_empty() + owned.finish_io(threads) + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="timed_out", + process="not_resumed", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + ) + setup_owner._write_create_once( + attempt_dir / "terminal.json", terminal.to_dict() + ) + return 1 + owned.resume() + resumed = True + process = _wait_for_process(owned, request.deadline_utc, attempt_dir / ".stop") + exit_code = owned._api.exit_code(owned.process_handle) + if not owned.empty(): + owned.terminate() + owned.wait_empty() + owned.finish_io(threads) + stdout, stdout_truncated = output.get("stdout", (b"", False)) + stderr, stderr_truncated = output.get("stderr", (b"", False)) + stdout.decode("utf-8", errors="strict") + stderr.decode("utf-8", errors="strict") + exit_value = None if exit_code == _STILL_ACTIVE else exit_code + outcome = process + if process == "exited": + outcome = ( + "process_succeeded" + if exit_value == 0 and not stdout_truncated and not stderr_truncated + else "process_failed" + ) + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome=outcome, + process=process, + cleanup="tree_gone", + exit_code=exit_value, + stdout=stdout, + stderr=stderr, + stdout_truncated=stdout_truncated, + stderr_truncated=stderr_truncated, + finished_at=datetime.now(UTC), + ) + setup_owner._publish_terminal(attempt_dir, request, terminal) + return 0 + except BaseException as error: + cleanup = "cleanup_unverified" + if owned is not None: + try: + owned.terminate() + owned.wait_empty() + cleanup = "tree_gone" + except (OSError, RuntimeError): + pass + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="launch_failed" if not resumed else "process_failed", + process="not_resumed" if not resumed else "failed", + cleanup=cleanup, + stdout=b"", + stderr=b"", + finished_at=datetime.now(UTC), + message=f"{type(error).__name__}: {error}"[:4096], + ) + try: + setup_owner._publish_terminal(attempt_dir, request, terminal) + except OSError: + pass + return 1 + finally: + if owned is not None: + owned.close() + + +def _wait_for_process( + owned: WindowsSetupProcess, + deadline_utc: datetime, + stop_path: Path, +) -> str: + remaining = max(0.0, (deadline_utc - datetime.now(UTC)).total_seconds()) + deadline = time.monotonic() + min(remaining, 5 * 60) + while True: + if stop_path.exists(): + owned.terminate() + return "cancelled" + wait_seconds = deadline - time.monotonic() + if wait_seconds <= 0: + owned.terminate() + return "timed_out" + result = owned._api.wait_process( + owned.process_handle, + min(100, max(1, int(wait_seconds * 1000))), + ) + if result == _WAIT_OBJECT_0: + return _wait_for_owned_job(owned, deadline_utc, stop_path) + if result != _WAIT_TIMEOUT: + raise ctypes.WinError(ctypes.get_last_error()) + + +def _wait_for_owned_job( + owned: WindowsSetupProcess, + deadline_utc: datetime, + stop_path: Path, +) -> str: + while True: + if stop_path.exists(): + owned.terminate() + return "cancelled" + if datetime.now(UTC) >= deadline_utc: + if not owned.empty(): + owned.terminate() + return "timed_out" + if owned.empty(): + return "exited" + time.sleep(0.02) + + +def _powershell_arguments(script_size: int, script_sha256: str) -> list[str]: + encoded = base64.b64encode( + powershell_bootstrap(script_size, script_sha256).encode("utf-16-le") + ).decode("ascii") + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encoded, + ] + + +def _system_powershell() -> str: + root = os.environ.get("SystemRoot", r"C:\Windows") + return str( + Path(root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + ) + + +def _kernel32(): + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] + kernel.SetInformationJobObject.restype = wintypes.BOOL + kernel.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel.QueryInformationJobObject.restype = wintypes.BOOL + kernel.InitializeProcThreadAttributeList.argtypes = [ + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel.InitializeProcThreadAttributeList.restype = wintypes.BOOL + kernel.UpdateProcThreadAttribute.argtypes = [ + wintypes.LPVOID, + wintypes.DWORD, + ctypes.c_size_t, + wintypes.LPVOID, + ctypes.c_size_t, + wintypes.LPVOID, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel.UpdateProcThreadAttribute.restype = wintypes.BOOL + kernel.DeleteProcThreadAttributeList.argtypes = [wintypes.LPVOID] + kernel.DeleteProcThreadAttributeList.restype = None + kernel.CreatePipe.argtypes = [ + ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(_SecurityAttributes), + wintypes.DWORD, + ] + kernel.CreatePipe.restype = wintypes.BOOL + kernel.SetHandleInformation.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ] + kernel.SetHandleInformation.restype = wintypes.BOOL + kernel.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + wintypes.LPVOID, + wintypes.LPVOID, + wintypes.BOOL, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.LPCWSTR, + wintypes.LPVOID, + ctypes.POINTER(_ProcessInformation), + ] + kernel.CreateProcessW.restype = wintypes.BOOL + kernel.ResumeThread.argtypes = [wintypes.HANDLE] + kernel.ResumeThread.restype = wintypes.DWORD + kernel.IsProcessInJob.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ctypes.POINTER(wintypes.BOOL), + ] + kernel.IsProcessInJob.restype = wintypes.BOOL + kernel.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel.TerminateJobObject.restype = wintypes.BOOL + kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel.WaitForSingleObject.restype = wintypes.DWORD + kernel.GetExitCodeProcess.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD), + ] + kernel.GetExitCodeProcess.restype = wintypes.BOOL + kernel.GetProcessTimes.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ] + kernel.GetProcessTimes.restype = wintypes.BOOL + kernel.ReadFile.argtypes = [ + wintypes.HANDLE, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + kernel.ReadFile.restype = wintypes.BOOL + kernel.WriteFile.argtypes = kernel.ReadFile.argtypes + kernel.WriteFile.restype = wintypes.BOOL + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.OpenJobObjectW.argtypes = [ + wintypes.DWORD, + wintypes.BOOL, + wintypes.LPCWSTR, + ] + kernel.OpenJobObjectW.restype = wintypes.HANDLE + kernel.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel.TerminateProcess.restype = wintypes.BOOL + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + return kernel + + +def main() -> int: + if os.name != "nt" or len(sys.argv) != 3 or sys.argv[1] != "--keeper": + return 2 + return run_keeper(Path(sys.argv[2])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index a34182d..a80d11b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,11 @@ from __future__ import annotations -import json import io +import json import os import subprocess import sys +from pathlib import Path from types import SimpleNamespace import pytest @@ -49,12 +50,16 @@ def test_capabilities_accepts_required_typed_call_error_reason(capsys) -> None: assert json.loads(capsys.readouterr().out)["status"] == "compatible" -def test_setup_owner_launch_reads_exact_raw_json_from_stdin(monkeypatch, capsys) -> None: +def test_setup_owner_launch_reads_exact_raw_json_from_stdin( + monkeypatch, capsys +) -> None: raw = b'{"schema_version":1,"attempt_id":"exact bytes"}\n' def fake_launch(raw_request: bytes): assert raw_request == raw - return SimpleNamespace(to_dict=lambda: {"schema_version": 1, "status": "accepted"}) + return SimpleNamespace( + to_dict=lambda: {"schema_version": 1, "status": "accepted"} + ) monkeypatch.setattr(cli, "launch_setup", fake_launch) monkeypatch.setattr(cli.sys, "stdin", SimpleNamespace(buffer=io.BytesIO(raw))) @@ -69,17 +74,30 @@ def test_setup_owner_stop_routes_complete_fence(monkeypatch, capsys) -> None: def fake_stop(attempt_id: str, **kwargs): captured["attempt_id"] = attempt_id captured.update(kwargs) - return SimpleNamespace(to_dict=lambda: {"schema_version": 1, "status": "terminal"}) + return SimpleNamespace( + to_dict=lambda: {"schema_version": 1, "status": "terminal"} + ) monkeypatch.setattr(cli, "stop_setup", fake_stop) attempt_id = "bbsa_" + "A" * 43 launch_id = "bbsl_" + "B" * 43 - assert cli.main([ - "setup-owner", "stop", "--attempt-id", attempt_id, - "--expect-request-sha256", "c" * 64, - "--expect-launch-id", launch_id, "--json", - ]) == 0 + assert ( + cli.main( + [ + "setup-owner", + "stop", + "--attempt-id", + attempt_id, + "--expect-request-sha256", + "c" * 64, + "--expect-launch-id", + launch_id, + "--json", + ] + ) + == 0 + ) assert captured == { "attempt_id": attempt_id, "expected_request_sha256": "c" * 64, @@ -96,17 +114,26 @@ def test_setup_owner_parser_rejects_generic_process_inputs() -> None: assert error.value.code == 2 -def test_windows_setup_owner_capability_fails_closed_off_windows(monkeypatch, capsys) -> None: +def test_windows_setup_owner_capability_fails_closed_off_windows( + monkeypatch, capsys +) -> None: monkeypatch.setattr(cli, "_platform_system", lambda: "Linux") - exit_code = cli.main([ - "capabilities", "--require", "blender-box-v1", - "--require-capability", "windows-setup-owner-v1", - ]) + exit_code = cli.main( + [ + "capabilities", + "--require", + "blender-box-v1", + "--require-capability", + "windows-setup-owner-v1", + ] + ) assert exit_code == 1 assert json.loads(capsys.readouterr().out)["status"] == "incompatible" -def test_windows_setup_owner_capability_runs_runtime_self_test(monkeypatch, capsys) -> None: +def test_windows_setup_owner_capability_runs_runtime_self_test( + monkeypatch, capsys +) -> None: calls = 0 def pass_self_test() -> None: @@ -115,13 +142,20 @@ def pass_self_test() -> None: monkeypatch.setattr(cli, "_platform_system", lambda: "Windows") monkeypatch.setattr(cli, "windows_setup_owner_self_test", pass_self_test) - exit_code = cli.main([ - "capabilities", "--require", "blender-box-v1", - "--require-capability", "windows-setup-owner-v1", - ]) + exit_code = cli.main( + [ + "capabilities", + "--require", + "blender-box-v1", + "--require-capability", + "windows-setup-owner-v1", + ] + ) assert exit_code == 0 assert calls == 1 - assert "windows-setup-owner-v1" in json.loads(capsys.readouterr().out)["capabilities"] + assert ( + "windows-setup-owner-v1" in json.loads(capsys.readouterr().out)["capabilities"] + ) def test_capabilities_rejects_unknown_contract() -> None: diff --git a/tests/test_setup_owner.py b/tests/test_setup_owner.py index 549cac9..141e846 100644 --- a/tests/test_setup_owner.py +++ b/tests/test_setup_owner.py @@ -2,6 +2,7 @@ import hashlib import json +import threading from datetime import UTC, datetime, timedelta from pathlib import Path @@ -21,7 +22,9 @@ def raw_launch(now: datetime) -> bytes: "schema_version": 1, "attempt_id": ATTEMPT_ID, "launch_id": LAUNCH_ID, - "deadline_utc": (now + timedelta(minutes=4)).isoformat().replace("+00:00", "Z"), + "deadline_utc": (now + timedelta(minutes=4)) + .isoformat() + .replace("+00:00", "Z"), "operation_revision": "windows-setup-owner-v1", "script": { "artifact_id": f"{ATTEMPT_ID}.ps1", @@ -64,17 +67,38 @@ def test_replay_requires_byte_identical_raw_json(tmp_path: Path) -> None: setup_owner.accept_launch_request(raw + b"\n", state_root=tmp_path, now=now) +def test_byte_identical_replay_remains_valid_after_deadline(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + raw = raw_launch(now) + first = setup_owner.accept_launch_request(raw, state_root=tmp_path, now=now) + replay = setup_owner.accept_launch_request( + raw, + state_root=tmp_path, + now=now + timedelta(hours=1), + ) + assert replay == first + + @pytest.mark.parametrize( ("mutate", "message"), [ (lambda value: value | {"schema_version": 2}, "schema_version"), + (lambda value: value | {"schema_version": True}, "schema_version"), (lambda value: value | {"attempt_id": "../escape"}, "Attempt ID"), (lambda value: value | {"launch_id": "bbsl_short"}, "Launch ID"), - (lambda value: value | {"operation_revision": "generic-command-v1"}, "operation_revision"), + ( + lambda value: value | {"operation_revision": "generic-command-v1"}, + "operation_revision", + ), (lambda value: value | {"unknown": True}, "unknown"), - (lambda value: value | {"deadline_utc": "2026-09-03T12:05:00.001Z"}, "five minutes"), ( - lambda value: value | {"script": value["script"] | {"artifact_id": "chosen.ps1"}}, + lambda value: value | {"deadline_utc": "2026-09-03T12:05:00.001Z"}, + "five minutes", + ), + ( + lambda value: ( + value | {"script": value["script"] | {"artifact_id": "chosen.ps1"}} + ), "artifact_id", ), ], @@ -90,9 +114,13 @@ def test_request_is_strictly_bounded(tmp_path: Path, mutate, message: str) -> No ) -def test_stop_before_dispatch_requires_full_fence_and_is_immutable(tmp_path: Path) -> None: +def test_stop_before_dispatch_requires_full_fence_and_is_immutable( + tmp_path: Path, +) -> None: now = datetime(2026, 9, 3, 12, tzinfo=UTC) - request = setup_owner.accept_launch_request(raw_launch(now), state_root=tmp_path, now=now) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) with pytest.raises(setup_owner.SetupOwnerError, match="request SHA-256"): setup_owner.stop_setup( ATTEMPT_ID, @@ -102,6 +130,18 @@ def test_stop_before_dispatch_requires_full_fence_and_is_immutable(tmp_path: Pat now=now, platform_name="Windows", ) + with pytest.raises(setup_owner.SetupOwnerError, match="Launch ID"): + setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id="bbsl_" + "C" * 43, + state_root=tmp_path, + now=now, + platform_name="Windows", + ) + assert not ( + tmp_path / "setup-attempts" / ATTEMPT_ID / "terminal.json" + ).exists() first = setup_owner.stop_setup( ATTEMPT_ID, expected_request_sha256=request.request_sha256, @@ -123,7 +163,9 @@ def test_stop_before_dispatch_requires_full_fence_and_is_immutable(tmp_path: Pat assert first.terminal.cleanup == "tree_gone" -def test_inflight_pre_receipt_stop_never_claims_tree_gone(tmp_path: Path, monkeypatch) -> None: +def test_inflight_pre_receipt_stop_never_claims_tree_gone( + tmp_path: Path, monkeypatch +) -> None: now = datetime(2026, 9, 3, 12, tzinfo=UTC) stage_script(tmp_path) view = setup_owner.launch_setup( @@ -148,7 +190,9 @@ def test_inflight_pre_receipt_stop_never_claims_tree_gone(tmp_path: Path, monkey def test_existing_terminal_prevents_late_dispatch(tmp_path: Path) -> None: now = datetime(2026, 9, 3, 12, tzinfo=UTC) - request = setup_owner.accept_launch_request(raw_launch(now), state_root=tmp_path, now=now) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) terminal = setup_owner.stop_setup( ATTEMPT_ID, expected_request_sha256=request.request_sha256, @@ -162,22 +206,535 @@ def test_existing_terminal_prevents_late_dispatch(tmp_path: Path) -> None: state_root=tmp_path, now=now, platform_name="Windows", - dispatch_keeper=lambda _directory: (_ for _ in ()).throw(AssertionError("dispatch")), + dispatch_keeper=lambda _directory: (_ for _ in ()).throw( + AssertionError("dispatch") + ), ) assert replay == terminal +def test_stop_wins_when_dispatch_reaches_the_keeper_late( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + stage_script(tmp_path) + dispatch_started = threading.Event() + release_dispatch = threading.Event() + launch_result: list[setup_owner.SetupView] = [] + from blendersessiond import windows_setup_process + + monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) + monkeypatch.setattr( + windows_setup_process.WindowsSetupProcess, + "create_suspended", + lambda **_values: (_ for _ in ()).throw(AssertionError("process started")), + ) + + def dispatch(directory: Path) -> None: + dispatch_started.set() + assert release_dispatch.wait(timeout=2) + windows_setup_process.run_keeper(directory) + + launch = threading.Thread( + target=lambda: launch_result.append( + setup_owner.launch_setup( + raw_launch(now), + state_root=tmp_path, + now=now, + platform_name="Windows", + dispatch_keeper=dispatch, + ) + ) + ) + launch.start() + assert dispatch_started.wait(timeout=2) + request = setup_owner._load_request( + tmp_path / "setup-attempts" / ATTEMPT_ID + ) + stopped = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + release_dispatch.set() + launch.join(timeout=2) + assert not launch.is_alive() + assert len(launch_result) == 1 + assert stopped.terminal.cleanup == "cleanup_unverified" + assert not ( + tmp_path / "setup-attempts" / ATTEMPT_ID / "launch-receipt.json" + ).exists() + + +def test_active_launch_replay_does_not_dispatch_twice(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + raw = raw_launch(now) + dispatches = 0 + + def dispatch(directory: Path) -> None: + nonlocal dispatches + dispatches += 1 + request = setup_owner._load_request(directory) + receipt = setup_owner.LaunchReceipt( + attempt_id=request.attempt_id, + launch_id=request.launch_id, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(request.launch_id), + owned_at=now, + ) + setup_owner._write_create_once( + directory / "launch-receipt.json", receipt.to_dict() + ) + + stage_script(tmp_path) + setup_owner.launch_setup( + raw, + state_root=tmp_path, + now=now, + platform_name="Windows", + dispatch_keeper=dispatch, + ) + replay = setup_owner.launch_setup( + raw, + state_root=tmp_path, + now=now + timedelta(hours=1), + platform_name="Windows", + dispatch_keeper=dispatch, + ) + assert replay.receipt is not None + assert dispatches == 1 + + +def test_launch_timeout_rechecks_a_concurrent_receipt( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + stage_script(tmp_path) + + def dispatch(directory: Path) -> None: + request = setup_owner._load_request(directory) + receipt = setup_owner.LaunchReceipt( + attempt_id=request.attempt_id, + launch_id=request.launch_id, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(request.launch_id), + owned_at=now, + ) + setup_owner._write_create_once( + directory / "launch-receipt.json", receipt.to_dict() + ) + + monkeypatch.setattr(setup_owner, "_spawn_keeper", dispatch) + monkeypatch.setattr(setup_owner, "_LAUNCH_WAIT_SECONDS", 0) + result = setup_owner.launch_setup( + raw_launch(now), + state_root=tmp_path, + now=now, + platform_name="Windows", + ) + assert result.receipt is not None + assert result.terminal is None + + def test_only_three_json_records_define_attempt_state(tmp_path: Path) -> None: now = datetime(2026, 9, 3, 12, tzinfo=UTC) stage_script(tmp_path) setup_owner.launch_setup( - raw_launch(now), state_root=tmp_path, now=now, platform_name="Windows", + raw_launch(now), + state_root=tmp_path, + now=now, + platform_name="Windows", dispatch_keeper=lambda _directory: None, ) - names = {path.name for path in (tmp_path / "setup-attempts" / ATTEMPT_ID).glob("*.json")} + names = { + path.name for path in (tmp_path / "setup-attempts" / ATTEMPT_ID).glob("*.json") + } assert names <= {"request.json", "launch-receipt.json", "terminal.json"} +def test_owner_loss_is_persisted_only_after_tree_and_job_are_absent( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + receipt = setup_owner.LaunchReceipt( + attempt_id=ATTEMPT_ID, + launch_id=LAUNCH_ID, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(LAUNCH_ID), + owned_at=now, + ) + setup_owner._write_create_once(directory / "launch-receipt.json", receipt.to_dict()) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) + monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: False) + + first = setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + second = setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert first == second + assert first.terminal.outcome == "owner_lost" + assert first.terminal.cleanup == "tree_gone" + + +def test_pid_reuse_is_safe(tmp_path: Path, monkeypatch) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + receipt = setup_owner.LaunchReceipt( + attempt_id=ATTEMPT_ID, + launch_id=LAUNCH_ID, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:old", + root_pid=11, + root_creation_time="windows:old-root", + job_name=setup_owner._job_name(LAUNCH_ID), + owned_at=now, + ) + setup_owner._write_create_once(directory / "launch-receipt.json", receipt.to_dict()) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) + monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: True) + monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) + from blendersessiond import windows_setup_process + + terminated: list[tuple[int, str]] = [] + monkeypatch.setattr( + windows_setup_process, + "terminate_exact_process", + lambda pid, identity: terminated.append((pid, identity)), + ) + result = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert terminated == [] + assert result.terminal.cleanup == "cleanup_unverified" + + +def test_owner_loss_job_query_error_is_cleanup_unverified( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + receipt = setup_owner.LaunchReceipt( + attempt_id=ATTEMPT_ID, + launch_id=LAUNCH_ID, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(LAUNCH_ID), + owned_at=now, + ) + setup_owner._write_create_once(directory / "launch-receipt.json", receipt.to_dict()) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: False) + monkeypatch.setattr(setup_owner, "_OWNER_LOSS_WAIT_SECONDS", 0) + monkeypatch.setattr( + setup_owner, + "_job_exists", + lambda _name: (_ for _ in ()).throw(OSError("query failed")), + ) + result = setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert result.terminal.outcome == "cleanup_unverified" + assert result.terminal.cleanup == "cleanup_unverified" + + +def test_keeper_loss_reconciles_while_the_root_still_exists( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + receipt = setup_owner.LaunchReceipt( + attempt_id=ATTEMPT_ID, + launch_id=LAUNCH_ID, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(LAUNCH_ID), + owned_at=now, + ) + setup_owner._write_create_once(directory / "launch-receipt.json", receipt.to_dict()) + monkeypatch.setattr( + setup_owner, + "process_matches", + lambda pid, _identity: pid == receipt.root_pid, + ) + monkeypatch.setattr(setup_owner, "_OWNER_LOSS_WAIT_SECONDS", 0) + monkeypatch.setattr(setup_owner, "_job_exists", lambda _name: True) + result = setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert result.terminal.outcome == "cleanup_unverified" + assert result.terminal.cleanup == "cleanup_unverified" + + +def test_keeper_termination_error_is_cleanup_unverified( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + receipt = setup_owner.LaunchReceipt( + attempt_id=ATTEMPT_ID, + launch_id=LAUNCH_ID, + request_sha256=request.request_sha256, + keeper_pid=10, + keeper_creation_time="windows:10", + root_pid=11, + root_creation_time="windows:11", + job_name=setup_owner._job_name(LAUNCH_ID), + owned_at=now, + ) + setup_owner._write_create_once(directory / "launch-receipt.json", receipt.to_dict()) + monkeypatch.setattr(setup_owner, "process_matches", lambda *_args: True) + monkeypatch.setattr(setup_owner, "_STOP_WAIT_SECONDS", 0) + from blendersessiond import windows_setup_process + + monkeypatch.setattr( + windows_setup_process, + "terminate_exact_process", + lambda *_args: (_ for _ in ()).throw(OSError("terminate failed")), + ) + result = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert result.terminal.outcome == "cleanup_unverified" + assert result.terminal.cleanup == "cleanup_unverified" + + +def test_staged_script_read_is_bounded_to_the_declared_size( + tmp_path: Path, monkeypatch +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + path = stage_script(tmp_path) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + real_open = Path.open + read_sizes: list[int] = [] + + class Stream: + def __init__(self, wrapped) -> None: + self.wrapped = wrapped + + def __enter__(self): + self.wrapped.__enter__() + return self + + def __exit__(self, *args) -> None: + self.wrapped.__exit__(*args) + + def fileno(self) -> int: + return self.wrapped.fileno() + + def read(self, size: int) -> bytes: + read_sizes.append(size) + return self.wrapped.read(size) + + def open_spy(target: Path, *args, **kwargs): + opened = real_open(target, *args, **kwargs) + return Stream(opened) if target == path else opened + + monkeypatch.setattr(Path, "open", open_spy) + assert setup_owner.read_staged_script(tmp_path, request) == SCRIPT + assert read_sizes == [len(SCRIPT) + 1] + + +@pytest.mark.parametrize( + "mutate", + [ + lambda payload: payload["script"].__setitem__( + "size", setup_owner.MAX_SCRIPT_BYTES + 1 + ), + lambda payload: payload["script"].__setitem__("size", True), + lambda payload: payload.__setitem__( + "operation_revision", "generic-command-v1" + ), + lambda payload: payload["script"].__setitem__( + "artifact_id", "replacement.ps1" + ), + ], +) +def test_recovery_revalidates_request_invariants( + tmp_path: Path, mutate +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + setup_owner.accept_launch_request(raw_launch(now), state_root=tmp_path, now=now) + path = tmp_path / "setup-attempts" / ATTEMPT_ID / "request.json" + payload = json.loads(path.read_text()) + mutate(payload) + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(setup_owner.SetupOwnerError, match="request record"): + setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=payload["request_sha256"], + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("schema_version", True), + ("exit_code", False), + ("stdout_truncated", 0), + ("stderr_truncated", 0), + ("outcome", "looks_successful"), + ("process", "unknown"), + ("cleanup", "probably_gone"), + ], +) +def test_recovery_rejects_malformed_terminal_fields( + tmp_path: Path, field: str, value +) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + result = setup_owner.stop_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + now=now, + platform_name="Windows", + ) + path = tmp_path / "setup-attempts" / ATTEMPT_ID / "terminal.json" + payload = result.terminal.to_dict() + payload[field] = value + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(setup_owner.SetupOwnerError, match="record"): + setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + + +def test_cancelled_terminal_round_trips_through_recovery(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="cancelled", + process="cancelled", + cleanup="tree_gone", + stdout=b"", + stderr=b"", + finished_at=now, + ) + recovered = setup_owner._terminal_from_record(terminal.to_dict(), request) + assert recovered == terminal + + +def test_recovery_rejects_inconsistent_success_state(tmp_path: Path) -> None: + now = datetime(2026, 9, 3, 12, tzinfo=UTC) + request = setup_owner.accept_launch_request( + raw_launch(now), state_root=tmp_path, now=now + ) + terminal = setup_owner.SetupTerminal.owned( + request=request, + outcome="process_succeeded", + process="cancelled", + cleanup="tree_gone", + exit_code=0, + stdout=b"", + stderr=b"", + finished_at=now, + ) + with pytest.raises(setup_owner.SetupOwnerError, match="terminal record"): + setup_owner._terminal_from_record(terminal.to_dict(), request) + + +def test_status_terminalizes_an_expired_dispatch_without_a_receipt( + tmp_path: Path, +) -> None: + accepted_at = datetime.now(UTC) - timedelta(minutes=10) + raw = raw_launch(accepted_at) + request = setup_owner.accept_launch_request( + raw, + state_root=tmp_path, + now=accepted_at, + ) + directory = tmp_path / "setup-attempts" / ATTEMPT_ID + (directory / ".dispatch").touch() + result = setup_owner.status_setup( + ATTEMPT_ID, + expected_request_sha256=request.request_sha256, + expected_launch_id=LAUNCH_ID, + state_root=tmp_path, + platform_name="Windows", + ) + assert result.terminal.outcome == "launch_failed" + assert result.terminal.cleanup == "cleanup_unverified" + + def test_non_windows_fails_closed(tmp_path: Path) -> None: with pytest.raises(setup_owner.SetupOwnerError, match="Windows"): - setup_owner.status_setup(ATTEMPT_ID, state_root=tmp_path, platform_name="Darwin") + setup_owner.status_setup( + ATTEMPT_ID, state_root=tmp_path, platform_name="Darwin" + ) diff --git a/tests/test_windows_setup_process.py b/tests/test_windows_setup_process.py index c929932..3951fd2 100644 --- a/tests/test_windows_setup_process.py +++ b/tests/test_windows_setup_process.py @@ -1,8 +1,16 @@ from __future__ import annotations +import hashlib +import json +import os +import threading +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta from pathlib import Path -from blendersessiond import windows_setup_process +import pytest + +from blendersessiond import setup_owner, windows_setup_process class FakeWin32: @@ -18,7 +26,11 @@ def set_kill_on_close(self, job) -> None: def create_standard_pipes(self): self.calls.append("create_standard_pipes") - return ("child-in", "child-out", "child-err"), ("parent-in", "parent-out", "parent-err") + return ("child-in", "child-out", "child-err"), ( + "parent-in", + "parent-out", + "parent-err", + ) def create_process(self, **kwargs): self.calls.append(("create_process", kwargs)) @@ -35,8 +47,17 @@ def is_process_in_job(self, process, job) -> bool: self.calls.append(("is_process_in_job", process, job)) return True + def terminate_process(self, process) -> None: + self.calls.append(("terminate_process", process)) + + def wait_process(self, process, milliseconds: int) -> int: + self.calls.append(("wait_process", process, milliseconds)) + return windows_setup_process._WAIT_OBJECT_0 -def test_process_is_suspended_and_atomically_assigned_before_receipt(tmp_path: Path) -> None: + +def test_process_is_suspended_and_atomically_assigned_before_receipt( + tmp_path: Path, +) -> None: api = FakeWin32() owned = windows_setup_process.WindowsSetupProcess.create_suspended( api=api, @@ -44,8 +65,13 @@ def test_process_is_suspended_and_atomically_assigned_before_receipt(tmp_path: P job_name="Local\\BlenderSessiond.Setup.bbsl_test", ) assert [call if isinstance(call, str) else call[0] for call in api.calls] == [ - "create_job", "set_kill_on_close", "create_standard_pipes", "create_process", - "close_handles", "process_creation_filetime", "is_process_in_job", + "create_job", + "set_kill_on_close", + "create_standard_pipes", + "create_process", + "close_handles", + "process_creation_filetime", + "is_process_in_job", ] create = api.calls[3][1] assert create["creation_flags"] & windows_setup_process.CREATE_SUSPENDED @@ -58,10 +84,37 @@ def test_process_is_suspended_and_atomically_assigned_before_receipt(tmp_path: P assert owned.root_creation_filetime == 456 +def test_process_creation_rejects_a_child_outside_the_job(tmp_path: Path) -> None: + api = FakeWin32() + api.is_process_in_job = lambda _process, _job: False + with pytest.raises(RuntimeError, match="not inside"): + windows_setup_process.WindowsSetupProcess.create_suspended( + api=api, + powershell_path=tmp_path / "powershell.exe", + job_name="Local\\BlenderSessiond.Setup.bbsl_test", + ) + closed = [ + call[1] + for call in api.calls + if isinstance(call, tuple) and call[0] == "close_handles" + ] + assert ("thread", "process", "parent-in", "parent-out", "parent-err") in closed + assert ("job",) in closed + assert ("terminate_process", "process") in api.calls + assert ("wait_process", "process", 5000) in api.calls + + def test_receipt_flush_precedes_resume() -> None: events: list[str] = [] owned = object.__new__(windows_setup_process.WindowsSetupProcess) - owned._api = type("API", (), {"resume_thread": lambda _self, _thread: events.append("resume")})() + owned._api = type( + "API", + (), + { + "resume_thread": lambda _self, _thread: events.append("resume"), + "close_handles": lambda _self, _handles: None, + }, + )() owned.thread_handle = "thread" owned.publish_then_resume(lambda: events.append("receipt_flushed")) assert events == ["receipt_flushed", "resume"] @@ -70,8 +123,292 @@ def test_receipt_flush_precedes_resume() -> None: def test_bootstrap_reads_exact_bytes_and_decodes_strict_utf8() -> None: bootstrap = windows_setup_process.powershell_bootstrap(27, "a" * 64) assert "OpenStandardInput" in bootstrap - assert "UTF8Encoding($false, $true)" in bootstrap + assert "UTF8Encoding]::new($false, $true)" in bootstrap assert "SHA256" in bootstrap assert "ReadByte() -ne -1" in bootstrap assert "27" in bootstrap assert "a" * 64 in bootstrap + + +def test_stdin_writer_keeps_the_owned_handle_when_the_field_is_cleared( + monkeypatch, +) -> None: + writes: list[tuple[object, bytes]] = [] + + class API: + def write(self, handle: object, content: bytes) -> None: + writes.append((handle, content)) + + def close_handles(self, _handles: object) -> None: + return None + + def read(self, _handle: object) -> None: + return None + + owned = windows_setup_process.WindowsSetupProcess( + _api=API(), + job_handle="job", + process_handle="process", + thread_handle="thread", + root_pid=123, + root_creation_filetime=456, + parent_stdin="parent-in", + parent_stdout="parent-out", + parent_stderr="parent-err", + ) + + class ImmediateThread: + def __init__(self, *, target, args=(), daemon=False) -> None: + self.target = target + self.args = args + + def start(self) -> None: + owned.parent_stdin = 0 + self.target(*self.args) + + def join(self, timeout=None) -> None: + return None + + def is_alive(self) -> bool: + return False + + monkeypatch.setattr(windows_setup_process.threading, "Thread", ImmediateThread) + output: dict[str, tuple[bytes, bool]] = {} + threads: list[threading.Thread] = [] + owned.start_io(b"script", output, threads) + assert writes == [("parent-in", b"script")] + + +@pytest.mark.skipif(os.name != "nt", reason="requires Windows Job Objects") +def test_concrete_windows_job_list_runtime_self_test() -> None: + windows_setup_process.runtime_self_test() + + +def _attempt(tmp_path: Path, deadline: datetime) -> Path: + attempt_id = "bbsa_" + "A" * 43 + launch_id = "bbsl_" + "B" * 43 + script = b"Write-Output 'setup'\n" + raw = json.dumps( + { + "schema_version": 1, + "attempt_id": attempt_id, + "launch_id": launch_id, + "deadline_utc": deadline.isoformat().replace("+00:00", "Z"), + "operation_revision": "windows-setup-owner-v1", + "script": { + "artifact_id": f"{attempt_id}.ps1", + "size": len(script), + "sha256": hashlib.sha256(script).hexdigest(), + }, + }, + separators=(",", ":"), + ).encode() + setup_owner.accept_launch_request( + raw, + state_root=tmp_path, + now=deadline - timedelta(minutes=1), + ) + path = setup_owner.script_path_for_attempt(tmp_path, attempt_id) + path.write_bytes(script) + return path.parent + + +class FakeOwnedProcess: + root_pid = 123 + root_creation_filetime = 456 + + def __init__(self, *, drain_error: bool = False) -> None: + self.drain_error = drain_error + self.resumed = False + self.terminated = False + self.closed = False + self.io_started = False + + def start_io(self, _script, _output, _threads) -> None: + self.io_started = True + + def resume(self) -> None: + self.resumed = True + + def terminate(self) -> None: + self.terminated = True + + def wait_empty(self) -> None: + if self.drain_error: + raise RuntimeError("did not drain") + + def close(self) -> None: + self.closed = True + + def finish_io(self, _threads) -> None: + return None + + +def test_keeper_does_not_resume_after_the_absolute_deadline( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime(2026, 9, 3, 12, tzinfo=UTC) + directory = _attempt(tmp_path, deadline) + owned = FakeOwnedProcess() + + class Clock: + @classmethod + def now(cls, _tz=None) -> datetime: + return deadline + timedelta(seconds=1) + + monkeypatch.setattr(windows_setup_process, "datetime", Clock) + monkeypatch.setattr( + windows_setup_process.WindowsSetupProcess, + "create_suspended", + lambda **_values: owned, + ) + monkeypatch.setattr( + windows_setup_process, "process_start_time", lambda _pid: "windows:keeper" + ) + assert windows_setup_process.run_keeper(directory) == 1 + terminal = setup_owner._read_optional_record(directory / "terminal.json") + assert terminal["outcome"] == "timed_out" + assert terminal["cleanup"] == "tree_gone" + assert owned.terminated + assert owned.io_started + assert not owned.resumed + + +def test_keeper_persists_unverified_cleanup_when_job_drain_times_out( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime.now(UTC) + timedelta(minutes=1) + directory = _attempt(tmp_path, deadline) + owned = FakeOwnedProcess(drain_error=True) + monkeypatch.setattr( + windows_setup_process.WindowsSetupProcess, + "create_suspended", + lambda **_values: owned, + ) + monkeypatch.setattr( + windows_setup_process, "process_start_time", lambda _pid: "windows:keeper" + ) + monkeypatch.setattr( + windows_setup_process, + "_wait_for_process", + lambda *_args: (_ for _ in ()).throw(OSError("wait failed")), + ) + assert windows_setup_process.run_keeper(directory) == 1 + terminal = setup_owner._read_optional_record(directory / "terminal.json") + assert terminal["outcome"] == "process_failed" + assert terminal["cleanup"] == "cleanup_unverified" + assert owned.closed + + +def test_keeper_resumes_while_the_attempt_lock_is_held( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime.now(UTC) + timedelta(minutes=1) + directory = _attempt(tmp_path, deadline) + attempt_lock = threading.Lock() + lock_state_at_resume: list[bool] = [] + owned = FakeOwnedProcess() + + @contextmanager + def tracked_lock(_path): + with attempt_lock: + yield + + def resume() -> None: + lock_state_at_resume.append(attempt_lock.locked()) + raise OSError("stop after observation") + + owned.resume = resume + monkeypatch.setattr(windows_setup_process, "file_lock", tracked_lock) + monkeypatch.setattr( + windows_setup_process.WindowsSetupProcess, + "create_suspended", + lambda **_values: owned, + ) + monkeypatch.setattr( + windows_setup_process, "process_start_time", lambda _pid: "windows:keeper" + ) + assert windows_setup_process.run_keeper(directory) == 1 + assert lock_state_at_resume == [True] + + +def test_active_descendants_must_finish_before_success( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime.now(UTC) + timedelta(minutes=1) + states = iter([False, True]) + terminated: list[bool] = [] + owned = type( + "Owned", + (), + { + "empty": lambda _self: next(states), + "terminate": lambda _self: terminated.append(True), + }, + )() + monkeypatch.setattr(windows_setup_process.time, "sleep", lambda _seconds: None) + outcome = windows_setup_process._wait_for_owned_job( + owned, + deadline, + tmp_path / ".stop", + ) + assert outcome == "exited" + assert terminated == [] + + +def test_active_descendants_are_not_success_after_deadline( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime(2026, 9, 3, 12, tzinfo=UTC) + terminated: list[bool] = [] + owned = type( + "Owned", + (), + { + "empty": lambda _self: False, + "terminate": lambda _self: terminated.append(True), + }, + )() + + class Clock: + @classmethod + def now(cls, _tz=None) -> datetime: + return deadline + timedelta(seconds=1) + + monkeypatch.setattr(windows_setup_process, "datetime", Clock) + outcome = windows_setup_process._wait_for_owned_job( + owned, + deadline, + tmp_path / ".stop", + ) + assert outcome == "timed_out" + assert terminated == [True] + + +def test_empty_job_after_deadline_is_still_timed_out( + tmp_path: Path, monkeypatch +) -> None: + deadline = datetime(2026, 9, 3, 12, tzinfo=UTC) + terminated: list[bool] = [] + owned = type( + "Owned", + (), + { + "empty": lambda _self: True, + "terminate": lambda _self: terminated.append(True), + }, + )() + + class Clock: + @classmethod + def now(cls, _tz=None) -> datetime: + return deadline + timedelta(microseconds=1) + + monkeypatch.setattr(windows_setup_process, "datetime", Clock) + outcome = windows_setup_process._wait_for_owned_job( + owned, + deadline, + tmp_path / ".stop", + ) + assert outcome == "timed_out" + assert terminated == [] From 6acdf88603838105c239fb1b7d279738040754e3 Mon Sep 17 00:00:00 2001 From: Bram Van Rompuy Date: Thu, 3 Sep 2026 19:08:21 +0200 Subject: [PATCH 4/4] fix(setup-owner): resolve trusted PowerShell path --- src/blendersessiond/windows_setup_process.py | 22 +++++++++++++++----- tests/test_windows_setup_process.py | 15 +++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/blendersessiond/windows_setup_process.py b/src/blendersessiond/windows_setup_process.py index 27f6d8b..16bf92e 100644 --- a/src/blendersessiond/windows_setup_process.py +++ b/src/blendersessiond/windows_setup_process.py @@ -813,17 +813,29 @@ def _powershell_arguments(script_size: int, script_sha256: str) -> list[str]: ] -def _system_powershell() -> str: - root = os.environ.get("SystemRoot", r"C:\Windows") - return str( - Path(root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" - ) +def _system_powershell(kernel: Any | None = None) -> str: + native = _kernel32() if kernel is None else kernel + size = 260 + while size <= 32768: + buffer = ctypes.create_unicode_buffer(size) + length = native.GetSystemDirectoryW(buffer, size) + if length == 0: + raise ctypes.WinError(ctypes.get_last_error()) + if length < size: + root = buffer.value + break + size = length + 1 + else: + raise RuntimeError("Windows system directory exceeds its supported bound.") + return str(Path(root) / "WindowsPowerShell" / "v1.0" / "powershell.exe") def _kernel32(): kernel = ctypes.WinDLL("kernel32", use_last_error=True) kernel.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.GetSystemDirectoryW.argtypes = [wintypes.LPWSTR, wintypes.UINT] + kernel.GetSystemDirectoryW.restype = wintypes.UINT kernel.SetInformationJobObject.argtypes = [ wintypes.HANDLE, ctypes.c_int, diff --git a/tests/test_windows_setup_process.py b/tests/test_windows_setup_process.py index 3951fd2..53fdedc 100644 --- a/tests/test_windows_setup_process.py +++ b/tests/test_windows_setup_process.py @@ -130,6 +130,21 @@ def test_bootstrap_reads_exact_bytes_and_decodes_strict_utf8() -> None: assert "a" * 64 in bootstrap +def test_system_powershell_ignores_inherited_system_root(monkeypatch) -> None: + trusted = r"C:\Windows\System32" + + class Kernel: + def GetSystemDirectoryW(self, buffer, size: int) -> int: + assert size > len(trusted) + buffer.value = trusted + return len(trusted) + + monkeypatch.setenv("SystemRoot", r"C:\attacker") + assert windows_setup_process._system_powershell(Kernel()) == str( + Path(trusted) / "WindowsPowerShell" / "v1.0" / "powershell.exe" + ) + + def test_stdin_writer_keeps_the_owned_handle_when_the_field_is_cleared( monkeypatch, ) -> None: