Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ and deserialized through a registry.
| `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. |
| `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_<NAME>`), and `omarchy-4.5`, a disk alone built on the composing machine from Omarchy's own VM image (its `[forms.disk]` `source`) by `disk/omarchy/`'s recipe, its services systemd user units of the graphical session that start through `with-seed`, booted by the launcher as a `linux-desktop` guest (`env/envs/computer/utils/omarchy_disk.py`, on `thin_disk.py`, which Windows' disks use too), aliased through `[artifacts]`. It has no tasks either. `disk/linux/` is the Linux disk form's recipe: `boot/`, whose Dockerfile makes a disk form (a GPT disk of an EFI system partition holding one UKI, Ubuntu's kernel with a busybox initramfs whose init reads a NoCloud seed and `switch_root`s into the image's entrypoint, and an ext4 root filesystem that is a container image's, plus `/etc/agentenv/vm/image.env` from its OCI config), `tools/`, the disk tools that make it unprivileged on the builder's own platform (`mkfs.ext4 -d`), and `launcher/`, the QEMU launcher that boots one under KVM. A base's `[forms.disk]` with no image is made from its container form; the composer makes each arch's disk from its composed container, keeping the base disk's EFI system partition, keyed by the container's key, the base disk and the recipe; gate 3 probes it where a provider boots disks natively (`disk_image_platforms()`), and a deploy's first boot with KVM probes one left `unverified`; a deploy naming `form = "disk"` boots it through `create_vm(image=..., env=...)`, its settings in the seed. |
| `browsers/` | The `browsers` bundle: the website browsers agent-env ships, today `chromium`, a `computer_app` env (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). All of it, its pin, install step, policies and command, is in `envs/chromium/chromium.py`; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. |
| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. |
| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, the page driver aliased `page`, a browser computer's, whose screen is the browser's active tab and which serves Playwright MCP's `browser_*` tools beside `computer_*` from a Playwright MCP it runs attached to the browser over a CDP address the relay issues it (`browser_evaluate` and `browser_run_code_unsafe` hidden by default; `scripts/upgrade_playwright_mcp.py` pins its version), and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. |
| `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker, and `desktop`, a computer env of `ubuntu-24.04` and `reference` with a probe task and an agent task). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, `<name> = "<package>"` shipping `src/<package as a path>/<name>/`. |

### Training helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
extension,
get_data,
injecting,
mcp_port,
reset_data,
tool,
)
Expand Down Expand Up @@ -94,6 +95,7 @@
"AgentEnvStarletteApplication",
"ForwardedTool",
"create_fastmcp_app",
"mcp_port",
"environment_card",
"reset_data",
"add_data",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,22 @@ def deco(fn: Callable) -> Callable:
class ForwardedTool(NamedTuple):
"""A tool an environment serves on another server's behalf, as that server lists it: its descriptor, its own
schema included, its MCP ``annotations`` and its ``output_schema``; ``call`` takes the call's arguments as keywords
and returns the other server's ``CallToolResult``, which reaches the caller as it is."""
and returns the other server's ``CallToolResult``, which reaches the caller as it is. ``hidden_by_default`` hides it
as ``@tool(hidden_by_default=True)`` hides a tool of the environment's own."""

tool: EnvironmentTool
call: Callable[..., Any]
annotations: dict | None = None
output_schema: dict | None = None
hidden_by_default: bool = False

@classmethod
def listed(cls, tool: dict, call: Callable[..., Any], *, hidden_by_default: bool = False) -> "ForwardedTool":
"""The tool another server's ``tools/list`` lists as ``tool``, called through ``call``: its name, description,
input and output schemas and annotations as listed."""
return cls(EnvironmentTool(name=tool["name"], description=tool.get("description"),
inputSchema=tool["inputSchema"]),
call, tool.get("annotations"), tool.get("outputSchema"), hidden_by_default)


class _ResourceMarker(NamedTuple):
Expand Down Expand Up @@ -450,7 +460,8 @@ def _register_forwarded(self, app: Any, descriptor: EnvironmentTool, fn: Callabl
fn_metadata=FuncMetadata(arg_model=_passed_through(), output_schema=schema,
output_model=_as_it_came() if schema is not None else None),
is_async=True,
annotations=_mcp_tool_annotations(forwarded.annotations) if forwarded.annotations is not None else None)
annotations=_mcp_tool_annotations(forwarded.annotations) if forwarded.annotations is not None else None,
meta={HIDDEN_BY_DEFAULT_META_KEY: True} if forwarded.hidden_by_default else None)

def _register_resource(self, app: Any, resource: _ServedResource) -> None:
"""A resource as it is, its description absent when it has none, or a template through FastMCP's own
Expand Down Expand Up @@ -592,6 +603,11 @@ def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> N
app.router.routes.append(Route(path, handler, methods=methods))


def mcp_port() -> int:
"""The port ``create_fastmcp_app`` serves on: ``MCP_PORT``'s, else 18765."""
return int(os.environ.get("MCP_PORT", "18765"))


