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
35 changes: 20 additions & 15 deletions src/agent_env/env/envs/computer/utils/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
site's names (``issue``), and the site proxy (``site_proxy.py``) is installed through the sandbox's root channel and
started (``route``), which a reset does again on its fresh computer. In the computer they live in
``/etc/agentenv/sites``, root's alone (0700) before the config and its keys are written into it, until the install has
made the keys and the config private; the CA and certificates are valid for the deploy's ttl and a day more, from a few
minutes back for a clock that's behind. The CA also joins the trust agentenv adds, two files at fixed paths for each OS
site routing reaches (``TRUST_ENVIRONMENTS``), which on Linux are the local provider's (``LOCAL_TRUST_ENV``):
``/etc/agentenv/ca.pem``, every CA agentenv adds, for an app with a certificate list of its own, and
``/etc/agentenv/ca-bundle.pem``, the public roots and them. The computer's boot environment names them, so every program
in it trusts the sites however it was started. Site routing reaches Linux computers alone."""
made the keys and the config private; the CA and certificates are valid from 1950 to the end of 9999 (``UNIVERSE_FROM``
to ``UNIVERSE_UNTIL``), since its programs check them against the universe's time. The CA also
joins the trust agentenv adds, two files at fixed paths for each OS site routing reaches (``TRUST_ENVIRONMENTS``),
which on Linux are the local provider's (``LOCAL_TRUST_ENV``): ``/etc/agentenv/ca.pem``, every CA agentenv adds, for
an app with a certificate list of its own, and ``/etc/agentenv/ca-bundle.pem``, the public roots and them. The
computer's boot environment names them, so every program in it trusts the sites however it was started. Site routing
reaches Linux computers alone."""

from __future__ import annotations

Expand All @@ -26,7 +27,7 @@
import time
from collections.abc import Collection
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Iterable, Optional
from urllib.parse import urlsplit
Expand Down Expand Up @@ -69,8 +70,12 @@
NETWORK_LOG = FOLDER / "network.jsonl"
SYNTHETIC, ALLOWLIST = "synthetic", "allowlist"
RANKS = (SYNTHETIC, ALLOWLIST)
VALID_MARGIN = timedelta(days=1)
CLOCK_SKEW = timedelta(minutes=5)
#: The dates the sites' CA and certificates are valid between, the widest a certificate carries: from 1950, the
#: earliest X.509's UTCTime holds, to the end of 9999, RFC 5280's date for one with no expiry. A program on the computer
#: checks them against the universe's time, so they hold however far the universe is from now. The CA is
#: name-constrained and its key gone once they're signed, so the wide window vouches for nothing more.
UNIVERSE_FROM = datetime(1950, 1, 1, tzinfo=UTC)
UNIVERSE_UNTIL = datetime(9999, 12, 31, 23, 59, 59, tzinfo=UTC)
CARD_ATTEMPTS = 3
CARD_RETRY_S = 1.0
#: How long a computer's deploy waits for the run's websites it routes to say they're ready, as each said at its own
Expand Down Expand Up @@ -228,14 +233,14 @@ class Issued:
leaves: dict[str, tuple[str, str]]


def issue(routes: list[Route], *, valid_for: timedelta, label: str, now: Optional[datetime] = None) -> Issued:
def issue(routes: list[Route], *, label: str) -> Issued:
"""A CA name-constrained to the routes' names, so it vouches for nothing else however widely it's trusted, and a
certificate signed by it for each route's names served over HTTPS, all valid ``valid_for`` and a margin more."""
certificate signed by it for each route's names served over HTTPS, all valid from ``UNIVERSE_FROM`` to
``UNIVERSE_UNTIL``."""
tls = [route for route in routes if route.tls_names]
if not tls:
return Issued(None, {})
now = now or datetime.now(UTC)
start, end = now - CLOCK_SKEW, now + valid_for + VALID_MARGIN
start, end = UNIVERSE_FROM, UNIVERSE_UNTIL
ca_key = ec.generate_private_key(ec.SECP256R1())
subject = x509.Name([x509.NameAttribute(NameOID.ORGANIZATION_NAME, "agentenv"),
x509.NameAttribute(NameOID.COMMON_NAME, f"agentenv sites {label}"[:64])])
Expand Down Expand Up @@ -430,7 +435,7 @@ async def _exec(self, argv: list[str]) -> str:


async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], network: Optional[dict],
valid_for: timedelta, label: str, os_type: str, display: str, live_hosts: Iterable[str] = ()) -> dict:
label: str, os_type: str, display: str, live_hosts: Iterable[str] = ()) -> dict:
"""Have the ``os_type`` computer on ``sandbox`` reach each of ``sites`` by its names: the table made, the CA and
certificates issued, the proxy installed and started as root, with its forward port on a computer with no
``display``, a browser alone's, which also takes the run's ``live_hosts`` straight to them, and each name checked
Expand All @@ -446,7 +451,7 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net
return {"names": table.to_dict()}
from agent_env.providers.sandbox_providers.sandbox_provider import sandbox_request_headers_for_url

