Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Added

- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser PID to its Linux `/proc/<pid>/stat` start-time identity and fails closed unless that exact root process terminates after session/driver shutdown; PID reuse counts only as termination of the original identity, and this does not yet prove termination of every Chromium descendant or process ownership outside the controlled runner.
- Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery.
- Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile.
- Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules.
Expand Down
120 changes: 119 additions & 1 deletion scripts/ci/run_mv3_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import http.client
import http.server
import json
import math
import os
import pathlib
import socket
Expand All @@ -42,8 +43,10 @@
REQUEST_TIMEOUT_SECONDS = 5.0
STARTUP_TIMEOUT_SECONDS = 20.0
FIXTURE_TIMEOUT_SECONDS = 20.0
PROCESS_EXIT_TIMEOUT_SECONDS = 5.0
MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576
MAX_PROC_STATUS_CHARACTERS = 65_536
MAX_PROC_STAT_CHARACTERS = 65_536
MAX_BROWSER_PROCESS_TREE_SIZE = 256
MAX_PROC_PROCESS_SCAN_SIZE = 32_768
MAX_SEMANTIC_LOCATOR_CANDIDATES = 128
Expand Down Expand Up @@ -405,6 +408,100 @@ def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, in
return process_id, parent_process_id


def _parse_linux_proc_stat_process_identity(stat_text: str) -> tuple[int, int]:
"""Parse one Linux proc-stat PID/start-time identity without trusting ``comm`` text."""

if not isinstance(stat_text, str) or not stat_text:
raise ValueError("Linux proc stat must be non-empty text")
command_open = stat_text.find(" (")
command_close = stat_text.rfind(") ")
if command_open <= 0 or command_close <= command_open + 2:
raise ValueError("malformed Linux proc stat process identity")

raw_process_id = stat_text[:command_open]
if not raw_process_id.isascii() or not raw_process_id.isdigit():
raise ValueError("malformed Linux proc stat process identifier")
process_id = int(raw_process_id, 10)
if process_id <= 0:
raise ValueError("Linux proc stat process identifier must be positive")

command_text = stat_text[command_open + 2 : command_close]
if not command_text:
raise ValueError("Linux proc stat command must not be empty")
suffix_fields = stat_text[command_close + 2 :].split()
if len(suffix_fields) < 20 or len(suffix_fields[0]) != 1:
raise ValueError("Linux proc stat does not contain field 22 start time")
for raw_field in suffix_fields[1:]:
unsigned_field = raw_field[1:] if raw_field[:1] in {"+", "-"} else raw_field
if not unsigned_field or not unsigned_field.isascii() or not unsigned_field.isdigit():
raise ValueError("malformed Linux proc stat numeric field")

raw_start_time_ticks = suffix_fields[19]
if not raw_start_time_ticks.isascii() or not raw_start_time_ticks.isdigit():
raise ValueError("malformed Linux proc stat start time")
start_time_ticks = int(raw_start_time_ticks, 10)
if start_time_ticks <= 0:
raise ValueError("Linux proc stat start time must be positive")
if start_time_ticks > MAX_U64:
raise OverflowError("Linux proc stat start time exceeds u64 range")
return process_id, start_time_ticks


def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | None:
"""Read one bounded Linux PID/start-time identity, returning absence after exit."""

if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0:
raise ValueError("invalid Linux process identifier")
stat_path = pathlib.Path("/proc") / str(process_id) / "stat"
try:
with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file:
stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1)
except FileNotFoundError:
return None
if len(stat_text) > MAX_PROC_STAT_CHARACTERS:
raise RuntimeError("Linux proc stat exceeded the bounded text limit")
identity = _parse_linux_proc_stat_process_identity(stat_text)
if identity[0] != process_id:
raise RuntimeError("Linux proc stat identity did not match its directory")
return identity


def _wait_for_linux_process_identity_exit(
process_id: int,
start_time_ticks: int,
*,
timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS,
) -> bool:
"""Wait boundedly until the exact PID/start-time identity exits or is reused."""

