diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md index b9fd9ff67..a05aa5bc5 100644 --- a/src/agent_env/computers/README.md +++ b/src/agent_env/computers/README.md @@ -239,3 +239,8 @@ computer failed for good. This bundle has no tasks of its own: other bundles pull its computers by alias, and `agent-env run computers` has nothing to run. + +`envs/clock/` is the `clock` app, which a Linux X11 computer env composes (`apps = ["clock"]`) to follow its multi env's +gateway clock: agent-env's libfaketime from `clock/`, preloaded into every process, and the clock follower that keeps it +on the gateway's universe time once `sync_env_clock` arms the multi env. `clock/README.md` says how. Name it from +another bundle as `clock`, the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/computers/clock`. diff --git a/src/agent_env/computers/aliases.toml b/src/agent_env/computers/aliases.toml index 5e3e33252..fa74b45f1 100644 --- a/src/agent_env/computers/aliases.toml +++ b/src/agent_env/computers/aliases.toml @@ -5,3 +5,7 @@ "macos-26" = ["macos-26"] "windows-11" = ["windows-11"] "omarchy-4.5" = ["omarchy-4.5"] + +# Ids a computer env's apps can name these by; a store's env of the id wins over an alias. +[envs] +clock = ["clock"] diff --git a/src/agent_env/computers/clock/README.md b/src/agent_env/computers/clock/README.md index c997dc325..cb16cd664 100644 --- a/src/agent_env/computers/clock/README.md +++ b/src/agent_env/computers/clock/README.md @@ -38,11 +38,41 @@ What follows the universe, and what doesn't: | `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.conf` | the library's default settings | Apache-2.0 | +| `clock-follower` | the follower: keeps the page in step with the gateway's clock (below) | Apache-2.0 | +| `follow` | root's loop that keeps the follower running as `agentenv-clock` | 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] +## On a computer: the `clock` app and the follower + +A computer env follows its multi env's gateway clock by composing the `clock` app (`../envs/clock`, the alias +`clock`): `apps = ["clock"]` on a Linux X11 computer, `amd64` or `arm64`. Its install builds this library at compose, +installs `clock.conf` and preloads the library into every process (`/etc/ld.so.preload`), and adds the follower, its +loop and a user of its own, `agentenv-clock`, whose uid `clock.conf` names as the page's owner, so the library trusts no +page another user made. + +When `sync_env_clock` arms a multi env whose computer composes it, core configures the computer's follower before the +arm, as root, through the sandbox's root channel (`agent_env.env.envs.computer.utils.clock`), never through the driver, +which runs as the session user. It writes the follower's config (the gateway's `/clock/watch` URL as the computer's +sandbox reaches it, the computer's read token, its follower name and a config id) to +`/etc/agentenv/clock-follower/follower.json`, the follower's alone to read; makes the page, the follower's from its first +byte; and starts `follow`, which runs `clock-follower` as `agentenv-clock` and runs it again within a second whenever it +exits. The arm then waits for the computer to show the clock (`wait_followers_s`) and fails the step naming it if it +doesn't; the app's `clock/v1` `sync_time` reports what the follower showed for that config id. + +Each round, the follower long-polls `/clock/watch` with the version it last showed, which acknowledges that version. On a +new version it writes the page: the universe the gateway reported, at its rate, from the local real time the gateway's +`sent` stamp corresponds to, the offset between the two clocks estimated from the watch's four stamps as NTP does. It +records what it showed in `/run/agentenv-clock/status.json`. A failed round is recorded there and retried with backoff, +and a new config starts it over from no version. + +The session user can't write the page or the clock file, read the follower's config, or stop either process: each is +another user's. Not covered yet: a computer reset (`reset_env`) boots it afresh with no follower until the next arm; +agent-env's own servers run on universe time too, with no real-time marker; and routed sites' certificates, Chromium's +time zone and an egress proxy for live sites at other dates are later work. + ## The clock page An 80-byte page (default `/dev/shm/agentenv-clock`, version 2) that `clockpage.py` rewrites in place: `seq` odd, the diff --git a/src/agent_env/computers/clock/clock-follower b/src/agent_env/computers/clock/clock-follower new file mode 100755 index 000000000..93819a439 --- /dev/null +++ b/src/agent_env/computers/clock/clock-follower @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""The computer's clock follower: keeps the clock page agent-env's libfaketime reads in step with the gateway's clock. + +It runs as its own user (agentenv-clock), restarted by root's ``follow`` loop, so the session user can't stop it, read +its config or write the page. Core writes its config through the sandbox's root channel at arm time, never through the +driver: ``{"watch_url", "token", "follower", "config_id"}`` in CONFIG, readable by this user alone. + +Each round long-polls the gateway's ``/clock/watch`` with the version it last showed, which is its acknowledgement of +that version. The gateway answers at once when the clock is at another version, else at its next change or after its +hold. On a new version it writes the page (``clockpage.write``): the universe the gateway reported, at its rate, from +the local real time the gateway's ``sent`` stamp corresponds to, the offset between the two clocks estimated from four +stamps as NTP does. Then it records what it showed in STATUS, which anyone may read, for the clock app's ``sync_time``. +A failed round is recorded there too and retried with backoff; a changed config starts over from no version.""" +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import clockpage # noqa: E402 (beside this file, in /opt/agentenv/clock) + +CONFIG = os.environ.get("AGENTENV_CLOCK_FOLLOWER_CONFIG", "/etc/agentenv/clock-follower/follower.json") +STATUS = os.environ.get("AGENTENV_CLOCK_FOLLOWER_STATUS", "/run/agentenv-clock/status.json") +#: Beyond the gateway's hold (CLOCK_WATCH_HOLD_S, 25 s), how long a watch may take before it's given up and tried again. +WATCH_TIMEOUT_S = 40.0 +BACKOFF_S = (0.2, 0.5, 1.0, 2.0, 5.0) + + +def load_config() -> dict | None: + try: + with open(CONFIG) as f: + config = json.load(f) + except FileNotFoundError: + return None + missing = [key for key in ("watch_url", "token", "follower", "config_id") if not config.get(key)] + if missing: + raise ValueError(f"{CONFIG} has no {', '.join(missing)}") + return config + + +def epoch(rfc3339: str) -> float: + return datetime.fromisoformat(rfc3339.replace("Z", "+00:00")).timestamp() + + +def watch(config: dict, after: str) -> tuple[dict, float, float]: + """One watch: the gateway's answer, and the local real times it was sent and received.""" + query = urllib.parse.urlencode({"follower": config["follower"], "after": after}) + request = urllib.request.Request(f"{config['watch_url']}?{query}", + headers={"Authorization": f"Bearer {config['token']}"}) + sent = time.time() + with urllib.request.urlopen(request, timeout=WATCH_TIMEOUT_S) as response: + body = json.load(response) + return body, sent, time.time() + + +def show(state: dict, sent: float, received: float) -> dict: + """Write the page for the gateway's ``state``, its ``received`` and ``sent`` stamps against ours.""" + offset = ((state["received"] - sent) + (state["sent"] - received)) / 2 # gateway's real time minus ours + if not state.get("armed"): + return clockpage.write(0, 0.0, 1.0, time.time()) + return clockpage.write(1, epoch(state["virtual_time"]), float(state["virtual_seconds_per_real_second"]), + state["sent"] - offset) + + +def record(status: dict) -> None: + os.makedirs(os.path.dirname(STATUS), exist_ok=True) + with open(STATUS + ".part", "w") as f: + json.dump({**status, "at": time.time()}, f) + os.chmod(STATUS + ".part", 0o644) + os.replace(STATUS + ".part", STATUS) + + +def main() -> None: + # The clock file and the status are the follower's to write and anyone's to read, whatever umask it was started + # with: one of 0000 (docker exec's) would let the session user rewrite the clock file. + os.umask(0o022) + after, config_id, failures = "", None, 0 + while True: + try: + config = load_config() + if config is None: + time.sleep(1) + continue + if config["config_id"] != config_id: + after, config_id = "", config["config_id"] + state, sent, received = watch(config, after) + if state.get("version") != after: + written = show(state, sent, received) + after = state["version"] + record({"config_id": config_id, "version": after, "armed": bool(state.get("armed")), + "virtual_time": state.get("virtual_time"), "page": written.get("spec")}) + failures = 0 + except Exception as e: # any round's failure is the status's to show; the next round tries again + record({"config_id": config_id, "version": after or None, "error": f"{type(e).__name__}: {e}"}) + time.sleep(BACKOFF_S[min(failures, len(BACKOFF_S) - 1)]) + failures += 1 + + +if __name__ == "__main__": + main() diff --git a/src/agent_env/computers/clock/follow b/src/agent_env/computers/clock/follow new file mode 100755 index 000000000..bf70fa064 --- /dev/null +++ b/src/agent_env/computers/clock/follow @@ -0,0 +1,16 @@ +#!/bin/sh +# As root: the computer's clock follower, kept running as agentenv-clock, back within a second whenever it exits. Core +# starts it through the sandbox's root channel once it has written the follower's config; a second start replaces the +# first. The session user can't stop either process: each runs as another user. +set -u +# Whatever exec started it (docker exec's is 0000), nothing the follower writes is any other user's to change. +umask 022 +for pid in $(pgrep -f 'opt/agentenv/clock/follow$'); do + [ "$pid" != "$$" ] && kill "$pid" 2>/dev/null +done +pkill -u agentenv-clock 2>/dev/null +while :; do + setpriv --reuid=agentenv-clock --regid=agentenv-clock --init-groups --no-new-privs --reset-env \ + python3 /opt/agentenv/clock/clock-follower + sleep 1 +done diff --git a/src/agent_env/computers/envs/clock/app.py b/src/agent_env/computers/envs/clock/app.py new file mode 100644 index 000000000..6ff8eb206 --- /dev/null +++ b/src/agent_env/computers/envs/clock/app.py @@ -0,0 +1,57 @@ +"""The computer's clock on universe time: agent-env's libfaketime and its TLS shim, which keeps certificate checks on +real time, built at compose from the recipe in ./recipe (src/agent_env/computers/clock) and upstream's tarball, which +the app downloads at the sha256 build.sh pins, preloaded into every process (install.sh), with the clock follower +beside them. + +The follower keeps the clock page in step with the gateway's clock. Core configures and starts it through the sandbox's +root channel when ``sync_env_clock`` arms the env (``agent_env.env.envs.computer.utils.clock``), never through this app, +whose server runs as the session user. The app's ``clock/v1`` ``sync_time`` only reports what the follower has shown, +which is also what makes a multi env's gateway count the computer as one of its clock's followers.""" +import asyncio +import json +import re +import time +from pathlib import Path + +from agentenv_protocol.agent_env_environment import extension +from agentenv_protocol.computer import (ComputerAppEnvironment, Download, Packages, RunScript, computer_app, linux) +from agentenv_protocol.computer.errors import InvalidArgument, Timeout +from agentenv_protocol.website.clock import CLOCK_EXTENSION_URI + +_BUILD = (Path(__file__).parent / "recipe" / "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) +#: What the follower last showed, written by it (agentenv-clock) and readable by anyone. +STATUS = Path("/run/agentenv-clock/status.json") +#: How long ``sync_time`` waits for the follower to show the config core just wrote. +SYNC_WAIT_S = 20.0 + + +@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) +@linux(packages=Packages(apt=["build-essential", "patch"]), + downloads=[Download(url=TARBALL, to="libfaketime.tar.gz", sha256=SHA256)], + run=[RunScript(script="install.sh")]) +class Clock(ComputerAppEnvironment): + + @extension(CLOCK_EXTENSION_URI, description=( + "The computer's clock follows the gateway's. Core writes the follower's config as root and starts it; this " + "waits until the follower has shown the clock for that config and returns what it showed.")) + async def sync_time(self, env_get_time_url: str, follower_config: str = "") -> dict: + """What the follower showed for ``follower_config``, the id core gave the config it wrote, once it has shown + any version for it. ``env_get_time_url`` is the gateway-network URL every server is handed; the follower + reaches the gateway at the URL in its config instead, as this computer's sandbox reaches it.""" + if not follower_config: + raise InvalidArgument("sync_time needs follower_config: core writes the follower's config through the " + "sandbox's root channel before it calls this") + deadline, status = time.monotonic() + SYNC_WAIT_S, None + while time.monotonic() < deadline: + try: + status = json.loads(STATUS.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + status = None + if status and status.get("config_id") == follower_config and status.get("version") and \ + not status.get("error"): + return {"follower": status, "env_get_time_url": env_get_time_url} + await asyncio.sleep(0.2) + raise Timeout(f"the clock follower didn't show config {follower_config!r} within {SYNC_WAIT_S:g} s; " + f"its status: {status}") diff --git a/src/agent_env/computers/envs/clock/env.toml b/src/agent_env/computers/envs/clock/env.toml new file mode 100644 index 000000000..60e142353 --- /dev/null +++ b/src/agent_env/computers/envs/clock/env.toml @@ -0,0 +1,2 @@ +type = "computer_app" +module = "app.py:Clock" diff --git a/src/agent_env/computers/envs/clock/install.sh b/src/agent_env/computers/envs/clock/install.sh new file mode 100755 index 000000000..320596f95 --- /dev/null +++ b/src/agent_env/computers/envs/clock/install.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# As root at compose: agent-env's libfaketime and its TLS shim, built from ./recipe and the downloaded tarball, preloaded +# into every process the computer starts, the library first, with the clock follower and its root loop beside them. The follower's user, agentenv-clock, +# owns the clock page, which core creates at arm time; clock.conf says so, so the library trusts no other page. +set -eu +sh recipe/build.sh /opt/agentenv/clock libfaketime.tar.gz +useradd --system --no-create-home --shell /usr/sbin/nologin agentenv-clock +install -m 0644 recipe/clockpage.py /opt/agentenv/clock/clockpage.py +install -m 0755 recipe/clock-follower /opt/agentenv/clock/clock-follower +install -m 0700 recipe/follow /opt/agentenv/clock/follow +install -d -m 0755 /etc/agentenv +sed "s/^AGENTENV_CLOCK_PAGE_UID=.*/AGENTENV_CLOCK_PAGE_UID=$(id -u agentenv-clock)/" recipe/clock.conf \ + > /etc/agentenv/clock.conf +chmod 0644 /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/src/agent_env/computers/envs/clock/recipe/COPYING b/src/agent_env/computers/envs/clock/recipe/COPYING new file mode 120000 index 000000000..d20f6eae0 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/COPYING @@ -0,0 +1 @@ +../../../clock/COPYING \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/agentenv_clock_page.c b/src/agent_env/computers/envs/clock/recipe/agentenv_clock_page.c new file mode 120000 index 000000000..3a7ca4adf --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/agentenv_clock_page.c @@ -0,0 +1 @@ +../../../clock/agentenv_clock_page.c \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/agentenv_clock_waits.c b/src/agent_env/computers/envs/clock/recipe/agentenv_clock_waits.c new file mode 120000 index 000000000..8a8177972 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/agentenv_clock_waits.c @@ -0,0 +1 @@ +../../../clock/agentenv_clock_waits.c \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/build.sh b/src/agent_env/computers/envs/clock/recipe/build.sh new file mode 120000 index 000000000..48844b770 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/build.sh @@ -0,0 +1 @@ +../../../clock/build.sh \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/clock-follower b/src/agent_env/computers/envs/clock/recipe/clock-follower new file mode 120000 index 000000000..9eb006292 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/clock-follower @@ -0,0 +1 @@ +../../../clock/clock-follower \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/clock.conf b/src/agent_env/computers/envs/clock/recipe/clock.conf new file mode 120000 index 000000000..0fcc412bf --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/clock.conf @@ -0,0 +1 @@ +../../../clock/clock.conf \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/clockpage.py b/src/agent_env/computers/envs/clock/recipe/clockpage.py new file mode 120000 index 000000000..a83f4b745 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/clockpage.py @@ -0,0 +1 @@ +../../../clock/clockpage.py \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/follow b/src/agent_env/computers/envs/clock/recipe/follow new file mode 120000 index 000000000..6a2175c95 --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/follow @@ -0,0 +1 @@ +../../../clock/follow \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/libfaketime.patch b/src/agent_env/computers/envs/clock/recipe/libfaketime.patch new file mode 120000 index 000000000..680e16dda --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/libfaketime.patch @@ -0,0 +1 @@ +../../../clock/libfaketime.patch \ No newline at end of file diff --git a/src/agent_env/computers/envs/clock/recipe/tls_realtime.c b/src/agent_env/computers/envs/clock/recipe/tls_realtime.c new file mode 120000 index 000000000..565dd09cf --- /dev/null +++ b/src/agent_env/computers/envs/clock/recipe/tls_realtime.c @@ -0,0 +1 @@ +../../../clock/tls_realtime.c \ No newline at end of file diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 666041e8d..04579f8f6 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -65,7 +65,7 @@ def test_the_shipped_env_aliases_name_the_mail_env_the_validator_deploys_the_dri "macos-harness": "@local/agentenv-framework/drivers/macos-harness", "codex-computer-use": "@local/agentenv-framework/drivers/codex-computer-use", "aside": "@local/agentenv-framework/drivers/aside", - "chromium": "@local/agentenv-framework/browsers/chromium"} + "chromium": "@local/agentenv-framework/browsers/chromium", "clock": "@local/agentenv-framework/computers/clock"} assert A2AAgent.VALIDATION_ENV_ID == "mail" def test_a_task_naming_no_agent_plans_with_the_shipped_default(tmp_path, monkeypatch, local_stores, cli_routing): 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..b8b5e421c --- /dev/null +++ b/tst/unit/env/envs/test_computer_clock.py @@ -0,0 +1,163 @@ +"""The computer's clock follower and the clock app's sync_time, run here against stand-ins for the gateway's watch and +the clock page; the real thing is in tst/integration/computer/clock_follower_test.py.""" +import importlib.machinery +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +from agentenv_protocol.agent_env_environment import _EXT_ATTR +from agentenv_protocol.computer.errors import InvalidArgument, Timeout + +import agent_env + +CLOCK = Path(agent_env.__file__).parent / "computers" / "clock" +APP = Path(agent_env.__file__).parent / "computers" / "envs" / "clock" + + +@pytest.fixture +def follower(monkeypatch, tmp_path): + """The follower's module, its clock page writes recorded instead of made, its config and status in ``tmp_path``.""" + monkeypatch.syspath_prepend(str(CLOCK)) + loader = importlib.machinery.SourceFileLoader("clock_follower", str(CLOCK / "clock-follower")) + module = importlib.util.module_from_spec(importlib.util.spec_from_loader("clock_follower", loader)) + loader.exec_module(module) + writes = [] + monkeypatch.setattr(module.clockpage, "write", + lambda armed, universe, rate, anchor: writes.append((armed, universe, rate, anchor)) or + {"spec": f"@{universe} x{rate:g}" if armed else "+0 x1"}) + monkeypatch.setattr(module, "CONFIG", str(tmp_path / "follower.json")) + monkeypatch.setattr(module, "STATUS", str(tmp_path / "run" / "status.json")) + monkeypatch.setattr(module.time, "sleep", lambda s: None) + module.writes = writes + yield module + sys.modules.pop("clock_follower", None) + + +def _configure(module, config_id="c1"): + Path(module.CONFIG).write_text(json.dumps({"watch_url": "http://gw/clock/watch", "token": "t", "follower": "desktop", + "config_id": config_id})) + + +def _armed(version, virtual_time="2026-05-01T09:00:00Z", rate=1.0, received=1000.05, sent=1000.15): + return {"armed": True, "version": version, "virtual_time": virtual_time, "virtual_seconds_per_real_second": rate, + "received": received, "sent": sent} + + +def _run(module, answers): + """The follower's loop over ``answers``, each a watch's (state, local sent, local received) or an exception; the + ``after`` each watch carried.""" + afters, answers = [], list(answers) + + def watch(config, after): + afters.append(after) + if not answers: + raise SystemExit + answer = answers.pop(0) + if isinstance(answer, BaseException): + raise answer + return answer + + module.watch = watch + with pytest.raises(SystemExit): + module.main() + return afters + + +def test_the_page_is_written_from_the_local_time_the_gateways_sent_stamp_corresponds_to(follower): + # The gateway's clock is 900 s ahead of ours: it received at 1000.05 what we sent at 100, and sent at 1000.15 what + # we received at 100.2; so its sent stamp is our 100.15. + follower.show(_armed("b.1", rate=60), 100.0, 100.2) + (armed, universe, rate, anchor), = follower.writes + assert (armed, rate) == (1, 60.0) and anchor == pytest.approx(100.15) + assert universe == follower.epoch("2026-05-01T09:00:00Z") + + +def test_each_new_version_is_written_once_acknowledged_by_the_next_watch_and_recorded(follower): + _configure(follower) + held = _armed("b.2") # the same version again: a watch that ran out its hold + afters = _run(follower, [({"armed": False, "version": "b.1", "received": 1, "sent": 1}, 1, 1), + (_armed("b.2"), 1, 1), (held, 1, 1)]) + + assert afters == ["", "b.1", "b.2", "b.2"] + assert [w[0] for w in follower.writes] == [0, 1] + status = json.loads(Path(follower.STATUS).read_text()) + assert (status["config_id"], status["version"], status["armed"]) == ("c1", "b.2", True) + + +def test_a_new_config_starts_over_from_no_version(follower): + _configure(follower, "c1") + + def watch(config, after): + if config["config_id"] == "c1" and after == "b.1": + _configure(follower, "c2") # core re-arms with a new config while a watch is held + if len(follower.writes) == 2: + raise SystemExit + return _armed("b.1"), 1, 1 + + follower.watch = watch + with pytest.raises(SystemExit): + follower.main() + assert json.loads(Path(follower.STATUS).read_text())["config_id"] == "c2" + + +def test_a_failed_watch_is_recorded_and_tried_again(follower): + _configure(follower) + afters = _run(follower, [OSError("connection refused"), (_armed("b.1"), 1, 1)]) + + assert afters == ["", "", "b.1"] + assert json.loads(Path(follower.STATUS).read_text())["version"] == "b.1" + + +def test_without_a_config_the_follower_waits_for_one(follower, monkeypatch): + slept = [] + monkeypatch.setattr(follower.time, "sleep", lambda s: slept.append(s) or (_ for _ in ()).throw(SystemExit)) + with pytest.raises(SystemExit): + follower.main() + assert slept == [1] + + +@pytest.fixture +def app(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(APP)) + sys.modules.pop("app", None) + module = importlib.import_module("app") + monkeypatch.setattr(module, "STATUS", tmp_path / "status.json") + monkeypatch.setattr(module, "SYNC_WAIT_S", 0.5) + yield module + sys.modules.pop("app", None) + + +@pytest.mark.asyncio +async def test_the_clock_apps_sync_time_reports_what_the_follower_showed_for_the_config_core_wrote(app): + app.STATUS.write_text(json.dumps({"config_id": "c1", "version": "b.2", "armed": True})) + result = await app.Clock.sync_time(None, env_get_time_url="http://gw/clock/time", follower_config="c1") + assert result["follower"]["version"] == "b.2" + + +@pytest.mark.asyncio +async def test_the_clock_apps_sync_time_times_out_on_another_config_or_a_failing_follower(app): + app.STATUS.write_text(json.dumps({"config_id": "c0", "version": "b.2"})) + with pytest.raises(Timeout, match="didn't show config 'c1'"): + await app.Clock.sync_time(None, env_get_time_url="u", follower_config="c1") + app.STATUS.write_text(json.dumps({"config_id": "c1", "version": "b.2", "error": "URLError: refused"})) + with pytest.raises(Timeout, match="refused"): + await app.Clock.sync_time(None, env_get_time_url="u", follower_config="c1") + with pytest.raises(InvalidArgument): + await app.Clock.sync_time(None, env_get_time_url="u") + + +def test_the_clock_app_advertises_a_sync_time_that_takes_the_followers_config(app): + extension = getattr(app.Clock.sync_time, _EXT_ATTR) + request = extension.params["methods"]["sync_time"]["request"] + assert extension.uri == "urn:agentenv:clock/v1" + assert {"env_get_time_url", "follower_config"} <= set(request["properties"]) + + +def test_the_follower_writes_nothing_another_user_may_change_whatever_umask_it_started_with(follower, monkeypatch): + masks = [] + monkeypatch.setattr(follower.os, "umask", lambda mask: masks.append(mask) or 0) + _configure(follower) + _run(follower, []) + assert masks == [0o022]