def create_fastmcp_app(handler: Any, *, card: EnvironmentCard, **fastmcp_kwargs: Any) -> Any:
"""FastMCP app wired for the agent-env deploy contract, with ``handler`` mounted under ``card``; returned un-served."""
try:
Expand All @@ -600,7 +616,7 @@ def create_fastmcp_app(handler: Any, *, card: EnvironmentCard, **fastmcp_kwargs:
raise ImportError("create_fastmcp_app requires the 'mcp' package: pip install 'mcp>=1.25,<2'") from e
app = FastMCP(card.name, **{"instructions": _instructions(handler), **fastmcp_kwargs})
app.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")
app.settings.port = int(os.environ.get("MCP_PORT", "18765"))
app.settings.port = mcp_port()
app.settings.transport_security.enable_dns_rebinding_protection = False
AgentEnvFastMCPApplication(card, handler).add_routes_to_app(app)
return app
Expand Down Expand Up @@ -1283,6 +1299,8 @@ async def call(**arguments: Any) -> Any:
return await forwarded.call(**arguments)

setattr(call, _FORWARDED_ATTR, forwarded)
if forwarded.hidden_by_default:
setattr(call, _HIDDEN_BY_DEFAULT_ATTR, True)
return call


Expand Down
10 changes: 10 additions & 0 deletions packages/agentenv-protocol/tests/test_agent_env_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
agent_skill,
create_fastmcp_app,
environment_card,
mcp_port,
extension,
get_data,
reset_data,
Expand Down Expand Up @@ -1192,6 +1193,15 @@ class _Child(_ServeEnv):
assert "items_add_item" in app.tools


def test_an_app_serves_on_the_port_mcp_port_names_else_18765(monkeypatch):
monkeypatch.delenv("MCP_PORT", raising=False)
default = (mcp_port(), create_fastmcp_app(_ToolHandler(), card=EnvironmentCard(name="items")).settings.port)
monkeypatch.setenv("MCP_PORT", "40123")
named = (mcp_port(), create_fastmcp_app(_ToolHandler(), card=EnvironmentCard(name="items")).settings.port)

assert default == (18765, 18765) and named == (40123, 40123)


def test_create_fastmcp_app_without_mcp_raises_install_hint(monkeypatch):
monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", None)
with pytest.raises(ImportError, match="requires the 'mcp' package"):
Expand Down
29 changes: 29 additions & 0 deletions packages/agentenv-protocol/tests/test_tool_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from mcp.types import CallToolResult, TextContent # noqa: E402

from agentenv_protocol import ( # noqa: E402
HIDDEN_BY_DEFAULT_META_KEY,
ROLE_HEADER,
ROLE_META_KEY,
AgentEnvEnvironment,
Expand Down Expand Up @@ -140,6 +141,34 @@ async def click(**arguments):
assert agent_tools == ["read", "write"] and refused.isError


@pytest.mark.asyncio
async def test_a_forwarded_tool_hidden_by_default_stays_hidden_through_every_tool_rule_until_a_rule_names_it():
class Forwarding(_Env):
def forwarded_tools(self):
async def evaluate(**arguments):
return CallToolResult(content=[TextContent(type="text", text="evaluated")])
return [ForwardedTool(EnvironmentTool(name="evaluate", inputSchema={"type": "object"}), evaluate,
hidden_by_default=True)]

with anyio.fail_after(30):
async with _served(Forwarding()) as (rest, session):
async with session("agent") as agent:
listed, refused = await _names(agent), await agent.call_tool("evaluate", {})
await rest.post("/tools/enable", json={"role": "agent", "tools": "*"})
async with session("agent") as agent:
after_every_tool = await _names(agent)
await rest.post("/tools/enable", json={"role": "agent", "tools": ["evaluate"]})
async with session("agent") as agent:
shown = {t.name: t for t in (await agent.list_tools()).tools}
called = await agent.call_tool("evaluate", {})
async with session() as anyone:
anyone_tools = await _names(anyone)
assert listed == after_every_tool == anyone_tools == ["read", "write"]
assert refused.isError and refused.structuredContent["code"] == "permission_denied"
assert sorted(shown) == ["evaluate", "read", "write"] and not called.isError
assert shown["evaluate"].meta == {HIDDEN_BY_DEFAULT_META_KEY: True} and called.content[0].text == "evaluated"


@pytest.mark.asyncio
async def test_everything_off_then_one_tool_on_for_one_role():
with anyio.fail_after(30):
Expand Down
14 changes: 14 additions & 0 deletions packages/agentenv-protocol/tests/test_tool_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ async def test_a_forwarded_tool_is_listed_with_its_own_schema_and_called_with_it
assert {t.name: t.inputSchema for t in env.mounted_card().capabilities.tools}["click"] == _CLICK


def test_a_tool_another_server_lists_is_forwarded_as_it_lists_it():
async def call(**arguments) -> CallToolResult:
return CallToolResult(content=[])

click = ForwardedTool.listed({"name": "click", "title": "Click", "description": "Click an element.",
"inputSchema": _CLICK, "outputSchema": _FOUND, "annotations": {"readOnlyHint": False}},
call)
find = ForwardedTool.listed({"name": "find", "inputSchema": {"type": "object"}}, call, hidden_by_default=True)

assert click == ForwardedTool(EnvironmentTool(name="click", description="Click an element.", inputSchema=_CLICK),
call, {"readOnlyHint": False}, _FOUND)
assert find == ForwardedTool(EnvironmentTool(name="find", inputSchema={"type": "object"}), call, None, None, True)


_OTHER_FILES = [
{"uri": "skill://other/SKILL.md", "name": "other", "description": "The skill.", "mimeType": "text/markdown"},
{"uri": "skill://other/LINUX.md", "name": "LINUX.md", "mimeType": "text/markdown"},
Expand Down
Loading
Loading