diff --git a/.github/workflows/clock.yml b/.github/workflows/clock.yml index 127cc4b2b..e34488eff 100644 --- a/.github/workflows/clock.yml +++ b/.github/workflows/clock.yml @@ -1,6 +1,6 @@ name: Clock gates -# agent-env's libfaketime (src/agent_env/computers/clock), the library every computer's session programs load to read +# agent-env's libfaketime (src/agent_env/computers/artifacts/ubuntu-24.04/clock), the library every computer's session programs load to read # universe time, built with stock 0.9.12 beside it in an Ubuntu 24.04 image and run through tst/clock's gates on x86_64 # and arm64: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, # condvar and semaphore stress, the waits again with futex_waitv refused, a stalled writer and a dead one, torn page @@ -10,7 +10,7 @@ name: Clock gates on: pull_request: paths: - - "src/agent_env/computers/clock/**" + - "src/agent_env/computers/artifacts/ubuntu-24.04/clock/**" - "src/agent_env/browsers/envs/chromium/chromium.py" - "tst/clock/**" - ".github/workflows/clock.yml" @@ -52,7 +52,7 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.AGENTENV_TEST_CLOCK_CACHE }} - key: clock-downloads-${{ matrix.arch }}-${{ hashFiles('src/agent_env/browsers/envs/chromium/chromium.py', 'src/agent_env/computers/clock/build.sh', 'tst/clock/conftest.py') }} + key: clock-downloads-${{ matrix.arch }}-${{ hashFiles('src/agent_env/browsers/envs/chromium/chromium.py', 'src/agent_env/computers/artifacts/ubuntu-24.04/clock/build.sh', 'tst/clock/conftest.py') }} - name: Clock gates run: .venv/bin/python -m pytest tst/clock -rA --timeout=2400 --junitxml=junit-clock-${{ matrix.arch }}.xml - name: No skips diff --git a/AGENTS.md b/AGENTS.md index 43e1a22f2..c80ddc3a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ or one of them only where it is used. | `make int-test-fast` | `tst/integration` minus `int_test_slow`, in parallel; needs Docker and a local OCI registry (`docker run -d -p 5000:5000 public.ecr.aws/docker/library/registry:2`) | | `make int-test-slow` | the `int_test_slow` tests, serially (they build the same Docker tags from module fixtures and race under xdist) | | `make installer-test` | `tst/installer`: `plugin add` / `remove` through the real pip, uv and pipx, offline against wheels built from the checkout, and the container user journey (`python:3.12-slim`, `--network none`) with the PEP 668 refusal; needs uv, pipx and Docker | -| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back: live sites verify, expired and untrusted certificates don't; and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each checking with its own), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive, upstream's tarball and OpenSSL 1.1.1w's once into `AGENTENV_TEST_CLOCK_CACHE` | +| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/artifacts/ubuntu-24.04/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back: live sites verify, expired and untrusted certificates don't; and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each checking with its own), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive, upstream's tarball and OpenSSL 1.1.1w's once into `AGENTENV_TEST_CLOCK_CACHE` | | `make trl-test` | `tst/trl`: one `GRPOTrainer` step on CPU through `agent_env.training.trl`, with a tiny model from the Hub; needs trl and CPU torch, which the `dev` extra leaves out | | `make nemo-gym-test` | `tst/nemo_gym`: rollouts through `agent_env.training.nemo_gym`'s resources server in-process; needs the `nemo-gym` extra, on Python 3.13.14 or later, which the `dev` extra leaves out (`uv sync --extra dev --extra nemo-gym`) | | `make verl-test` | `tst/verl`: rows played through `agent_env.training.verl`'s agent loop on verl itself, with Qwen3's tokenizer from the Hub and a stand-in rollout server; needs verl and CPU torch, which the `dev` extra leaves out | @@ -138,7 +138,7 @@ and deserialized through a registry. | `explorer/` | Optional local web UI: `agent-env up`, binds loopback `:8234`. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | -| `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), and `omarchy-4.5`, a disk alone built on the composing machine from Omarchy's own VM image (its `[forms.disk]` `source`) by `disk/omarchy/`'s recipe, its services systemd user units of the graphical session that start through `with-seed`, booted by the launcher as a `linux-desktop` guest (`env/envs/computer/utils/omarchy_disk.py`, on `thin_disk.py`, which Windows' disks use too), aliased through `[artifacts]`. It has no tasks either. `disk/linux/` is the Linux disk form's recipe: `boot/`, whose Dockerfile makes a disk form (a GPT disk of an EFI system partition holding one UKI, Ubuntu's kernel with a busybox initramfs whose init reads a NoCloud seed and `switch_root`s into the image's entrypoint, and an ext4 root filesystem that is a container image's, plus `/etc/agentenv/vm/image.env` from its OCI config), `tools/`, the disk tools that make it unprivileged on the builder's own platform (`mkfs.ext4 -d`), and `launcher/`, the QEMU launcher that boots one under KVM. A base's `[forms.disk]` with no image is made from its container form; the composer makes each arch's disk from its composed container, keeping the base disk's EFI system partition, keyed by the container's key, the base disk and the recipe; gate 3 probes it where a provider boots disks natively (`disk_image_platforms()`), and a deploy's first boot with KVM probes one left `unverified`; a deploy naming `form = "disk"` boots it through `create_vm(image=..., env=...)`, its settings in the seed. `clock/` is the computer's clock: agent-env's build of libfaketime 0.9.12 (`libfaketime.patch` and the two `agentenv_clock_*.c` files it includes, GPL-2.0-only as libfaketime is, built by `build.sh` from upstream's sha256-pinned tarball), which session programs load to read universe time and wait for a set clock time from a page its follower writes (`clockpage.py`), monotonic and relative waits staying real, with its default settings (`clock.conf`); `tst/clock` gates it. | +| `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, settings as `AGENTENV_SETTING_`; it starts as root, its container form's `start_as`, whose supervisord, `root.conf`, runs the clock follower as `agentenv-clock` and the session's supervisord as the session user, which runs every session program), and `omarchy-4.5`, a disk alone built on the composing machine from Omarchy's own VM image (its `[forms.disk]` `source`) by `disk/omarchy/`'s recipe, its services systemd user units of the graphical session that start through `with-seed`, booted by the launcher as a `linux-desktop` guest (`env/envs/computer/utils/omarchy_disk.py`, on `thin_disk.py`, which Windows' disks use too), aliased through `[artifacts]`. It has no tasks either. `disk/linux/` is the Linux disk form's recipe: `boot/`, whose Dockerfile makes a disk form (a GPT disk of an EFI system partition holding one UKI, Ubuntu's kernel with a busybox initramfs whose init reads a NoCloud seed and `switch_root`s into the image's entrypoint, and an ext4 root filesystem that is a container image's, plus `/etc/agentenv/vm/image.env` from its OCI config), `tools/`, the disk tools that make it unprivileged on the builder's own platform (`mkfs.ext4 -d`), and `launcher/`, the QEMU launcher that boots one under KVM. A base's `[forms.disk]` with no image is made from its container form; the composer makes each arch's disk from its composed container, keeping the base disk's EFI system partition, keyed by the container's key, the base disk and the recipe; gate 3 probes it where a provider boots disks natively (`disk_image_platforms()`), and a deploy's first boot with KVM probes one left `unverified`; a deploy naming `form = "disk"` boots it through `create_vm(image=..., env=...)`, its settings in the seed. `artifacts/ubuntu-24.04/clock/` is the computer's clock, in the base's folder since an image builds only from its own: agent-env's build of libfaketime 0.9.12 (`libfaketime.patch` and the two `agentenv_clock_*.c` files it includes, GPL-2.0-only as libfaketime is, built by `build.sh` from upstream's sha256-pinned tarball, in a stage of the base's Dockerfile of its own) and the TLS shim, which `/etc/ld.so.preload` loads into every process and which fake only the session user's: clock reads and waits for a set clock time follow the page the follower (`clock-follower`) writes (`clockpage.py`), monotonic and relative waits staying real, with its default settings (`clock.conf`); `tst/clock` gates it. The follower long-polls the gateway's `/clock/watch` under the config core writes through the root channel when a multi env deploys the computer and after a reset (`env/envs/computer/utils/clock.py`), each time waiting on the computer's `sync_time` until it follows, a warning naming why when it doesn't (arming the clock refuses such a computer), and the driver serves `clock/v1` `sync_time` where the follower is (`agentenv_protocol.computer.clock`), not on a disk guest, whose follower core can't reach. | | `browsers/` | The `browsers` bundle: the website browsers agent-env ships, today `chromium`, a `computer_app` env (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). All of it, its pin, install step, policies and command, is in `envs/chromium/chromium.py`; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | | `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, the page driver aliased `page`, a browser computer's, whose screen is the browser's active tab and which serves Playwright MCP's `browser_*` tools beside `computer_*` from a Playwright MCP it runs attached to the browser over a CDP address the relay issues it (`browser_evaluate` and `browser_run_code_unsafe` hidden by default; each call an entry in the browser's log, begun and ended with `browser/v1` `calls/begin` and `calls/end`, that its CDP commands name as `for`; `scripts/upgrade_playwright_mcp.py` pins its version), and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker, and `desktop`, a computer env of `ubuntu-24.04` and `reference` with a probe task and an agent task). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | diff --git a/Makefile b/Makefile index 78e6fce1f..167bb1dd9 100644 --- a/Makefile +++ b/Makefile @@ -66,9 +66,9 @@ installer-test: ## Run plugin add/remove through the real pip, uv and pipx, and # The TRL contract tier trains one GRPOTrainer step on CPU through agent_env.training.trl, with a tiny model from the # Hub. trl and torch aren't in the dev extra: install them first, as .github/workflows/trl.yml does. -# The clock tier builds agent-env's libfaketime (src/agent_env/computers/clock) and stock 0.9.12 in an Ubuntu 24.04 image -# for this machine's Docker and runs its gates there, Chromium launches included; .github/workflows/clock.yml runs it on -# x86_64 and arm64. +# The clock tier builds agent-env's libfaketime (src/agent_env/computers/artifacts/ubuntu-24.04/clock) and stock 0.9.12 +# in an Ubuntu 24.04 image for this machine's Docker and runs its gates there, Chromium launches included; +# .github/workflows/clock.yml runs it on x86_64 and arm64. clock-test: ## Build agent-env's libfaketime and run its gates in Docker (tst/clock) $(PYTHON) -m pytest tst/clock --timeout=2400 $(PYTEST_ARGS) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 643a20c47..79edc47a8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2546,7 +2546,7 @@ Platform-specific native binaries that npm installs for the machine running the ## libfaketime -`src/agent_env/computers/clock/` holds agent-env's changes to [libfaketime](https://github.com/wolfcw/libfaketime) +`src/agent_env/computers/artifacts/ubuntu-24.04/clock/` holds agent-env's changes to [libfaketime](https://github.com/wolfcw/libfaketime) 0.9.12: `libfaketime.patch`, `agentenv_clock_page.c` and `agentenv_clock_waits.c`. Being derived from libfaketime, those three files are licensed as it is, under the GNU General Public License version 2 (`GPL-2.0-only`); the folder's `COPYING` is that license's text. libfaketime's own source is not in the repository: the folder's `build.sh` downloads diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/card.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/card.py index f83647e2f..2f99ca982 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/card.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/card.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING +from . import clock from .dataplane import DEFAULT_DATA_PATHS from .recording import recording_params from ..types import METHOD_ADD, EnvironmentExtension, EnvironmentTool, IntakeDeclaration, IntakeFormat, intake_extension @@ -55,6 +56,8 @@ def served_extensions(driver: ComputerDriverEnvironment, continue if extension.uri == COMPUTER_RECORDING and driver.recording is None: continue + if extension.uri == clock.CLOCK and not clock.follows(): + continue dropped = {op for (uri, op), need in _METHOD_NEEDS.items() if uri == extension.uri and not driver.has(need)} if dropped: methods = {op: entry for op, entry in extension.params["methods"].items() if op not in dropped} diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/clock.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/clock.py new file mode 100644 index 000000000..19667b72d --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/clock.py @@ -0,0 +1,100 @@ +"""A computer's side of clock/v1. A base that follows the universe clock runs a follower (its image has ``FOLLOWER``), +which core configures through the sandbox's root channel (``CONFIG``, root's and the follower's alone, holding the +computer's read token) and which says how it's doing in ``STATUS``, holding no token. The driver serves clock/v1's +``sync_time``, the method every server that follows the clock serves, so the computer's card is what tells core and the +gateway that it follows: called when the clock is armed, it checks the task's zone against the one the computer booted +with and answers once the follower shows the gateway's current version, or says why it doesn't. That version it reads +from the gateway's watch, as any reader may: a watch naming no follower is answered at once and acknowledges nothing.""" +from __future__ import annotations + +import asyncio +import json +import os +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional + +from .errors import InvalidArgument, TargetUnavailable + +CLOCK = "urn:agentenv:clock/v1" +FOLLOWER = "/usr/local/lib/agentenv-clock/clock-follower" +#: The follower's user, whose group may read ``CONFIG``. +FOLLOWER_USER = "agentenv-clock" +CONFIG = "/etc/agentenv/clock/follower.json" +STATUS = "/run/agentenv-clock/status.json" +#: What a disk form's guest has, made from the computer's container image: core's root channel reaches its launcher, +#: not the guest, so it can't configure a follower there, and a disk computer stays on real time for now. +DISK_GUEST = "/etc/agentenv/vm/image.env" +#: How long ``sync_time`` waits for the follower to show the gateway's current version, as after a gateway restart +#: or a clock change it catches up. +CONNECT_WAIT_S = 10.0 +_DIRECT = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def follows() -> bool: + """Whether this computer follows the universe clock: its base runs the follower, and it isn't a disk guest.""" + return os.path.exists(FOLLOWER) and not os.path.exists(DISK_GUEST) + + +def session_zone() -> str: + """The zone the computer booted with, which every session program is in.""" + try: + return os.environ.get("TZ") or Path("/etc/timezone").read_text().strip() or "UTC" + except OSError: + return "UTC" + + +def follower_status() -> dict: + try: + status = json.loads(Path(STATUS).read_text()) + except (OSError, ValueError): + return {} + return status if isinstance(status, dict) else {} + + +def gateway_version(watch_url: str) -> str: + """The version the gateway's clock has now, read from its watch naming no version and no follower.""" + with _DIRECT.open(f"{watch_url}?{urllib.parse.urlencode({'after': ''})}", timeout=5) as answer: + return json.loads(answer.read())["version"] + + +def _why(status: dict, current: Optional[str], unread: Optional[str]) -> str: + if status.get("error"): + return f"it can't reach the gateway at {status.get('url')}: {status['error']}" + if not status.get("url"): + return "core hasn't written its config, which it does when a multi env deploys this computer" + if not status.get("connected"): + return f"it hasn't heard from the gateway at {status['url']} yet" + if unread is not None: + return f"this computer can't read the gateway's version at {status['url']}: {unread}" + return f"it shows the gateway's version {status.get('version')!r}, and the gateway is at {current!r}" + + +async def sync_time(env_get_time_url: str, timezone: Optional[str] = None) -> dict: + """``clock/v1``'s ``sync_time`` on a computer: its follower already follows the gateway core configured it with, + so ``env_get_time_url`` is taken as every server takes it and not used; ``timezone``, the task's, must be the one + the computer booted with, which a deploy sets (``computer_settings.timezone``).""" + if not follows(): + raise TargetUnavailable("this computer's base doesn't follow the universe clock") + zone = session_zone() + if timezone is not None and timezone.strip() != zone: + raise InvalidArgument(f"the task's clock is in {timezone.strip()!r} and this computer's session in {zone!r}; " + f"deploy it with computer_settings.timezone = {timezone.strip()!r}") + deadline = time.monotonic() + CONNECT_WAIT_S + while True: + status, current, unread = follower_status(), None, None + if status.get("connected") and status.get("url"): + try: + current = await asyncio.to_thread(gateway_version, status["url"]) + except Exception as e: # noqa: BLE001 - away or answering something else: said as why, tried again + unread = f"{type(e).__name__}: {e}"[:300] + status = follower_status() + if current is not None and status.get("connected") and status.get("version") == current: + return {"synced": True, "timezone": zone, "env_get_time_url": env_get_time_url, + "follower": {key: status.get(key) for key in ("name", "url", "version", "armed")}} + if time.monotonic() >= deadline: + raise TargetUnavailable("this computer's clock follower isn't following the gateway: " + + _why(status, current, unread)) + await asyncio.sleep(0.1) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/extensions.py index 2732f7190..78aaf932d 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/extensions.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Callable, Optional from ..agent_env_environment import agent_skill, extension -from . import card +from . import card, clock from .card import ( COMPUTER_DISPLAY, COMPUTER_EXEC, @@ -52,6 +52,12 @@ async def _computer_ready_status(self) -> dict: status["failed"] = "; ".join(f"{c.name}: {c.detail}" for c in failed) return status + @extension(clock.CLOCK, description="Follow the gateway's universe clock: the computer's clock follower already " + "follows the gateway core configured it with; this checks the task's zone against the one the computer " + "booted with and answers once the follower is connected, or says why it isn't.") + async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: + return await clock.sync_time(env_get_time_url, timezone) + @extension(COMPUTER_DISPLAY, op="get", method="GET", description="Get or set the display's resolution and scale.") async def _computer_display_get(self) -> dict: return _display_data(await self._computer.display_now(), self.display_modes) diff --git a/packages/agentenv-protocol/tests/test_computer_clock.py b/packages/agentenv-protocol/tests/test_computer_clock.py new file mode 100644 index 000000000..253f1e360 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_computer_clock.py @@ -0,0 +1,110 @@ +"""A computer's side of clock/v1: the driver serves sync_time where the base runs the clock follower and it isn't a disk +guest, the method every server that follows the clock serves; it checks the task's zone against the one the computer +booted with and answers once the follower shows the gateway's current version, or says why it doesn't.""" +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("PIL") + +from computer_fake import FakeDriver, http_client # noqa: E402 + +from agentenv_protocol.computer import clock # noqa: E402 +from agentenv_protocol.computer.errors import InvalidArgument, TargetUnavailable # noqa: E402 + +CLOCK = "urn:agentenv:clock/v1" + + +@pytest.fixture +def base(tmp_path, monkeypatch): + """A base that runs the follower, its status at ``tmp_path/status.json``, the session in Europe/Paris.""" + follower = tmp_path / "clock-follower" + follower.write_text("") + monkeypatch.setattr(clock, "FOLLOWER", str(follower)) + monkeypatch.setattr(clock, "DISK_GUEST", str(tmp_path / "image.env")) + monkeypatch.setattr(clock, "STATUS", str(tmp_path / "status.json")) + monkeypatch.setattr(clock, "CONNECT_WAIT_S", 0.3) + monkeypatch.setenv("TZ", "Europe/Paris") + monkeypatch.setattr(clock, "gateway_version", lambda url: "b:3") + return tmp_path + + +async def _clock_extension(driver) -> dict | None: + async with http_client(driver) as http: + card = (await http.get("/.well-known/agent-env.json")).json() + return next((e for e in card["capabilities"]["extensions"] if e["uri"] == CLOCK), None) + + +@pytest.mark.asyncio +async def test_the_card_serves_sync_time_where_the_base_runs_the_follower_and_it_isnt_a_disk_guest(base, monkeypatch): + served = await _clock_extension(FakeDriver()) + (base / "image.env").write_text("user=root\n") + on_a_disk = await _clock_extension(FakeDriver()) + monkeypatch.setattr(clock, "FOLLOWER", str(base / "absent")) + + assert set(served["params"]["methods"]) == {"sync_time"} + assert set(served["params"]["methods"]["sync_time"]["request"]["properties"]) == {"env_get_time_url", "timezone"} + assert on_a_disk is None and await _clock_extension(FakeDriver()) is None + + +@pytest.mark.asyncio +async def test_sync_time_answers_once_the_follower_shows_the_gateways_current_version(base): + (base / "status.json").write_text(json.dumps({"connected": True, "url": "http://gw/clock/watch", "name": "desktop", + "version": "b:3", "armed": True, "token": "never read"})) + + synced = await clock.sync_time("http://gw/clock/time", " Europe/Paris ") + + assert synced == {"synced": True, "timezone": "Europe/Paris", "env_get_time_url": "http://gw/clock/time", + "follower": {"name": "desktop", "url": "http://gw/clock/watch", "version": "b:3", "armed": True}} + + +@pytest.mark.parametrize("version, why", [ + ("b:3", "it shows the gateway's version 'b:2', and the gateway is at 'b:3'"), + (OSError("refused"), "this computer can't read the gateway's version at http://gw/clock/watch: OSError: refused"), +]) +@pytest.mark.asyncio +async def test_a_follower_behind_the_gateways_version_or_a_gateway_it_cant_read_is_named(base, monkeypatch, version, + why): + def answer(url): + assert url == "http://gw/clock/watch" + if isinstance(version, Exception): + raise version + return version + + monkeypatch.setattr(clock, "gateway_version", answer) + (base / "status.json").write_text(json.dumps({"connected": True, "url": "http://gw/clock/watch", "version": "b:2"})) + + with pytest.raises(TargetUnavailable, match="isn't following the gateway: " + why.replace("(", r"\(")): + await clock.sync_time("http://gw/clock/time") + + +@pytest.mark.asyncio +async def test_a_task_in_another_zone_is_refused_naming_the_setting_to_change(base): + with pytest.raises(InvalidArgument, match="deploy it with computer_settings.timezone = 'America/Chicago'"): + await clock.sync_time("http://gw/clock/time", "America/Chicago") + + +@pytest.mark.parametrize("status, why", [ + (None, "core hasn't written its config"), + ({"connected": False, "url": "http://gw/clock/watch", "error": "URLError: refused"}, + "it can't reach the gateway at http://gw/clock/watch: URLError: refused"), + ({"connected": False, "url": "http://gw/clock/watch", "error": None}, "it hasn't heard from the gateway"), +]) +@pytest.mark.asyncio +async def test_a_follower_that_isnt_connected_is_named_with_why(base, status, why): + if status is not None: + (base / "status.json").write_text(json.dumps(status)) + + with pytest.raises(TargetUnavailable, match="this computer's clock follower isn't following the gateway: " + why): + await clock.sync_time("http://gw/clock/time") + + +@pytest.mark.asyncio +async def test_a_computer_whose_base_doesnt_follow_the_clock_says_so(base, monkeypatch): + monkeypatch.setattr(clock, "FOLLOWER", str(base / "absent")) + + with pytest.raises(TargetUnavailable, match="doesn't follow the universe clock"): + await clock.sync_time("http://gw/clock/time") diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index e7e3d2d88..cab72b294 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -138,6 +138,17 @@ class ContainerForm(BaseModel): image: ArtifactRef init: Literal["supervisord"] input_channel: Literal["env"] + start_as: Literal["session", "root"] = Field(default="session", description=( + "Who its init starts as: the session user, or root, which sets up what the session must not touch (the clock " + "follower, as its own user) before it starts the session as its user")) + + @model_serializer(mode="wrap") + def _start_as_when_root(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + """``start_as`` only when it's root, so a form that starts as the session user is stored as it was before.""" + data = handler(self) + if data.get("start_as") == "session": + del data["start_as"] + return data class DiskSource(BaseModel): diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile b/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile index c8f53a2c3..922a3697f 100644 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile @@ -1,7 +1,19 @@ -# The ubuntu-24.04 base computer: Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, every program run by supervisord -# as the session user, whose home has a desktop's standard folders (Documents, Downloads, ...). Composing a driver or an -# app onto it adds its services as programs in /etc/supervisor/conf.d. The trust files agentenv adds are in -# /etc/agentenv from boot: ca.pem, empty until a deploy adds a CA, and ca-bundle.pem, the public roots and those CAs. +# The ubuntu-24.04 base computer: Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, whose session user's home has a +# desktop's standard folders (Documents, Downloads, ...). It starts as root: root's supervisord (root.conf) runs the clock +# follower as its own user and the session's supervisord (supervisord.conf) as the session user, which runs every +# session program. Composing a driver or an app onto it adds its services as programs in /etc/supervisor/conf.d. The +# trust files agentenv adds are in /etc/agentenv from boot: ca.pem, empty until a deploy adds a CA, and ca-bundle.pem, +# the public roots and those CAs. +# +# The universe clock (clock/): agent-env's libfaketime and its TLS shim, built in a stage of their own so no compiler is +# left in the image, are preloaded into every process and fake only the session user's, from the clock page the +# follower writes, which the session user can read but neither write nor replace. +FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:24737cef17f4e22b1977df7d9a05e77c4e28b7da5b8911f8d8ccda136bf3573c AS clock +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential patch curl ca-certificates +COPY clock/ /clock/ +RUN sh /clock/build.sh /out + FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:24737cef17f4e22b1977df7d9a05e77c4e28b7da5b8911f8d8ccda136bf3573c ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -16,14 +28,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && userdel --remove ubuntu \ && useradd --create-home --uid 1000 --shell /bin/bash user \ && runuser -u user -- xdg-user-dirs-update \ - && mkdir -p /etc/supervisor/conf.d /var/log/agentenv /run/agentenv \ - && mkdir -p /etc/agentenv && touch /etc/agentenv/ca.pem \ + && useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin agentenv-clock \ + && mkdir -p /etc/supervisor/conf.d /var/log/agentenv /run/agentenv /var/log/agentenv-root /run/agentenv-root \ + /usr/local/lib/agentenv-clock /etc/agentenv/clock \ + && chmod 0700 /var/log/agentenv-root /run/agentenv-root \ + && chown root:agentenv-clock /etc/agentenv/clock && chmod 0750 /etc/agentenv/clock \ + && touch /etc/agentenv/ca.pem \ && cp /etc/ssl/certs/ca-certificates.crt /etc/agentenv/ca-bundle.pem \ && chown user:user /var/log/agentenv /run/agentenv +COPY --from=clock /out/libfaketimeMT.so.1 /out/libtls_realtime.so /usr/local/lib/agentenv-clock/ +COPY clock/clockpage.py clock/clock-follower /usr/local/lib/agentenv-clock/ +COPY clock/clock.conf /etc/agentenv/clock.conf +RUN sed -i -e "s/^AGENTENV_SESSION_UID=.*/AGENTENV_SESSION_UID=$(id -u user)/" \ + -e "s/^AGENTENV_CLOCK_PAGE_UID=.*/AGENTENV_CLOCK_PAGE_UID=$(id -u agentenv-clock)/" /etc/agentenv/clock.conf \ + && chmod 0755 /usr/local/lib/agentenv-clock/clock-follower \ + && printf '%s\n' /usr/local/lib/agentenv-clock/libfaketimeMT.so.1 /usr/local/lib/agentenv-clock/libtls_realtime.so \ + > /etc/ld.so.preload +COPY root.conf /etc/supervisor/root.conf COPY supervisord.conf /etc/supervisor/supervisord.conf COPY xfwm4.xml /etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml COPY start-desktop /usr/local/bin/start-desktop -USER user +USER root WORKDIR /home/user ENV DISPLAY=:99 XDG_SESSION_TYPE=x11 LANG=en_US.UTF-8 ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml b/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml index 07f3f05ee..aa8131765 100644 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml @@ -1,5 +1,5 @@ type = "computer" -description = "Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, run by supervisord as the session user" +description = "Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, run by supervisord as the session user, on the universe clock" arch = ["amd64", "arm64"] os = { type = "linux", distribution = "ubuntu", version = "24.04", display = "x11" } session = { user = "user" } @@ -8,6 +8,7 @@ session = { user = "user" } image = { dockerfile = "Dockerfile" } init = "supervisord" input_channel = "env" +start_as = "root" [forms.disk] init = "supervisord" diff --git a/src/agent_env/computers/clock/COPYING b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/COPYING similarity index 100% rename from src/agent_env/computers/clock/COPYING rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/COPYING diff --git a/src/agent_env/computers/clock/README.md b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/README.md similarity index 79% rename from src/agent_env/computers/clock/README.md rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/README.md index c997dc325..bbce9b687 100644 --- a/src/agent_env/computers/clock/README.md +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/README.md @@ -5,6 +5,19 @@ server does. Instead each session program loads agent-env's build of [libfaketim (through `/etc/ld.so.preload`), which answers clock reads with universe time from a page a follower process keeps in step with the gateway. +It is part of the ubuntu-24.04 base, which builds the library and the shim from this folder in a stage of its +Dockerfile, since an image builds only from its own folder. The base starts as root: init sets the zone, makes the clock page and file for the follower's user, +`agentenv-clock`, and starts root's supervisord (`../root.conf`), which runs the follower (`clock-follower`) as that user +and the session's supervisord as the session user. The session user can read the clock but neither write, replace nor +stop it. + +The follower long-polls the gateway's `/clock/watch`, writes each state it answers to the page and acknowledges it with +its next watch. Core writes its config, the gateway's watch URL as the computer reaches it, the computer's name and its +read token (for now its control token), through the sandbox's root channel when a multi env deploys the computer, and +again after a reset; root and the follower alone can read it. The follower reads it every quarter second and reports in +a status file that holds no token, which the driver's `clock/v1` `sync_time` reads: the computer's card serving +`sync_time` is what tells core and the gateway that it follows the clock (`agentenv_protocol.computer.clock`). + What follows the universe, and what doesn't: - **Universe time:** clock reads (`CLOCK_REALTIME`, `_COARSE`, `_ALARM`, `CLOCK_TAI`, `gettimeofday`, `time`) and waits @@ -37,11 +50,12 @@ What follows the universe, and what doesn't: | `build.sh` | builds `libfaketimeMT.so.1` from upstream's sha256-pinned tarball and the files above, and the shim's `libtls_realtime.so` | Apache-2.0 | | `tls_realtime.c` | the TLS shim: OpenSSL's certificate checks on real time, preloaded after the library | Apache-2.0 | | `clockpage.py` | the clock page's writer, which the follower uses | Apache-2.0 | +| `clock-follower` | the follower: the gateway's watch, the page written, the status reported | Apache-2.0 | | `clock.conf` | the library's default settings | Apache-2.0 | Build it on x86_64 or aarch64 Linux with a C toolchain, make, patch and curl: - src/agent_env/computers/clock/build.sh OUT [TARBALL] + src/agent_env/computers/artifacts/ubuntu-24.04/clock/build.sh OUT [TARBALL] ## The clock page diff --git a/src/agent_env/computers/clock/agentenv_clock_page.c b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/agentenv_clock_page.c similarity index 100% rename from src/agent_env/computers/clock/agentenv_clock_page.c rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/agentenv_clock_page.c diff --git a/src/agent_env/computers/clock/agentenv_clock_waits.c b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/agentenv_clock_waits.c similarity index 100% rename from src/agent_env/computers/clock/agentenv_clock_waits.c rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/agentenv_clock_waits.c diff --git a/src/agent_env/computers/clock/build.sh b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/build.sh similarity index 100% rename from src/agent_env/computers/clock/build.sh rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/build.sh diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clock-follower b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clock-follower new file mode 100755 index 000000000..652431687 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clock-follower @@ -0,0 +1,204 @@ +#!/usr/bin/python3 -I +"""The computer's clock follower: holds a long poll on the gateway's ``/clock/watch`` and writes each state it learns +to the clock page (``clockpage.py``), which agent-env's libfaketime reads in every session program, so the session reads +the universe's time. It runs as its own user, under root's supervisord, so the session user can read the clock but +neither write it nor stop it. + +Core writes its config, the gateway's watch URL as this computer reaches it, the computer's name and its read token, +through the sandbox's root channel (``CONFIG``, readable by root and this user alone); never through the driver, which +runs as the session user. It reads the config every quarter second, during a watch too, and follows a changed one at +once. Until there is one, the clock stays as root made it at boot: real time. It reports how it's doing in ``STATUS``, +which anyone may read and which holds no token: the gateway it follows, whether it's connected, the version it shows, +or its last error. + +The gateway's clock is read against this machine's: its offset is four-stamp, ``((received - t1) + (sent - t4)) / 2``, +from the newest of the last 8 answers whose round trip, less the time the gateway held the watch, isn't an outlier. A +state is written anchored, universe V at this machine's real time A (the gateway's ``sent``) at rate R, so every reader +computes V + (now - A) * R. Each watch names the version it shows, which is its acknowledgement of that version.""" +import json +import os +import queue +import random +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import clockpage # noqa: E402 + +CONFIG = os.environ.get("AGENTENV_CLOCK_CONFIG", "/etc/agentenv/clock/follower.json") +STATUS = os.environ.get("AGENTENV_CLOCK_STATUS", "/run/agentenv-clock/status.json") +#: How long a watch may take: the gateway holds one up to 25 s (its card's ``hold_s``), plus a margin. +WATCH_TIMEOUT_S = 35.0 +#: How often the config is read, during a watch too. +CONFIG_POLL_S = 0.25 +#: A re-anchoring (the offset moved) is written only past this much, so the page doesn't churn on noise. +ADJUST_S = 0.001 +SAMPLES = 8 +#: After a failed watch it waits before asking again: RETRY_FIRST_S at first, doubled after each failure up to +#: RETRY_MAX_S, each wait a random fraction (RETRY_JITTER) of that, so computers that lost a gateway don't all return at +#: once. +RETRY_FIRST_S = 0.05 +RETRY_MAX_S = 2.0 +RETRY_JITTER = (0.5, 1.0) +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def log(event: str, **fields) -> None: + sys.stdout.write(json.dumps({"event": event, "real": time.time(), **fields}) + "\n") + sys.stdout.flush() + + +def atomic_write(path: str, text: str) -> None: + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w") as f: + f.write(text) + os.chmod(tmp, 0o644) + os.replace(tmp, path) + + +def read_config() -> tuple[bytes | None, dict | None]: + """The config's bytes and what they say, (None, None) when there is none, (bytes, None) when it isn't one.""" + try: + with open(CONFIG, "rb") as f: + raw = f.read() + except OSError: + return None, None + try: + config = json.loads(raw) + return raw, config if isinstance(config, dict) and all( + isinstance(config.get(key), str) and config[key] for key in ("watch_url", "name", "token")) else None + except ValueError: + return raw, None + + +def parse_time(text: str) -> float: + return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() + + +def offset(samples: list[dict]) -> tuple[float, int]: + """The offset from the newest sample whose delay isn't an outlier (over twice the median and 2 ms), and how many + were dropped.""" + delays = sorted(sample["delay"] for sample in samples) + median = delays[len(delays) // 2] + kept = [sample for sample in samples if sample["delay"] <= max(2 * median, median + 0.002)] + return (kept or samples)[-1]["theta"], len(samples) - len(kept) + + +def state_of(body: dict, theta: float) -> tuple[int, float, float, float]: + """(armed, universe at the anchor, rate, anchor on this machine's clock) for a watch's answer ``body``.""" + if not body.get("armed"): + return 0, 0.0, 1.0, time.time() + return (1, parse_time(body["virtual_time"]), float(body["virtual_seconds_per_real_second"]), + body["sent"] - theta) + + +def watch(url: str, name: str, token: str, after: str, out: queue.Queue) -> None: + """One watch, its answer (or what failed) put on ``out`` with the stamps around it.""" + t1 = time.time() + try: + request = urllib.request.Request( + f"{url}?{urllib.parse.urlencode({'after': after, 'follower': name})}", + headers={"Authorization": f"Bearer {token}"}) + with OPENER.open(request, timeout=WATCH_TIMEOUT_S) as answer: + body = json.loads(answer.read()) + out.put((t1, time.time(), body, None)) + except Exception as e: # noqa: BLE001 - away, restarting or refusing: the follower says so and asks again + out.put((t1, time.time(), None, e)) + + +class Follower: + """Follows one config until it changes.""" + + def __init__(self, raw: bytes, config: dict): + self.raw, self.url, self.name, self.token = raw, config["watch_url"], config["name"], config["token"] + self.after, self.samples, self.written = "", [], None + self.errors, self.backoff = 0, RETRY_FIRST_S + self.status = {"url": self.url, "name": self.name, "connected": False, "version": None, "armed": False, + "error": None} + + def report(self, **fields) -> None: + self.status.update(fields) + atomic_write(STATUS, json.dumps(self.status)) + + def run(self) -> None: + log("following", url=self.url, name=self.name) + self.report() + while True: + answers: queue.Queue = queue.Queue() + threading.Thread(target=watch, args=(self.url, self.name, self.token, self.after, answers), + daemon=True).start() + while True: + try: + t1, t4, body, error = answers.get(timeout=CONFIG_POLL_S) + break + except queue.Empty: + if read_config()[0] != self.raw: + return # its watch is left to end on its own; nothing it answers is applied + if error is not None: + self.failed(error) + continue + if read_config()[0] != self.raw: + return + try: + self.apply(t1, t4, body) + except (KeyError, TypeError, ValueError, AttributeError) as e: + self.failed(ValueError(f"not a clock watch's answer ({type(e).__name__}: {e})")) + + def failed(self, error: Exception) -> None: + self.errors += 1 + code = error.code if isinstance(error, urllib.error.HTTPError) else None + self.report(connected=False, error=f"{type(error).__name__}: {error}"[:300], status=code, errors=self.errors, + error_at=time.time()) + if self.errors <= 20 or self.errors % 100 == 0: + log("error", errors=self.errors, status=code, error=repr(error)[:200], backoff=self.backoff) + deadline = time.monotonic() + self.backoff * random.uniform(*RETRY_JITTER) + while time.monotonic() < deadline and read_config()[0] == self.raw: + time.sleep(min(CONFIG_POLL_S, max(0.0, deadline - time.monotonic()))) + self.backoff = min(self.backoff * 2, RETRY_MAX_S) + + def apply(self, t1: float, t4: float, body: dict) -> None: + if self.errors: + log("reconnected", after_errors=self.errors) + self.errors, self.backoff = 0, RETRY_FIRST_S + held = body["sent"] - body["received"] + self.samples = (self.samples + [{"delay": max(0.0, (t4 - t1) - held), + "theta": ((body["received"] - t1) + (body["sent"] - t4)) / 2}])[-SAMPLES:] + theta, dropped = offset(self.samples) + armed, universe, rate, anchor = state_of(body, theta) + meaning = (armed, rate, universe - rate * anchor if armed else 0.0) + changed = body["version"] != self.after + moved = (self.written is None or meaning[:2] != self.written[:2] + or abs(meaning[2] - self.written[2]) > ADJUST_S) + if changed or moved: + written = clockpage.write(armed, universe, rate, anchor) + self.written = meaning + log("applied" if changed else "adjusted", version=body["version"], armed=bool(armed), + virtual_time=body.get("virtual_time"), spec=written["spec"], theta=theta, held=held, dropped=dropped) + self.after = body["version"] + self.report(connected=True, version=self.after, armed=bool(armed), virtual_time=body.get("virtual_time"), + rate=body.get("virtual_seconds_per_real_second"), theta=theta, applied_at=time.time(), error=None, + status=None, errors=0) + + +def main() -> None: + log("start", pid=os.getpid(), uid=os.getuid()) + atomic_write(STATUS, json.dumps({"connected": False, "error": None, "waiting": "no config yet"})) + while True: + raw, config = read_config() + if config is None: + if raw is not None: + atomic_write(STATUS, json.dumps({"connected": False, "error": "its config isn't one: it needs " + "watch_url, name and token"})) + while read_config()[0] == raw: + time.sleep(CONFIG_POLL_S) + continue + Follower(raw, config).run() + + +if __name__ == "__main__": + main() diff --git a/src/agent_env/computers/clock/clock.conf b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clock.conf similarity index 100% rename from src/agent_env/computers/clock/clock.conf rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/clock.conf diff --git a/src/agent_env/computers/clock/clockpage.py b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clockpage.py similarity index 98% rename from src/agent_env/computers/clock/clockpage.py rename to src/agent_env/computers/artifacts/ubuntu-24.04/clock/clockpage.py index 9c8238bf0..53e57db1d 100644 --- a/src/agent_env/computers/clock/clockpage.py +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/clock/clockpage.py @@ -35,7 +35,8 @@ LAYOUT = struct.Struct(", their defaults artifact.toml's: -# init applies the time zone for every program and starts supervisord, which runs xvfb at the resolution, and with-x -# runs a program, the XFCE session first, once X answers. +# The base's start, with the settings a deploy passes as AGENTENV_SETTING_, their defaults artifact.toml's. init, +# as root at the computer's start, sets the time zone for every program (docker exec's included), makes what the +# session must not touch (the clock page and file, the clock user's from their first byte), then starts root's +# supervisord (root.conf), which runs the clock follower as its own user and the session's supervisord as the session +# user. That one runs xvfb at the resolution, and with-x runs a program, the XFCE session first, once X answers. set -eu resolution="${AGENTENV_SETTING_RESOLUTION:-1280x800}" width="${resolution%%x*}" height="${resolution#*x}" @@ -11,8 +13,12 @@ case "${1:-}" in init) timezone="${AGENTENV_SETTING_TIMEZONE:-UTC}" [ -f "/usr/share/zoneinfo/$timezone" ] || { echo "timezone '$timezone' isn't in /usr/share/zoneinfo" >&2; exit 2; } + [ "$(id -u)" = 0 ] || { echo "init starts as root, not uid $(id -u)" >&2; exit 2; } export TZ="$timezone" - exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf + ln -sfn "/usr/share/zoneinfo/$timezone" /etc/localtime && echo "$timezone" > /etc/timezone + install -d -o agentenv-clock -g agentenv-clock -m 0755 /run/agentenv-clock + python3 -I /usr/local/lib/agentenv-clock/clockpage.py create agentenv-clock + exec /usr/bin/supervisord -c /etc/supervisor/root.conf ;; xvfb) # supervisord runs one X at a time, so a lock here is one a killed X left, and X refuses to start while the pid diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf index f88c6c94b..6260b4c06 100644 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf @@ -1,4 +1,6 @@ -; The base computer's own programs, all run as the session user. A composed driver or app adds its own in conf.d. +; The session's programs, all run as the session user by this supervisord, which root's (root.conf) runs as that user; +; every one is on the universe's clock, and only agent-env's own servers carry the clock's real-time marker, on their +; own programs (the composer's). A composed driver or app adds its own in conf.d. [supervisord] nodaemon=true logfile=/var/log/agentenv/supervisord.log diff --git a/src/agent_env/env/envs/_multi_computers.py b/src/agent_env/env/envs/_multi_computers.py index 78dd344b8..b6ec90e30 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -13,10 +13,9 @@ from agentenv_protocol.computer import tool_prefix_problem -from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, Env -from agent_env.env.envs.computer.utils import sites +from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, Env, gateway_url_of +from agent_env.env.envs.computer.utils import clock as computer_clock, sites from agent_env.env.gateway import ExternalChild -from agent_env.env.gateway.constants import EXT_CLOCK_URI from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_env_sandbox_provider if TYPE_CHECKING: @@ -97,7 +96,7 @@ def routes_later(entry: Optional[dict]) -> bool: def _clock_read_token(record: DeployedEnv) -> str | None: """The read token a computer's clock watch carries when its card says it's a clock follower (``clock/v1``'s ``sync_time``): for now its control token, which a token of its own can replace without the gateway knowing.""" - return record.control_token if record.supports(EXT_CLOCK_URI, "sync_time") else None + return record.control_token if computer_clock.follows(record) else None def refusal(computer: ComputerEnv, entry: dict, sandbox_type: str | None, websites: Mapping[str, str]) -> str | None: @@ -228,7 +227,8 @@ async def finish(self, deployed: DeployedEnv) -> None: """Once the gateway is up, before ``deployed``, the multi env's record, is registered: each computer that routes the multi env's websites, those its ``site_routing`` names or all, has its start tabs checked against their names as the cards declare them and the live hosts (``start_tab_problems``), then routes them once they're - ready and launches its held apps; then each computer's own record is kept in ``deployed`` (``record``).""" + ready and launches its held apps; each computer whose card says it follows the clock has its follower follow + the gateway's; then each computer's own record is kept in ``deployed`` (``record``).""" if self._routes: # Selected by the name each website is served as, its environment_name, which its card's name needn't be. routed = {name: await sites.deployed_sites([deployed], services=only) for name, only in self._routes.items()} @@ -243,6 +243,10 @@ async def finish(self, deployed: DeployedEnv) -> None: await each(self._owner, "route its sites", [(f"computer {env.environment_name!r}", env.route_sites(routed[env.environment_name])) for env in self.every if env.environment_name in routed]) + if (gateway := gateway_url_of(deployed)) and (following := [ + env for env in self.every if env._deployed is not None and computer_clock.follows(env._deployed)]): + await each(self._owner, "follow the gateway's clock", [(f"computer {env.environment_name!r}", + env.follow_clock(gateway)) for env in following]) self.record(deployed) def record(self, deployed: DeployedEnv) -> None: diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index cc4bf169e..5c0a06982 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -306,6 +306,13 @@ async def route_sites(self, sites: list[dict]) -> None: if self._deployed.instance_id: update_env_instance_metadata(self._deployed.instance_id, {"computer": self._deployed.metadata["computer"]}) + async def follow_clock(self, gateway_url: str) -> None: + """Have this computer, deployed in a multi env whose gateway is at ``gateway_url``, follow that gateway's clock; + its record, and the instance store's, then keep the follower's config.""" + await as_builtin(self._env_provider).follow_clock(self._deployed, gateway_url, self.environment_name) + if self._deployed.instance_id: + update_env_instance_metadata(self._deployed.instance_id, {"computer": self._deployed.metadata["computer"]}) + async def run_env_params(self, env_params: Optional[dict], deployed_envs: list[DeployedEnv]) -> Optional[dict]: """With ``site_routing`` and no ``sites``, ``env_params`` naming as its sites the websites the run deployed before the computer, all of them or those of the env ids ``site_routing`` lists, once each says it's ready.""" diff --git a/src/agent_env/env/envs/computer/utils/clock.py b/src/agent_env/env/envs/computer/utils/clock.py new file mode 100644 index 000000000..db2ef6840 --- /dev/null +++ b/src/agent_env/env/envs/computer/utils/clock.py @@ -0,0 +1,83 @@ +"""A computer's clock follower from core's side: its config, the gateway's watch URL as the computer reaches it, the +computer's name as the gateway knows it and its read token, written through the sandbox's root channel and never +through the driver, which runs as the session user, so whatever it could write the session user could too, and point +the follower at a clock of its own. Only root and the follower can read it; it's written beside its place and renamed +into it, so the follower, which reads it every quarter second, never reads half of one. Core then calls the computer's +own clock/v1 ``sync_time``, which answers once the follower shows the gateway's current version, so a deploy or a reset +returns with the computer on the universe's clock. One that can't follow is logged as a warning naming why, and the +deploy or reset goes on: a task that never arms the clock needn't care, and one that does is refused when it arms it, +as ``sync_env_clock`` calls the same ``sync_time``. The deploy record keeps the URL and name (``metadata.computer.clock``) +beside its control token, the read token for now, so a reset configures the fresh computer as the one it replaced.""" +from __future__ import annotations + +import json +import logging +import shlex +import uuid +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Optional + +import httpx +from agentenv_protocol.client import ExtensionCallError +from agentenv_protocol.computer.clock import CONFIG, FOLLOWER_USER + +from agent_env.env.envs.computer.utils.sites import RootChannel +from agent_env.env.gateway.constants import EXT_CLOCK_URI + +if TYPE_CHECKING: + from agent_env.env.env import DeployedEnv + from agent_env.providers.sandbox_providers.sandbox_provider import Sandbox + +logger = logging.getLogger(__name__) + +WATCH_PATH = "/clock/watch" +TIME_PATH = "/clock/time" + + +def follows(record: DeployedEnv) -> bool: + """Whether the computer ``record`` deployed follows the universe clock, as its card says.""" + return record.supports(EXT_CLOCK_URI, "sync_time") + + +def recorded(record: DeployedEnv) -> Optional[dict]: + """The follower's config ``record`` keeps, its token aside: the watch URL and the computer's name.""" + clock = ((record.metadata or {}).get("computer") or {}).get("clock") + return clock if isinstance(clock, dict) and clock.get("watch_url") and clock.get("name") else None + + +async def configure(sandbox: Sandbox, record: DeployedEnv, gateway_url: str, name: str) -> None: + """Have the computer ``record`` deployed on ``sandbox`` follow the gateway at ``gateway_url`` (as this machine + reaches it) as ``name``, with its control token, and keep the URL and name in ``record``.""" + watch_url = sandbox.url_from_sandbox(gateway_url.rstrip("/") + WATCH_PATH) + await _write(sandbox, {"watch_url": watch_url, "name": name, "token": record.control_token}) + record.metadata.setdefault("computer", {})["clock"] = {"watch_url": watch_url, "name": name} + await _following(record, watch_url) + + +async def reconfigure(sandbox: Sandbox, record: DeployedEnv) -> None: + """The config ``record`` keeps, written again on the fresh computer a reset booted; nothing for one that has none.""" + if (kept := recorded(record)) is not None: + await _write(sandbox, {**kept, "token": record.control_token}) + await _following(record, kept["watch_url"]) + + +async def _following(record: DeployedEnv, watch_url: str) -> None: + """Once the computer's follower shows the gateway's current version, as its own ``sync_time``, handed the gateway's + time URL as the computer reaches it, says; a warning naming why when it doesn't.""" + try: + await record.invoke(EXT_CLOCK_URI, "sync_time", + {"env_get_time_url": watch_url.removesuffix(WATCH_PATH) + TIME_PATH}, timeout=30) + except ExtensionCallError as e: + logger.warning(f"{record.env_id}: its clock follower doesn't follow the gateway at {watch_url}, so its " + f"session reads real time until it does: {str(e)[:600]}") + except httpx.HTTPError as e: + logger.warning(f"{record.env_id}: couldn't ask whether its clock follower follows the gateway at {watch_url}: " + f"{type(e).__name__}: {e}") + + +async def _write(sandbox: Sandbox, config: dict) -> None: + channel = RootChannel(sandbox) + staged = f"{CONFIG}.{uuid.uuid4().hex}" + await channel.write_secret(PurePosixPath(staged), json.dumps(config)) + await channel.run(["sh", "-c", f"chown root:{shlex.quote(FOLLOWER_USER)} {shlex.quote(staged)} && " + f"chmod 0640 {shlex.quote(staged)} && mv -f {shlex.quote(staged)} {shlex.quote(CONFIG)}"]) diff --git a/src/agent_env/env/envs/computer/utils/composer.py b/src/agent_env/env/envs/computer/utils/composer.py index 309f5521c..d9d6c0089 100644 --- a/src/agent_env/env/envs/computer/utils/composer.py +++ b/src/agent_env/env/envs/computer/utils/composer.py @@ -936,10 +936,23 @@ def _stage(context: Path, layer: _Layer, what: str, files: FileArtifactUniverse, (context / layer.conf).write_text(_programs(programs, layer.prefix, environment)) +#: agent-env's own servers in a computer, the driver's (whose recorder runs in it) and an app's: the only programs on +#: real time, through the clock library's marker, which it takes out of their environment so what they start is on the +#: universe's, as every other program a driver or app runs is. +REAL_TIME_MODULES = ("agentenv_protocol.computer.serve", "agentenv_protocol.computer.serve_app") +REAL_TIME_MARKER = {"AGENTENV_REAL_TIME": "1"} + + +def _real_time(command: str) -> bool: + argv = shlex.split(command) + return any(argv[i] == "-m" and argv[i + 1] in REAL_TIME_MODULES for i in range(len(argv) - 1)) + + def _programs(commands: dict[str, str], prefix: str, environment: dict[str, Any]) -> str: - variables = ",".join(f'{key}="{value}"' for key, value in environment.items()) programs = [] for name, command in commands.items(): + own = {**environment, **(REAL_TIME_MARKER if _real_time(command) else {})} + variables = ",".join(f'{key}="{value}"' for key, value in own.items()) program, _, rest = command.partition(" ") if "/" in program and not program.startswith("/"): program = f"{prefix}/{program}" @@ -982,7 +995,8 @@ def _dockerfile(source: str, layers: list[_Layer], base: ComputerArtifact, build lock's .debs, mounted into the RUN that installs them, then each layer's files under its prefix, its venv beside them from the lock's wheels, mounted the same way, its run scripts and an app's install steps, each run as root or the session user, every install with the network closed; then its - programs, and an app's server's folder owned by the session user.""" + programs, and an app's server's folder owned by the session user. It starts as the base's container form says: as + the session user, or as root, whose init starts the session as its user.""" user = base.session.user lines = [f"FROM {source}", "USER root", *(f"ENV AGENTENV_SETTING_{name.upper()}={json.dumps(str(value))}" for name, value in build_settings.items())] @@ -1012,7 +1026,8 @@ def _dockerfile(source: str, layers: list[_Layer], base: ComputerArtifact, build if _venv(layer): checks.append(f"test -x {prefix}/venv/bin/python") lines.append(f"RUN {' && '.join(checks)}") - lines.append(f"USER {user}") + container = base.forms.container + lines.append("USER root" if container is not None and container.start_as == "root" else f"USER {user}") return "\n".join(lines) + "\n" @@ -1077,8 +1092,8 @@ def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str], d return None arches = [arch for arch in base.arch if arch in built] image = _joined(env, "computer_image", "composed computer", built, arches) - forms = {"container": {"image": {"id": image.id, "version": image.version}, "init": base.forms.container.init, - "input_channel": base.forms.container.input_channel}} + forms = {"container": {**base.forms.container.model_dump(exclude={"image"}), + "image": {"id": image.id, "version": image.version}}} if base.forms.disk is not None and all(arch in disks for arch in arches): disk = _joined(env, "computer_disk", "composed computer's disk", disks, arches) forms["disk"] = {**base.forms.disk.model_dump(exclude={"image"}), "image": {"id": disk.id, "version": disk.version}} 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 4615656ac..439de29bb 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -23,7 +23,7 @@ from agent_env.env.envs.computer.utils import params as computer_params from agent_env.env.envs.computer.utils.install import platform_of from agent_env.env.envs.computer.utils.probing import probe_first_boot -from agent_env.env.envs.computer.utils import sites +from agent_env.env.envs.computer.utils import clock as computer_clock, sites from agent_env.env.envs.computer.utils.boot import ( app_settings_environment, boot, @@ -139,6 +139,12 @@ async def route_sites(self, env: ComputerEnv, record: DeployedSandboxEnv, sites: if routed := await _finish_boot(env, record, sandbox, params, valid_for_s, computer.get("display")): computer["sites"] = routed + async def follow_clock(self, record: DeployedSandboxEnv, gateway_url: str, name: str) -> None: + """Have the computer ``record`` names follow the clock of the gateway at ``gateway_url`` as ``name``: its + follower's config written through the root channel; its record then keeps it, so a reset writes it again.""" + sandbox = await build_sandbox_provider(record.sandbox_type).get_sandbox(record.sandbox_id) + await computer_clock.configure(sandbox, record, gateway_url, name) + async def install_changelog_triggers(self, environment_name: str) -> None: """No state store, so no changelog to install.""" @@ -246,10 +252,12 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sandbox, params: computer_params.ComputerParams, valid_for_s: int, resolution: Optional[str]) -> Optional[dict]: - """What a deploy and a reset do once the computer's driver answers, in order: 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 tabs read into the record, the boot failing on one on a site of the env's that - didn't load. What the record keeps of the routing, or None without it.""" + """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 + tabs read into the record, the boot failing on one on a site of the env's that didn't load. What the record keeps of + the routing, or None without it.""" + await computer_clock.reconfigure(sandbox, record) routed = None if params.site_routing: routed = await _route_sites(env, sandbox, params, valid_for_s, diff --git a/tst/clock/conftest.py b/tst/clock/conftest.py index c135f9d00..bbc799cb9 100644 --- a/tst/clock/conftest.py +++ b/tst/clock/conftest.py @@ -1,4 +1,4 @@ -"""The clock tier: agent-env's libfaketime (src/agent_env/computers/clock) built in an Ubuntu 24.04 image with stock +"""The clock tier: agent-env's libfaketime (src/agent_env/computers/artifacts/ubuntu-24.04/clock) built in an Ubuntu 24.04 image with stock 0.9.12 beside it and the gate programs, each gate run in a container of that image by harness.py. Needs Docker; the image is for the Docker daemon's own arch, and Chrome is the browsers bundle's pinned build for it. Chrome's archive, upstream libfaketime's tarball and OpenSSL 1.1.1w's are fetched once into AGENTENV_TEST_CLOCK_CACHE @@ -18,7 +18,7 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] -RECIPE = REPO / "src" / "agent_env" / "computers" / "clock" +RECIPE = REPO / "src" / "agent_env" / "computers" / "artifacts" / "ubuntu-24.04" / "clock" CHROMIUM_APP = REPO / "src" / "agent_env" / "browsers" / "envs" / "chromium" / "chromium.py" IMAGE = "agentenv-clock-gates:test" #: Tries at the downloads and the image build (its apt-get), which fail now and then on a network's hiccup. diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 4d3d3f83b..2e17bbe94 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -1,5 +1,8 @@ -"""The ubuntu-24.04 base computer, built from its folder: its desktop runs as the session user with the settings a -deploy passes and comes back however often X dies, a setting it can't use stops it at start, and pushed to the local +"""The ubuntu-24.04 base computer, built from its folder: it starts as root, which runs only its init, the clock +follower as its own user and the session's supervisord, and its desktop runs as the session user with the settings a +deploy passes and comes back however often X dies; the session user can neither write, replace, read the config of nor +stop the clock, and only the session's programs read universe time, a program marked real reading real time and what it +starts universe time; a follower killed comes back; a setting it can't use stops it at start, and pushed to the local registry it registers as a computer artifact whose image put reads back. Needs Docker and the local registry on localhost:5000.""" @@ -9,14 +12,17 @@ import time import tomllib import uuid +from pathlib import PurePosixPath import pytest +from agentenv_protocol.computer.clock import CONFIG, FOLLOWER, FOLLOWER_USER, STATUS from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.config import configure, reset_config -from tst.integration.computer.conftest import BASE, docker +from tst.integration.computer.conftest import (BASE, FOLLOWER_BACK_S, PAGE_WRITER, docker, kill_follower, + only_session_and_clock, process_owners) pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -43,18 +49,21 @@ def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes( "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", base) try: running = _all_running(name) - users = set(_exec(name, "ps", "-eo", "user=").split()) + owners = process_owners(name) + zone = _exec(name, "readlink", "/etc/localtime").strip() uid = _exec(name, "id", "-u", "user").strip() screen = _exec(name, "xdpyinfo") wm = _exec(name, "xprop", "-root", "_NET_SUPPORTING_WM_CHECK") - environment = _exec(name, "sh", "-c", 'tr "\\0" "\\n" < /proc/$(pgrep -x xfwm4)/environ') + # As the session user, whose process it is: root here lacks the ptrace capability to read another's. + environment = _exec_as(name, "user", "sh", "-c", 'tr "\\0" "\\n" < /proc/$(pgrep -o -x xfwm4)/environ') accessibility = _exec(name, "pgrep", "-c", "at-spi2-registr") folders = set(_exec(name, "ls", "/home/user").split()) finally: subprocess.run(["docker", "rm", "-f", name], capture_output=True) assert {line.split()[0] for line in running.splitlines()} == {"dbus", "desktop", "xvfb"} assert all(" RUNNING " in line for line in running.splitlines()), running - assert users == {"user"} and uid == "1000" + assert uid == "1000" and only_session_and_clock(owners) + assert zone == "/usr/share/zoneinfo/Europe/Paris" assert "dimensions: 1600x900 pixels" in screen and "window id #" in wm assert "TZ=Europe/Paris" in environment.splitlines() and "DISPLAY=:99" in environment.splitlines() assert int(accessibility) == 1 @@ -90,7 +99,9 @@ def test_the_desktop_comes_back_from_a_restart_that_killed_x_with_its_lock_namin docker("run", "-d", "--name", name, base) try: _all_running(name) - _exec(name, "sh", "-c", 'printf "%10d\\n" 1 > /tmp/lock && chmod 444 /tmp/lock && mv -f /tmp/lock /tmp/.X99-lock') + # As X leaves it, X running as the session user. + _exec_as(name, "user", "sh", "-c", + 'printf "%10d\\n" 1 > /tmp/lock && chmod 444 /tmp/lock && mv -f /tmp/lock /tmp/.X99-lock') docker("restart", "-t", "0", name) running = _all_running(name) finally: @@ -127,6 +138,82 @@ def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_b assert found["image"] == pinned.image_name +def test_the_session_user_can_neither_change_nor_read_the_config_of_nor_stop_the_clock(base): + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + docker("run", "-d", "--name", name, base) + try: + _all_running(name) + follower = _follower(name) + _exec(name, "sh", "-c", f"echo '{{}}' > {CONFIG}.x && chown root:{FOLLOWER_USER} {CONFIG}.x && " + f"chmod 0640 {CONFIG}.x && mv {CONFIG}.x {CONFIG}") + attempts = {what: _as_session(name, command).returncode for what, command in { + "write the page": "echo x > /dev/shm/agentenv-clock", + "replace the page": "rm -f /dev/shm/agentenv-clock", + "write the clock file": "echo '+0 x1' > /run/agentenv-clock/clock", + "add a file beside it": f"touch {STATUS}.x", + "read the follower's config": f"cat {CONFIG}", + "list its folder": f"ls {PurePosixPath(CONFIG).parent}", + "kill the follower": f"kill -9 {follower}", + "stop it through root's supervisord": "supervisorctl -c /etc/supervisor/root.conf stop clock", + "change what's preloaded": "echo > /etc/ld.so.preload", + "change the library's settings": "echo > /etc/agentenv/clock.conf", + }.items()} + page = _exec(name, "stat", "-c", "%U %a", "/dev/shm/agentenv-clock").strip() + still = _follower(name) + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + assert {what: code for what, code in attempts.items() if code == 0} == {}, attempts + assert page == f"{FOLLOWER_USER} 644" and still == follower + + +def test_only_the_sessions_programs_read_universe_time_and_a_marked_one_real_time_but_not_what_it_starts(base): + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + docker("run", "-d", "--name", name, base) + try: + _all_running(name) + universe = time.time() - 182 * 86400 + _exec_as(name, FOLLOWER_USER, "python3", PAGE_WRITER, "set", repr(universe), "1") + read = {who: float(subprocess.run(["docker", "exec", *flags, name, *command], check=True, capture_output=True, + text=True).stdout) - time.time() + for who, flags, command in [ + ("session", ["-u", "user"], ["date", "+%s.%N"]), + ("marked", ["-u", "user", "-e", "AGENTENV_REAL_TIME=1"], ["date", "+%s.%N"]), + ("started by a marked one", ["-u", "user", "-e", "AGENTENV_REAL_TIME=1"], + ["sh", "-c", "date +%s.%N"]), + ("root", ["-u", "0"], ["date", "+%s.%N"]), + ("the follower's user", ["-u", FOLLOWER_USER], ["date", "+%s.%N"])]} + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + back = universe - time.time() + assert {who: abs(offset - back) < 5 for who, offset in read.items()} == { + "session": True, "marked": False, "started by a marked one": True, "root": False, "the follower's user": False} + assert all(abs(offset) < 5 for who, offset in read.items() if who in ("marked", "root", "the follower's user")) + + +def test_a_follower_killed_comes_back(base): + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + docker("run", "-d", "--name", name, base) + try: + _all_running(name) + killed, back, took = kill_follower(name) + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + assert back not in ("-", killed) and took < FOLLOWER_BACK_S, took + + +def _follower(name: str) -> str: + return subprocess.run(["docker", "exec", name, "pgrep", "-u", FOLLOWER_USER, "-f", FOLLOWER], + capture_output=True, text=True).stdout.strip() + + +def _as_session(name: str, command: str) -> subprocess.CompletedProcess: + return subprocess.run(["docker", "exec", "-u", "user", name, "sh", "-c", command], capture_output=True, text=True) + + +def _exec_as(name: str, user: str, *argv: str) -> str: + return subprocess.run(["docker", "exec", "-u", user, name, *argv], check=True, capture_output=True, text=True).stdout + + def _all_running(name: str) -> str: for _ in range(60): running = subprocess.run(["docker", "exec", name, *SUPERVISOR], capture_output=True, text=True).stdout diff --git a/tst/integration/computer/clock-desktop/envs/curl/app.py b/tst/integration/computer/clock-desktop/envs/curl/app.py new file mode 100644 index 000000000..f7ece408c --- /dev/null +++ b/tst/integration/computer/clock-desktop/envs/curl/app.py @@ -0,0 +1,8 @@ +"""curl, which the base doesn't have, for the clock desktop test to reach a live site with on universe time.""" +from agentenv_protocol.computer import ComputerAppEnvironment, Packages, computer_app, linux + + +@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) +@linux(packages=Packages(apt=["curl"])) +class Curl(ComputerAppEnvironment): + pass diff --git a/tst/integration/computer/clock-desktop/envs/curl/env.toml b/tst/integration/computer/clock-desktop/envs/curl/env.toml new file mode 100644 index 000000000..2ba2fb075 --- /dev/null +++ b/tst/integration/computer/clock-desktop/envs/curl/env.toml @@ -0,0 +1,2 @@ +type = "computer_app" +module = "app.py:Curl" diff --git a/tst/integration/computer/clock-desktop/envs/desktop/env.toml b/tst/integration/computer/clock-desktop/envs/desktop/env.toml index 68fbca210..bf6456150 100644 --- a/tst/integration/computer/clock-desktop/envs/desktop/env.toml +++ b/tst/integration/computer/clock-desktop/envs/desktop/env.toml @@ -1,4 +1,4 @@ type = "computer" computer = "ubuntu-24.04" driver = "reference" -apps = ["chromium", "universe-clock"] +apps = ["chromium", "curl"] diff --git a/tst/integration/computer/clock-desktop/envs/universe-clock/app.py b/tst/integration/computer/clock-desktop/envs/universe-clock/app.py deleted file mode 100644 index 5f5626474..000000000 --- a/tst/integration/computer/clock-desktop/envs/universe-clock/app.py +++ /dev/null @@ -1,21 +0,0 @@ -"""agent-env's libfaketime and its TLS shim on the computer, for the clock desktop test: built at compose from the -recipe the test copies into ./clock (src/agent_env/computers/clock) and upstream's tarball, which the app downloads at -the sha256 build.sh pins, then preloaded into every process with the library's settings and the clock page's writer -beside them (install.sh), and curl, which the base doesn't have, to reach a live site with. The page itself is the -test's to write, as root, as the computer's clock follower will.""" -import re -from pathlib import Path - -from agentenv_protocol.computer import (ComputerAppEnvironment, Download, Packages, RunScript, computer_app, linux) - -_BUILD = (Path(__file__).parent / "clock" / "build.sh").read_text() -TARBALL = re.search(r"https://\S+\.tar\.gz", _BUILD).group(0) -SHA256 = re.search(r"\b[0-9a-f]{64}\b", _BUILD).group(0) - - -@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) -@linux(packages=Packages(apt=["build-essential", "patch", "curl"]), - downloads=[Download(url=TARBALL, to="libfaketime.tar.gz", sha256=SHA256)], - run=[RunScript(script="install.sh")]) -class UniverseClock(ComputerAppEnvironment): - pass diff --git a/tst/integration/computer/clock-desktop/envs/universe-clock/env.toml b/tst/integration/computer/clock-desktop/envs/universe-clock/env.toml deleted file mode 100644 index 675554ec6..000000000 --- a/tst/integration/computer/clock-desktop/envs/universe-clock/env.toml +++ /dev/null @@ -1,2 +0,0 @@ -type = "computer_app" -module = "app.py:UniverseClock" diff --git a/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh b/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh deleted file mode 100755 index 72f94bc28..000000000 --- a/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Builds agent-env's libfaketime and its TLS shim from ./clock and the downloaded tarball, installs the library's -# settings and the page's writer, and preloads both into every process the computer starts, the library first. -set -eu -sh clock/build.sh /opt/agentenv/clock libfaketime.tar.gz -install -m 0644 clock/clockpage.py /opt/agentenv/clock/clockpage.py -install -D -m 0644 clock/clock.conf /etc/agentenv/clock.conf -printf '%s\n' /opt/agentenv/clock/libfaketimeMT.so.1 /opt/agentenv/clock/libtls_realtime.so > /etc/ld.so.preload diff --git a/tst/integration/computer/clock_desktop_test.py b/tst/integration/computer/clock_desktop_test.py index cd206fd03..8b671d1db 100644 --- a/tst/integration/computer/clock_desktop_test.py +++ b/tst/integration/computer/clock_desktop_test.py @@ -1,6 +1,7 @@ """agent-env's libfaketime on a real computer: the clock desktop test bundle's, the ubuntu-24.04 desktop with the reference driver and agentenv's chromium, every process loading the library. Composed, it passes the conformance -probe at full. Deployed, with its clock page written as root, as the computer's follower will write it: curl and +probe at full. Deployed alone, with no gateway for its follower to follow, and its clock page written as the +follower's user, as the follower writes it: curl and 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, @@ -13,8 +14,10 @@ import re import subprocess import time +from pathlib import PurePosixPath import pytest +from agentenv_protocol.computer.clock import FOLLOWER, FOLLOWER_USER from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client @@ -24,6 +27,7 @@ from agent_env.task.teardown import teardown_run 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.website_browser_test import _window_center pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -40,20 +44,19 @@ 'xprop -id "$w" _NET_WM_NAME; done') #: The processes that must run on through the clock's changes: Xvfb, the desktop's panel and Chrome's browser process. PIDS = "pgrep -o -x Xvfb; pgrep -o -x xfce4-panel; pgrep -o -f -- --remote-debugging-pipe" -PAGE_WRITER = "/opt/agentenv/clock/clockpage.py" #: A live site reached from the session, the clock months back, with no proxy: each exits 0 once its certificate #: verifies. REACH = {"curl": "curl -sS -o /dev/null --noproxy '*' --max-time 30 https://example.com/", "python": ("python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler({}))" ".open('https://example.com/', timeout=30)\"")} -LIBRARY = "/opt/agentenv/clock/libfaketimeMT.so.1" +LIBRARY = str(PurePosixPath(FOLLOWER).parent / "libfaketimeMT.so.1") def test_composed_with_the_library_in_every_process_the_computer_passes_the_probe_at_full(clock_desktop): results = clock_desktop.metadata["composed"]["container"] assert results[ARCH]["status"] == "ready" and results[ARCH]["probe"]["passed"], results[ARCH] - assert [app.environment_name for app in clock_desktop.composed_apps()] == ["chromium", "universe-clock"] + assert [app.environment_name for app in clock_desktop.composed_apps()] == ["chromium", "curl"] @pytest.mark.asyncio @@ -118,7 +121,8 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_ class _Clock: - """The computer's clock page, written as root as its follower writes it, and what the session reads of it.""" + """The computer's clock page, written as its follower's user, as its follower writes it, and what the session + reads of it.""" def __init__(self, deployed): self.container = _container(deployed) @@ -127,7 +131,7 @@ def __init__(self, deployed): def set(self, universe: float, rate: float) -> dict: """The page written to ``universe`` at ``rate``: the write's own answer, its rate, and when it started.""" started = time.time() - out = subprocess.run(["docker", "exec", "-u", "0", self.container, "python3", PAGE_WRITER, "set", + out = subprocess.run(["docker", "exec", "-u", FOLLOWER_USER, self.container, "python3", PAGE_WRITER, "set", repr(universe), repr(rate)], capture_output=True, text=True, check=True).stdout self.page = ast.literal_eval(out.strip()) self.page.update(rate=rate, started=started) diff --git a/tst/integration/computer/computer_clock_test.py b/tst/integration/computer/computer_clock_test.py new file mode 100644 index 000000000..9f2c9c31d --- /dev/null +++ b/tst/integration/computer/computer_clock_test.py @@ -0,0 +1,122 @@ +"""The ubuntu-24.04 computer on its multi env's gateway clock, on the local backend: the deploy has its follower follow +the gateway, as the computer's sandbox reaches it, under the computer's name and control token, which the record +keeps but the token; sync_env_clock arms the clock as a task does, calling the computer's sync_time; the session +reads universe time, and each change is acknowledged in time for a write that waits for it; a follower killed comes +back within 3 s and acknowledges the next advance; and a reset brings the page and the follower back on the clock's +current version. Needs Docker and the local registry on localhost:5000.""" + +import asyncio +import subprocess +import time +from datetime import datetime, timedelta, timezone +from urllib.parse import urlsplit + +import pytest +from agentenv_protocol.computer.clock import FOLLOWER, FOLLOWER_USER + +from agent_env.env import Env +from agent_env.env.env import DeployedEnv +from agent_env.env.envs.computer.utils import inside +from agent_env.env.gateway.constants import EXT_CLOCK_URI +from agent_env.store.routing import namespace_routing +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep +from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep +from tst.integration.computer.conftest import CONTAINER, FOLLOWER_BACK_S, kill_follower, multi_env + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +DAY = 86400 + + +def _iso(moment: datetime) -> str: + return moment.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@pytest.mark.asyncio +async def test_a_multi_envs_computer_follows_its_gateways_clock_through_arming_a_killed_follower_and_a_reset( + desktop, items): + with namespace_routing(): + office = multi_env(items, Env.get(desktop.id)) + step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, + child_envs={"desktop": CONTAINER}) + context = TaskStepContext() + try: + [deployed] = (await step.execute(context)).deployed_envs + own = DeployedEnv.from_dict(deployed.child_envs["desktop"]) + kept = own.metadata["computer"]["clock"] + container = _container(own.sandbox_id) + + universe = datetime.now(timezone.utc) - timedelta(days=182) + await SyncEnvClockTaskStep(id="clock", version=None, env_id=office.id, + virtual_time=_iso(universe)).execute(context) + synced = context.metadata["clock_configurations"][-1]["synced"] + armed = await _session_offset(own) - (universe.timestamp() - time.time()) + + day_on = universe + timedelta(days=1) + await _advance(deployed, day_on) + advanced = await _session_offset(own) - (day_on.timestamp() - time.time()) + + killed, back, came_back = kill_follower(container) + later = day_on + timedelta(hours=8) + await _advance(deployed, later) + after_kill = await _session_offset(own) - (later.timestamp() - time.time()) + + await ResetEnvTaskStep(id="reset", version=None, env_id=office.id, fail_task_on_error=True).execute(context) + state = await _shown(deployed) + after_reset = await _session_offset(own) - (_universe_now(state) - time.time()) + state_after_reset = await _shown(deployed) + finally: + report = await teardown_run(context) + + assert kept == {"watch_url": kept["watch_url"], "name": "desktop"} + assert urlsplit(kept["watch_url"]).hostname == "host.docker.internal" and kept["watch_url"].endswith("/clock/watch") + assert [entry["service"] for entry in synced if entry.get("service") == "desktop"] == ["desktop"] + assert abs(armed) < 5 and abs(advanced) < 5 and abs(after_kill) < 5 and abs(after_reset) < 5, ( + armed, advanced, after_kill, after_reset) + assert back not in ("-", killed) and came_back < FOLLOWER_BACK_S, came_back + assert state_after_reset["followers"]["desktop"]["version"] == state_after_reset["version"], state_after_reset + assert not report.failed + + +async def _advance(deployed, to: datetime) -> None: + """The clock moved to ``to``, waiting up to 15 s for every computer to show it, as a 504 naming any that doesn't.""" + await deployed.invoke(EXT_CLOCK_URI, "advance", {"virtual_time": _iso(to), "wait_followers_s": 15}, timeout=30) + + +async def _shown(deployed, within: float = 15) -> dict: + """The clock's state once the desktop shows its current version.""" + deadline = time.monotonic() + within + while True: + state = await deployed.invoke(EXT_CLOCK_URI, "state", timeout=30) + if state["followers"]["desktop"]["version"] == state["version"] or time.monotonic() > deadline: + return state + await asyncio.sleep(0.2) + + +def _universe_now(state: dict) -> float: + return datetime.fromisoformat(state["virtual_time"].replace("Z", "+00:00")).timestamp() + + +async def _session_offset(computer) -> float: + """How far the session's clock is from real time, read with ``date`` through the driver, as the session user.""" + ran = await inside.run(computer, ["date", "+%s.%N"], timeout_s=30) + assert ran["exit_code"] == 0, ran + return float(ran["stdout"]) - time.time() + + +def _follower(container: str) -> str: + return subprocess.run(["docker", "exec", container, "pgrep", "-u", FOLLOWER_USER, "-f", FOLLOWER], + capture_output=True, text=True).stdout.strip() + + +def _container(sandbox_id: str) -> str: + """The computer's container, the one of its sandbox's (each named for the sandbox) that runs the follower.""" + listed = subprocess.run(["docker", "ps", "-q", "--filter", f"name={sandbox_id}"], capture_output=True, text=True, + check=True).stdout.split() + for container in listed: + if _follower(container): + return container + raise AssertionError(f"no container of sandbox {sandbox_id} runs the follower: {listed}") diff --git a/tst/integration/computer/computer_env_test.py b/tst/integration/computer/computer_env_test.py index d258d04ea..2d5660bc3 100644 --- a/tst/integration/computer/computer_env_test.py +++ b/tst/integration/computer/computer_env_test.py @@ -49,7 +49,7 @@ from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep from agent_env.task_step.task_steps.run_code import RunCodeTaskStep from agent_env.task_step.task_steps.computer.snapshot_computer_env import SnapshotComputerEnvTaskStep -from tst.integration.computer.conftest import docker +from tst.integration.computer.conftest import docker, only_session_and_clock, process_owners pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -89,13 +89,12 @@ async def test_the_image_of_this_machines_arch_boots_as_the_session_user_and_pas try: url = await _ready(name) reports = [await run_probe(url, "full") for _ in range(3)] - users = set(subprocess.run(["docker", "exec", name, "ps", "-eo", "user="], capture_output=True, text=True, - check=True).stdout.split()) + owners = process_owners(name) finally: subprocess.run(["docker", "rm", "-f", name], capture_output=True) for report in reports: assert report.passed, [f"{c.check} {c.name}: {c.detail}" for c in report.checks if c.status == "failed"] - assert users == {"user"} + assert only_session_and_clock(owners) def test_an_env_that_changes_only_its_display_and_name_reuses_the_image(desktop): diff --git a/tst/integration/computer/conftest.py b/tst/integration/computer/conftest.py index 3d2fdaf35..720388a17 100644 --- a/tst/integration/computer/conftest.py +++ b/tst/integration/computer/conftest.py @@ -15,10 +15,12 @@ import subprocess import tempfile import uuid -from pathlib import Path +from pathlib import Path, PurePosixPath import pytest +from agentenv_protocol.computer.clock import FOLLOWER, FOLLOWER_USER + import agent_env from agent_env.artifact.store import reset_artifact_store from agent_env.bundle import parse_bundle @@ -28,11 +30,15 @@ from agent_env.bundle.resolve import BuiltImage, resolve_bundle from agent_env.config import configure, reset_config from agent_env.env import Env +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.computer.utils import builder from agent_env.providers.sandbox_providers.host_images import host_platform from agent_env.store.object_store.local.grant_server import grant_server from agent_env.store.routing import namespace_routing from agent_env.task.store import get_task_instance_store +from tst.integration.env.envs.server_env_local_test import _put_items_image +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs from tst.util import package_cache from tst.util.published_image import pull_published_else_build @@ -43,8 +49,6 @@ BROWSER_COMPUTER = Path(__file__).parent / "browser-computer" TLS = Path(__file__).parent / "tls" CLOCK_DESKTOP = Path(__file__).parent / "clock-desktop" -#: agent-env's libfaketime recipe, which the clock desktop's universe-clock app builds. -CLOCK_RECIPE = Path(agent_env.__file__).parent / "computers" / "clock" #: Playwright and Puppeteer, which connect to a cdp address from Node. CDP_CLIENTS = Path(__file__).parent / "cdp_clients" CHROMIUM_ALONE = "@local/agentenv-framework/browsers/chromium" @@ -123,15 +127,9 @@ def tls_desktop(tmp_path_factory): @pytest.fixture(scope="module") def clock_desktop(tmp_path_factory): - """The clock desktop test bundle's desktop, the ubuntu-24.04 base and the reference driver with agentenv's chromium - and the universe-clock app, which builds agent-env's libfaketime from a copy of its recipe and preloads it into every - process; composed as ``desktop`` is.""" - root = tmp_path_factory.mktemp("clock-desktop") - bundle = root / "bundle" - shutil.copytree(CLOCK_DESKTOP, bundle) - shutil.copytree(CLOCK_RECIPE, bundle / "envs" / "universe-clock" / "clock", - ignore=shutil.ignore_patterns("__pycache__")) - yield from _composed(root, parse_bundle(bundle)) + """The clock desktop test bundle's desktop, the ubuntu-24.04 base, whose every process loads agent-env's + libfaketime, and the reference driver with agentenv's chromium and curl; composed as ``desktop`` is.""" + yield from _composed(tmp_path_factory.mktemp("clock-desktop"), parse_bundle(CLOCK_DESKTOP)) @pytest.fixture(scope="module") @@ -203,3 +201,81 @@ def run_context(output: str) -> dict: """The context of the run whose ``agent-env run`` output ``output`` is.""" with namespace_routing(): return get_task_instance_store().get(re.search(r"instance (@local/.+)$", output, re.M).group(1)).context + + +#: What root runs: tini, the init it started, which is root's supervisord, and the ps that lists them. +ROOTS = ("/usr/bin/tini -- /usr/local/bin/start-desktop init", + "/usr/bin/python3 /usr/bin/supervisord -c /etc/supervisor/root.conf", "ps -eo user:32=,args=") +#: The clock page's writer, which the base installs beside the follower. +PAGE_WRITER = str(PurePosixPath(FOLLOWER).parent / "clockpage.py") + + +def process_owners(name: str) -> dict[str, list[str]]: + """Each process's command line in the container ``name``, by the user that runs it.""" + owners: dict[str, list[str]] = {} + for line in _exec_text(name, "ps", "-eo", "user:32=,args=").splitlines(): + user, _, args = line.strip().partition(" ") + owners.setdefault(user, []).append(args.strip()) + return owners + + +def only_session_and_clock(owners: dict[str, list[str]]) -> bool: + """Whether root runs nothing but its init and supervisord, the clock's user nothing but the follower, and every + other process is the session user's.""" + assert set(owners) == {"root", FOLLOWER_USER, "user"}, owners + assert all(args in ROOTS for args in owners["root"]), owners["root"] + assert owners[FOLLOWER_USER] == [f"/usr/bin/python3 -I {FOLLOWER}"], owners[FOLLOWER_USER] + return True + + +def _exec_text(name: str, *argv: str) -> str: + return subprocess.run(["docker", "exec", name, *argv], check=True, capture_output=True, text=True).stdout + + +#: Kills the follower and times it until another runs, inside the container, as root, polling every 20 ms: prints the +#: pid killed, the pid back, and the seconds between. +_KILL_FOLLOWER = """import os, signal, subprocess, sys, time +def running(): + return subprocess.run(["pgrep", "-u", sys.argv[2], "-f", sys.argv[1]], capture_output=True, text=True).stdout.strip() +killed = running() +os.kill(int(killed), signal.SIGKILL) +start = time.monotonic() +while (back := running()) in ("", killed) and time.monotonic() - start < 10: + time.sleep(0.02) +print(killed, back or "-", time.monotonic() - start) +""" + + +#: How soon supervisord has a killed follower back: it notices the exit when it next wakes, up to a second later, +#: and the follower starts Python; on a CI runner busy with other tests that has taken 2.03 s. +FOLLOWER_BACK_S = 3 + + +def kill_follower(name: str) -> tuple[str, str, float]: + """The clock follower in the container ``name`` killed with SIGKILL: its pid, the pid of the one back ("-" if + none within 10 s), and how long that took, timed in the container.""" + killed, back, took = subprocess.run(["docker", "exec", "-u", "0", name, "python3", "-c", _KILL_FOLLOWER, FOLLOWER, + FOLLOWER_USER], + check=True, capture_output=True, text=True).stdout.split() + return killed, back, float(took) + + +#: A multi env's computer on the local backend in its container form, as its child_envs entry gives it. +CONTAINER = {"sandbox_type": "local", "env_params": {"form": "container"}} + + +@pytest.fixture(scope="module") +def items(desktop): + """The gateway's and service-db's envs, and the items MCP server under a local id, since a multi env naming the + desktop's local id must be local too and a store's id can't reference a local one.""" + with namespace_routing(): + _seed_bootstrap_envs() + uid = uuid.uuid4().hex[:8] + return MCPServerEnv.put(id=f"@local/~/office-{uid}/items", environment_name="items", + docker_image_artifact=_put_items_image(f"office-items-{uid}")) + + +def multi_env(items, *computers, websites=()) -> MultiEnv: + """A multi env named office of the ``items`` MCP server, ``websites`` and ``computers``, under a local id.""" + return MultiEnv.put(id=f"@local/~/office-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[items], + website_envs=list(websites), computer_envs=list(computers), name="office") diff --git a/tst/integration/computer/multi_env_computers_test.py b/tst/integration/computer/multi_env_computers_test.py index 6524714d6..9c4a5451c 100644 --- a/tst/integration/computer/multi_env_computers_test.py +++ b/tst/integration/computer/multi_env_computers_test.py @@ -32,7 +32,6 @@ from agent_env.env.envs.computer.env import COMPUTER_TOOLS, ComputerEnv from agent_env.env.envs.computer.utils import inside from agent_env.env.envs.mcp_server import MCPServerEnv -from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_ENVIRONMENT_NAME from agent_env.env.envs.website import WebsiteEnv from agent_env.env.legacy_protocol import card_servers @@ -46,24 +45,13 @@ from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep from agent_env.task_step.task_steps.snapshot_env import SnapshotEnvTaskStep from tst.util import local_sandboxes -from tst.integration.env.envs.server_env_local_test import _items_artifact, _put_items_image -from tst.integration.store.local_parallel_deploy_test import _docker, _seed_bootstrap_envs +from tst.integration.computer.conftest import CONTAINER, multi_env +from tst.integration.env.envs.server_env_local_test import _items_artifact +from tst.integration.store.local_parallel_deploy_test import _docker pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] UNPREFIXED = re.compile(r"^computer_") -CONTAINER = {"sandbox_type": "local", "env_params": {"form": "container"}} - - -@pytest.fixture(scope="module") -def items(desktop): - """The gateway's and service-db's envs, and the items MCP server under a local id, since a multi env naming the - desktop's local id must be local too and a store's id can't reference a local one.""" - with namespace_routing(): - _seed_bootstrap_envs() - uid = uuid.uuid4().hex[:8] - return MCPServerEnv.put(id=f"@local/~/office-{uid}/items", environment_name="items", - docker_image_artifact=_put_items_image(f"office-items-{uid}")) def _image(fixture: str, tag: str) -> DockerImageArtifact: @@ -93,11 +81,6 @@ def shop(items): backend_docker_image_artifact=_image("agentenv_website", f"office-shop-{uid}")) -def _multi(items, *computers, websites=()) -> MultiEnv: - return MultiEnv.put(id=f"@local/~/office-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[items], - website_envs=list(websites), computer_envs=list(computers), name="office") - - LOAD_TIMEOUT_S = 20 # a site's answer inside the computer RUN_TIMEOUT_S = LOAD_TIMEOUT_S + 20 # the command that loads it, which must outlast the load @@ -184,7 +167,7 @@ def _gateway(deployed, made: list[str]) -> str: async def test_a_computer_in_a_multi_env_is_served_through_its_gateway_under_its_name_and_torn_down_with_it(desktop, items, shop): with namespace_routing(): - office = _multi(items, Env.get(desktop.id), websites=[shop]) + office = multi_env(items, Env.get(desktop.id), websites=[shop]) step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, child_envs={"desktop": {**CONTAINER, "env_params": {"form": "container", "site_routing": True}}}) assert step.preflight() == [] @@ -266,7 +249,7 @@ async def test_two_computers_keep_serving_through_restarts_and_a_failed_redeploy with namespace_routing(): laptop = ComputerEnv.put(id=f"{desktop.id}-laptop", computer=desktop.computer, driver=desktop.driver, environment_name="laptop") - env = Env.get(_multi(items, Env.get(desktop.id), laptop).id) + env = Env.get(multi_env(items, Env.get(desktop.id), laptop).id) try: deployed = await env.deploy(sandbox_type="local", ttl_seconds=900, child_envs={"desktop": CONTAINER, "laptop": CONTAINER}) @@ -315,7 +298,7 @@ async def refuse(self, deployed): monkeypatch.setattr(ComputerEnv, "_give_tools", refuse) with namespace_routing(): - office = _multi(items, Env.get(desktop.id)) + office = multi_env(items, Env.get(desktop.id)) context = TaskStepContext() with pytest.raises(RuntimeError, match="the computer's tools couldn't be applied"): await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=600, @@ -330,7 +313,7 @@ async def test_a_computer_entry_the_multi_env_refuses_makes_no_sandbox(desktop, for name in ("create_sandbox", "create_vm", "create_container"): monkeypatch.setattr(LocalSandboxProvider, name, lambda *args, _name=name, **kwargs: made.append(_name)) with namespace_routing(): - office = _multi(items, Env.get(desktop.id)) + office = multi_env(items, Env.get(desktop.id)) refused = [ ({"desktop": {**CONTAINER, "env_params": {"site_routing": True}}}, "child_envs.desktop: env_params.site_routing routes the multi env's websites, and it has none"), diff --git a/tst/integration/computer/website_browser_test.py b/tst/integration/computer/website_browser_test.py index 62ea16e7a..0a84dffe1 100644 --- a/tst/integration/computer/website_browser_test.py +++ b/tst/integration/computer/website_browser_test.py @@ -44,7 +44,8 @@ from agent_env.task_step.task_steps.browser.sign_in import BrowserSignInTaskStep from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep -from tst.integration.computer.multi_env_computers_test import CONTAINER, _image +from tst.integration.computer.conftest import CONTAINER +from tst.integration.computer.multi_env_computers_test import _image from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] diff --git a/tst/unit/bundle/shipped_computers_test.py b/tst/unit/bundle/shipped_computers_test.py index e8a095c12..72fc4e626 100644 --- a/tst/unit/bundle/shipped_computers_test.py +++ b/tst/unit/bundle/shipped_computers_test.py @@ -45,7 +45,10 @@ def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_ima assert (computer.arch, computer.os.version, computer.session.user) == (["amd64", "arm64"], "24.04", "user") assert image.id == f"{UBUNTU}__forms_container_image" and image.dockerfile_path == "Dockerfile" built = next(call["build"] for call in builds if call["build"][2] == image.image_name) - assert built[1] == ["Dockerfile", "artifact.toml", "start-desktop", "supervisord.conf", "xfwm4.xml"] + assert built[1] == ["Dockerfile", "artifact.toml", *(f"clock/{name}" for name in ( + "COPYING", "README.md", "agentenv_clock_page.c", "agentenv_clock_waits.c", "build.sh", "clock-follower", + "clock.conf", "clockpage.py", "libfaketime.patch", "tls_realtime.c")), "root.conf", "start-desktop", + "supervisord.conf", "xfwm4.xml"] assert computer.metadata["validated_images"]["container"]["arm64"]["status"] == "uninspected" diff --git a/tst/unit/env/envs/_computer.py b/tst/unit/env/envs/_computer.py index 768cc6157..ea5d9e02b 100644 --- a/tst/unit/env/envs/_computer.py +++ b/tst/unit/env/envs/_computer.py @@ -355,7 +355,7 @@ async def create_vm(self, **size): #: The program of the mail app's own server. MAIL_SERVER = ("[program:app-mail]\ncommand=/opt/agentenv/apps/mail/venv/bin/python -m " "agentenv_protocol.computer.serve_app app.py:Mail\ndirectory=/opt/agentenv/apps/mail\n" - "environment=ENVIRONMENT_NAME=\"mail\"\nautorestart=true\nredirect_stderr=true\n" + "environment=ENVIRONMENT_NAME=\"mail\",AGENTENV_REAL_TIME=\"1\"\nautorestart=true\nredirect_stderr=true\n" "stdout_logfile=/var/log/agentenv/app-mail.log\n") diff --git a/tst/unit/env/envs/test_computer_clock.py b/tst/unit/env/envs/test_computer_clock.py new file mode 100644 index 000000000..ff7c37434 --- /dev/null +++ b/tst/unit/env/envs/test_computer_clock.py @@ -0,0 +1,102 @@ +"""A computer's clock follower from core's side: its config written through the root channel, root's and the follower's +alone and renamed into place, its URL as the computer's sandbox reaches the gateway, kept in the record without its +token, and written again on the fresh computer a reset boots; each time core waits on the computer's own sync_time, and +a follower that can't follow is logged as a warning naming why, the deploy or reset going on.""" +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + +from agentenv_protocol.client import ExtensionCallError +from agentenv_protocol.computer.clock import CONFIG + +from agent_env.env.env import DeployedSandboxEnv +from agent_env.env.envs.computer.utils import clock + + +def _sandbox(calls: list, written: dict) -> SimpleNamespace: + async def exec_with_output(*argv): + calls.append(argv) + return 0, "0\n" if argv == ("id", "-u") else "", "" + + async def write_file_from_text(content, path): + calls.append(("write", path)) + written[path] = json.loads(content) + + return SimpleNamespace(url_from_sandbox=lambda url: url.replace("http://localhost:8123", "http://host.docker.internal:8123"), + exec_with_output=exec_with_output, write_file_from_text=write_file_from_text) + + +@pytest.fixture(autouse=True) +def synced(monkeypatch) -> AsyncMock: + """The computer's sync_time, which answers once its follower follows.""" + invoke = AsyncMock(return_value={"synced": True}) + monkeypatch.setattr(DeployedSandboxEnv, "invoke", invoke) + return invoke + + +def _record(**metadata) -> DeployedSandboxEnv: + return DeployedSandboxEnv(env_id="desktop", env_version=1, sandbox_id="sb", sandbox_type="local", + control_token="token-desktop", metadata={"computer": {"form": "container", **metadata}}) + + +@pytest.mark.asyncio +async def test_the_config_is_written_through_the_root_channel_readable_by_the_follower_alone_and_renamed_into_place( + synced): + calls, written, record = [], {}, _record() + + await clock.configure(_sandbox(calls, written), record, "http://localhost:8123/", "desktop") + + (staged, config), = written.items() + assert staged.startswith(f"{CONFIG}.") and config == { + "watch_url": "http://host.docker.internal:8123/clock/watch", "name": "desktop", "token": "token-desktop"} + assert calls == [("id", "-u"), ("mkdir", "-p", "/etc/agentenv/clock"), ("write", staged), + ("sh", "-c", f"chown root:agentenv-clock {staged} && chmod 0640 {staged} && mv -f {staged} {CONFIG}")] + assert record.metadata["computer"]["clock"] == {"watch_url": "http://host.docker.internal:8123/clock/watch", + "name": "desktop"} + synced.assert_awaited_once_with("urn:agentenv:clock/v1", "sync_time", + {"env_get_time_url": "http://host.docker.internal:8123/clock/time"}, timeout=30) + + +@pytest.mark.asyncio +async def test_a_reset_writes_the_kept_config_again_with_the_records_token_and_one_without_writes_nothing(synced): + calls, written = [], {} + kept = {"watch_url": "https://gw.example/clock/watch", "name": "desktop"} + + await clock.reconfigure(_sandbox(calls, written), _record(clock=kept)) + await clock.reconfigure(_sandbox(calls, written), _record()) + + assert list(written.values()) == [{**kept, "token": "token-desktop"}] + assert len(calls) == 4 and synced.await_count == 1 + + +@pytest.mark.asyncio +async def test_a_follower_that_cant_follow_is_logged_as_a_warning_naming_why_and_the_reset_goes_on(synced, caplog): + answer = httpx.Response(503, json={"ok": False, "error": {"code": "target_unavailable", "message": ( + "this computer's clock follower isn't following the gateway: it can't reach the gateway: URLError: refused"), + "retryable": False}}, request=httpx.Request("POST", "http://desktop/agentenv/ext/sync_time")) + synced.side_effect = ExtensionCallError(answer) + written = {} + + with caplog.at_level("WARNING"): + await clock.reconfigure(_sandbox([], written), _record(clock={"watch_url": "https://gw.example/clock/watch", + "name": "desktop"})) + + assert len(written) == 1 + assert ("desktop: its clock follower doesn't follow the gateway at https://gw.example/clock/watch, so its session " + "reads real time until it does: 503 target_unavailable: this computer's clock follower isn't following " + "the gateway: it can't reach the gateway: URLError: refused") in caplog.text + assert "retryable" not in caplog.text + + +def test_a_computer_follows_the_clock_when_its_card_serves_sync_time(): + card = {"name": "desktop", "capabilities": {"extensions": [ + {"uri": "urn:agentenv:clock/v1", "params": {"methods": {"sync_time": {"method": "POST", "endpoint": "/x"}}}}]}} + + assert clock.follows(DeployedSandboxEnv(env_id="desktop", env_version=1, sandbox_id="sb", environment_card=card)) + assert not clock.follows(DeployedSandboxEnv(env_id="desktop", env_version=1, sandbox_id="sb", + environment_card={"name": "desktop"})) diff --git a/tst/unit/env/envs/test_computer_clock_base.py b/tst/unit/env/envs/test_computer_clock_base.py new file mode 100644 index 000000000..caa736cfe --- /dev/null +++ b/tst/unit/env/envs/test_computer_clock_base.py @@ -0,0 +1,60 @@ +"""The ubuntu-24.04 base's clock files agree with what core and the driver read from agentenv_protocol.computer.clock: +its Dockerfile, init and root's supervisord name the follower, its config's folder, its status's folder and its user as +the protocol does, and the library's settings name the page and file its writer writes. A disk form's guest has the file +the driver takes for one, so it serves no sync_time there.""" +from __future__ import annotations + +import configparser +import importlib.util +from pathlib import Path, PurePosixPath + +import agent_env +from agentenv_protocol.computer import clock + +COMPUTERS = Path(agent_env.__file__).parent / "computers" +BASE = COMPUTERS / "artifacts" / "ubuntu-24.04" +USER = clock.FOLLOWER_USER + + +def _settings() -> dict[str, str]: + return dict(line.split("=", 1) for line in (BASE / "clock" / "clock.conf").read_text().splitlines() + if "=" in line and not line.startswith("#")) + + +def _page_writer(): + spec = importlib.util.spec_from_file_location("clockpage_defaults", BASE / "clock" / "clockpage.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_the_bases_dockerfile_installs_the_follower_and_makes_its_user_and_config_folder_as_the_protocol_names_them(): + dockerfile = (BASE / "Dockerfile").read_text() + config_folder = PurePosixPath(clock.CONFIG).parent + + assert f"clock/clock-follower {PurePosixPath(clock.FOLLOWER).parent}/\n" in dockerfile + assert f"--shell /usr/sbin/nologin {USER} \\\n" in dockerfile + assert f"chown root:{USER} {config_folder} && chmod 0750 {config_folder}" in dockerfile + + +def test_roots_supervisord_runs_the_follower_as_its_user_and_init_makes_its_status_folder_and_page_its_own(): + root = configparser.ConfigParser(interpolation=None) + root.read_string((BASE / "root.conf").read_text()) + init = (BASE / "start-desktop").read_text() + + assert (root["program:clock"]["command"], root["program:clock"]["user"]) == ( + f"/usr/bin/python3 -I {clock.FOLLOWER}", USER) + assert f"install -d -o {USER} -g {USER} -m 0755 {PurePosixPath(clock.STATUS).parent}\n" in init + assert f"clockpage.py create {USER}\n" in init + + +def test_the_librarys_settings_name_the_page_and_file_the_writer_writes_beside_the_followers_status(): + settings, writer = _settings(), _page_writer() + + assert (settings["AGENTENV_CLOCK_PAGE"], settings["FAKETIME_TIMESTAMP_FILE"]) == (writer.PAGE, writer.FILE) + assert PurePosixPath(writer.FILE).parent == PurePosixPath(clock.STATUS).parent + + +def test_a_disk_guest_has_the_file_the_driver_takes_for_one(): + assert f"COPY --from=start /image.env {clock.DISK_GUEST}\n" in ( + COMPUTERS / "disk" / "linux" / "boot" / "Dockerfile").read_text() diff --git a/tst/unit/env/envs/test_computer_clock_follower.py b/tst/unit/env/envs/test_computer_clock_follower.py new file mode 100644 index 000000000..367a7af41 --- /dev/null +++ b/tst/unit/env/envs/test_computer_clock_follower.py @@ -0,0 +1,205 @@ +"""The ubuntu-24.04 base's clock follower, run as it runs on a computer, against a stand-in for the gateway's watch that +holds a watch while the version it names is current, as the gateway does: it follows nothing until it has a config, +then writes each state the gateway answers to the clock page and acknowledges it with its next watch; it follows a +changed config at once, during a held watch too; and one it can't reach is named in its status, which holds no token.""" +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + +import agent_env +from agentenv_protocol.computer import clock as protocol_clock + +CLOCK = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" / "clock" +FOLLOWER = CLOCK / "clock-follower" + + + +@pytest.fixture(autouse=True) +def _disable_network(): + """The loopback stand-in for the gateway is the point here, so the unit tier's socket block is lifted.""" + yield + + +def _clockpage(page: Path): + spec = importlib.util.spec_from_file_location("clockpage_under_test", CLOCK / "clockpage.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.PAGE = str(page) + return module + + +class Gateway: + """A watch that holds while ``after`` is the current version (``hold`` seconds), answers the clock's state with + its receive and send stamps, and records each watch's version, follower and token.""" + + def __init__(self, hold: float = 5.0): + self.hold, self.watches = hold, [] + self.state = {"armed": False, "version": "boot:0"} + self.changed = threading.Condition() + gateway = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + received = time.time() + query = {key: values[0] for key, values in parse_qs(urlsplit(self.path).query).items()} + gateway.watches.append((query.get("after", ""), query.get("follower"), + self.headers.get("Authorization"))) + with gateway.changed: + if query.get("after", "") == gateway.state["version"]: + gateway.changed.wait(gateway.hold) + body = json.dumps({**gateway.state, "received": received, "sent": time.time()}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.server.daemon_threads = True + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/clock/watch" + + def set(self, version: str, universe: datetime, rate: float) -> None: + with self.changed: + self.state = {"armed": True, "version": version, "virtual_time": universe.isoformat(), + "virtual_seconds_per_real_second": rate} + self.changed.notify_all() + + def close(self) -> None: + with self.changed: + self.changed.notify_all() + self.server.shutdown() + + +@pytest.fixture +def follower(tmp_path): + """The follower started with its config, status, page and file under ``tmp_path``; yields a namespace with + ``configure(config)``, ``status()`` and ``universe()`` (the page's time now).""" + paths = {"AGENTENV_CLOCK_CONFIG": tmp_path / "follower.json", "AGENTENV_CLOCK_STATUS": tmp_path / "status.json", + "AGENTENV_CLOCK_PAGE": tmp_path / "page", "AGENTENV_CLOCK_FILE": tmp_path / "clock"} + process = subprocess.Popen([sys.executable, "-I", str(FOLLOWER)], env={**os.environ, **{k: str(v) for k, v in paths.items()}}, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + page = _clockpage(paths["AGENTENV_CLOCK_PAGE"]) + + class Running: + def configure(self, config: dict) -> None: + staged = tmp_path / "follower.json.staged" + staged.write_text(json.dumps(config)) + staged.replace(paths["AGENTENV_CLOCK_CONFIG"]) + + def status(self) -> dict: + try: + return json.loads(paths["AGENTENV_CLOCK_STATUS"].read_text()) + except (OSError, ValueError): + return {} + + def universe(self) -> float: + with open(page.PAGE, "rb") as f: + fields = page.LAYOUT.unpack(f.read(page.LAYOUT.size)) + _, _, _, universe_ns, anchor_ns, rate, armed, *_ = fields + return page._universe_at(armed, universe_ns, anchor_ns, rate, time.time_ns()) / 1e9 + + def until(self, check, within: float = 5.0): + deadline = time.monotonic() + within + while time.monotonic() < deadline: + if result := check(): + return result + time.sleep(0.02) + raise AssertionError(f"not within {within} s; status {self.status()}; process alive: {process.poll() is None}") + + yield Running() + process.kill() + process.wait() + + +def test_the_followers_paths_are_the_ones_core_and_the_driver_use(): + source = FOLLOWER.read_text() + assert f'"{protocol_clock.CONFIG}"' in source and f'"{protocol_clock.STATUS}"' in source + + +def test_it_follows_the_gateway_its_config_names_and_acknowledges_each_version_with_its_next_watch(follower): + gateway = Gateway() + try: + assert follower.until(lambda: follower.status().get("waiting")) + gateway.set("boot:1", datetime(2020, 1, 1, tzinfo=timezone.utc), 1.0) + follower.configure({"watch_url": gateway.url, "name": "desktop", "token": "read-token"}) + + follower.until(lambda: follower.status().get("version") == "boot:1") + assert abs(follower.universe() - datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()) < 2 + follower.until(lambda: ("boot:1", "desktop", "Bearer read-token") in gateway.watches) + + frozen = datetime(2021, 6, 1, 12, tzinfo=timezone.utc) + gateway.set("boot:2", frozen, 0.0) + follower.until(lambda: follower.status().get("version") == "boot:2", within=2) + assert follower.universe() == frozen.timestamp() + follower.until(lambda: ("boot:2", "desktop", "Bearer read-token") in gateway.watches) + assert "read-token" not in json.dumps(follower.status()) + finally: + gateway.close() + + +def test_a_changed_config_is_followed_at_once_during_a_held_watch(follower): + first, second = Gateway(hold=30), Gateway(hold=30) + try: + first.set("a:1", datetime(2020, 1, 1, tzinfo=timezone.utc), 1.0) + second.set("b:1", datetime(2030, 1, 1, tzinfo=timezone.utc), 1.0) + follower.configure({"watch_url": first.url, "name": "desktop", "token": "t"}) + follower.until(lambda: ("a:1", "desktop", "Bearer t") in first.watches) # held now + + changed = time.monotonic() + follower.configure({"watch_url": second.url, "name": "desktop", "token": "t"}) + follower.until(lambda: follower.status().get("version") == "b:1", within=2) + + assert time.monotonic() - changed < 1.5 + assert abs(follower.universe() - datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp()) < 3 + finally: + first.close() + second.close() + + +def test_a_gateway_it_cant_reach_is_named_in_its_status_and_it_keeps_trying(follower): + follower.configure({"watch_url": "http://127.0.0.1:9/clock/watch", "name": "desktop", "token": "t"}) + + status = follower.until(lambda: follower.status() if follower.status().get("error") else None) + + assert status["connected"] is False and status["url"] == "http://127.0.0.1:9/clock/watch" + assert "Connection refused" in status["error"] or "URLError" in status["error"] + follower.until(lambda: follower.status().get("errors", 0) >= 2) + + +def test_a_config_that_isnt_one_is_named_and_a_good_one_then_followed(follower): + gateway = Gateway() + try: + follower.configure({"watch_url": gateway.url, "name": "desktop"}) + follower.until(lambda: "isn't one" in (follower.status().get("error") or "")) + + gateway.set("boot:1", datetime(2020, 1, 1, tzinfo=timezone.utc), 1.0) + follower.configure({"watch_url": gateway.url, "name": "desktop", "token": "t"}) + follower.until(lambda: follower.status().get("connected")) + finally: + gateway.close() + + +def test_the_drivers_read_of_the_gateways_version_names_no_follower_so_it_acknowledges_nothing(): + gateway = Gateway() + try: + gateway.set("boot:7", datetime(2020, 1, 1, tzinfo=timezone.utc), 1.0) + + assert protocol_clock.gateway_version(gateway.url) == "boot:7" + assert gateway.watches == [("", None, None)] + finally: + gateway.close() diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 0e60904e6..838dbd3be 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -121,11 +121,26 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ assert composed["arm64"]["lock"].startswith("sha256:") and composed["arm64"]["lock"] != composed["amd64"]["lock"] assert arm64["conf"] == ( "[program:driver-desk-driver]\ncommand=/opt/agentenv/drivers/desk/venv/bin/python -m agentenv_protocol.computer.serve " - 'driver.py:Desk\ndirectory=/opt/agentenv/drivers/desk\nenvironment=MCP_PORT="18765"\nautorestart=true\n' + 'driver.py:Desk\ndirectory=/opt/agentenv/drivers/desk\nenvironment=MCP_PORT="18765",AGENTENV_REAL_TIME="1"\n' + 'autorestart=true\n' "redirect_stderr=true\nstdout_logfile=/var/log/agentenv/driver-desk-driver.log\n") assert ComputerEnv.from_dict(stored.to_dict()).to_dict() == stored.to_dict() +def test_a_base_whose_container_form_starts_as_root_composes_an_image_that_starts_as_root(base, driver, docker): + image = base.image() + rooted = _base(image, forms={"container": {"image": {"id": image.id, "version": image.version}, "init": "supervisord", + "input_channel": "env", "start_as": "root"}}) + + _put(rooted, driver) + + composed = Env.get("@local/~/kit/desktop").metadata["computer"] + assert ComputerArtifact.get(rooted.id, rooted.version).forms.container.start_as == "root" + assert ComputerArtifact.get(composed["id"], composed["version"]).forms.container.start_as == "root" + assert all(build["dockerfile"].splitlines()[-2] == "USER root" for build in docker.builds) + assert "start_as" not in base.model_dump()["forms"]["container"] + + def test_a_key_covers_its_build_files_executable_bits_and_the_composers_format(base, driver, docker, tmp_path, monkeypatch): folder = tmp_path / "ctx" diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 903c91a88..1b49f38f6 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -40,10 +40,16 @@ GATEWAY = "https://gw.example" -def _record(name: str) -> DeployedSandboxEnv: +#: A computer's card whose driver serves clock/v1's sync_time: it follows the gateway's clock. +CLOCK_CARD = {"capabilities": {"extensions": [ + {"uri": "urn:agentenv:clock/v1", "params": {"methods": {"sync_time": {"method": "POST", "endpoint": "/clock/sync"}}}}]}} + + +def _record(name: str, *, clock: bool = False) -> DeployedSandboxEnv: return DeployedSandboxEnv(env_id=f"{name}-computer", env_version=1, env_provider_type="computer", sandbox_id=f"sb-{name}", sandbox_type="modal_vm", environment_card_url=f"https://{name}.example{WELL_KNOWN_PATH}", - mcp_url=f"https://{name}.example/mcp", control_token=f"token-{name}") + mcp_url=f"https://{name}.example/mcp", control_token=f"token-{name}", + environment_card={"name": name, **CLOCK_CARD} if clock else None) class FakeComputer: @@ -53,11 +59,12 @@ class FakeComputer: def __init__(self, name: str, *, fails: Exception | None = None, refusal: str | None = None, log: list | None = None, apps: tuple[str, ...] = (), resets: Exception | None = None, routes: Exception | None = None, - loads: Exception | None = None): + loads: Exception | None = None, clock: bool = False): self.id, self.version, self.environment_name = f"{name}-computer", 1, name self.fails, self.refusal, self.log = fails, refusal, [] if log is None else log self.deploys, self.refusals, self._deployed, self.closes = [], [], None, 0 self.apps, self.reset_fails, self.route_fails, self.load_fails = apps, resets, routes, loads + self.clock = clock def composed_apps(self): return [SimpleNamespace(environment_name=app, declaration=SimpleNamespace(browser=False)) for app in self.apps] @@ -85,6 +92,10 @@ async def route_sites(self, sites): if self.route_fails: raise self.route_fails + async def follow_clock(self, gateway_url): + inspect.signature(ComputerEnv.follow_clock).bind(self, gateway_url) + self.log.append(("follows", self.environment_name, gateway_url)) + async def reset(self, deployed): inspect.signature(ComputerEnv.reset).bind(self, deployed) await asyncio.sleep(0.01) @@ -103,7 +114,7 @@ async def deploy(self, **options): await asyncio.sleep(0.01) if self.fails: raise self.fails - self._deployed = _record(self.environment_name) + self._deployed = _record(self.environment_name, clock=self.clock) self.log.append(("deployed", self.environment_name)) return self._deployed @@ -315,9 +326,19 @@ async def test_deploy_hands_the_gateway_each_computer_deployed_under_its_name_an def test_a_computer_whose_card_follows_a_clock_hands_the_gateway_its_control_token_as_its_read_token(): from agent_env.env.envs._multi_computers import _clock_read_token - following = dataclasses.replace(_record("desktop"), environment_card={"name": "desktop", "capabilities": {"extensions": [ - {"uri": "urn:agentenv:clock/v1", "params": {"methods": {"sync_time": {"method": "POST", "endpoint": "/clock/sync"}}}}]}}) - assert (_clock_read_token(following), _clock_read_token(_record("laptop"))) == ("token-desktop", None) + assert (_clock_read_token(_record("desktop", clock=True)), _clock_read_token(_record("laptop"))) == ( + "token-desktop", None) + + +@pytest.mark.asyncio +async def test_once_the_gateway_is_up_each_computer_whose_card_follows_the_clock_follows_the_gateways(gateway, providers): + log = gateway.log + desktop, laptop = FakeComputer("desktop", log=log, clock=True), FakeComputer("laptop", log=log) + + deployed = await _multi(desktop, laptop).deploy() + + assert [entry for entry in log if entry[0] == "follows"] == [("follows", "desktop", GATEWAY)] + assert set(deployed.child_envs) == {"desktop", "laptop"} @pytest.mark.asyncio