-
Notifications
You must be signed in to change notification settings - Fork 1k
feat: support cloud devices in agent CLI runs #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <uuid> or --device-id <uuid>" | ||
| ) | ||
|
|
||
| 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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Codex review — [P2]: Cloud runs do not enable |
||
| 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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Codex review — [P1]: Combining |
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leave the comment about difference pls