Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
57f6048
feat(multi): a multi env names its website browser, and a browser com…
earakely-scale Nov 10, 2026
6138547
fix(multi): a reset of the multi env as stored resets the website bro…
earakely-scale Nov 10, 2026
3388d26
feat(multi): on modal_vm, a browser held to the gateway allows every …
earakely-scale Nov 10, 2026
a282ce2
fix(gateway): a CDP address behind a tunnel that says no scheme takes…
earakely-scale Nov 10, 2026
07a2b0f
fix(multi): the run's browser computer counts for browser steps, an M…
earakely-scale Nov 10, 2026
2897406
test(multi): the browser test finds its gateway among the sandboxes i…
earakely-scale Nov 10, 2026
13eb5e1
test(multi): a failed redeploy closes only its own browser computer a…
earakely-scale Nov 10, 2026
cce2785
refactor(multi): the website browser's network is its own env's, as a…
earakely-scale Nov 10, 2026
7393cb0
test(multi): the multi env browser tests join browser_computer_test, …
earakely-scale Nov 10, 2026
8ade114
feat(browser): browser_connect_cdp hands an agent an address to a mul…
earakely-scale Nov 11, 2026
06d0421
fix(browser): browser_connect_cdp refuses a multi env browser's addre…
earakely-scale Nov 11, 2026
6b2495c
refactor(gateway): the gateway is told its gateway_url, the record's …
earakely-scale Nov 11, 2026
5b0a38a
fix(browser): a multi env's CDP address takes its scheme from the ori…
earakely-scale Nov 11, 2026
95a0dcc
fix(browser): a browser that takes no origin, behind a tunnel gateway…
earakely-scale Nov 11, 2026
767ed3e
refactor(multi): one place picks a multi env's website browser and wh…
earakely-scale Nov 11, 2026
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
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions src/agent_env/browsers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ alone only; `locale`; `time_zone`; and
(`apps = [{ env = "chromium", settings = { start_urls = ["https://example.test/"] } }]`), and a deploy overrides any of
them (`env_params.app_settings = {chromium = {start_urls = [...]}}`). Its data is its profile, `~/.config/chromium`, which
the image has none of, so each deploy and each reset starts with a clean one. Name it from another bundle as `chromium`,
the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`.
the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`. A multi env names it as
its website browser with `browser_envs = ["chromium"]`, in place of the run's `playwright-mcp`: it then deploys on a
sandbox of its own beside the multi env's computers, sized by the deploy step's `child_envs.chromium`, its tools served
under `chromium_`, routing the multi env's synthetic sites through the gateway, its network its own env's, as any
computer child's.

A task changes a browser's state with the steps in `agent_env.task_step.task_steps.browser`, each a call to one
`browser/v1` operation on the browser in the env `env_id`, the one `browser` names when the env has several:
Expand All @@ -36,7 +40,11 @@ each cookie is set for every name the site is reached by (an exact name by its U
and the browser's `data/add` loads them, which opens the start tabs again while no agent has acted. A task runs it
after the load that seeds the site and again after `reset_env`, which starts the browser afresh. Preflight refuses a
step on an env with no browser, none of the name given, or several and none named. On a multi env, the steps reach
the browser apps of its computers, each through the computer's own record and control token; `browser_connect_cdp`
doesn't reach those yet.
`browser_connect_cdp` hands an agent a CDP address to the same browser. A whole state, Playwright's `storageState`
the browser apps of its computers, each through the computer's own record and control token.
`browser_connect_cdp` hands an agent a CDP address to the same browser, under the origin where the agent reaches the
env, so through a tunnel that ends TLS it's https and wss; on a multi env, one at its gateway, asked through the
gateway's `/svc/<computer>/` route, so the agent reaches the browser through the gateway's CDP relay as it reaches the
rest of the env, with the multi env's tools hidden from its role; a computer there with several website browsers is
refused, as the relay doesn't tell them apart yet, and so, behind a tunnel that ends TLS, is a browser that takes no
origin, one composed before agentenv-framework 0.9.1505. A whole state, Playwright's `storageState`
included, loads through `load_artifact` or `data/add` instead.
21 changes: 17 additions & 4 deletions src/agent_env/bundle/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,8 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity
if name is None:
continue
group = ref.where.partition("[")[0]
if group == "browser_envs": # a browser computer runs as a computer, any other browser as an MCP server
group = "computer_envs" if self._is_computer(ref, store_latest) else "mcp_server_envs"
named.setdefault(group, set()).add(name)
if group == "computer_envs":
computers.append(name)
Expand Down Expand Up @@ -1028,8 +1030,10 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str
for child in children:
if child.kind is not EntityKind.ENV:
continue
held = (self._server_names(child, store_latest) if child.where.startswith("computer_envs[") else
{self._environment_name(child, store_latest)})
computer = child.where.startswith("computer_envs[") or (
child.where.startswith("browser_envs[") and self._is_computer(child, store_latest))
held = (self._server_names(child, store_latest) if computer
else {self._environment_name(child, store_latest)})
names |= held if held is not None else {None}
else:
names = {self.accepted.get(_key(ref.local), {}).get("environment_name")}
Expand All @@ -1044,8 +1048,10 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str
except (NotFoundError, ValueError, KeyError, TypeError):
return None # reported by the store ref check
if isinstance(env, MultiEnv):
return {*(child.environment_name for child in [*env.mcp_server_envs, *env.website_envs]),
*(name for computer in env.computer_envs
browsers = env.browser_envs or []
return {*(child.environment_name for child in [*env.mcp_server_envs, *env.website_envs,
*(b for b in browsers if not isinstance(b, ComputerEnv))]),
*(name for computer in [*env.computer_envs, *(b for b in browsers if isinstance(b, ComputerEnv))]
for name in (computer.environment_name, *(app.environment_name for app in computer.composed_apps())))}
if isinstance(env, ComputerEnv):
return {env.environment_name, *(app.environment_name for app in env.composed_apps())}
Expand Down Expand Up @@ -1081,6 +1087,13 @@ def _domains(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], in
return list(self.accepted.get(_key(ref.local), {}).get("domains") or [])
return list(getattr(self._store_entity(ref, store_latest), "domains", None) or [])

def _is_computer(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> bool:
"""Whether the env ``ref`` names is a computer env, the bundle's own by its type, a store one's as stored."""
if isinstance(ref.local, BundleEntry):
cls = get_env_registry().get(ref.local.type)
return cls is not None and issubclass(cls, ComputerEnv)
return isinstance(self._store_entity(ref, store_latest), ComputerEnv)

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:
Expand Down
24 changes: 18 additions & 6 deletions src/agent_env/cli/env/multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,32 @@ def multi():
@click.option("--computer", "computers", multiple=True,
help="ComputerEnv id[:version], deployed on a sandbox of its own; its tools are served under its "
"environment_name and '_'")
@click.option("--browser", "browsers", multiple=True,
help="Website browser env id[:version]: an MCP server env serving one, such as playwright-mcp, or a "
"computer env with a website browser app, such as chromium, deployed on a sandbox of its own "
"(default: the run's website_browser_env_id when there are websites)")
@click.option("--no-browser", is_flag=True, default=False, help="Deploy no website browser, even with websites")
@click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)")
@click.option("--name", default=None, help="Name agents see this env's MCP server under, e.g. crm -> mcp__crm__<tool> (default: env + 4 random digits per deploy)")
@env_provider_type_option("What deploys the env: 'gateway' (a gateway in front of its servers and websites, whatever their own "
"env_provider_type), or the type of an installed agent_env.env_providers plugin, which deploys them all", "multi")
@click.option("--validate", "run_validation", is_flag=True, default=False, help="Validate the environment after registering it")
def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], computers: tuple[str, ...],
metadata_pairs: tuple[str, ...], name: str | None, env_provider_type: str, run_validation: bool):
browsers: tuple[str, ...], no_browser: bool, metadata_pairs: tuple[str, ...], name: str | None,
env_provider_type: str, run_validation: bool):
"""Create a MultiEnv from MCPServerEnv, WebsiteEnv and ComputerEnv ids."""

