Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f569fa2
feat(computer): macOS computer/v1 module and executable TCC grants
earakely-scale Nov 5, 2026
09d3b20
fix(lume): keep a builder whose shutdown hangs, with its guest's log
earakely-scale Nov 5, 2026
9deb3ad
feat(computer): a driver declares settings of its own
earakely-scale Nov 5, 2026
fb50f5c
feat(drivers): agent-desktop and macos-harness on macOS
earakely-scale Nov 5, 2026
aac41f1
test(bundle): the shipped aliases include agent-desktop and macos-har…
earakely-scale Nov 5, 2026
e55b2d8
fix(drivers): the macOS arms' protocol floor is this source's version
earakely-scale Nov 5, 2026
1d1b677
build: lock the protocol's macos extra
earakely-scale Nov 5, 2026
0db9d92
test(drivers): a slow recording reader waits for the source, not a fi…
earakely-scale Nov 5, 2026
27de5fe
fix(computer): a macOS venv copies the pinned Python from its own folder
earakely-scale Nov 5, 2026
59147a4
fix(computer): MacOSDriver spaces a scroll's wheel notches
earakely-scale Nov 5, 2026
6ab57d0
fix(computer): MacOSDriver rests the pointer 0.2 s and holds each press
earakely-scale Nov 5, 2026
c18a013
fix(lume): a hung builder's saved log keeps its shutdown lines
earakely-scale Nov 5, 2026
86a8848
fix(computer): MacOSDriver's pointer move waits until macOS has it there
earakely-scale Nov 5, 2026
bc0c9d6
fix(lume): a builder gets two minutes to shut down before it's stoppe…
earakely-scale Nov 5, 2026
e17a809
fix: Greptile's review of the macOS arms
earakely-scale Nov 5, 2026
6bd0dcd
test(lume): check the saved shutdown log's commands whatever their qu…
earakely-scale Nov 5, 2026
7d3b064
fix(drivers): macos-harness's instructions send the agent to its skil…
earakely-scale Nov 5, 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
5 changes: 5 additions & 0 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ computer = [
"uvicorn>=0.30",
"websockets>=13",
]
# A macOS driver on MacOSDriver: ScreenCaptureKit captures the display and Quartz events send its input, through PyObjC.
macos = [
"pyobjc-framework-Quartz>=11; sys_platform == 'darwin'",
"pyobjc-framework-ScreenCaptureKit>=11; sys_platform == 'darwin'",
]
dev = [
"a2a-sdk[http-server]>=0.3.26,<0.4",
"python-multipart>=0.0.13",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,17 @@
COMPUTER_SPACES,
)
from .driver import DEFAULT_IDENTITY, ComputerDriverEnvironment, DriverDeclaration, computer_driver
from ._macos import MacOSDriver
from .recording import RecordedFrame, RecordedSegment, RecordHints, Recording, frames_in_thread

# The names whose modules need the computer extra (mcp, av and Pillow), by module.
# The names whose modules need the computer extra (mcp, av and Pillow), by module; MacOSDriver's, the macos extra too,
# on first use of the Mac.
_ON_USE = {
"driver": ("ComputerDriverEnvironment", "computer_driver", "DEFAULT_IDENTITY", "DriverDeclaration"),
"card": ("COMPUTER", "COMPUTER_READY", "COMPUTER_DISPLAY", "COMPUTER_SPACES", "COMPUTER_POINTER",
"COMPUTER_KEYBOARD", "COMPUTER_PAGE", "COMPUTER_EXEC", "COMPUTER_RECORDING"),
"recording": ("Recording", "RecordHints", "RecordedFrame", "RecordedSegment", "frames_in_thread"),
"_macos": ("MacOSDriver",),
}


Expand All @@ -116,7 +119,7 @@ def __getattr__(name: str) -> Any:


__all__ = [
"ComputerDriverEnvironment", "computer_driver", "DEFAULT_IDENTITY", "DriverDeclaration",
"ComputerDriverEnvironment", "computer_driver", "DEFAULT_IDENTITY", "DriverDeclaration", "MacOSDriver",
"ComputerAppEnvironment", "computer_app", "install", "linux", "install_step", "ready_check", "Linux", "Service",
"Launch", "Ready", "Packages", "AptSource", "Download", "RunScript",
"AppDeclaration", "AppOs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .card import COMPUTER
from .driver import ComputerDriverEnvironment, DriverDeclaration, DriverPlatform
from .platform import supported
from .settings import ComputerSettings

#: The env type that stores each kind of declaration, which the printed JSON carries as ``type``.
_ENV_TYPES = {AppDeclaration: "computer_app", DriverDeclaration: "computer_driver"}
Expand Down Expand Up @@ -82,7 +83,8 @@ def declare(folder: Path, file: str, name: str) -> AppDeclaration | DriverDeclar
if not platforms:
raise ValueError(f"{name} declares no platform: name them with @computer_driver(supports=[...])")
cards = [(platform, cls.declaration(platform)) for platform in platforms]
return DriverDeclaration(name=cards[0][1].name, supports=list(cls.supports), platforms=[
own = cls.Settings.model_json_schema() if cls.Settings is not ComputerSettings else None
return DriverDeclaration(name=cards[0][1].name, supports=list(cls.supports), settings=own, platforms=[
DriverPlatform(platform=platform, provides=[e.uri for e in card.capabilities.extensions],
tools=[t.name for t in card.capabilities.tools],
features=next({k: v for k, v in e.params.items() if k not in ("endpoint", "methods")}
Expand Down
433 changes: 433 additions & 0 deletions packages/agentenv-protocol/src/agentenv_protocol/computer/_macos.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .keys import canonical
from .operations import declared
from .recording import check_recording
from .settings import ComputerSettings

BUTTONS = ("left", "right", "middle", "back", "forward")

Expand Down Expand Up @@ -37,6 +38,8 @@ def check_features(cls: type) -> None:
problems.append("keys are W3C key values in their W3C case")
except AgentEnvError as e:
problems.append(f"keys: {e.message}")
if not (isinstance(cls.Settings, type) and issubclass(cls.Settings, ComputerSettings)):
problems.append(f"Settings is ComputerSettings or a subclass of it, got {cls.Settings!r}")
if cls.screen_kind not in ("desktop", "page"):
problems.append(f"screen_kind is desktop or page, got {cls.screen_kind!r}")
if ("set_display" in declared(cls)) != bool(cls.display_modes):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ class DriverPlatform(BaseModel):

class DriverDeclaration(BaseModel):
"""What a ComputerDriverEnvironment class declares, as a ``computer_driver`` env stores it: its card's ``name``, the
platforms it ``supports``, as predicates, and what it serves on each of them."""
platforms it ``supports``, as predicates, what it serves on each of them, and ``settings``, the JSON schema of its
``Settings`` when it adds to the base ones."""

model_config = ConfigDict(extra="forbid")

name: str
supports: list[dict[str, Any]]
platforms: list[DriverPlatform]
settings: Optional[dict[str, Any]] = None


class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironment):
Expand All @@ -77,6 +79,8 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
layouts: ClassVar[tuple[str, ...]] = ("us",)
#: The platforms the driver supports, as the driver env declares them: ``[{"os": {...}, "arch": [...]}]``.
supports: ClassVar[list] = []
#: The settings a deploy gives the driver, read as it starts: ComputerSettings, or a subclass adding its own.
Settings: ClassVar[type[ComputerSettings]] = ComputerSettings
#: computer-recording/v1, from ``@record`` if the driver has one, else by polling ``@capture``; ``None`` turns
#: it off.
recording: ClassVar[Recording | None] = Recording()
Expand All @@ -96,7 +100,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
check_features(cls)

def __init__(self, settings: ComputerSettings | None = None, platform: Platform | None = None) -> None:
self.settings = settings if settings is not None else load_settings()
self.settings = settings if settings is not None else load_settings(model=type(self).Settings)
self.platform = platform if platform is not None else detect()
self._computer = ComputerRuntime(self)
#: The routes each app's card opens to agents, by app, read once it answers.
Expand All @@ -109,7 +113,7 @@ def declaration(cls, platform: Platform) -> EnvironmentCard:
running its ``__init__``: what a ``computer_driver`` env records for each platform it supports. The card
hooks may read the class and the base class's state, not what a subclass's ``__init__`` sets."""
driver = cls.__new__(cls)
ComputerDriverEnvironment.__init__(driver, settings=ComputerSettings(), platform=platform)
ComputerDriverEnvironment.__init__(driver, settings=cls.Settings(), platform=platform)
config = dict(getattr(cls, _CARD_CONFIG_ATTR, None) or {})
card = EnvironmentCard(**{**config, "name": config.get("name") or cls.__name__})
return AgentEnvFastMCPApplication(card, driver).environment_card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ class ComputerSettings(BaseModel):
exec_timeout_s: float = Field(default=60.0, gt=0, le=3600)


def load_settings(environ: dict | None = None) -> ComputerSettings:
def load_settings(environ: dict | None = None, model: type[ComputerSettings] = ComputerSettings) -> ComputerSettings:
"""The settings in ``environ`` (this process's environment by default), read as ``model``: a driver's ``Settings``,
the base ones and those it adds."""
raw = (os.environ if environ is None else environ).get(SETTINGS_ENV)
if not raw:
return ComputerSettings()
return model()
try:
return ComputerSettings.model_validate(json.loads(raw))
return model.model_validate(json.loads(raw))
except (ValueError, ValidationError) as e:
raise ValueError(f"{SETTINGS_ENV} is not valid computer settings: {e}") from None
12 changes: 10 additions & 2 deletions packages/agentenv-protocol/tests/test_computer_declare.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
DRIVER = '''import not_installed_here
from not_installed_here.input import Base, Keyboard

from agentenv_protocol.computer import (ComputerDriverEnvironment, Display, capture, click, computer_driver, display,
drag, press, scroll, type_text)
from agentenv_protocol.computer import (ComputerDriverEnvironment, ComputerSettings, Display, capture, click,
computer_driver, display, drag, press, scroll, type_text)

SHIFT = Keyboard.keysym("Shift_L")[0]
print("loading the desk driver")
Expand Down Expand Up @@ -62,6 +62,11 @@ def type_in(self, text): pass

@press
def press_keys(self, keys): pass


class Headed(Desk):
class Settings(ComputerSettings):
headed: bool = False
'''
APP = '''import not_installed_here
from pydantic import BaseModel, SecretStr
Expand Down Expand Up @@ -160,6 +165,9 @@ def test_a_class_whose_imports_arent_installed_declares_what_it_serves_on_each_p
linux = declared.platforms[0]
assert {"urn:agentenv:computer/v1", "urn:agentenv:computer-exec/v1"} <= set(linux.provides)
assert len(linux.tools) == 7 and linux.features["buttons"] == ["left", "right"]
assert declared.settings is None
assert declare(folder, "driver.py", "Headed").settings["properties"]["headed"] == {"default": False, "title": "Headed",
"type": "boolean"}


def test_a_stub_is_itself_again_except_for_dunders(folder):
Expand Down
9 changes: 9 additions & 0 deletions packages/agentenv-protocol/tests/test_computer_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ def test_a_driver_reads_its_settings_and_platform_from_the_environment(monkeypat
driver = _define({})()
assert driver.settings.queue_wait_s == 2 and driver.platform == LINUX

class Own(ComputerSettings):
headed: bool = False

monkeypatch.setenv(SETTINGS_ENV, '{"queue_wait_s": 2, "headed": true}')
own = _define({"Settings": Own})()
assert own.settings.headed and own.settings.queue_wait_s == 2
with pytest.raises(TypeError, match="Settings is ComputerSettings or a subclass of it"):
_define({"Settings": dict})


@pytest.mark.asyncio
async def test_the_card_declares_the_contract_the_driver_implements():
Expand Down
214 changes: 214 additions & 0 deletions packages/agentenv-protocol/tests/test_computer_macos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""MacOSDriver, the computer/v1 operations on macOS, against a fake Mac that records the Quartz events it's sent: keys
pressed as a US keyboard sends them, modifiers around what they modify, held keys and buttons on every later event,
drags through their waypoints, captures as BGRA, and the TCC permissions computer-ready reports."""
from __future__ import annotations

import pytest

pytest.importorskip("mcp.server.fastmcp")
pytest.importorskip("PIL")

from agentenv_protocol.computer import ( # noqa: E402
ComputerSettings,
DeliveryFailed,
Image,
MacOSDriver,
Platform,
Unsupported,
computer_driver,
)
from agentenv_protocol.computer import _macos # noqa: E402

MACOS = Platform(os="macos", arch="arm64", display="aqua")
SHIFT, CONTROL, COMMAND = 0x20000, 0x40000, 0x100000
LEFT_DOWN, LEFT_UP, LEFT_DRAGGED, MOVED = 1, 2, 6, 5


class FakeMac:
def __init__(self) -> None:
self.sent: list[tuple] = []
self.at = (0.0, 0.0)
self.lacking: list[str] = []
self.fails = False

def missing(self) -> list[str]:
return self.lacking

def display(self) -> tuple[int, int, float]:
return 1920, 1080, 1.0

def capture(self) -> tuple[int, int, bytes]:
return 2, 1, bytes([1, 2, 3, 255, 4, 5, 6, 255])

def mouse(self, kind, x, y, button, count, flags) -> None:
if self.fails:
raise ValueError("no event")
self.sent.append(("mouse", kind, x, y, button, count, flags))
self.at = (x, y)

def wheel(self, x, y, lines, across, flags) -> None:
self.sent.append(("wheel", x, y, lines, across, flags))

def key(self, code, down, flags, character=None) -> None:
self.sent.append(("key", code, down, flags, character))

def location(self) -> tuple[float, float]:
return self.at


@computer_driver(name="desk", supports=[{"os": {"type": "macos", "display": ["aqua"]}, "arch": ["arm64"]}])
class Desk(MacOSDriver):
pass


@pytest.fixture
def mac(monkeypatch) -> FakeMac:
fake = FakeMac()
monkeypatch.setattr(_macos, "Quartz", lambda: fake)
monkeypatch.setattr(_macos, "SETTLE_S", 0)
monkeypatch.setattr(_macos, "CLICK_GAP_S", 0)
monkeypatch.setattr(_macos, "CLICK_HOLD_S", 0)
monkeypatch.setattr(_macos, "SCROLL_GAP_S", 0)
return fake


@pytest.fixture
def desk(mac) -> Desk:
return Desk(ComputerSettings(tap_hold_ms=0, type_delay_ms=0), MACOS)


def keys(sent: list[tuple]) -> list[tuple]:
return [event[1:] for event in sent if event[0] == "key"]


@pytest.mark.asyncio
async def test_typed_text_goes_out_as_the_keys_of_a_us_keyboard_and_text_off_it_as_the_characters(desk, mac):
await desk.type_in("aB ,\n\tö")

assert keys(mac.sent) == [
(0, True, 0, None), (0, False, 0, None),
(56, True, SHIFT, None), (11, True, SHIFT, None), (11, False, SHIFT, None), (56, False, 0, None),
(49, True, 0, None), (49, False, 0, None),
(43, True, 0, None), (43, False, 0, None),
(36, True, 0, None), (36, False, 0, None),
(48, True, 0, None), (48, False, 0, None),
(0, True, 0, "ö"), (0, False, 0, "ö"),
]


@pytest.mark.asyncio
async def test_a_held_key_stays_down_on_every_event_until_released(desk, mac):
await desk.keys_down(["Shift"])
await desk.type_in("a")
await desk.click_at(10, 20, "left", 1, [])
await desk.keys_up(["Shift"])
await desk.type_in("d")

assert keys(mac.sent)[:3] == [(56, True, SHIFT, None), (0, True, SHIFT, None), (0, False, SHIFT, None)]
assert [event[-1] for event in mac.sent if event[0] == "mouse"] == [SHIFT, SHIFT, SHIFT]
assert keys(mac.sent)[3:] == [(56, False, 0, None), (2, True, 0, None), (2, False, 0, None)]


@pytest.mark.asyncio
async def test_a_chord_presses_its_modifiers_around_its_key_in_order_and_releases_them_in_reverse_shift_too(desk,
mac):
await desk.press_keys(["Control", "Meta", "/"])
await desk.press_keys(["Shift"])

assert keys(mac.sent) == [
(59, True, CONTROL, None), (55, True, CONTROL | COMMAND, None),
(44, True, CONTROL | COMMAND, None), (44, False, CONTROL | COMMAND, None),
(55, False, CONTROL, None), (59, False, 0, None),
(56, True, SHIFT, None), (56, False, 0, None),
]
with pytest.raises(Unsupported, match="no key on a US keyboard"):
await desk.press_keys(["é"])
mac.sent.clear()
await desk.press_keys(["Control", "A"])
await desk.press_keys(["!"])

assert keys(mac.sent) == [
(59, True, CONTROL, None), (56, True, CONTROL | SHIFT, None), (0, True, CONTROL | SHIFT, None),
(0, False, CONTROL | SHIFT, None), (56, False, CONTROL, None), (59, False, 0, None),
(56, True, SHIFT, None), (18, True, SHIFT, None), (18, False, SHIFT, None), (56, False, 0, None),
]


@pytest.mark.asyncio
async def test_a_click_moves_there_first_and_counts_its_presses(desk, mac):
await desk.click_at(100, 50, "left", 2, ["Meta"])

mouse = [event[1:] for event in mac.sent if event[0] == "mouse"]
assert mouse == [(MOVED, 100, 50, 0, 1, COMMAND), (LEFT_DOWN, 100, 50, 0, 1, COMMAND),
(LEFT_UP, 100, 50, 0, 1, COMMAND), (LEFT_DOWN, 100, 50, 0, 2, COMMAND),
(LEFT_UP, 100, 50, 0, 2, COMMAND)]
assert keys(mac.sent) == [(55, True, COMMAND, None), (55, False, 0, None)]


@pytest.mark.asyncio
async def test_a_drag_presses_at_its_start_moves_through_its_waypoints_and_releases_at_its_end(desk, mac):
await desk.drag_along([(0, 0), (40, 0), (40, 30)], "left", 0, [])

kinds = [(event[1], event[2], event[3]) for event in mac.sent]
assert kinds[:2] == [(MOVED, 0, 0), (LEFT_DOWN, 0, 0)] and kinds[-1] == (LEFT_UP, 40, 30)
moved = [(x, y) for kind, x, y in kinds if kind == LEFT_DRAGGED]
assert (40, 0) in moved and moved[-1] == (40, 30)
assert all(abs(x1 - x0) + abs(y1 - y0) <= _macos.DRAG_STEP_PX for (x0, y0), (x1, y1) in zip(moved, moved[1:]))


@pytest.mark.asyncio
async def test_the_pointer_group_holds_a_button_across_moves(desk, mac):
await desk.move(5, 5)
await desk.down("left")
await desk.move(30, 40)
await desk.up("left")
await desk.move(31, 41)

assert [event[1:4] for event in mac.sent] == [(MOVED, 5, 5), (LEFT_DOWN, 5, 5), (LEFT_DRAGGED, 30, 40),
(LEFT_UP, 30, 40), (MOVED, 31, 41)]
assert await desk.where() == (31, 41)


@pytest.mark.asyncio
async def test_a_scroll_sends_one_wheel_line_per_step_where_it_points(desk, mac):
await desk.scroll_at(7, 8, "down", 3, [])
await desk.scroll_at(7, 8, "left", 1, [])

assert [event for event in mac.sent if event[0] == "wheel"] == [("wheel", 7, 8, -1, 0, 0)] * 3 + [
("wheel", 7, 8, 0, 1, 0)]


@pytest.mark.asyncio
async def test_a_capture_is_bgra_and_the_display_its_size(desk):
shot = await desk.grab()
screen = await desk.screen()

assert shot == Image(2, 1, bytes([1, 2, 3, 255, 4, 5, 6, 255]), "BGRA")
assert (screen.width, screen.height, screen.scale) == (1920, 1080, 1.0)


@pytest.mark.asyncio
async def test_computer_ready_names_the_permissions_macos_hasnt_granted(desk, mac):
(granted,) = await desk.ready_checks()
mac.lacking = ["Accessibility", "Screen Recording"]
(lacking,) = await desk.ready_checks()

assert granted.ok
assert not lacking.ok and "Accessibility, Screen Recording" in lacking.detail


@pytest.mark.asyncio
async def test_input_quartz_fails_to_send_is_a_failed_delivery(desk, mac):
mac.fails = True
with pytest.raises(DeliveryFailed, match="Quartz failed"):
await desk.click_at(1, 1, "left", 1, [])


def test_its_card_declares_the_pointer_and_keyboard_groups_and_the_macos_defaults():
card = Desk.declaration(MACOS)
tools = {tool.name for tool in card.capabilities.tools}
uris = {extension.uri for extension in card.capabilities.extensions}

assert {"computer_pointer_move", "computer_keyboard_down", "computer_keyboard_up"} <= tools
assert {"urn:agentenv:computer-pointer/v1", "urn:agentenv:computer-keyboard/v1",
"urn:agentenv:computer-exec/v1"} <= uris
Loading
Loading