From 38d986ba487133ae2b5f7df17202be5e6e52b3c7 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 12 Nov 2026 14:50:36 -0800 Subject: [PATCH 1/2] feat(computers): routed sites' certificates hold at any date a computer's clock can show (FD-5480) The deploy's CA and each routed site's certificate are valid from 1971 to 2099, not the deploy's ttl from real now, so a program on a computer whose clock is armed in the past or the future, which checks a certificate against the universe's time, still takes them. The CA is name-constrained and its key discarded once they're signed, so the wider window vouches for nothing more. The ttl the window took is no longer passed to the site routing or kept in the deploy record. Co-Authored-By: Claude Opus 5.5 --- .../env/envs/computer/utils/sites.py | 34 ++++++----- .../env_providers/env_computer_provider.py | 34 +++++------ .../computer/clock_desktop_test.py | 34 ++++++++++- tst/unit/env/envs/test_computer_env.py | 16 +++-- tst/unit/env/envs/test_computer_site_proxy.py | 18 +++--- tst/unit/env/envs/test_computer_sites.py | 58 +++++++++++-------- 6 files changed, 116 insertions(+), 78 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 040bfe734..9a31879db 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -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 over every date a computer's clock can show +(``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 @@ -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 @@ -69,8 +70,11 @@ 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: every date a computer's clock can show, so a program +#: on it, which checks a certificate against the universe's time, takes them 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(1971, 1, 1, tzinfo=UTC) +UNIVERSE_UNTIL = datetime(2099, 12, 31, 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 @@ -228,14 +232,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])]) @@ -430,7 +434,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 @@ -446,7 +450,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) diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index 0176ed859..a4090fd43 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -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 @@ -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, @@ -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: @@ -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) @@ -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 @@ -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): @@ -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, diff --git a/tst/integration/computer/clock_desktop_test.py b/tst/integration/computer/clock_desktop_test.py index 8b671d1db..b55ed139b 100644 --- a/tst/integration/computer/clock_desktop_test.py +++ b/tst/integration/computer/clock_desktop_test.py @@ -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 and six years on, its +certificate checked against the universe's time. Needs Docker and the local registry on localhost:5000.""" import ast import asyncio @@ -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] @@ -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): @@ -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(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): + 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": "landed" 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}, 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.""" diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 838dbd3be..e3fdf992b 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -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 @@ -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" @@ -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 @@ -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) == ( @@ -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 diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index 5fd2000a2..a6818f9e3 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -18,7 +18,6 @@ import sys import time import urllib.error -from datetime import timedelta from pathlib import Path from types import SimpleNamespace @@ -121,7 +120,7 @@ async def _exchange(port, request: bytes, *, server_hostname=None, cadata=None, def _issued(tmp_path, routes): - issued = sites.issue(routes, valid_for=timedelta(hours=1), label="test") + issued = sites.issue(routes, label="test") files = {} for name, (cert, key) in issued.leaves.items(): (tmp_path / f"{name}.crt").write_text(cert) @@ -875,7 +874,7 @@ def test_install_writes_the_sites_files_names_and_trust_then_starts_the_proxy_on table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"]), SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"]), SiteParams(name="bare", url="http://gw/website/bare/")]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=True, direct=["github.com"]))) started = [] @@ -922,7 +921,7 @@ def test_install_keeps_the_upstream_headers_in_a_file_root_alone_reads_and_serve folder.mkdir() folder.chmod(0o700) table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"])]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") given = sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=False, upstream_headers={"notes": {"CF-Access-Client-Secret": "s3cret"}}) (folder / "config.json").write_text(json.dumps(given)) @@ -1061,8 +1060,7 @@ def _calls(store: Path) -> list[str]: def test_every_jdk_trusts_the_ca_an_apt_one_through_the_system_hook_the_rest_by_keytool(tmp_path, monkeypatch): issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") fingerprint = site_proxy.sha256_fingerprint(issued.ca_pem) ca = tmp_path / "ca.pem" ca.write_text(issued.ca_pem) @@ -1101,8 +1099,7 @@ def test_a_jdk_store_the_ca_isnt_listed_in_after_routing_is_named(tmp_path, monk apt.unlink() apt.symlink_to(hook) issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") trusted = site_proxy.trust_java(site_proxy.Paths(root), str(tmp_path / "ca.pem"), issued.ca_pem) @@ -1120,8 +1117,7 @@ def test_each_runtime_loads_the_first_https_site_through_its_own_trust_and_says_ monkeypatch.delenv("SSL_CERT_FILE", raising=False) unset = site_proxy.check_runtimes("https://notes.test/") issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") (tmp_path / "bundle.pem").write_text(issued.ca_pem) monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "bundle.pem")) opened = [] @@ -1153,7 +1149,7 @@ def test_a_wildcard_name_starts_dns_even_when_resolv_conf_names_no_other_nameser if resolv is not None: (root / "etc/resolv.conf").write_text(resolv) table = sites.site_table([SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"])]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=False))) started = [] diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index bf90a5485..3335aff44 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -101,13 +101,12 @@ def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): table = sites.site_table([_site("notes", "notes.test"), _site("slack", "slack.com", "*.slack.com"), _site("bare")]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=3), label="desktop", now=now) + issued = sites.issue(table.routes, label="desktop") assert set(issued.leaves) == {"notes", "slack"} ca = x509.load_pem_x509_certificate(issued.ca_pem.encode()) constraints = ca.extensions.get_extension_for_class(x509.NameConstraints).value assert [n.value for n in constraints.permitted_subtrees] == ["notes.test", "slack.com"] - assert ca.not_valid_after_utc == now + timedelta(hours=3) + sites.VALID_MARGIN for site, name in (("notes", "notes.test"), ("slack", "slack.com"), ("slack", "eu.slack.com")): _verify(issued.ca_pem, issued.leaves[site][0], name, now) with pytest.raises(VerificationError): @@ -115,19 +114,32 @@ def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): assert "PRIVATE KEY" in issued.leaves["notes"][1] and "PRIVATE KEY" not in issued.ca_pem -def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone(): +def test_the_ca_and_certificates_hold_at_any_date_a_computers_clock_can_show(): + issued = sites.issue(sites.site_table([_site("notes", "notes.test")]).routes, label="desktop") now = datetime.now(UTC).replace(microsecond=0) + + for cert in (issued.ca_pem, issued.leaves["notes"][0]): + loaded = x509.load_pem_x509_certificate(cert.encode()) + assert (loaded.not_valid_before_utc, loaded.not_valid_after_utc) == (sites.UNIVERSE_FROM, sites.UNIVERSE_UNTIL) + for universe in (sites.UNIVERSE_FROM, now - timedelta(days=6 * 365), now, now + timedelta(days=6 * 365), + sites.UNIVERSE_UNTIL): + _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", universe) + for outside in (sites.UNIVERSE_FROM - timedelta(seconds=1), sites.UNIVERSE_UNTIL + timedelta(seconds=1)): + with pytest.raises(VerificationError): + _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", outside) + + +def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone(): name = f"{'a' * 63}.test" - issued = sites.issue(sites.site_table([_site("long", name)]).routes, valid_for=timedelta(hours=1), label="x", - now=now) + issued = sites.issue(sites.site_table([_site("long", name)]).routes, label="x") leaf = x509.load_pem_x509_certificate(issued.leaves["long"][0].encode()) assert len(leaf.subject) == 0 and leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical - _verify(issued.ca_pem, issued.leaves["long"][0], name, now) + _verify(issued.ca_pem, issued.leaves["long"][0], name, datetime.now(UTC)) def test_no_ca_is_made_when_no_name_is_served_over_https(): - assert sites.issue(sites.site_table([_site("bare")]).routes, valid_for=timedelta(hours=1), label="x") == \ + assert sites.issue(sites.site_table([_site("bare")]).routes, label="x") == \ sites.Issued(None, {}) @@ -185,7 +197,7 @@ async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_com sandbox = FakeVm(_REPORT) routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert set(sandbox.written) == {"/etc/agentenv/sites/site_proxy.py", "/etc/agentenv/sites/config.json"} config = json.loads(sandbox.written["/etc/agentenv/sites/config.json"]) @@ -210,7 +222,7 @@ async def test_route_gives_a_browser_alone_the_forward_port_and_a_computer_with_ for sandbox, display in ((alone, "none"), (desktop, "x11")): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display=display) + label="desktop", os_type="linux", display=display) configs = [json.loads(sandbox.written["/etc/agentenv/sites/config.json"]) for sandbox in (alone, desktop)] assert [config.get("proxy_port") for config in configs] == [3129, None] @@ -221,10 +233,10 @@ async def test_a_browser_alones_forward_port_takes_the_live_sites_hosts_straight routed, alone, desktop = FakeVm(_REPORT), FakeVm(_REPORT), FakeVm(_REPORT) live = ("github.com", "*.githubassets.com") - await sites.route(routed, [_site("notes", "notes.test")], user="user", network=None, valid_for=timedelta(hours=1), - label="browser", os_type="linux", display="none", live_hosts=live) + await sites.route(routed, [_site("notes", "notes.test")], user="user", network=None, label="browser", + os_type="linux", display="none", live_hosts=live) for sandbox, display in ((alone, "none"), (desktop, "x11")): - await sites.route(sandbox, [], user="user", network=None, valid_for=timedelta(hours=1), label="browser", + await sites.route(sandbox, [], user="user", network=None, label="browser", os_type="linux", display=display, live_hosts=live) beside = json.loads(routed.written["/etc/agentenv/sites/config.json"]) @@ -239,7 +251,7 @@ async def test_the_config_holding_the_keys_is_written_over_stdin_into_a_private_ sandbox = FakeVm(_REPORT) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert ("write", "/etc/agentenv/sites/config.json") not in sandbox.events secret = sandbox.events.index(("secret", "/tmp/secret-1/config.json")) @@ -257,7 +269,7 @@ async def test_the_headers_the_gateways_provider_asks_for_go_in_the_private_conf "CF-Access-Client-Secret": "s3cret"} if url.startswith("http://host.docker.internal:8080/") else {}) routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") (site,) = json.loads(sandbox.written["/etc/agentenv/sites/config.json"])["sites"] assert site["upstream_headers"] == {"CF-Access-Client-Secret": "s3cret"} @@ -272,7 +284,7 @@ async def test_the_sites_keys_land_in_a_folder_root_alone_can_enter(): sandbox = FakeVm(_REPORT) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites") assert private < sandbox.events.index(("cp", "/tmp/secret-1/config.json", "computer-1:/etc/agentenv/sites/config.json")) @@ -285,7 +297,7 @@ async def test_a_name_that_doesnt_load_in_the_computer_fails_the_route(): with pytest.raises(RuntimeError, match="https://notes.test/ didn't load in the computer: certificate verify"): await sites.route(FakeVm(report), [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") @pytest.mark.asyncio @@ -295,8 +307,8 @@ async def test_a_network_that_keeps_the_computer_from_the_gateway_is_refused_bef with pytest.raises(RuntimeError, match="allows only api.example, so it can't reach the gateway at " "host.docker.internal"): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", - network={"mode": "allowlist", "allow_hosts": ["api.example"]}, valid_for=timedelta(hours=1), - label="desktop", os_type="linux", display="x11") + network={"mode": "allowlist", "allow_hosts": ["api.example"]}, label="desktop", + os_type="linux", display="x11") assert sandbox.scripts == [] and sandbox.written == {} @@ -310,7 +322,7 @@ async def test_a_gateway_the_sandboxs_applied_network_allows_by_its_address_rang routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network={"mode": "allowlist", "allow_hosts": ["api.example"]}, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert routed["checks"] == _REPORT["checks"] routes = [sites.Route("notes", "http://10.0.0.7:8080/website/notes/", ("notes.test",))] @@ -335,7 +347,7 @@ async def write_file_from_text(content, path): write_file_from_text=write_file_from_text) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert calls == [("id", "-u"), ("mkdir", "-p", "/etc/agentenv/sites"), ("chmod", "0700", "/etc/agentenv/sites"), ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/site_proxy.py"), @@ -360,7 +372,7 @@ async def write_file_from_text(content, path): with pytest.raises(RuntimeError, match="the coreweave sandbox runs the computer's commands as uid 1000"): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert calls == [("id", "-u")] @@ -373,7 +385,7 @@ async def test_a_site_naming_the_gateway_its_routes_are_on_is_refused_before_any with pytest.raises(RuntimeError, match=r"the gateway at gw.sites.test \(named by notes's .*sites.test\) would"): await sites.route(sandbox, [_site("notes", *domains)], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert sandbox.scripts == [] and sandbox.written == {} @@ -430,7 +442,7 @@ def gateway(env_id, *domains): table = sites.site_table([Site(**s) for s in found]) assert {route.site: route.domains for route in table.routes} == {"team-a-notes": ("notes.test",), "team-b-notes": ("notes.example",)} - assert set(sites.issue(table.routes, valid_for=timedelta(hours=1), label="x").leaves) == {"team-a-notes", + assert set(sites.issue(table.routes, label="x").leaves) == {"team-a-notes", "team-b-notes"} clash = await sites.deployed_sites([gateway("team-a", "notes.test"), gateway("team-b", "notes.test")]) with pytest.raises(sites.SiteConflict, match="'notes.test' \\(team-b-notes\\) overlaps 'notes.test' \\(team-a-notes\\)"): From 6741f25e3163448a5ffd5ee16d88012de1d3dbce Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 12 Nov 2026 15:06:58 -0800 Subject: [PATCH 2/2] fix(computers): routed sites' certificates span the widest window a certificate carries, 1950 to 9999 The gateway's clock takes dates past 2099, which the 1971 to 2099 window left out. The CA and site certificates now run from 1950, the earliest a certificate holds, to 9999-12-31 23:59:59, RFC 5280's date for no expiry, and the clock desktop test loads the routed site 80 years on too. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/utils/sites.py | 15 ++++++++------- tst/integration/computer/clock_desktop_test.py | 10 +++++----- tst/unit/env/envs/test_computer_sites.py | 10 +++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 9a31879db..931d902d8 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -8,8 +8,8 @@ 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 over every date a computer's clock can show -(``UNIVERSE_FROM`` to ``UNIVERSE_UNTIL``), since its programs check them against the universe's time. The CA also +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 @@ -70,11 +70,12 @@ NETWORK_LOG = FOLDER / "network.jsonl" SYNTHETIC, ALLOWLIST = "synthetic", "allowlist" RANKS = (SYNTHETIC, ALLOWLIST) -#: The dates the sites' CA and certificates are valid between: every date a computer's clock can show, so a program -#: on it, which checks a certificate against the universe's time, takes them 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(1971, 1, 1, tzinfo=UTC) -UNIVERSE_UNTIL = datetime(2099, 12, 31, tzinfo=UTC) +#: 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 diff --git a/tst/integration/computer/clock_desktop_test.py b/tst/integration/computer/clock_desktop_test.py index b55ed139b..0a604b9f8 100644 --- a/tst/integration/computer/clock_desktop_test.py +++ b/tst/integration/computer/clock_desktop_test.py @@ -5,8 +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. Routing a site, a fresh Chrome loads it over HTTPS with the clock six years back and six years on, its -certificate checked against the universe's time. 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 @@ -129,7 +129,7 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_ @pytest.mark.asyncio -async def test_a_fresh_chrome_loads_a_routed_site_six_years_either_side_of_now(clock_desktop): +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"]}]} @@ -137,7 +137,7 @@ async def test_a_fresh_chrome_loads_a_routed_site_six_years_either_side_of_now(c context = TaskStepContext(deployed_envs=[deployed]) try: clock, loaded = _Clock(deployed), {} - for years in (-6, 6): + 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) @@ -148,7 +148,7 @@ async def test_a_fresh_chrome_loads_a_routed_site_six_years_either_side_of_now(c 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}, loaded + assert {years: shown["landed"] for years, shown in loaded.items()} == {-6: True, 6: True, 80: True}, loaded assert not report.failed diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index 3335aff44..85937cfe1 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -121,12 +121,12 @@ def test_the_ca_and_certificates_hold_at_any_date_a_computers_clock_can_show(): for cert in (issued.ca_pem, issued.leaves["notes"][0]): loaded = x509.load_pem_x509_certificate(cert.encode()) assert (loaded.not_valid_before_utc, loaded.not_valid_after_utc) == (sites.UNIVERSE_FROM, sites.UNIVERSE_UNTIL) - for universe in (sites.UNIVERSE_FROM, now - timedelta(days=6 * 365), now, now + timedelta(days=6 * 365), - sites.UNIVERSE_UNTIL): + for universe in (datetime(1950, 1, 1, tzinfo=UTC), now - timedelta(days=6 * 365), now, + now + timedelta(days=6 * 365), datetime(2100, 1, 1, tzinfo=UTC), + datetime(9999, 12, 31, 23, 59, 59, tzinfo=UTC)): _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", universe) - for outside in (sites.UNIVERSE_FROM - timedelta(seconds=1), sites.UNIVERSE_UNTIL + timedelta(seconds=1)): - with pytest.raises(VerificationError): - _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", outside) + with pytest.raises(VerificationError): + _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", sites.UNIVERSE_FROM - timedelta(seconds=1)) def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone():