From 8e0985dcfb3a640569e7e90dba720292d1c9f2f3 Mon Sep 17 00:00:00 2001 From: sleynsol Date: Tue, 1 Sep 2026 17:41:06 +0200 Subject: [PATCH] feat: support cloud devices in agent CLI runs --- docs/guides/cli.mdx | 46 +++++++++- mobilerun/cli/main.py | 75 ++++++++++++++- tests/test_cloud_agent_cli.py | 168 ++++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 tests/test_cloud_agent_cli.py diff --git a/docs/guides/cli.mdx b/docs/guides/cli.mdx index 79128d94..69d8bfd2 100644 --- a/docs/guides/cli.mdx +++ b/docs/guides/cli.mdx @@ -247,6 +247,9 @@ mobilerun devices # Found 2 connected device(s): # • emulator-5554 # • 192.168.1.100:5555 + +# List only Mobilerun Cloud devices +mobilerun devices --cloud ``` --- @@ -357,7 +360,48 @@ mobilerun device apps --system mobilerun device start com.example.app ``` -All `device` subcommands support `--device`, `--config`, `--tcp`, and `--ios` flags. +All `device` subcommands support `--device`, `--config`, `--tcp`, `--ios`, +`--cloud`, `--device-id`, and `--base-url` flags. For cloud devices, omit +`--ios` even when the selected device is an iPhone; CloudDriver handles the +platform remotely. + +### Cloud login and control + +Authenticate once with the Cloud OAuth device flow: + +```bash +mobilerun login +mobilerun whoami +mobilerun devices --cloud +``` + +Alternatively, set `MOBILERUN_CLOUD_API_KEY`. The environment variable takes +precedence over the saved login. + +Use the UUID printed by `devices --cloud` for direct actions: + +```bash +DEVICE_ID="123e4567-e89b-12d3-a456-426614174000" + +mobilerun device ui --cloud -d "$DEVICE_ID" +mobilerun device screenshot --cloud -d "$DEVICE_ID" +mobilerun device press home --cloud -d "$DEVICE_ID" +``` + +Run the local Mobilerun agent with its tools backed by CloudDriver: + +```bash +mobilerun run \ + "Open Settings and report the OS version without changing anything" \ + --cloud \ + -d "$DEVICE_ID" \ + --provider OpenAIResponses \ + --model gpt-5.4-mini +``` + +The cloud-device credential and LLM credential are separate. For the example +above, CloudDriver uses the saved cloud login or `MOBILERUN_CLOUD_API_KEY`, while +the agent model uses `OPENAI_API_KEY`. diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index cc0088ed..b04775b7 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -134,6 +134,8 @@ async def run_command( tracing: bool | None = None, debug: bool | None = None, tcp: bool | None = None, + cloud: bool = False, + cloud_base_url: str | None = None, control_backend: str | None = None, device_id: str | None = None, save_trajectory: str | None = None, @@ -219,6 +221,51 @@ async def run_command( if device_id is not None: config.device.device_id = device_id + injected_driver = None + if cloud: + if ios: + raise ValueError("--cloud and --ios are mutually exclusive") + if tcp: + raise ValueError("--cloud and --tcp are mutually exclusive") + if config.device.control_backend: + raise ValueError("--cloud and --control-backend are mutually exclusive") + + cloud_device_id = device_id or device + if not cloud_device_id: + configured_id = (config.device.device_id or "").strip() + if configured_id and configured_id != "auto": + cloud_device_id = configured_id + if not cloud_device_id: + raise ValueError( + "Cloud device id required: pass -d or --device-id " + ) + + cloud_api_key = resolve_cloud_api_key() + if not cloud_api_key: + raise ValueError( + f"No cloud credential found. Set {CLOUD_API_KEY_ENV} or run `mobilerun login`." + ) + + from mobilerun_core_local.driver.cloud import CloudDriver + + injected_driver = CloudDriver( + device_id=cloud_device_id, + api_key=cloud_api_key, + base_url=cloud_base_url or DEFAULT_CLOUD_BASE_URL, + ) + await injected_driver.connect() + + # CloudDriver returns the cloud API's Android-shaped accessibility + # payload for Android and iOS devices, so pair it with the framework's + # AndroidStateProvider. The driver still sends all actions to the + # selected cloud device. + config.device.platform = "android" + config.device.serial = None + config.device.device_id = cloud_device_id + config.device.use_tcp = False + config.device.auto_setup = False + logger.info(f"☁️ Cloud device: {cloud_device_id}") + if ( config.device.control_backend or "" ).strip().lower() == VISUAL_REMOTE_CONNECTION: @@ -292,6 +339,7 @@ async def run_command( goal=command, llms=llm, config=config, + driver=injected_driver, timeout=1000, **droid_agent_kwargs, ) @@ -335,7 +383,8 @@ async def run_command( logger.debug(traceback.format_exc()) return False finally: - await _cleanup_android_keyboard(config) + if not cloud: + await _cleanup_android_keyboard(config) async def _cleanup_android_keyboard(config: MobileConfig) -> None: @@ -457,7 +506,12 @@ def _run_grok_oauth_login( @cli.command() @click.argument("command", type=str) @click.option("--config", "-c", help="Path to custom config file", default=None) -@click.option("--device", "-d", help="Device serial number or IP address", default=None) +@click.option( + "--device", + "-d", + help="Local device serial/IP or cloud device UUID with --cloud", + default=None, +) @click.option( "--agent", "-a", @@ -522,6 +576,17 @@ def _run_grok_oauth_login( default=None, help="Use TCP communication for device control", ) +@click.option( + "--cloud", + is_flag=True, + default=False, + help="Run the local Mobilerun agent against a Mobilerun Cloud device", +) +@click.option( + "--cloud-base-url", + default=None, + help=f"Cloud API base URL (default {DEFAULT_CLOUD_BASE_URL})", +) @click.option( "--control-backend", type=click.Choice([VISUAL_REMOTE_CONNECTION]), @@ -531,7 +596,7 @@ def _run_grok_oauth_login( @click.option( "--device-id", default=None, - help="Device id for backends that expose multiple devices.", + help="Cloud device UUID or id for backends that expose multiple devices.", ) @click.option( "--save-trajectory", @@ -559,6 +624,8 @@ async def run( tracing: bool | None, debug: bool | None, tcp: bool | None, + cloud: bool, + cloud_base_url: str | None, control_backend: str | None, device_id: str | None, save_trajectory: str | None, @@ -583,6 +650,8 @@ async def run( tracing=tracing, debug=debug, tcp=tcp, + cloud=cloud, + cloud_base_url=cloud_base_url, control_backend=control_backend, device_id=device_id, temperature=temperature, diff --git a/tests/test_cloud_agent_cli.py b/tests/test_cloud_agent_cli.py new file mode 100644 index 00000000..aa5d4c1f --- /dev/null +++ b/tests/test_cloud_agent_cli.py @@ -0,0 +1,168 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from click.testing import CliRunner + +from mobilerun.cli.main import cli, run_command +from mobilerun.config_manager.config_manager import MobileConfig + +DEVICE_ID = "123e4567-e89b-12d3-a456-426614174000" + + +class FakeHandler: + async def stream_events(self): + if False: + yield None + + def __await__(self): + async def done(): + return SimpleNamespace(success=True) + + return done().__await__() + + +class CloudAgentCliTest(unittest.TestCase): + def test_cli_forwards_cloud_options(self): + async_run_command = AsyncMock(return_value=True) + + with patch("mobilerun.cli.main.run_command", async_run_command): + result = CliRunner().invoke( + cli, + [ + "run", + "Check iOS version", + "--cloud", + "-d", + DEVICE_ID, + "--cloud-base-url", + "https://cloud.example/v1", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + kwargs = async_run_command.await_args.kwargs + self.assertTrue(kwargs["cloud"]) + self.assertEqual(kwargs["device"], DEVICE_ID) + self.assertEqual(kwargs["cloud_base_url"], "https://cloud.example/v1") + + def test_run_command_injects_cloud_driver(self): + created_drivers = [] + created_agents = [] + cloud_config = MobileConfig() + cloud_config.device.use_tcp = True + + class FakeCloudDriver: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.connect = AsyncMock() + created_drivers.append(self) + + class FakeAgent: + def __init__(self, **kwargs): + created_agents.append(kwargs) + + def run(self): + return FakeHandler() + + with ( + patch("mobilerun.cli.main.ConfigLoader.load", return_value=cloud_config), + patch("mobilerun.cli.main.MobileAgent", FakeAgent), + patch("mobilerun.cli.main.resolve_cloud_api_key", return_value="token"), + patch("mobilerun_core_local.driver.cloud.CloudDriver", FakeCloudDriver), + patch("mobilerun.cli.main.adb.device", AsyncMock()) as adb_device, + ): + success = asyncio.run( + run_command( + "Check iOS version", + cloud=True, + device=DEVICE_ID, + cloud_base_url="https://cloud.example/v1", + debug=False, + ) + ) + + self.assertTrue(success) + driver = created_drivers[0] + self.assertEqual(driver.kwargs["device_id"], DEVICE_ID) + self.assertEqual(driver.kwargs["api_key"], "token") + self.assertEqual(driver.kwargs["base_url"], "https://cloud.example/v1") + driver.connect.assert_awaited_once() + self.assertIs(created_agents[0]["driver"], driver) + config = created_agents[0]["config"] + self.assertEqual(config.device.platform, "android") + self.assertEqual(config.device.device_id, DEVICE_ID) + self.assertIsNone(config.device.serial) + self.assertFalse(config.device.use_tcp) + self.assertFalse(config.device.auto_setup) + adb_device.assert_not_called() + + def test_run_command_requires_cloud_credential(self): + with ( + patch("mobilerun.cli.main.ConfigLoader.load", return_value=MobileConfig()), + patch("mobilerun.cli.main.resolve_cloud_api_key", return_value=None), + patch("mobilerun.cli.main.MobileAgent") as agent, + ): + success = asyncio.run( + run_command( + "Check iOS version", + cloud=True, + device=DEVICE_ID, + debug=False, + ) + ) + + self.assertFalse(success) + agent.assert_not_called() + + def test_cloud_rejects_conflicting_backends(self): + for extra in ( + {"ios": True}, + {"tcp": True}, + {"control_backend": "visual-remote"}, + ): + with ( + self.subTest(extra=extra), + patch( + "mobilerun.cli.main.ConfigLoader.load", return_value=MobileConfig() + ), + patch("mobilerun.cli.main.MobileAgent") as agent, + ): + success = asyncio.run( + run_command( + "Check iOS version", + cloud=True, + device=DEVICE_ID, + debug=False, + **extra, + ) + ) + + self.assertFalse(success) + agent.assert_not_called() + + def test_cloud_rejects_configured_control_backend(self): + config = MobileConfig.from_dict( + {"device": {"control_backend": "visual-remote"}} + ) + + with ( + patch("mobilerun.cli.main.ConfigLoader.load", return_value=config), + patch("mobilerun.cli.main.MobileAgent") as agent, + ): + success = asyncio.run( + run_command( + "Check iOS version", + cloud=True, + device=DEVICE_ID, + debug=False, + ) + ) + + self.assertFalse(success) + agent.assert_not_called() + + +if __name__ == "__main__": + unittest.main()