Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
EXIT_WAIT_S = 5.0
#: How long a stopped app may take to exit before it's killed.
STOP_TIMEOUT_S = 10.0
HOLD_BOOT_LAUNCH_ENV = "AGENTENV_HOLD_BOOT_LAUNCH"


class AppReadiness(BaseModel):
Expand Down Expand Up @@ -74,9 +75,10 @@ def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[]

async def start(self) -> None:
"""Once its X display is up, when it has one, add the app's menu launcher on a desktop and launch a boot app,
unless the computer has launched it since it booted, at boot or on demand: a server that restarts leaves the
app be, or with ``relaunch_on_start``, for an app only the server that launched it can talk to, stops it and
launches it again, whether it was launched at boot or on demand."""
unless the computer has launched it since it booted, at boot or on demand, or its boot launch is held
(``boot_launch_held``): a server that restarts leaves the app be, or with ``relaunch_on_start``, for an app
only the server that launched it can talk to, stops it and launches it again, whether it was launched at boot
or on demand."""
if self.launch is None:
return
if os.environ.get("DISPLAY"):
Expand All @@ -85,7 +87,7 @@ async def start(self) -> None:
session.add_launcher(os.environ["ENVIRONMENT_NAME"], Path.cwd())
launched = _launched_at() is not None
relaunch = self.relaunch_on_start and launched
if relaunch or (self.launch.at == "boot" and not launched):
if relaunch or (self.launch.at == "boot" and not launched and not boot_launch_held()):
try:
if relaunch:
await self.stop_app()
Expand Down Expand Up @@ -190,6 +192,14 @@ async def _ready_check(self, name: str, fn: Callable[[], Any]) -> Optional[str]:
return None if passed else f"@ready_check {name} returned {passed!r}"


def boot_launch_held() -> bool:
"""Whether the computer holds its boot apps' launches (``HOLD_BOOT_LAUNCH_ENV`` is ``1`` in its environment): each
app's server then leaves its boot app unlaunched, the driver counts it ready until something launches it, and
whoever booted the computer launches each one through ``computer-app/v1``'s ``launch`` once it has set the computer
up for them, as a deploy routing the run's websites does once their names, CA and proxy are in place."""
return os.environ.get(HOLD_BOOT_LAUNCH_ENV) == "1"


def _mark() -> Path:
return LAUNCHED / Path.cwd().name

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""The apps composed onto a computer, as its driver finds them, whether each is ready, and how the driver fronts them.
Each app's own server answers on its folder's socket (``serve_app``): its card says how the app launches and which
paths it owns, and its ``computer-app/v1`` ``ready`` whether it's ready. Every app's server must answer before the
computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app is checked once
something launches it. The driver lists each app as a child environment named for its folder, its card's paths under
computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app, or a boot app
while the computer holds its launch (``boot_launch_held``), is checked once something launches it. The driver lists each app as a child environment named for its folder, its card's paths under
``/svc/<name>/``, and forwards each request there to the app's server."""
from __future__ import annotations

Expand All @@ -16,7 +16,7 @@
from .. import client
from ..types import RPC_PATH, WELL_KNOWN_PATH, EnvironmentCard
from .app import COMPUTER_APP, FAILURE, SOCKET
from .app_runtime import CHECK_TIMEOUT_S, AppReadiness
from .app_runtime import CHECK_TIMEOUT_S, AppReadiness, boot_launch_held
from .operations import ReadyCheck

