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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ the container before using it.
## Register it

In a bundle, `env.toml` declares the env: copied to a bundle's `envs/notes/`, `agent-env bundle register <bundle>`
builds the image with that folder as its context. Its keys are `type`, `environment_name` and `backend_image`; domains
are not one of them, as they come from the card. Without a bundle:
builds the image with that folder as its context. Its keys are `type`, `environment_name` and `backend_image`, and it
may name `domains`, the names the site is reached by, such as `domains = ["notes.example"]`. These win over the
card's; without them a computer reaches the site by the names its card declares (`notes.test`), or as
`notes.localhost` when it declares none. Two sites of one multi env can't claim one name. Without a bundle (`--domain`
names the site, and can be repeated):

```bash
agent-env env website put --id notes-site --server-dockerfile Dockerfile --server-docker-context .
Expand Down
25 changes: 21 additions & 4 deletions src/agent_env/bundle/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from agent_env.env.envs.computer.env import ComputerEnv
from agent_env.env.envs.computer.app_env import ComputerAppEnv
from agent_env.env.envs.computer.driver_env import ComputerDriverEnv
from agent_env.env.envs.computer.utils import sites
from agent_env.env.envs.mcp_server import MCPServerEnv
from agent_env.env.envs.multi_env import MultiEnv
from agent_env.env.envs.website import WebsiteEnv
Expand Down Expand Up @@ -893,7 +894,7 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity
"""Each env of a multi runs as containers its environment_name names: an MCP server as one by that name, a
website as its backend's and frontend's. No two of a multi's envs may name one container, and a provider
other than the core's, which gives a multi one env card, can't tell an MCP server and a website of one name
apart."""
apart. Nor may two of its websites claim one name."""
for key in sorted(closure, key=self.rank.__getitem__):
source = self.nodes[key]
if isinstance(source, BundleImage) or source.entry.kind is not BundleKind.ENV:
Expand All @@ -903,6 +904,12 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity
where = self._path(source.entry)
taken: dict[str, str] = {} # each container a child runs as, by the field naming the child
named: dict[str, set[str]] = {} # the environment_names of each list's envs
claimed = [sites.Claim(domain, sites.SYNTHETIC, ref.where) for ref in source.references
if ref.where.startswith("website_envs[")
for domain in dict.fromkeys(self._domains(ref, store_latest))]
if conflicts := sites.name_conflicts(claimed):
self.problems.append(f"{where}: website_envs: names claimed twice: {'; '.join(conflicts)}; each site of "
"a multi needs names of its own")
for ref in source.references:
name = self._environment_name(ref, store_latest)
if name is None:
Expand Down Expand Up @@ -1041,14 +1048,24 @@ def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind,
return next((name for path, name in self._folder_environments(ref.local) or () if not path), None)
if ref.local is not None:
return self.accepted.get(_key(ref.local), {}).get("environment_name")
return getattr(self._store_entity(ref, store_latest), "environment_name", None)

def _domains(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> list[str]:
"""The names the website ``ref`` names stores: a bundle env's from its accepted toml, a store one's at the
version the plan read, or none when that's unknown."""
if ref.local is not None:
return list(self.accepted.get(_key(ref.local), {}).get("domains") or [])
return list(getattr(self._store_entity(ref, store_latest), "domains", None) or [])

def _store_entity(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> Any:
"""The store's entity ``ref`` names at the version the plan read, or None when that's unknown."""
if store_latest is None:
return None
try:
entity = STORE_GETTERS[ref.kind](ref.id, ref.version if ref.version is not None
else store_latest.get((ref.kind, ref.id)))
return STORE_GETTERS[ref.kind](ref.id, ref.version if ref.version is not None
else store_latest.get((ref.kind, ref.id)))
except (NotFoundError, ValueError, KeyError, TypeError):
return None # reported by the store ref check
return getattr(entity, "environment_name", None)

def _build_tasks(self, closure: set[_Key]) -> None:
"""Build each task's steps the way a write will, so a bad step fails before anything is written."""
Expand Down
10 changes: 7 additions & 3 deletions src/agent_env/cli/env/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from agent_env.utils.card_naming import card_name_from_github, card_name_from_source
from agent_env.utils.docker.build import DEFAULT_BUILD_PLATFORM, build_image
from agent_env.env.envs.website import WebsiteEnv, health_path_problem
from agent_env.env.envs.website import WebsiteEnv, domains_problem, health_path_problem
from agent_env.providers.env_providers.constants import AGENT_ENV_WEBSITE_HEALTH_PATH
from agent_env.store.ids import derive_id, image_repository

Expand Down Expand Up @@ -49,6 +49,9 @@ def website():
@click.option("--frontend-docker-context-github-url", default=None, help="GitHub URL to frontend build context directory")
@click.option("--health-path", default=AGENT_ENV_WEBSITE_HEALTH_PATH, show_default=True,
help="The path that answers 200 once the site is usable")
@click.option("--domain", "domains", multiple=True,
help="A name the site is reached by, such as slack.com or *.slack.com (repeatable); without one, the "
"names its card declares")
@environment_name_options
@env_provider_type_option("What deploys the env: 'gateway' (a gateway in front of the website), or the type of an installed "
"agent_env.env_providers plugin", "website")
Expand All @@ -67,6 +70,7 @@ def put(
frontend_dockerfile_github_url: str | None,
frontend_docker_context_github_url: str | None,
health_path: str,
domains: tuple[str, ...],
environment_name: str | None,
env_provider_type: str,
metadata_pairs: tuple[str, ...],
Expand All @@ -93,7 +97,7 @@ def put(
click.echo("Error: --server-image-ref needs --environment-name, which a registry image's card can't give here",
err=True)
sys.exit(1)
if problem := health_path_problem(health_path):
if problem := health_path_problem(health_path) or domains_problem(list(domains)):
click.echo(f"Error: {problem}", err=True)
sys.exit(1)
if frontend_dockerfile or frontend_dockerfile_github_url:
Expand All @@ -107,7 +111,7 @@ def put(
sys.exit(1)
key, value = pair.split("=", 1)
user_metadata[key] = value
site = {"health_path": health_path}
site = {"health_path": health_path, "domains": list(domains)}

if use_github:
if environment_name is None:
Expand Down
60 changes: 40 additions & 20 deletions src/agent_env/env/envs/computer/utils/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID

from agent_env.env.env import Env, gateway_url_of
from agent_env.env.envs.computer.utils.site_proxy import is_local, matches
from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox
from agent_env.store.base import NotFoundError

if TYPE_CHECKING:
from agent_env.env.env import DeployedEnv
Expand Down Expand Up @@ -148,6 +150,13 @@ def claims(sites: Iterable[Site], allow_hosts: Iterable[str] = ()) -> list[Claim
return found + [Claim(host.lower(), ALLOWLIST, "network") for host in dict.fromkeys(allow_hosts)]


def name_conflicts(every: list[Claim]) -> list[str]:
"""Each overlap of two claims of one rank by different owners, the later named first, as ``site_table`` refuses
them."""
return [f"{claim.name!r} ({claim.owner}) overlaps {k.name!r} ({k.owner})" for i, claim in enumerate(every)
for k in every[:i] if k.rank == claim.rank and k.owner != claim.owner and overlap(k.name, claim.name)]


def site_table(sites: list[Site], allow_hosts: Iterable[str] = ()) -> SiteTable:
"""The deployment's names: a synthetic site's routed, the rest left to the network but those a stronger claim
covers, a stronger claim shadowing each weaker one that overlaps it. Raises SiteConflict naming every name two sites
Expand All @@ -161,17 +170,15 @@ def site_table(sites: list[Site], allow_hosts: Iterable[str] = ()) -> SiteTable:
if not re.fullmatch(r"[A-Za-z0-9_-]+", name)]
if invalid:
raise ValueError("; ".join(invalid))
conflicts, kept, table = [], [], SiteTable()
if conflicts := name_conflicts(every):
raise SiteConflict("names claimed twice: " + "; ".join(conflicts))
kept, table = [], SiteTable()
for rank in RANKS:
for claim in (c for c in every if c.rank == rank):
same = [k for k in kept if k.rank == rank and k.owner != claim.owner and overlap(k.name, claim.name)]
conflicts += [f"{claim.name!r} ({claim.owner}) overlaps {k.name!r} ({k.owner})" for k in same]
stronger = [k for k in kept if k.rank != rank and overlap(k.name, claim.name)]
if stronger := sorted(stronger, key=lambda k: not covers(k.name, claim.name))[:1]:
table.shadowed.append((claim, stronger[0]))
kept.append(claim)
if conflicts:
raise SiteConflict("names claimed twice: " + "; ".join(conflicts))
by_site = {site.name: site for site in sites}
for owner in dict.fromkeys(c.owner for c in kept if c.rank == SYNTHETIC):
table.routes.append(Route(owner, by_site[owner].url,
Expand Down Expand Up @@ -405,19 +412,18 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net
async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[str]] = None) -> list[dict]:
"""The websites a run has deployed, as ``env_params.sites`` names them: each ``website/v1`` child env of a deployed
env's stored card, with the route its composer serves it to a browser at (its params' ``browse_url``, joined onto
the env's address), and the names its params declare. A record whose card names no ``browse_url``, as
one a gateway made before it did, takes the route from its ``website_frontend_urls``. A site those name that the
stored card doesn't hold, every site of a record without a card or one whose card the gateway couldn't read when it
composed its own, is read live at ``/svc/<name>``, and has no names when it serves no card (404 or no JSON).
Reading one fails otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare.
the env's address), and its names: those its env stores (a website env's ``domains``), else those its params
declare. A record whose card names no ``browse_url``, as one a gateway made before it did, takes the route from
its ``website_frontend_urls``. A site those name that the stored card doesn't hold, every site of a record without
a card or one whose card the gateway couldn't read when it composed its own, is read live at ``/svc/<name>``
unless its env stores its names, and has no names when it serves no card (404 or no JSON). Reading one fails
otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare.
``only`` keeps the websites of the envs it names, each of which must be deployed. Sites of different envs that
share a name are each named for their env (``_env_qualified``), so they're routed apart, while two that claim one
domain are still refused."""
from agentenv_protocol import client as protocol_v1
from agentenv_protocol.website import WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI

from agent_env.env.env import gateway_url_of

records = list(deployed_envs)
if only is not None:
missing = [env_id for env_id in only if env_id not in {d.env_id for d in deployed_envs}]
Expand All @@ -429,10 +435,11 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s
for record in records:
address = gateway_url_of(record) or getattr(record, "environment_url", None)
frontends = getattr(record, "website_frontend_urls", None) or {}
children = [child for child in protocol_v1.children(getattr(record, "environment_card", None) or {})
if child.find_extension(WEBSITE_EXTENSION_URI) is not None and child.name]
stored = _stored_domains(record) if children or frontends else {}
named = set()
for child in protocol_v1.children(getattr(record, "environment_card", None) or {}):
if child.find_extension(WEBSITE_EXTENSION_URI) is None or not child.name:
continue
for child in children:
params = protocol_v1.extension_params(child.card, WEBSITE_EXTENSION_URI)
browse = params.get(WEBSITE_BROWSE_URL_PARAM)
service = _served_as(child)
Expand All @@ -443,11 +450,13 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s
"isn't routed", child.name, record.env_id)
continue
named.add(service)
found.append((record.env_id, _found(child.name, url, params)))
found.append((record.env_id, _found(child.name, url, params, stored.get(service))))
for name, url in frontends.items():
if name not in named:
card = await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}", name)
found.append((record.env_id, _found(name, url, protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI))))
card = {} if name in stored else await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}",
name)
params = protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI)
found.append((record.env_id, _found(name, url, params, stored.get(name))))
return _env_qualified(found)


Expand All @@ -468,8 +477,19 @@ def _served_as(child) -> str:
return parts[1] if len(parts) > 1 and parts[0] == "svc" and parts[1] else child.name


def _found(name: str, url: str, params: dict) -> dict:
return {"name": name, "url": url, "domains": list(params.get("domains") or [])}
def _stored_domains(record: DeployedEnv) -> dict[str, list[str]]:
"""The names each website of the env ``record`` deployed stores, by its environment_name: a multi env's websites',
or a website env's own. None for an env the store doesn't hold."""
try:
env = Env.get(record.env_id, getattr(record, "env_version", None))
except NotFoundError:
return {}
websites = getattr(env, "website_envs", None) or [env]
return {site.environment_name: list(site.domains) for site in websites if getattr(site, "domains", None)}


def _found(name: str, url: str, params: dict, stored: Optional[list[str]] = None) -> dict:
return {"name": name, "url": url, "domains": list(stored or params.get("domains") or [])}


def _joined(address: str, url: str) -> str:
Expand Down
5 changes: 5 additions & 0 deletions src/agent_env/env/envs/multi_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _snapshot_after_load_default() -> bool:
as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal,
plugin_provider_like_a_builtin, provider_or_class,
)
from agent_env.env.envs.computer.utils import sites
from agent_env.env.envs.mcp_server import MCPServerEnv
from agent_env.env.envs.website import WebsiteEnv
from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME
Expand Down Expand Up @@ -86,6 +87,10 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer
self.env_provider_type = env_provider_type # the children's own types are ignored: this one deploys them all
self.mcp_server_envs = mcp_server_envs
self.website_envs = website_envs or []
if conflicts := sites.name_conflicts([sites.Claim(name, sites.SYNTHETIC, f"{env.environment_name} at website_envs[{i}]")
for i, env in enumerate(self.website_envs)
for name in dict.fromkeys(env.domains)]):
raise sites.SiteConflict(f"multi env {id!r}: its websites claim names twice: {'; '.join(conflicts)}")
self._sandbox = None
self._gateway_url = None
self._gateway_mode = GatewayMode.PERFORMANCE
Expand Down
Loading
Loading