issued = issue(table.routes, valid_for=valid_for, label=label)
issued = issue(table.routes, label=label)
upstream_headers = {r.site: sandbox_request_headers_for_url(r.url) for r in table.routes}
channel = RootChannel(sandbox)
await channel.private_folder(FOLDER)
Expand Down
34 changes: 15 additions & 19 deletions src/agent_env/providers/env_providers/env_computer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import asyncio
import functools
import logging
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from typing import TYPE_CHECKING, ClassVar, Optional
from urllib.parse import urlsplit

Expand Down Expand Up @@ -68,7 +68,7 @@ class EnvironmentComputerProvider(_SandboxEnvironmentProvider):

def __init__(self) -> None:
super().__init__()
self._later: dict[str, tuple[Sandbox, int]] = {} # each computer booted with sites_later: its sandbox and CA lifetime
self._later: dict[str, Sandbox] = {} # each computer booted with sites_later: its sandbox

async def deploy(
self,
Expand Down Expand Up @@ -131,13 +131,13 @@ async def deploy(
async def route_sites(self, env: ComputerEnv, record: DeployedSandboxEnv, sites: list[dict]) -> None:
"""Route ``sites`` on the computer ``record`` names, which this provider booted with ``sites_later``, launch its
held apps and finish its boot as a deploy does; its record then keeps them, so a reset routes them again."""
sandbox, valid_for_s = self._later.pop(record.sandbox_id)
sandbox = self._later.pop(record.sandbox_id)
computer = record.metadata["computer"]
params, _, problems = computer_params.checked(env, {**computer["env_params"], "sites": sites})
if problems:
raise ValueError(f"{env.id}: " + "; ".join(problems))
computer["env_params"] = params.model_dump(exclude_defaults=True)
if routed := await _finish_boot(env, record, sandbox, params, valid_for_s, computer.get("display")):
if routed := await _finish_boot(env, record, sandbox, params, computer.get("display")):
computer["sites"] = routed

async def follow_clock(self, record: DeployedSandboxEnv, gateway_url: str, name: str) -> None:
Expand Down Expand Up @@ -211,8 +211,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par
"env_params": params.model_dump(exclude_defaults=True)}},
)
if sites_later:
self._later[record.sandbox_id] = (booted.sandbox, ttl_seconds)
elif routed := await _finish_boot(env, record, booted.sandbox, params, ttl_seconds, values.get("resolution")):
self._later[record.sandbox_id] = booted.sandbox
elif routed := await _finish_boot(env, record, booted.sandbox, params, values.get("resolution")):
record.metadata["computer"]["sites"] = routed
except BaseException:
await discard(booted.sandbox)
Expand Down Expand Up @@ -245,14 +245,12 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None:
card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), platform_of(env.computer.os)),
deployed.control_token, answering=bool(params.site_routing), sandbox=sandbox)
_require_name(env, card)
valid_for_s = (record.get("sites") or {}).get("valid_for_s", 0)
if routed := await _finish_boot(env, deployed, sandbox, params, valid_for_s, values.get("resolution")):
if routed := await _finish_boot(env, deployed, sandbox, params, values.get("resolution")):
record["sites"] = routed