#: Where a composed computer installs its apps, a folder each.
Expand Down Expand Up @@ -44,6 +44,8 @@ async def check(folder: Path) -> ReadyCheck:
return ReadyCheck(f"app {name}", False, _unanswered(folder))
if launch is not None and launch["at"] == "demand" and not answer.launched:
return ReadyCheck(f"app {name}", True, "its server answers; it launches on demand")
if launch is not None and boot_launch_held() and not answer.launched:
return ReadyCheck(f"app {name}", True, "its server answers; its boot launch is held")
if answer.ready:
return ReadyCheck(f"app {name}", True, "ready")
when = (f"not ready after {answer.timeout_s:.0f} s" if answer.waited_s > answer.timeout_s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness
from .readiness import READY_TIMEOUT_S, WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness
from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path
from .session import SESSION_COOKIE, SessionSigner
from .spec import NameTable, SpecError, WebsiteSpec, from_card, from_environment, load as load_spec, name_table
Expand All @@ -39,5 +39,5 @@
"WebsiteSpec", "SpecError", "NameTable", "load_spec", "from_card", "from_environment", "name_table",
"WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH",
"SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER",
"WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness",
"WEBSITE_READY_OP", "WEBSITE_BROWSE_URL_PARAM", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness",
]
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
it is usable now through its card's ``urn:agentenv:website/v1`` ``ready`` (``WEBSITE_READY_OP``), answering a
``WebsiteReadiness``. ``website_readiness`` asks one site as its card advertises it; ``env_readiness`` asks every site
of an env, a composed card's children or a single site's own card, and answers for the env as a whole; asking one
site may take ``READY_TIMEOUT_S`` by default."""
site may take ``READY_TIMEOUT_S`` by default. A composer serving a site to a browser at a route of its own, as the
env gateway serves ``/website/<name>/``, says where on the site's ``website/v1`` params, as
``WEBSITE_BROWSE_URL_PARAM``: a path joined onto the composed card's address, or a full URL."""
from __future__ import annotations

import asyncio
Expand All @@ -14,6 +16,7 @@

WEBSITE_EXTENSION_URI = "urn:agentenv:website/v1"
WEBSITE_READY_OP = "ready"
WEBSITE_BROWSE_URL_PARAM = "browse_url"
READY_TIMEOUT_S = 10.0


Expand Down
19 changes: 19 additions & 0 deletions packages/agentenv-protocol/tests/test_computer_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,25 @@ async def test_a_boot_app_is_launched_once_per_boot_its_clock_from_that_launch_a
assert answer["launched"] is True and answer["waited_s"] >= 100


@pytest.mark.asyncio
async def test_a_held_boot_launch_leaves_the_app_unlaunched_and_counted_ready_until_it_is_launched(desktop, monkeypatch):
shown, launched = desktop
monkeypatch.setattr(session, "add_launcher", lambda name, folder: None)
monkeypatch.setenv(app_runtime.HOLD_BOOT_LAUNCH_ENV, "1")
with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root:
monkeypatch.setattr(apps, "APPS", Path(root))
async with _serving(Mail(), Path(root) / "mail", monkeypatch) as mail:
await mail._computer_app.start()
held = await apps.check(Path(root) / "mail")
await mail._computer_app_launch()
shown.append({"instance": "mail", "class": "Mail", "title": "Inbox"})
mail.synced = True
launched_check = await apps.check(Path(root) / "mail")

assert held == ReadyCheck("app mail", True, "its server answers; its boot launch is held")
assert len(launched) == 1 and launched_check == ReadyCheck("app mail", True, "ready")


