Skip to content
Merged
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 @@ -39,6 +39,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Changed

- Controlled browser-crash compatibility evidence now credits a crash only after the exact PID/start-time identity is signalled through a revalidated Linux pidfd and that same pidfd becomes readable within the bounded deadline; generic WebDriver transport failures no longer substitute for process-termination proof, while sampled Chromium process-set teardown remains a separate recovery boundary.
- Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result.
- Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port.
- Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream.
Expand Down
90 changes: 71 additions & 19 deletions scripts/ci/run_mv3_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import math
import os
import pathlib
import select
import signal
import socket
import string
Expand Down Expand Up @@ -513,6 +514,68 @@ def _signal_linux_process_identity(
os.close(pidfd)


def _signal_and_wait_for_linux_process_identity_termination(
process_identity: tuple[int, int],
signal_number: int,
*,
timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS,
) -> bool:
"""Signal one exact Linux identity and await termination on that same pidfd."""

if not isinstance(process_identity, tuple) or len(process_identity) != 2:
raise ValueError("invalid Linux process identity")
process_id, start_time_ticks = process_identity
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(signal_number, bool)
or not isinstance(signal_number, int)
or signal_number <= 0
):
raise ValueError("invalid Linux process signal")
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 termination timeout")

expected_identity = (process_id, start_time_ticks)
if _read_linux_proc_stat_process_identity(process_id) != expected_identity:
return False
pidfd_open = getattr(os, "pidfd_open", None)
pidfd_send_signal = getattr(signal, "pidfd_send_signal", None)
if not callable(pidfd_open) or not callable(pidfd_send_signal):
raise RuntimeError("Linux pidfd signalling is unavailable")
try:
pidfd = pidfd_open(process_id, 0)
except ProcessLookupError:
return False
try:
if _read_linux_proc_stat_process_identity(process_id) != expected_identity:
return False
try:
pidfd_send_signal(pidfd, signal_number)
except ProcessLookupError:
return False
readable, _writable, _exceptional = select.select(
[pidfd],
[],
[],
float(timeout_seconds),
)
return bool(readable)
Comment on lines +568 to +574

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: pidfd poll coverage gap for non-child processes

The termination check relies on select.select reporting the pidfd readable when the target process exits. In production the browser is a grandchild of the runner (child of ChromeDriver), not a direct child; the contract test test_pidfd_termination_observes_killed_unreaped_child (test_agent_task_browser_crash_exact_exit_detection.py) only exercises a direct child. Modern kernels notify pidfd waiters for any process, so this works, but the non-child production path is untested. A kernel without that notification would make the trial fail closed rather than mis-credit.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

finally:
os.close(pidfd)


def _wait_for_linux_process_identity_exit(
process_id: int,
start_time_ticks: int,
Expand Down Expand Up @@ -2178,25 +2241,14 @@ def _run_agent_task_browser_crash_browser_pass(
required_root_identity=browser_process_identity,
)
)
if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL):
raise RuntimeError("Agent Task browser process identity changed before crash signal")

deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS
while True:
try:
_json_request(
driver_port,
"GET",
_webdriver_path(session_id, "/url"),
timeout=1.0,
)
except (OSError, RuntimeError, json.JSONDecodeError):
browser_process_crash_detected = True
break
remaining_seconds = deadline - time.monotonic()
if remaining_seconds <= 0:
raise RuntimeError("Agent Task browser remained usable after SIGKILL")
time.sleep(min(0.05, remaining_seconds))
if not _signal_and_wait_for_linux_process_identity_termination(
browser_process_identity,
signal.SIGKILL,
):
raise RuntimeError(
"Agent Task browser process was not observed terminated after crash signal"
)
browser_process_crash_detected = True
finally:
_cleanup_crashed_browser_session(driver_port, session_id)
_stop_crashed_driver(driver)
Expand Down
151 changes: 151 additions & 0 deletions tests/test_agent_task_browser_crash_exact_exit_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Contract for exact browser-root termination evidence before crash credit."""

from __future__ import annotations

import contextlib
import pathlib
import runpy
import signal
import subprocess
import sys
import unittest

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


class AgentTaskBrowserCrashExactExitDetectionContractTests(unittest.TestCase):
"""Prevent transport failures or unreaped zombies from faking crash evidence."""

def test_crash_detection_uses_exact_pidfd_termination_observation(self) -> None:
"""Signal and observe termination through one exact kernel process handle."""

runner = RUNNER.read_text(encoding="utf-8")
start = runner.index("def _run_agent_task_browser_crash_browser_pass(")
end = runner.index("\ndef _run_agent_task_browser_crash_trial(", start)
browser_pass = runner[start:end]

termination_call = browser_pass.index(
"_signal_and_wait_for_linux_process_identity_termination("
)
crash_credit = browser_pass.index(
"browser_process_crash_detected = True", termination_call
)

self.assertLess(termination_call, crash_credit)
self.assertNotIn(
"_wait_for_linux_process_identity_exit(",
browser_pass[termination_call:crash_credit],
)
self.assertNotIn(
"except (OSError, RuntimeError, json.JSONDecodeError):",
browser_pass[termination_call:crash_credit],
)

def test_pidfd_termination_observes_killed_unreaped_child(self) -> None:
"""Kernel termination evidence must not require the parent to reap the child."""

namespace = runpy.run_path(
str(RUNNER),
run_name="agent_task_browser_crash_exact_termination_contract",
)
signal_and_wait = namespace[
"_signal_and_wait_for_linux_process_identity_termination"
]
read_identity = namespace["_read_linux_proc_stat_process_identity"]

child = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
identity = read_identity(child.pid)
self.assertIsNotNone(identity)
assert identity is not None

self.assertTrue(
signal_and_wait(identity, signal.SIGKILL, timeout_seconds=1.0)
)
self.assertEqual(
read_identity(child.pid),
identity,
"the killed child should still be observable as unreaped procfs identity",
)
finally:
with contextlib.suppress(ProcessLookupError):
child.kill()
child.wait(timeout=5)

def test_pidfd_termination_does_not_credit_signal_delivery_as_exit(self) -> None:
"""A successfully delivered non-terminating signal is not termination evidence."""

namespace = runpy.run_path(
str(RUNNER),
run_name="agent_task_browser_crash_nonterminating_signal_contract",
)
signal_and_wait = namespace[
"_signal_and_wait_for_linux_process_identity_termination"
]
read_identity = namespace["_read_linux_proc_stat_process_identity"]

child = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
identity = read_identity(child.pid)
self.assertIsNotNone(identity)
assert identity is not None

self.assertFalse(
signal_and_wait(identity, signal.SIGCONT, timeout_seconds=0.05)
)
self.assertIsNone(
child.poll(),
"signal delivery without process exit must not be credited as termination",
)
finally:
with contextlib.suppress(ProcessLookupError):
child.kill()
child.wait(timeout=5)

def test_pidfd_termination_refuses_stale_identity_without_signalling(self) -> None:
"""A stale PID/start-time identity must never signal the current process owner."""

namespace = runpy.run_path(
str(RUNNER),
run_name="agent_task_browser_crash_stale_identity_contract",
)
signal_and_wait = namespace[
"_signal_and_wait_for_linux_process_identity_termination"
]
read_identity = namespace["_read_linux_proc_stat_process_identity"]

child = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
identity = read_identity(child.pid)
self.assertIsNotNone(identity)
assert identity is not None
stale_identity = (identity[0], identity[1] + 1)

self.assertFalse(
signal_and_wait(stale_identity, signal.SIGKILL, timeout_seconds=0.1)
)
self.assertIsNone(
child.poll(),
"stale identity validation must happen before any signal is delivered",
)
finally:
with contextlib.suppress(ProcessLookupError):
child.kill()
child.wait(timeout=5)


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