diff --git a/artemis/mcp/adb_server.py b/artemis/mcp/adb_server.py index be2b71a..fadbcda 100644 --- a/artemis/mcp/adb_server.py +++ b/artemis/mcp/adb_server.py @@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client from artemis.context import ArtemisContext, DeviceContext, DevicePlatform from artemis.controllers.unified_controller import UnifiedMobileController from artemis.platform import platform +from artemis.runtime.adb_endpoint import AdbEndpoint from artemis.utils.app_launch_utils import launch_app_with_retries @@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None): port_str = os.environ.get("ADB_PORT", "5037") port = int(port_str) if port_str.isdigit() else 5037 if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037): - os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}" + os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket adb = AdbClient(host=host, port=port) devices = adb.device_list() diff --git a/artemis/runtime/adb_endpoint.py b/artemis/runtime/adb_endpoint.py index 9729f96..3646d7c 100644 --- a/artemis/runtime/adb_endpoint.py +++ b/artemis/runtime/adb_endpoint.py @@ -92,6 +92,19 @@ class AdbEndpoint: def mode(self) -> str: return "local" if self.is_local_default else "remote" + @property + def client_host(self) -> str: + """ADB only permits daemon auto-start for the literal host 'localhost'. + + Keep forwarded/custom-port endpoints explicit, even on loopback, so a + missing tunnel never starts a different local daemon at that address. + """ + return "localhost" if self.is_local_default else self.host + + @property + def client_socket(self) -> str: + return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket + def to_dict(self) -> dict[str, Any]: return { **asdict(self), @@ -108,7 +121,7 @@ class AdbEndpoint: target = environment if environment is not None else os.environ target["ADB_HOST"] = self.host target["ADB_PORT"] = str(self.port) - target["ADB_SERVER_SOCKET"] = self.socket + target["ADB_SERVER_SOCKET"] = self.client_socket target[ADB_ENDPOINT_ID_ENV] = self.identity return target @@ -145,7 +158,7 @@ class AdbSession: return [ self.adb_path, "-H", - self.endpoint.host, + self.endpoint.client_host, "-P", str(self.endpoint.port), *arguments, diff --git a/artemis/runtime/device_pool.py b/artemis/runtime/device_pool.py index e6f3338..37472ff 100644 --- a/artemis/runtime/device_pool.py +++ b/artemis/runtime/device_pool.py @@ -25,6 +25,7 @@ import threading import time from typing import Any +from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint from artemis.runtime.device_lock import DeviceExecutionLock from artemis.toolchain import toolchain from artemis.utils.logger import get_logger @@ -242,23 +243,29 @@ class DevicePool: async def _start_adb_server(self, timeout: float) -> None: """Best-effort bounded `adb start-server` so later queries hit a warm daemon.""" + endpoint = current_adb_endpoint() + if not endpoint.is_local_default: + return adb = self._resolve_adb() if not adb: return proc = None try: - clean_env = os.environ.copy() - clean_env.pop("ADB_SERVER_SOCKET", None) + session = AdbSession(endpoint, adb_path=adb) proc = await asyncio.create_subprocess_exec( - adb, - "start-server", + *session.command(["start-server"]), stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=clean_env, + env=session.environment(), ) - await asyncio.wait_for(proc.communicate(), timeout=timeout) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + if proc.returncode != 0: + output = (stderr or stdout).decode(errors="replace").strip() + logger.warning( + f"Local ADB server startup failed (exit {proc.returncode}): {output}" + ) except Exception as exc: - logger.debug(f"adb start-server warm-up did not complete: {exc}") + logger.warning(f"Local ADB server startup did not complete: {exc}") if proc is not None: try: proc.kill() diff --git a/scripts/install_deps.ps1 b/scripts/install_deps.ps1 index b6fc716..80a611d 100644 --- a/scripts/install_deps.ps1 +++ b/scripts/install_deps.ps1 @@ -1,4 +1,4 @@ -# Copyright 2026 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -91,12 +91,23 @@ function Update-EnvironmentPath { function Start-LocalAdbServer { if (Test-CommandExists "adb") { + $adbErrorPreference = $ErrorActionPreference try { - $origSocket = $env:ADB_SERVER_SOCKET - Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue - & adb start-server 2>$null | Out-Null - if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket } - } catch {} + # Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects. + # ADB prints normal daemon-start messages there, so inspect its exit code. + $ErrorActionPreference = "Continue" + # Explicit options ignore inherited remote-server environment settings. + $adbOutput = & adb -H localhost -P 5037 start-server 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow + Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow + Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow + } + } catch { + Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow + } finally { + $ErrorActionPreference = $adbErrorPreference + } } } diff --git a/scripts/start.ps1 b/scripts/start.ps1 index bb98e5c..14e585c 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -126,12 +126,23 @@ function Update-EnvironmentPath { function Start-LocalAdbServer { if (Test-CommandExists "adb") { + $adbErrorPreference = $ErrorActionPreference try { - $origSocket = $env:ADB_SERVER_SOCKET - Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue - & adb start-server 2>$null | Out-Null - if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket } - } catch {} + # Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects. + # ADB prints normal daemon-start messages there, so inspect its exit code. + $ErrorActionPreference = "Continue" + # Explicit options ignore inherited remote-server environment settings. + $adbOutput = & adb -H localhost -P 5037 start-server 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow + Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow + Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow + } + } catch { + Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow + } finally { + $ErrorActionPreference = $adbErrorPreference + } } } diff --git a/tests/unit/admin_console/test_task_queue_service.py b/tests/unit/admin_console/test_task_queue_service.py index e48e347..f02b5d7 100644 --- a/tests/unit/admin_console/test_task_queue_service.py +++ b/tests/unit/admin_console/test_task_queue_service.py @@ -323,7 +323,9 @@ async def test_queue_worker_cmd_construction(): endpoint = enqueue_result["tasks"][0]["adb_endpoint"] assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"] assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"]) - assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"] + assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == ( + "tcp:localhost:5037" if endpoint["is_local_default"] else endpoint["socket"] + ) assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"] if sys.platform == "win32": assert executed_kwargs[0]["creationflags"] == ( diff --git a/tests/unit/core/test_adb_server_connection.py b/tests/unit/core/test_adb_server_connection.py index e49a1cf..8eee17d 100644 --- a/tests/unit/core/test_adb_server_connection.py +++ b/tests/unit/core/test_adb_server_connection.py @@ -70,6 +70,34 @@ def test_adb_session_builds_explicit_endpoint_command_and_environment(): assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"]) +def test_local_endpoint_allows_adb_auto_start_without_changing_task_identity(host): + endpoint = AdbServerEndpoint(host, 5037) + session = AdbSession(endpoint, adb_path="adb") + inherited = { + "ADB_SERVER_SOCKET": "tcp:remote.example:5040", + "ANDROID_ADB_SERVER_ADDRESS": "remote.example", + "ANDROID_ADB_SERVER_PORT": "5040", + } + + assert session.command(["devices"]) == ["adb", "-H", "localhost", "-P", "5037", "devices"] + environment = session.environment(inherited) + assert environment["ADB_SERVER_SOCKET"] == "tcp:localhost:5037" + assert environment["ADB_HOST"] == host + assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity + assert inherited["ADB_SERVER_SOCKET"] == "tcp:remote.example:5040" + + +@pytest.mark.parametrize( + ("host", "port"), [("127.0.0.1", 5038), ("::1", 5038), ("server.example", 5037)] +) +def test_forwarded_endpoint_never_uses_local_daemon_auto_start(host, port): + endpoint = AdbServerEndpoint(host, port) + session = AdbSession(endpoint, adb_path="adb") + assert session.command(["devices"])[2] == host + assert session.environment({})["ADB_SERVER_SOCKET"] == endpoint.socket + + @pytest.mark.asyncio async def test_connect_activates_forwarded_server_after_success(monkeypatch): captured: dict[str, object] = {} @@ -253,7 +281,7 @@ async def test_use_local_server_restores_standard_endpoint(monkeypatch): assert result["success"] is True assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict() assert settings.ADB_PORT == 5037 - assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5037" + assert os.environ["ADB_SERVER_SOCKET"] == "tcp:localhost:5037" @pytest.mark.asyncio diff --git a/tests/unit/runtime/test_device_pool.py b/tests/unit/runtime/test_device_pool.py index 0a12399..9c151c7 100644 --- a/tests/unit/runtime/test_device_pool.py +++ b/tests/unit/runtime/test_device_pool.py @@ -20,6 +20,7 @@ import pytest from artemis.runtime.device_lock import DeviceExecutionLock from artemis.runtime.device_pool import DevicePool, DeviceStatus +from artemis.config import settings device_pool_module = importlib.import_module("artemis.runtime.device_pool") @@ -245,6 +246,45 @@ def test_warm_up_without_adb_returns_false(monkeypatch): assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False +def test_adb_warmup_does_not_start_local_server_for_remote_endpoint(monkeypatch): + monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1") + monkeypatch.setattr(settings, "ADB_PORT", 5038) + + async def unexpected_spawn(*args, **kwargs): + pytest.fail("A forwarded endpoint must not start a local ADB daemon") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_spawn) + asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0)) + + +def test_adb_warmup_reports_failed_start_with_local_command(monkeypatch): + monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1") + monkeypatch.setattr(settings, "ADB_PORT", 5037) + monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:remote.example:5040") + captured = {} + warnings = [] + + class FailedProcess: + returncode = 1 + + async def communicate(self): + return b"", b"cannot bind listener" + + async def spawn(*args, **kwargs): + captured["command"] = args + captured["env"] = kwargs["env"] + return FailedProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(device_pool_module.logger, "warning", warnings.append) + asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0)) + assert captured["command"] == ("adb", "-H", "localhost", "-P", "5037", "start-server") + assert captured["env"]["ADB_SERVER_SOCKET"] == "tcp:localhost:5037" + assert len(warnings) == 1 + assert "exit 1" in warnings[0] + assert "cannot bind listener" in warnings[0] + + def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch): pool = DevicePool() monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None) diff --git a/tests/unit/test_adb_startup_scripts.py b/tests/unit/test_adb_startup_scripts.py new file mode 100644 index 0000000..71d4a69 --- /dev/null +++ b/tests/unit/test_adb_startup_scripts.py @@ -0,0 +1,75 @@ +"""Run just the ADB startup functions with fake commands; never touch host ADB.""" + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.skipif(not shutil.which("powershell.exe"), reason="Windows PowerShell required") +@pytest.mark.parametrize("script_name", ["start.ps1", "install_deps.ps1"]) +@pytest.mark.parametrize("outcome", ["success", "failure", "exception"]) +def test_adb_startup_reports_failure_and_preserves_server_environment( + tmp_path, script_name, outcome +): + harness = tmp_path / "adb-startup.ps1" + harness.write_text( + """ +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:ADB_TEST_SCRIPT, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +$function = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Start-LocalAdbServer' +}, $true) +Invoke-Expression $function.Extent.Text +function Test-CommandExists { return $true } +function adb { + Write-Host ('ADB_ARGS=' + ($args -join '|')) + if ($env:ADB_TEST_OUTCOME -eq 'exception') { throw 'fixture launch error' } + Write-Error 'fixture stderr' -ErrorAction Continue + $global:LASTEXITCODE = $(if ($env:ADB_TEST_OUTCOME -eq 'failure') { 7 } else { 0 }) +} +Start-LocalAdbServer +if ($env:ADB_SERVER_SOCKET -ne 'tcp:remote.example:5040') { throw 'Socket was changed' } +if ($env:ANDROID_ADB_SERVER_ADDRESS -ne 'remote.example') { throw 'Host was changed' } +if ($env:ANDROID_ADB_SERVER_PORT -ne '5040') { throw 'Port was changed' } +if ($ErrorActionPreference -ne 'Stop') { throw 'Error preference was changed' } +Write-Host 'HARNESS_OK' +""", + encoding="utf-8-sig", + ) + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(harness)], + env={ + **os.environ, + "ADB_TEST_SCRIPT": str(ROOT / "scripts" / script_name), + "ADB_TEST_OUTCOME": outcome, + "ADB_SERVER_SOCKET": "tcp:remote.example:5040", + "ANDROID_ADB_SERVER_ADDRESS": "remote.example", + "ANDROID_ADB_SERVER_PORT": "5040", + }, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=15, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "ADB_ARGS=-H|localhost|-P|5037|start-server" in result.stdout + assert "HARNESS_OK" in result.stdout + if outcome == "success": + assert "[警告]" not in result.stdout + elif outcome == "failure": + assert "退出码 7" in result.stdout + assert "fixture stderr" in result.stdout + else: + assert "启动异常" in result.stdout + assert "fixture launch error" in result.stdout