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
47 changes: 43 additions & 4 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ class MailApp(ComputerAppEnvironment):
- **`launch=Launch(...)`** says how it starts in the desktop session: with it (`at="boot"`) or when
the agent opens it (`"demand"`). `ready=Ready(...)` says when it's ready, within `timeout_s` of its
launch: a window matching `launch.window` by its class, instance or title, a port answering on
loopback, or a command exiting 0. `@ready_check` marks a method for readiness that needs logic (a
loopback, or a command exiting 0. `env` sets variables in the app's environment, over its server's. `@ready_check` marks a method for readiness that needs logic (a
sync one runs in a thread), and `@install_step(scope=...)` one a computer runs as it installs the
app, after its run scripts, offline and without the class's `__init__`. Each runs on every OS the
app installs on, or on those its `os=` names (`@install_step(os="linux")`, `@ready_check(os=[...])`);
Expand Down Expand Up @@ -716,9 +716,10 @@ desktop session, and `ready` (GET: an `AppReadiness` of `ready`, `problems`, `la
and `timeout_s`), which runs the app's readiness checks then, at once, each within 10 s. A server
that can't start, or whose start in the session fails (adding the launcher, say), writes why to
`.agentenv/server.failure` in the folder and exits, and the driver reports that in its place. Once
the session is up the server adds the app's launcher to the session user's menu, which runs `python
-m agentenv_protocol.computer.launch <folder>` to ask the server, so the server starts every copy of
the app, and launches a `boot` app, once per boot. The driver finds each app by the socket in its
the X display is up, when `DISPLAY` names one, the server adds the app's launcher to the session user's
menu, which runs `python -m agentenv_protocol.computer.launch <folder>` to ask the server, so the
server starts every copy of the app, and launches a `boot` app, once per boot. An app that runs with
no desktop, a website browser alone, gets no launcher. The driver finds each app by the socket in its
folder: the computer is ready once every app's server answers and each `boot` app, and each app with
no launch, says it's ready; a `demand` app counts once something launches it. What the server does
in the session (`agentenv_protocol.computer.session`) is Linux's.
Expand All @@ -739,6 +740,44 @@ requests to the app's server.
env stores, and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.

### Website browsers

`agentenv_protocol.website_browser.WebsiteBrowserEnvironment` is the computer app class for a Chromium
website browser, run inside a computer or alone. A subclass declares its build as any app does, its
`Launch` running the browser itself. It sets `version`, its build, and `policy_dir`, where that build
reads managed policies; `policies` adds its own over agentenv's, and `profile` is its profile folder
under the session user's home (`.config/chromium` by default), whose contents from its install steps
are the profile's template. The base class adds:

- **Its settings**, `BrowserSettings`, which a subclass's `Settings` extends: `start_urls`, opened in
tabs at launch, the first active (`["about:blank"]` by default; none may start with `-`), headless
the first from its command line and the rest over its pipe, since headless Chromium takes one; `window`,
1280 × 800, alone only; `locale`; and `time_zone`, set as `TZ` in its launch's `env`.
- **The flags agentenv owns**, after the subclass's command and before the start tabs: its profile,
`--remote-debugging-pipe`, `--no-first-run`, `--no-default-browser-check` and `--lang`. With
`AGENTENV_SITE_PROXY` set, it goes out through that proxy, with WebRTC's non-proxied UDP off.
Alone it bypasses nothing, loopback included; in a computer it bypasses loopback (`127.0.0.1`,
`[::1]`, `localhost`), so the apps beside it stay reachable while `*.localhost` names still go
through. Alone (`AGENTENV_BROWSER_ALONE=1`) it also takes `--window-size` and `--headless=new`,
except on the open web (`AGENTENV_BROWSER_OPEN_WEB=1`), and refuses to start with no proxy.
`declaration()` refuses a subclass whose command carries any of these, or `--remote-allow-origins`, or
any other way to open a debugging port.
- **Alone, no desktop**: its launch matches and waits on no window, its server adds no launcher and,
without `DISPLAY`, waits for no display, and its ready checks say when it's ready.
- **`write_policies`**, an install step that writes agentenv's unattended-use policies (`POLICIES`)
and the subclass's to `agentenv.json` in `policy_dir`.
- **A debugging pipe, not a port.** Its server launches it with a new pipe's ends as its fds 3 and 4
(`session.launch`'s `fds`, moved into place by `computer/_with_fds.py`, run alone with `-I -S`) and keeps the
other ends as `self.devtools`, a `DevToolsPipe`: `await self.devtools.call(method, params)` returns
the result or raises `DevToolsError`, and `ConnectionError` with no browser connected. No port
listens and no `DevToolsActivePort` is written, so nothing else in the computer can reach it over
CDP. Since only the server that launched it holds the pipe, a server that restarts stops it and
launches it again. The ready check `devtools_answers` asks `Browser.getVersion` over the pipe.
- **`browser/v1`** on its card, params only: `chromium` (its `version`), `clock: "real"` and, alone,
`window`.
- **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
`~/Downloads`, folders it makes.

## A2A agent framework

The distribution exposes two unrelated decorators named `extension`:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Run a program with fds it inherits moved to the numbers it expects: ``python -I -S _with_fds.py <from>=<to> ... --
<argv>``. ``session.launch`` runs an app launched with ``fds`` through it, by path, so its server runs no Python between
its fork and its exec and this loads nothing but the standard library."""
from __future__ import annotations

import fcntl
import os
import sys


def main(argv: list[str]) -> None:
split = argv.index("--")
moves = [tuple(int(number) for number in pair.split("=")) for pair in argv[:split]]
# Each source copied above every number named first, so no copy, source or target is another.
above = max((number for move in moves for number in move), default=2) + 1
copies = [(fcntl.fcntl(source, fcntl.F_DUPFD_CLOEXEC, above), target) for source, target in moves]
for source, _ in moves:
os.close(source)
for copy, target in copies:
os.dup2(copy, target)
os.close(copy)
os.execvp(argv[split + 1], argv[split + 1:])


if __name__ == "__main__":
main(sys.argv[1:])
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ class Launch(BaseModel):
#: Matches the app's window by its WM_CLASS ``class`` or ``instance`` and its ``title``, as given.
window: Optional[dict[str, str]] = None
ready: Optional[Ready] = None
#: Variables set in the app's environment, over its server's.
env: Optional[dict[str, str]] = None

@field_validator("window")
@classmethod
Expand Down Expand Up @@ -399,17 +401,36 @@ def __init__(self) -> None:
self.settings = cls.settings_on(on).model_validate(values)
self.build_settings = _built_settings(cls, on)
declared = cls._installs.get(on)
self.launch = declared.launch if declared is not None else None
launch = declared.launch if declared is not None else None
self.launch = self._launch_for(launch) if launch is not None else None
self.paths = list(declared.paths) if declared is not None else []
ready = self.launch.ready if self.launch is not None else None
ready = launch.ready if launch is not None else None
self._computer_app = AppRuntime(
self.launch, {fn.__name__: getattr(self, fn.__name__) for fn in _methods(cls)
if hasattr(fn, _READY_CHECK_ATTR) and _runs_on(getattr(fn, _READY_CHECK_ATTR), on)},
ready.timeout_s if ready is not None else DEFAULT_TIMEOUT_S)
ready.timeout_s if ready is not None else DEFAULT_TIMEOUT_S, desktop=self._on_desktop(),
launch_fds=self._launch_fds, relaunch_on_start=self._relaunch_on_start())
self._computer_app_data = AppDataPlane(
self._computer_app, {op: getattr(self, name) for op, name in cls._data_hooks.items()}, self.paths,
cls.data_timeout_s)

def _launch_for(self, launch: Launch) -> Launch:
"""The launch its server uses, from the one declared for its OS, whose ``ready`` timeout it keeps: as
declared."""
return launch

def _on_desktop(self) -> bool:
"""Whether it runs in a computer's desktop session, which lists it in its menu."""
return True

async def _launch_fds(self) -> dict[int, int]:
"""The fds each launch hands it, by the number each gets in it, made new for that launch: none."""
return {}

def _relaunch_on_start(self) -> bool:
"""Whether only the server that launched it can talk to it, so a server that restarts launches it again."""
return False

@classmethod
def settings_on(cls, os_type: Optional[str]) -> type[BaseModel]:
"""Its ``Settings`` on ``os_type``, with the fields its OS adds."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,51 @@ class AppRuntime:
"""An app's server's state: its ``launch``, its ``@ready_check`` methods by name, bound to the app, how long it may
take to be ready, why its last launch failed, and each sync check still running in its thread."""

def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[], Any]], timeout_s: float) -> None:
def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[], Any]], timeout_s: float, *,
desktop: bool = True, launch_fds: Optional[Callable[[], Awaitable[dict[int, int]]]] = None,
relaunch_on_start: bool = False) -> None:
self.launch = launch
self.ready_checks = ready_checks
self.timeout_s = timeout_s
self.desktop = desktop
self.launch_fds = launch_fds
self.relaunch_on_start = relaunch_on_start
self.started = time.time()
self.launch_failed: Optional[str] = None
self.checking: dict[str, asyncio.Future] = {}

async def start(self) -> None:
"""Once the desktop session is up, add the app's menu launcher 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."""
"""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."""
if self.launch is None:
return
await session.wait_until_up()
session.add_launcher(os.environ["ENVIRONMENT_NAME"], Path.cwd())
if self.launch.at == "boot" and _launched_at() is None:
if os.environ.get("DISPLAY"):
await session.wait_until_up()
if self.desktop:
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):
try:
if relaunch:
await self.stop_app()
await self.launch_app()
except Exception as e:
self.launch_failed = f"{type(e).__name__}: {e}"

async def launch_app(self) -> int:
"""Start the app from its folder, marked as launched; its pid."""
pid = await session.launch(self.launch.command, Path.cwd(), {"ENVIRONMENT_NAME": os.environ["ENVIRONMENT_NAME"]})
"""Start the app from its folder, marked as launched, with the fds the app gives it, whose copies here are
closed once it has them; its pid."""
fds = await self.launch_fds() if self.launch_fds is not None else {}
try:
pid = await session.launch(self.launch.command, Path.cwd(),
{**(self.launch.env or {}), "ENVIRONMENT_NAME": os.environ["ENVIRONMENT_NAME"]},
**({"fds": fds} if fds else {}))
finally:
for fd in fds.values():
os.close(fd)
self.launch_failed = None
PID.parent.mkdir(parents=True, exist_ok=True)
PID.write_text(str(pid))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
import os
import re
import signal
import sys
from pathlib import Path
from typing import Optional

#: How often a starting session is looked for.
POLL_S = 0.2
#: Moves the fds an app is launched with into place, run on its own so it loads nothing but the standard library.
WITH_FDS = Path(__file__).with_name("_with_fds.py")
#: The processes launched into the session, kept until they exit so each is reaped.
_running: set[asyncio.Task] = set()

Expand All @@ -22,12 +26,15 @@ async def wait_until_up() -> None:
await asyncio.sleep(POLL_S)


async def launch(argv: list[str], cwd: Path, env: dict[str, str]) -> int:
async def launch(argv: list[str], cwd: Path, env: dict[str, str], fds: Optional[dict[int, int]] = None) -> int:
"""Start ``argv`` in ``cwd``, in a session of its own so it outlives the server, with the server's environment and
``env``; its pid."""
``env``, and ``fds``, the server's fds it inherits by the number each gets in it; its pid."""
if fds:
argv = [sys.executable, "-I", "-S", str(WITH_FDS), *(f"{source}={target}" for target, source in fds.items()),
"--", *argv]
process = await asyncio.create_subprocess_exec(
*argv, cwd=cwd, env={**os.environ, **env}, start_new_session=True, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, pass_fds=tuple((fds or {}).values()))
reaped = asyncio.ensure_future(process.wait())
_running.add(reaped)
reaped.add_done_callback(_running.discard)
Expand Down
Loading
Loading