async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sandbox,
params: computer_params.ComputerParams, valid_for_s: int,
resolution: Optional[str]) -> Optional[dict]:
params: computer_params.ComputerParams, resolution: Optional[str]) -> Optional[dict]:
"""What a deploy and a reset do once the computer's driver answers, in order: the clock follower's config written
again when the record keeps one (a reset's); with ``site_routing``, the sites routed, each browser handed their
names and the held boot apps launched, the computer then ready; then its display checked; then each browser's start
Expand All @@ -261,8 +259,7 @@ async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sa
await computer_clock.reconfigure(sandbox, record)
routed = None
if params.site_routing:
routed = await _route_sites(env, sandbox, params, valid_for_s,
tuple(record.metadata["computer"].get("live_hosts") or ()))
routed = await _route_sites(env, sandbox, params, tuple(record.metadata["computer"].get("live_hosts") or ()))
await _launch_held_apps(env, record, record.environment_url, params.sites)
await _require_display(record, resolution)
if start_tabs := await _start_tabs(record):
Expand All @@ -272,13 +269,12 @@ async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sa


async def _route_sites(env: ComputerEnv, sandbox: Sandbox, params: computer_params.ComputerParams,
valid_for_s: int, live_hosts: tuple[str, ...] = ()) -> dict:
"""The computer on ``sandbox`` routed to ``params.sites`` under a CA of its own, valid ``valid_for_s``, a browser
alone's forward port taking ``live_hosts`` straight to them; what its record keeps of it."""
routed = await sites.route(sandbox, params.sites, user=env.computer.session.user, network=env.network,
valid_for=timedelta(seconds=valid_for_s), label=env.environment_name,
os_type=env.computer.os.type, display=env.computer.os.display, live_hosts=live_hosts)
return {**routed, "valid_for_s": valid_for_s}
live_hosts: tuple[str, ...] = ()) -> dict:
"""The computer on ``sandbox`` routed to ``params.sites`` under a CA of its own, a browser alone's forward port
taking ``live_hosts`` straight to them; what its record keeps of it."""
return await sites.route(sandbox, params.sites, user=env.computer.session.user, network=env.network,
label=env.environment_name, os_type=env.computer.os.type,
display=env.computer.os.display, live_hosts=live_hosts)


async def _launch_held_apps(env: ComputerEnv, record: DeployedSandboxEnv, url: str,
Expand Down
34 changes: 33 additions & 1 deletion tst/integration/computer/clock_desktop_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
Python reach a live site, its certificate checked against real time by the TLS shim; a shell, Chrome's JavaScript and
the driver read universe time through 30 forward jumps of 8 h, a day or a week, a rate change and a freeze; Chrome,
Xvfb and the desktop's panel run on through them; and the driver's input reaches Chrome after every jump and, frozen,
is drawn. Needs Docker and the local registry on localhost:5000."""
is drawn. Routing a site, a fresh Chrome loads it over HTTPS with the clock six years back, six years on and past
2099, its certificate checked against the universe's time. Needs Docker and the local registry on localhost:5000."""

import ast
import asyncio
Expand All @@ -28,6 +29,7 @@
from agent_env.task_step.context import TaskStepContext
from tst.clock.bounds import LATE_S
from tst.integration.computer.conftest import PAGE_WRITER
from tst.integration.computer.start_tabs import host_pages
from tst.integration.computer.website_browser_test import _window_center

pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow]
Expand All @@ -50,6 +52,12 @@
"python": ("python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler({}))"
".open('https://example.com/', timeout=30)\"")}
LIBRARY = str(PurePosixPath(FOLLOWER).parent / "libfaketimeMT.so.1")
#: A fresh Chrome, as the session user, its own profile beside the desktop's, printing the DOM of the page it's given:
#: each run makes a TLS handshake of its own, which checks the site's certificate against the universe's time (Chrome's
#: TLS isn't OpenSSL, so the TLS shim doesn't reach it).
FRESH_CHROME = ('profile=$(mktemp -d) && /opt/agentenv/apps/chromium/chromium/chrome/chrome --headless=new '
'--no-sandbox --user-data-dir="$profile" --dump-dom "$0"; rm -rf "$profile"')
ROUTED = "https://shop.test/landed"


def test_composed_with_the_library_in_every_process_the_computer_passes_the_probe_at_full(clock_desktop):
Expand Down Expand Up @@ -120,6 +128,30 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_
assert not report.failed