@pytest.mark.asyncio
async def test_a_restarted_server_finds_the_demand_app_it_launched_and_leaves_it_be(desktop, tmp_path, monkeypatch):
_, launched = desktop
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ dev = [
"tomli-w==1.2.0", # the Codex agent's config writer, which only its image installs; its tests import it
"moto>=5.0.0",
"psycopg2-binary>=2.9.0", # the gateway server module's driver; outside its container only the tests import it
"websockets>=13", # the gateway's WebSocket relay to a website; outside its container only the tests import it
"pytest>=9.0",
"pytest-asyncio>=0.23.0",
"pytest-dependency>=0.6.0",
Expand Down
5 changes: 5 additions & 0 deletions src/agent_env/env/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,11 @@ def deploy_refusal(self, *, sandbox_type: Optional[str] = None, env_params: Opti
overrides this; one that doesn't takes no ``env_params``."""
return f"{self.type} envs take no env_params" if env_params else None

async def run_env_params(self, env_params: Optional[dict], deployed_envs: list[DeployedEnv]) -> Optional[dict]:
"""``env_params`` as a run's deploy_env passes them to deploy(), once the run has deployed ``deployed_envs``:
as given, unless the type reads something of what the run deployed into them."""
return env_params

@classmethod
async def from_deployed_env(cls, deployed: DeployedEnv) -> Self:
"""The env a running deployment serves, for its caller to drive. A type that can be
Expand Down
18 changes: 17 additions & 1 deletion src/agent_env/env/envs/computer/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
deploy_through_provider,
provider_or_class,
)
from agent_env.env.envs.computer.utils import boot, composer, gate, inside
from agent_env.env.envs.computer.utils import boot, composer, gate, inside, sites
from agent_env.env.envs.computer.utils.composer import Composing
from agent_env.env.envs.computer.utils import params as computer_params
from agent_env.env.envs.computer.driver_env import ComputerDriverEnv
Expand Down Expand Up @@ -248,6 +248,22 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10,
env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution,
env_params=env_params)

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."""
routing = (env_params or {}).get("site_routing")
if routing is not None and routing is not False and routing is not True and not (
isinstance(routing, list) and all(isinstance(env_id, str) for env_id in routing)):
raise ValueError(f"computer env {self.id!r}: env_params.site_routing is true or a list of env ids, not "
f"{routing!r}")
if not routing or env_params.get("sites"):
return env_params
found = await sites.deployed_sites(deployed_envs, None if routing is True else list(routing))
if not found:
raise ValueError(f"computer env {self.id!r}: env_params.site_routing reaches the run's websites, and the "
"run deployed none before the computer")
return {**env_params, "sites": found}

def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None,
**options) -> str | None:
"""Why deploy() would refuse before booting anything, or None: an option the computer provider doesn't take, a
Expand Down
31 changes: 26 additions & 5 deletions src/agent_env/env/envs/computer/utils/params.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
"""What a deploy of a computer env takes as ``deploy_env`` ``env_params``: the form to boot, ``computer_settings``,
values of the base's deploy-phase settings, ``driver_settings``, the driver's ``ComputerSettings``, such as the default
coordinate space its tools describe, and ``app_settings``, values of each app's ``Settings`` by its name. The env's
display and network are its own, which no deploy overrides."""
coordinate space its tools describe, ``app_settings``, values of each app's ``Settings`` by its name, and
``site_routing``, opt-in, to reach the run's websites by their names from the computer: its ``sites``, which deploy_env
fills in from the websites the run deployed before the computer, all of them or those of the env ids it lists. The
env's display and network are its own, which no deploy overrides."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Optional, Union

from agentenv_protocol.computer import ComputerSettings
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError

from agent_env.artifact.artifacts.computer import SettingValue
from agent_env.env.envs.computer.utils import gate
from agent_env.env.envs.computer.utils import gate, sites

if TYPE_CHECKING:
from agent_env.env.envs.computer.env import ComputerEnv


class Site(BaseModel):
"""A website the computer reaches by its names: its route on the env gateway, as this process reaches it, and what
its card's website/v1 declares: its names, and for a live site, which the real site serves, the hosts it calls."""

model_config = ConfigDict(extra="forbid")

name: str
url: str
domains: list[str] = Field(default_factory=list)
live: bool = False
allow_hosts: list[str] = Field(default_factory=list)


class ComputerParams(BaseModel):
model_config = ConfigDict(extra="forbid")

form: Optional[str] = None
computer_settings: dict[str, Any] = Field(default_factory=dict)
driver_settings: dict[str, Any] = Field(default_factory=dict)
app_settings: dict[str, dict[str, Any]] = Field(default_factory=dict)
site_routing: Union[StrictBool, list[str]] = False
sites: list[Site] = Field(default_factory=list)


def checked(env: ComputerEnv, env_params: Optional[dict]) -> tuple[ComputerParams, dict[str, SettingValue], list[str]]:
Expand Down Expand Up @@ -55,6 +72,10 @@ def checked(env: ComputerEnv, env_params: Optional[dict]) -> tuple[ComputerParam
merged = app_settings(env, params.app_settings)
for name, app in apps.items():
problems += gate.check_app_settings(app, merged[name], f"env_params.app_settings.{name}", env.computer.os.type)
if params.sites and not params.site_routing:
problems.append("env_params.sites: a computer reaches sites only with site_routing")
allow_hosts = (env.network or {}).get("allow_hosts") or ()
problems += sites.table_problems(params.sites, allow_hosts) if params.sites else []
return params, values, problems


Expand Down
Loading
Loading