if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0:
raise ValueError("invalid Linux process identifier")
if (
isinstance(start_time_ticks, bool)
or not isinstance(start_time_ticks, int)
or start_time_ticks <= 0
):
raise ValueError("invalid Linux process start time")
if (
isinstance(timeout_seconds, bool)
or not isinstance(timeout_seconds, (int, float))
or timeout_seconds < 0
or not math.isfinite(timeout_seconds)
):
raise ValueError("invalid Linux process-exit timeout")

deadline = time.monotonic() + float(timeout_seconds)
expected_identity = (process_id, start_time_ticks)
while True:
current_identity = _read_linux_proc_stat_process_identity(process_id)
if current_identity is None or current_identity != expected_identity:
return True
remaining_seconds = deadline - time.monotonic()
if remaining_seconds <= 0:
return False
time.sleep(min(0.05, remaining_seconds))


def _sample_linux_process_rss_bytes(process_id: int) -> int:
"""Read one attributed Linux process RSS through a bounded ``/proc`` status file."""

Expand Down Expand Up @@ -818,6 +915,9 @@ def _run_agent_task_browser_pass(
started = time.monotonic()
driver_port = _free_loopback_port()
session_id: str | None = None
browser_process_id: int | None = None
browser_process_start_time_ticks: int | None = None
result: dict[str, Any] | None = None
driver = subprocess.Popen(
[str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"],
stdout=subprocess.DEVNULL,
Expand Down Expand Up @@ -878,6 +978,10 @@ def _run_agent_task_browser_pass(
or browser_process_id <= 0
):
raise RuntimeError("ChromeDriver did not return a valid browser process id")
browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id)
if browser_process_identity is None:
raise RuntimeError("Agent Task browser process identity disappeared after launch")
browser_process_start_time_ticks = browser_process_identity[1]

_json_request(
driver_port,
Expand Down Expand Up @@ -1003,7 +1107,7 @@ def _run_agent_task_browser_pass(
task_duration_ms = round((time.monotonic() - started) * 1000, 3)
if task_duration_ms <= 0:
raise RuntimeError("Agent Task measured a non-positive task duration")
return {
result = {
"browser_version": browser_version,
"post_condition": True,
"input_echo_verified": True,
Expand Down Expand Up @@ -1042,6 +1146,18 @@ def _run_agent_task_browser_pass(
driver.kill()
driver.wait(timeout=5)

if result is None:
raise RuntimeError("Agent Task browser pass returned no result after shutdown")
if browser_process_id is None or browser_process_start_time_ticks is None:
raise RuntimeError("Agent Task browser process identity was not captured")
if not _wait_for_linux_process_identity_exit(
browser_process_id,
browser_process_start_time_ticks,
):
raise RuntimeError("Agent Task browser process did not terminate")
result["browser_process_terminated"] = True
return result


def _run_agent_task_trial(
chrome_bin: pathlib.Path,
Expand Down Expand Up @@ -1104,6 +1220,7 @@ def _run_agent_task_trial(
"saved_credential_services_disabled"
],
"browser_process_rss_bytes": result["browser_process_rss_bytes"],
"browser_process_terminated": result["browser_process_terminated"],
"chromium_process_count": result["chromium_process_count"],
"chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"],
"semantic_observation_bytes": result["semantic_observation_bytes"],
Expand Down Expand Up @@ -1530,6 +1647,7 @@ def main() -> int:
and trial["structured_value_sha256"].startswith("sha256:")
and trial.get("extensions_disabled") is True
and trial.get("profile_cleaned") is True
and trial.get("browser_process_terminated") is True
and isinstance(trial.get("browser_process_rss_bytes"), int)
and trial["browser_process_rss_bytes"] > 0
and isinstance(trial.get("chromium_process_count"), int)
Expand Down
117 changes: 117 additions & 0 deletions tests/test_agent_task_process_termination_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Contract for proving the controlled Agent Task browser process terminates."""

from __future__ import annotations

import pathlib
import runpy
import unittest

ROOT = pathlib.Path(__file__).resolve().parents[1]
RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py"


def _proc_stat(process_id: int, command: str, start_time_ticks: int) -> str:
"""Build the bounded `/proc/<pid>/stat` prefix through field 22."""

fields_three_through_twenty_one = ["S", *[str(value) for value in range(4, 22)]]
return (
f"{process_id} ({command}) "
+ " ".join(fields_three_through_twenty_one)
+ f" {start_time_ticks}\n"
)


class AgentTaskProcessTerminationContractTests(unittest.TestCase):
"""Keep process-cleanup evidence PID-reuse-safe and fail closed."""

def test_runner_exposes_bounded_process_identity_and_exit_helpers(self) -> None:
"""The runner needs a Linux process identity boundary, not PID-only polling."""

namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_termination")
for expected in (
"MAX_PROC_STAT_CHARACTERS",
"PROCESS_EXIT_TIMEOUT_SECONDS",
"_parse_linux_proc_stat_process_identity",
"_read_linux_proc_stat_process_identity",
"_wait_for_linux_process_identity_exit",
):
with self.subTest(expected=expected):
self.assertIn(expected, namespace)

def test_proc_stat_identity_parser_handles_command_text_and_rejects_ambiguity(self) -> None:
"""PID reuse proof must bind a positive PID to the exact Linux start-time field."""

namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_stat_parser")
parser = namespace["_parse_linux_proc_stat_process_identity"]

self.assertEqual(parser(_proc_stat(321, "chrome worker", 987654)), (321, 987654))
self.assertEqual(parser(_proc_stat(322, "chrome ) helper", 987655)), (322, 987655))

for malformed in (
"",
"321 chrome S 1 2 3\n",
_proc_stat(0, "chrome", 10),
_proc_stat(321, "chrome", 0),
_proc_stat(321, "chrome", -1),
"321 (chrome) S 1 2 3\n",
"not-a-pid (chrome) S " + " ".join(["1"] * 20) + "\n",
"321 (chrome) S " + " ".join(["1"] * 19) + " not-a-time\n",
):
with self.subTest(malformed=malformed):
with self.assertRaises(ValueError):
parser(malformed)

def test_process_exit_waiter_distinguishes_exit_pid_reuse_and_live_identity(self) -> None:
"""A reused PID is not the original browser process and a live identity must fail closed."""

namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_exit_waiter")
waiter = namespace["_wait_for_linux_process_identity_exit"]
original_reader = waiter.__globals__["_read_linux_proc_stat_process_identity"]
try:
waiter.__globals__["_read_linux_proc_stat_process_identity"] = (
lambda _process_id: None
)
self.assertTrue(waiter(321, 987654, timeout_seconds=0.0))

waiter.__globals__["_read_linux_proc_stat_process_identity"] = (
lambda process_id: (process_id, 987655)
)
self.assertTrue(waiter(321, 987654, timeout_seconds=0.0))

waiter.__globals__["_read_linux_proc_stat_process_identity"] = (
lambda process_id: (process_id, 987654)
)
self.assertFalse(waiter(321, 987654, timeout_seconds=0.0))
finally:
waiter.__globals__["_read_linux_proc_stat_process_identity"] = original_reader

for process_id, start_time_ticks, timeout_seconds in (
(0, 987654, 0.0),
(321, 0, 0.0),
(321, 987654, -0.1),
):
with self.subTest(
process_id=process_id,
start_time_ticks=start_time_ticks,
timeout_seconds=timeout_seconds,
):
with self.assertRaises(ValueError):
waiter(process_id, start_time_ticks, timeout_seconds=timeout_seconds)

def test_successful_agent_task_requires_post_shutdown_process_termination_evidence(self) -> None:
"""A successful task must not be accepted while its original browser root is still live."""

runner = RUNNER.read_text(encoding="utf-8")
for expected in (
"browser_process_start_time_ticks",
'"browser_process_terminated"',
'result["browser_process_terminated"]',
'trial.get("browser_process_terminated") is True',
"Agent Task browser process did not terminate",
):
with self.subTest(expected=expected):
self.assertIn(expected, runner)


if __name__ == "__main__":
unittest.main()
Loading