@pytest.mark.asyncio
async def test_a_fresh_chrome_loads_a_routed_site_six_years_either_side_of_now_and_past_2099(clock_desktop):
with host_pages() as port, namespace_routing():
params = {"site_routing": True, "sites": [{"name": "shop", "url": f"http://127.0.0.1:{port}/website/shop/",
"domains": ["shop.test"]}]}
deployed = await Env.get(clock_desktop.id).deploy(env_params=params)
context = TaskStepContext(deployed_envs=[deployed])
try:
clock, loaded = _Clock(deployed), {}
for years in (-6, 6, 80):
clock.set(time.time() + years * 365 * DAY, 1)
await asyncio.sleep(2) # each program maps the page the next time it reads the clock, within a second
ran = await inside.run(deployed, ["sh", "-c", FRESH_CHROME, ROUTED], timeout_s=90)
loaded[years] = {"universe": await clock.read(deployed),
"landed": "<title>landed</title>" in ran["stdout"],
"output": (ran["stdout"] + ran.get("stderr", ""))[-600:]}
finally:
report = await teardown_run(context)

assert all(shown["universe"] for shown in loaded.values()), loaded
assert {years: shown["landed"] for years, shown in loaded.items()} == {-6: True, 6: True, 80: True}, loaded
assert not report.failed


class _Clock:
"""The computer's clock page, written as its follower's user, as its follower writes it, and what the session
reads of it."""
Expand Down
16 changes: 7 additions & 9 deletions tst/unit/env/envs/test_computer_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import shutil
import subprocess
import urllib.parse
from datetime import timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
Expand Down Expand Up @@ -1144,11 +1143,11 @@ async def test_a_computer_with_site_routing_reaches_the_websites_the_run_deploye
assert sandbox is boots.sandboxes[0]
assert [site.model_dump() for site in sites] == [{"name": "notes", "url": "http://localhost:8080/website/notes/",
"domains": ["notes.test"]}]
assert options == {"user": "user", "network": None, "valid_for": timedelta(seconds=3600), "label": "desktop",
"os_type": "linux", "display": "x11", "live_hosts": ()}
assert options == {"user": "user", "network": None, "label": "desktop", "os_type": "linux", "display": "x11",
"live_hosts": ()}
assert boots.boots[0]["environment"][HOLD_BOOT_LAUNCH_ENV] == "1"
record = context.deployed_envs[-1].metadata["computer"]
assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM", "valid_for_s": 3600}
assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM"}
assert record["env_params"]["site_routing"] is True and record["env_params"]["sites"][0]["name"] == "notes"


Expand All @@ -1166,9 +1165,8 @@ async def test_a_computer_deployed_to_route_its_sites_later_boots_held_and_route
assert boots.boots[0]["environment"].items() >= computer_sites.TRUST_ENVIRONMENTS["linux"].items()
((sandbox, sites, options),) = routes
assert sandbox is boots.sandboxes[0] and [site.name for site in sites] == ["notes"]
assert options["valid_for"] == timedelta(seconds=3600)
record = deployed.metadata["computer"]
assert record["env_params"]["sites"][0]["name"] == "notes" and record["sites"]["valid_for_s"] == 3600
assert record["env_params"]["sites"][0]["name"] == "notes" and record["sites"]["ca"] == "CA PEM"


@pytest.mark.asyncio
Expand Down Expand Up @@ -1390,7 +1388,7 @@ async def test_a_start_tab_on_one_of_the_env_sites_that_didnt_load_fails_the_boo

with pytest.raises(RuntimeError) as failed:
await env_computer_provider._finish_boot(SimpleNamespace(), record, None,
SimpleNamespace(site_routing=False), 0, None)
SimpleNamespace(site_routing=False), None)

assert record.metadata["computer"]["start_tabs"] == {"chromium": tabs}
assert str(failed.value) == (
Expand Down Expand Up @@ -1435,8 +1433,8 @@ async def test_a_reset_routes_the_fresh_computer_to_the_same_sites_under_a_ca_of
await ResetEnvTaskStep(id="reset", version=None, env_id=env.id, fail_task_on_error=True).execute(context)

first, again = routes
assert again[0] is sandbox and again[1] == first[1] and again[2]["valid_for"] == timedelta(seconds=3600)
assert context.deployed_envs[-1].metadata["computer"]["sites"]["valid_for_s"] == 3600
assert again[0] is sandbox and again[1] == first[1] and again[2] == first[2]
assert context.deployed_envs[-1].metadata["computer"]["sites"]["ca"] == "CA PEM"


@pytest.mark.asyncio
Expand Down
Loading
Loading