if not mcp_servers and not websites and not computers:
click.echo("Error: At least one of --mcp-server, --website or --computer must be provided.", err=True)
raise SystemExit(1)
if browsers and no_browser:
click.echo("Error: --browser and --no-browser can't both be given.", err=True)
raise SystemExit(1)

mcp_server_envs = _fetch(mcp_servers, "mcp_server", "MCPServerEnv")
website_envs = _fetch(websites, "website", "WebsiteEnv")
computer_envs = _fetch(computers, COMPUTER_ENV_TYPE, "ComputerEnv")
browser_envs = [] if no_browser else _fetch(browsers, None, "website browser env") if browsers else None

user_metadata = {}
for pair in metadata_pairs:
Expand All @@ -57,30 +67,32 @@ def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], co

try:
refusal = MultiEnv(id=env_id, version=None, mcp_server_envs=mcp_server_envs, website_envs=website_envs, name=name,
env_provider_type=env_provider_type, computer_envs=computer_envs).save_refusal()
env_provider_type=env_provider_type, computer_envs=computer_envs,
browser_envs=browser_envs).save_refusal()
except ValueError as e:
refusal = str(e)
if refusal:
click.echo(f"Error: {refusal}", err=True)
raise SystemExit(1)
click.echo("Creating MultiEnv...")
multi_env = MultiEnv.put(id=env_id, mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=metadata if metadata else None, name=name,
env_provider_type=env_provider_type, computer_envs=computer_envs)
env_provider_type=env_provider_type, computer_envs=computer_envs,
browser_envs=browser_envs)
click.echo(f"Created MultiEnv: id={multi_env.id} version={multi_env.version} env_provider_type={multi_env.env_provider_type}")
if run_validation:
click.echo("\nValidating environment...")
instance_id = asyncio.run(multi_env.validate(on_progress=click.echo))
click.echo(f"Validation task: {instance_id}")


def _fetch(refs: tuple[str, ...], env_type: str, label: str) -> list[Env]:
"""The env each of ``refs`` (id[:version]) names, exiting unless each is an ``env_type`` env."""
def _fetch(refs: tuple[str, ...], env_type: str | None, label: str) -> list[Env]:
"""The env each of ``refs`` (id[:version]) names, exiting unless each is an ``env_type`` env, when one is named."""
envs = []
for ref in refs:
env_id, version = parse_env_ref(ref)
click.echo(f"Fetching {label}: id={env_id} version={version or 'latest'}...")
env = Env.get(env_id, version=version)
if env.type != env_type:
if env_type is not None and env.type != env_type:
click.echo(f"Error: {env_id} is type '{env.type}', expected '{env_type}'", err=True)
raise SystemExit(1)
envs.append(env)
Expand Down
Loading
Loading