feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

@@ -0,0 +1,212 @@
# 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.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Security regression tests for the Artemis console boundary.
Covers the invariants the no-auth security model depends on:
- secrets never appear in HTTP responses,
- media endpoints cannot read files outside the media allowlist,
- cross-origin browser traffic and DNS-rebinding Hosts are rejected,
- no CORS grants exist,
- lifecycle controls stay loopback-only.
"""
import secrets as py_secrets
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from apps.admin_console.server import app
from artemis.config import TRACES_PATH, WORKSPACE_ROOT
def _client(**transport_kwargs) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=app, **transport_kwargs), base_url="http://localhost"
)
# ---------------------------------------------------------------------------
# Secret exposure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_credentials_endpoint_never_returns_key_material(monkeypatch):
from artemis.config import settings
honeytoken = f"sk-honeytoken-{py_secrets.token_hex(16)}"
monkeypatch.setattr(
type(settings), "get_api_key", lambda self, provider: SecretStr(honeytoken)
)
async with _client() as ac:
res = await ac.get("/api/system/credentials")
assert res.status_code == 200
assert honeytoken not in res.text
providers = {entry["name"]: entry["configured"] for entry in res.json()["providers"]}
assert providers.get("google") is True
@pytest.mark.asyncio
async def test_server_status_omits_lifecycle_token_and_metadata():
async with _client() as ac:
res = await ac.get("/api/system/server-status")
assert res.status_code == 200
data = res.json()
assert "metadata" not in data
assert "lifecycle_token" not in res.text
assert "cmdline" not in res.text
assert "current_pid" in data
# ---------------------------------------------------------------------------
# File access boundaries
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_videos_endpoint_refuses_dotenv_and_non_video_files():
async with _client() as ac:
for target in (".env", "pyproject.toml", "artemis/__init__.py"):
res = await ac.get(f"/videos/{target}")
assert res.status_code in (403, 404), target
assert "API_KEY" not in res.text
@pytest.mark.asyncio
async def test_videos_endpoint_refuses_encoded_traversal():
async with _client() as ac:
for target in (
"%2e%2e/%2e%2e/etc/passwd",
"..%5c..%5cwindows%5cwin.ini",
"%2e%2e%2f.env",
):
res = await ac.get(f"/videos/{target}")
assert res.status_code in (403, 404), target
@pytest.mark.asyncio
async def test_videos_endpoint_still_serves_real_recordings():
TRACES_PATH.mkdir(parents=True, exist_ok=True)
probe = TRACES_PATH / f"security-probe-{py_secrets.token_hex(4)}.mp4"
probe.write_bytes(b"\x00\x00\x00\x18ftypmp42")
try:
async with _client() as ac:
res = await ac.get(f"/videos/{probe.name}")
assert res.status_code == 200
assert res.headers["content-type"].startswith("video/mp4")
finally:
probe.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_local_file_endpoint_refuses_non_media_workspace_files():
async with _client() as ac:
for target in (
str(WORKSPACE_ROOT / ".env"),
str(WORKSPACE_ROOT / "pyproject.toml"),
"file://" + str(WORKSPACE_ROOT / ".env"),
):
res = await ac.get("/local_file", params={"path": target})
assert res.status_code in (403, 404), target
assert "API_KEY" not in res.text
@pytest.mark.asyncio
async def test_spa_route_does_not_leak_files_outside_static_roots():
async with _client() as ac:
res = await ac.get("/%2e%2e/%2e%2e/pyproject.toml")
# The catch-all SPA route must fall back to HTML, never the file content.
assert "requires-python" not in res.text
assert res.headers["content-type"].startswith("text/html")
# ---------------------------------------------------------------------------
# Browser boundary: Host, Origin, CORS
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dns_rebinding_host_is_rejected():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://attacker.example"
) as ac:
res = await ac.get("/api/system/emulator/status")
assert res.status_code == 403
@pytest.mark.asyncio
async def test_cross_origin_browser_request_is_rejected_without_cors_grant():
async with _client() as ac:
res = await ac.post(
"/api/system/emulator/dismiss",
headers={"Origin": "https://attacker.example"},
)
assert res.status_code == 403
assert "access-control-allow-origin" not in {k.lower() for k in res.headers}
@pytest.mark.asyncio
async def test_same_origin_browser_request_passes():
async with _client() as ac:
res = await ac.get(
"/api/system/emulator/status", headers={"Origin": "http://localhost"}
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_null_origin_is_rejected():
async with _client() as ac:
res = await ac.get("/api/system/emulator/status", headers={"Origin": "null"})
assert res.status_code == 403
@pytest.mark.asyncio
async def test_security_headers_present_and_api_responses_uncacheable():
async with _client() as ac:
res = await ac.get("/api/system/emulator/status")
assert res.headers.get("x-content-type-options") == "nosniff"
assert res.headers.get("referrer-policy") == "no-referrer"
assert res.headers.get("cache-control") == "no-store"
# ---------------------------------------------------------------------------
# Lifecycle controls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_restart_is_loopback_only():
with patch("threading.Thread") as mock_thread:
mock_thread.return_value = MagicMock()
async with AsyncClient(
transport=ASGITransport(app=app, client=("203.0.113.9", 51000)),
base_url="http://localhost",
) as ac:
res = await ac.post("/api/system/restart")
assert res.status_code == 403
mock_thread.assert_not_called()
@@ -0,0 +1,60 @@
"""Legacy "success" session rows must surface as the canonical "completed".
The sessions table historically received "success" from the DataEngine while
the queue reconcile wrote "completed", so API consumers saw a mix of both
terminal strings. Reads through the repository now normalize the legacy alias;
get_session_status stays raw so the queue reconcile can persist the rewrite.
"""
from apps.admin_console.database.connection import db_session
from apps.admin_console.database.repositories.session_repository import SessionRepository
def _insert_session(db_path, session_id, status):
with db_session(db_path) as conn:
conn.execute(
"INSERT INTO sessions "
"(session_id, initial_goal, start_time, end_time, status, device_info, pid) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, session_id, 1.0, 2.0, status, "{}", None),
)
conn.commit()
def test_get_all_sessions_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
_insert_session(db_path, "modern", "completed")
_insert_session(db_path, "broken", "failed")
statuses = {r["session_id"]: r["status"] for r in repository.get_all_sessions()}
assert statuses == {"legacy": "completed", "modern": "completed", "broken": "failed"}
def test_get_session_by_id_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
row = repository.get_session_by_id("legacy")
assert row is not None
assert row["status"] == "completed"
def test_get_latest_session_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
row = repository.get_latest_session()
assert row is not None
assert row["status"] == "completed"
def test_get_session_status_stays_raw_for_queue_reconcile(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
assert repository.get_session_status("legacy") == "success"
@@ -43,7 +43,7 @@ def test_emulator_uses_windows_console_isolation():
async def test_system_emulator_status_endpoint():
"""Verify GET /api/system/emulator/status returns valid schema."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.get("/api/system/emulator/status")
assert res.status_code == 200
data = res.json()
@@ -57,7 +57,7 @@ async def test_system_emulator_status_endpoint():
async def test_system_emulator_dismiss_endpoint():
"""Verify POST /api/system/emulator/dismiss resets tracker state."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post("/api/system/emulator/dismiss")
assert res.status_code == 200
data = res.json()
@@ -68,6 +68,34 @@ async def test_system_emulator_dismiss_endpoint():
async def test_system_emulator_launch_empty_validation():
"""Verify POST /api/system/emulator/launch validates empty avd_name."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post("/api/system/emulator/launch", json={"avd_name": " "})
assert res.status_code == 400
@pytest.mark.asyncio
async def test_adb_endpoint_mutation_rejects_cross_origin_browser_requests():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post(
"/api/system/adb/server/probe",
json={"host": "127.0.0.1", "port": 5038, "persist": False},
headers={"Origin": "https://untrusted.example"},
)
assert res.status_code == 403
assert "Artemis console" in res.json()["detail"]
@pytest.mark.asyncio
async def test_adb_endpoint_mutation_is_local_only_by_default(monkeypatch):
monkeypatch.delenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", raising=False)
transport = ASGITransport(app=app, client=("192.168.1.20", 42000))
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post(
"/api/system/adb/server/probe",
json={"host": "127.0.0.1", "port": 5038, "persist": False},
)
assert res.status_code == 403
assert "local-only" in res.json()["detail"]
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from types import SimpleNamespace
from unittest.mock import AsyncMock
from fastapi import HTTPException
@@ -112,3 +113,131 @@ async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch
assert kwargs.get("device_serial") == "emulator-5556"
set_active_mock.assert_called_once_with("emulator-5556")
@pytest.mark.asyncio
async def test_idempotent_retry_skips_device_probe_for_active_session(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(
tasks.readiness_engine, "run_device_submission_probe", run_probe
)
monkeypatch.setattr(tasks.state, "active_session_id", "sdk-task-1")
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(
tasks.session_repo,
"get_session_by_id",
lambda session_id: None,
)
result = await tasks.run_task(
RunRequest(
goal="Open Settings",
session_id="sdk-task-1",
device_serial="pixel-10",
ingress="python_sdk",
)
)
assert result["status"] == "running"
assert result["enqueued_count"] == 0
assert result["tasks"][0]["session_id"] == "sdk-task-1"
run_probe.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_explicit_device_is_rejected_before_readiness_probe(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(
return_value=[
SimpleNamespace(serial="pixel-10", state="device"),
]
),
)
result = await tasks.run_task(
RunRequest(
goal="Must not run",
device_serial="missing-device",
session_id="00000000-0000-4000-8000-000000000099",
)
)
assert result["status"] == "rejected"
assert result["tasks"] == []
assert "not connected" in result["error"]
run_probe.assert_not_awaited()
@pytest.mark.asyncio
async def test_explicit_device_is_rejected_when_attached_but_not_ready(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(
return_value=[
SimpleNamespace(serial="pixel-10", state="unauthorized"),
]
),
)
result = await tasks.run_task(
RunRequest(
goal="Must not run",
device_serial="pixel-10",
session_id="00000000-0000-4000-8000-000000000100",
)
)
assert result["status"] == "rejected"
assert "unauthorized" in result["error"]
run_probe.assert_not_awaited()
@pytest.mark.parametrize("enumeration", [None, []])
@pytest.mark.asyncio
async def test_explicit_device_proceeds_when_enumeration_is_indeterminate(
monkeypatch, enumeration
):
"""A failed (None) or empty enumeration must never hard-reject an explicit
serial: the submission queues and fails downstream if the device is truly
absent. This is the startup-storm regression guard."""
run_probe = AsyncMock(return_value=None)
enqueue_tasks = AsyncMock(return_value={"status": "queued", "tasks": []})
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(return_value=enumeration),
)
result = await tasks.run_task(
RunRequest(
goal="Queue through the startup storm",
device_serial="pixel-10",
session_id="00000000-0000-4000-8000-000000000101",
)
)
assert result["status"] == "queued"
enqueue_tasks.assert_awaited_once()
_, kwargs = enqueue_tasks.call_args
assert kwargs.get("device_serial") == "pixel-10"
@@ -25,12 +25,22 @@ from apps.admin_console.core.state import state
from apps.admin_console.routers.tasks import get_status
from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service
from artemis.runtime.device_lock import DeviceLockOwner
from artemis.runtime.adb_endpoint import AdbEndpoint
@pytest.fixture(autouse=True)
def clean_state(tmp_path, monkeypatch):
"""Reset global state between tests."""
isolated_pause_file = tmp_path / ".artemis_paused"
# Redirect DeviceExecutionLock's lock/queue directory into tmp_path so that
# enqueue reservations never touch the real %TEMP%/artemis/device-locks dir
# (a live daemon merges those tickets into its /api/status queue view).
isolated_lock_dir = tmp_path / "device-locks"
isolated_lock_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"artemis.runtime.device_lock.get_temp_dir",
lambda _subfolder=None: isolated_lock_dir,
)
state_module = importlib.import_module("apps.admin_console.core.state")
queue_module = importlib.import_module("apps.admin_console.services.task_queue_service")
monkeypatch.setattr(state_module, "PAUSE_FILE", isolated_pause_file)
@@ -62,16 +72,6 @@ def clean_state(tmp_path, monkeypatch):
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
from artemis.config.paths import get_temp_dir
from artemis.runtime.device_lock import DeviceExecutionLock
DeviceExecutionLock.cleanup_stale_locks()
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
if queue_dir.exists():
for p in queue_dir.glob("*.wait"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
def test_paused_error_reads_persisted_reason(tmp_path):
@@ -94,6 +94,37 @@ async def test_get_next_pending_task():
assert next_task["goal"] == "Goal A"
@pytest.mark.asyncio
async def test_enqueued_task_keeps_its_adb_endpoint_snapshot():
original = AdbEndpoint.create("127.0.0.1", 5038)
changed = AdbEndpoint.local()
with (
patch.object(TaskQueueService, "ensure_worker_running"),
patch(
"apps.admin_console.services.task_queue_service.current_adb_endpoint",
return_value=original,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
result = await TaskQueueService.enqueue_tasks(["Keep endpoint"])
task_item = result["tasks"][0]
assert task_item["adb_endpoint"]["identity"] == original.identity
with patch(
"apps.admin_console.services.task_queue_service.current_adb_endpoint",
return_value=changed,
):
target = TaskQueueService._task_target(task_item)
assert target.endpoint == original
assert target.serial == "emulator-5554"
assert target.lock_key == f"{original.identity}/emulator-5554"
@pytest.mark.parametrize(
("current_status", "returncode", "stopped", "expected"),
[
@@ -190,8 +221,18 @@ async def test_queue_worker_execution_lifecycle():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
# Enqueue two tasks
res = await task_queue_service.enqueue_tasks(["First Task", "Second Task"])
assert res["enqueued_count"] == 2
@@ -230,8 +271,18 @@ async def test_queue_worker_cmd_construction():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
enqueue_result = await task_queue_service.enqueue_tasks(
["Test Goal with Outputter"],
@@ -262,6 +313,11 @@ async def test_queue_worker_cmd_construction():
executed_kwargs[0]["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"]
== (enqueue_result["tasks"][0]["queue_ticket"])
)
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"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
if sys.platform == "win32":
assert executed_kwargs[0]["creationflags"] == (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
@@ -491,8 +547,18 @@ async def test_cancel_task_triggers_next_pending_task():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
await task_queue_service.enqueue_tasks(["Task 1", "Task 2"])
for _ in range(40):
@@ -536,8 +602,18 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
# Enqueue Task 1
res1 = await task_queue_service.enqueue_tasks(["Task 1"])
@@ -668,7 +744,7 @@ async def test_queue_worker_notifies_conversation():
patch("mcp_server.notifiers.notify") as mock_notify,
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch(
"artemis.runtime.device_pool.device_pool.list_devices_async",
"artemis.runtime.device_pool.device_pool.try_list_devices_async",
new=AsyncMock(return_value=[]),
),
):
@@ -854,7 +930,7 @@ async def test_enqueue_tasks_debounces_rapid_identical_submissions():
patch("apps.admin_console.services.task_queue_service.session_repo"),
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", return_value="ticket-456"),
patch(
"artemis.runtime.device_pool.device_pool.list_devices_async",
"artemis.runtime.device_pool.device_pool.try_list_devices_async",
new=AsyncMock(return_value=[]),
),
patch.object(TaskQueueService, "ensure_worker_running"),