diff --git a/Dockerfile b/Dockerfile index ab0d272..885d9b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,4 +41,8 @@ ENV PORT=8080 EXPOSE 8080 +# The console has no user authentication: publish the port to loopback only, +# e.g. `docker run -p 127.0.0.1:8080:8080 ...`, and reach it remotely through +# a Tailscale/SSH tunnel. (0.0.0.0 here is container-internal and required +# for the port mapping to work.) CMD ["uvicorn", "apps.admin_console.server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index c658944..9b47fe4 100644 --- a/README.md +++ b/README.md @@ -204,32 +204,31 @@ In Codex, Antigravity, or Claude Code, simply prompt:
-Embed the mobile automation engine into your Python workflows in just a few lines: +Install the zero-runtime-dependency client on the development machine. ADB, +agents, models, and image processing remain on the device host: + +```powershell +uv add "artemis-client @ git+https://github.com/google/artemis.git#subdirectory=packages/artemis-client" +``` ```python import asyncio -from artemis import ArtemisClient, ConcurrencyMode +from artemis_client import ArtemisClient async def main(): - # 1. Initialize client with optional device targeting and concurrency strategy: - # - concurrency_mode="per_device" (default): 1 task per device, allows multi-device parallel execution - # - concurrency_mode="global": 1 task globally across all devices client = ArtemisClient( + "http://artemis-host:8000", device_serial="emulator-5554", # optional: target specific device serial default_profile="flash", # "flash" (fast reactive) or "pro" (deep reasoning) - concurrency_mode="per_device", # or ConcurrencyMode.GLOBAL ) - # 2. Execute natural language end-to-end test case (can also override device per-run) result = await client.run( "Open System Settings, go to 'Battery', verify battery percentage is displayed, and check for any crash dialogs.", - device_serial="emulator-5554", # optional: override target device for this specific run ) - # 3. Structured assertions & execution tracing - assert result.status == "SUCCESS", f"Test failed: {result.error}" - print(f"✅ Test Passed! Device: {result.device_id} | Turns: {result.turns} | Trace ID: {result.trace_id}") + assert result.succeeded, f"Test failed: {result.error or result.status}" + print(f"✅ Test Passed! Device: {result.device_serial} | Trace ID: {result.trace_id}") if __name__ == "__main__": diff --git a/README_CN.md b/README_CN.md index c6824ac..5a13612 100644 --- a/README_CN.md +++ b/README_CN.md @@ -206,32 +206,30 @@ PYTHONPATH = "/path/to/artemis"
-只需几行代码,将移动端自动化智能体无缝嵌入你的 Python 脚本与 CI/CD 流水线中: +开发电脑只需安装零运行时依赖的薄客户端;ADB、Agent、模型与图像处理全部留在设备主机: + +```powershell +uv add "artemis-client @ git+https://github.com/google/artemis.git#subdirectory=packages/artemis-client" +``` ```python import asyncio -from artemis import ArtemisClient, ConcurrencyMode +from artemis_client import ArtemisClient async def main(): - # 1. 初始化测试客户端(支持指定设备序列号与并发策略): - # - concurrency_mode="per_device"(默认):单设备排队,跨设备支持多任务并发执行 - # - concurrency_mode="global":全局单一并发,整个集群跨所有设备同一时刻只执行一个任务 client = ArtemisClient( + "http://artemis-host:8000", device_serial="emulator-5554", # 可选:指定目标设备序列号(不传则自动选择空闲设备) default_profile="flash", # "flash" 极速校验 或 "pro" 深度推理自愈 - concurrency_mode="per_device", # 或 ConcurrencyMode.GLOBAL ) - # 2. 执行端到端任务(也可在调用时动态覆盖指定设备与并发模式) result = await client.run( "打开系统设置,进入『电池』页面,验证是否正常显示电量百分比,确认页面无异常报错弹窗。", - device_serial="emulator-5554", # 可选:覆盖当前调用的目标设备 ) - # 3. 结构化断言与执行追溯 - assert result.status == "SUCCESS", f"测试执行失败: {result.error}" - print(f"✅ 测试通过!设备: {result.device_id} | 步数: {result.turns} | Trace ID: {result.trace_id}") + assert result.succeeded, f"测试执行失败: {result.error or result.status}" + print(f"✅ 测试通过!设备: {result.device_serial} | Trace ID: {result.trace_id}") if __name__ == "__main__": diff --git a/apps/admin_console/core/security.py b/apps/admin_console/core/security.py new file mode 100644 index 0000000..a5b132c --- /dev/null +++ b/apps/admin_console/core/security.py @@ -0,0 +1,178 @@ +# 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. + +"""Same-origin security boundary for the Artemis console. + +Artemis runs without user accounts: whoever can reach the TCP port is the +operator. For that model to hold for a server that browsers also talk to, +two browser-borne attack vectors must be closed even on a loopback bind: + +- DNS rebinding: a page at ``attacker.example`` re-points its DNS record at + 127.0.0.1 so the victim's browser reads API responses under the attacker's + origin. Blocked by validating the ``Host`` header. +- Cross-site request forgery: any web page can fire side-effectful requests + at ``http://127.0.0.1:8000`` from a visitor's browser. Blocked by requiring + the ``Origin`` header, when a browser sends one, to match the request host. + +Non-browser clients (the SDK, curl, the CLI, MCP) send no ``Origin`` header +and pass through untouched. Remote access is expected to arrive over a +network-level tunnel (Tailscale, SSH port forward); a tunnel hostname that is +not an IP literal can be admitted via ``ARTEMIS_ALLOWED_HOSTS``. +""" + +from __future__ import annotations + +import ipaddress +import json +import os +from urllib.parse import urlsplit + +_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = ( + (b"x-content-type-options", b"nosniff"), + (b"referrer-policy", b"no-referrer"), + (b"x-frame-options", b"DENY"), + (b"cross-origin-opener-policy", b"same-origin"), +) + +# Paths whose responses may hold task data, media, or diagnostics and must not +# land in shared caches or survive on disk after the session. +_NO_STORE_PREFIXES = ("/api/",) + + +def _hostname(netloc: str) -> str: + """Extract the lowercase hostname from a ``host[:port]`` netloc.""" + netloc = netloc.strip().lower() + if netloc.startswith("["): # IPv6 literal, e.g. [::1]:8000 + return netloc.partition("]")[0].lstrip("[") + return netloc.rpartition(":")[0] if netloc.count(":") == 1 else netloc + + +def _is_ip_literal(host: str) -> bool: + """An IP-literal Host cannot be forged through DNS rebinding: a browser + only sends ``Host: 192.168.1.5`` when it actually connected to that IP.""" + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + + +def allowed_hostnames() -> frozenset[str]: + """Hostnames (not IP literals) accepted in Host and Origin headers.""" + names = {"localhost"} + extra = os.environ.get("ARTEMIS_ALLOWED_HOSTS", "") + names.update(part.strip().lower() for part in extra.split(",") if part.strip()) + return frozenset(names) + + +def host_is_allowed(host_header: str) -> bool: + host = _hostname(host_header) + if not host: + return False + return _is_ip_literal(host) or host in allowed_hostnames() + + +def origin_is_allowed(origin_header: str, host_header: str) -> bool: + """A browser request is acceptable when its page is same-origin with this + server, or was served from an explicitly allowed tunnel hostname.""" + origin = origin_header.strip().lower() + if not origin or origin == "null": + return False + origin_netloc = urlsplit(origin).netloc + if not origin_netloc: + return False + if origin_netloc == host_header.strip().lower(): + return True + return _hostname(origin_netloc) in allowed_hostnames() + + +class SameOriginBoundaryMiddleware: + """Pure ASGI middleware enforcing the Host/Origin boundary and appending + baseline security headers. Pure ASGI (not BaseHTTPMiddleware) so future + WebSocket endpoints cannot slip past the Origin check.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + + if os.environ.get("ARTEMIS_DISABLE_ORIGIN_GUARD", "").lower() in {"1", "true", "yes"}: + await self._forward(scope, receive, send) + return + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + host_header = headers.get("host", "") + origin_header = headers.get("origin") + + if not host_is_allowed(host_header): + await self._reject( + scope, + send, + "Unrecognized Host header. Access Artemis via localhost or an IP address, " + "or add your tunnel hostname to ARTEMIS_ALLOWED_HOSTS.", + ) + return + + if origin_header is not None and not origin_is_allowed(origin_header, host_header): + await self._reject( + scope, + send, + "Cross-origin browser requests are not accepted by the Artemis console.", + ) + return + + await self._forward(scope, receive, send) + + async def _forward(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + + async def send_with_headers(message): + if message["type"] == "http.response.start": + raw = list(message.get("headers", [])) + existing = {name.lower() for name, _ in raw} + for name, value in _SECURITY_HEADERS: + if name not in existing: + raw.append((name, value)) + if b"cache-control" not in existing and path.startswith(_NO_STORE_PREFIXES): + raw.append((b"cache-control", b"no-store")) + message = {**message, "headers": raw} + await send(message) + + await self.app(scope, receive, send_with_headers) + + @staticmethod + async def _reject(scope, send, detail: str): + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + body = json.dumps({"detail": detail}).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": 403, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("latin-1")), + *_SECURITY_HEADERS, + ], + } + ) + await send({"type": "http.response.body", "body": body}) diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py index b59e946..951b1de 100644 --- a/apps/admin_console/core/state.py +++ b/apps/admin_console/core/state.py @@ -51,7 +51,7 @@ class ServerState: self.ipc_serve_task: asyncio.Task | None = None self.ipc_port: int | None = None self.port: int = 8000 - self.host: str = "0.0.0.0" + self.host: str = "127.0.0.1" self.is_shutting_down: bool = False self.current_process: asyncio.subprocess.Process | None = None @@ -151,7 +151,7 @@ class ServerState: """Device serials currently owned by an in-flight run.""" self.prune_finished_runs() return { - str(run["device_id"]) + str(run.get("lock_key") or run["device_id"]) for run in self.active_runs.values() if run.get("device_id") } diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index 9350619..4cfe55d 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -21,6 +21,18 @@ except ImportError: from apps.admin_console.database.connection import db_session +def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]: + """Map the legacy "success" terminal status to the canonical "completed". + + Rows written before the vocabulary was unified may still carry "success"; + API consumers must only ever see "completed". get_session_status is left + raw on purpose so the queue reconcile can detect and persist the rewrite. + """ + if row.get("status") == "success": + row["status"] = "completed" + return row + + class SessionRepository: """Repository handling all database queries and updates for Sessions.""" @@ -42,7 +54,7 @@ class SessionRepository: with db_session(self.db_path) as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM sessions ORDER BY start_time DESC") - return [dict(r) for r in cursor.fetchall()] + return [_canonicalize_status(dict(r)) for r in cursor.fetchall()] def get_video_recordings_map(self) -> dict[str, str]: with db_session(self.db_path) as conn: @@ -240,7 +252,7 @@ class SessionRepository: (str(session_id),), ) row = cursor.fetchone() - return dict(row) if row else None + return _canonicalize_status(dict(row)) if row else None except Exception: return None @@ -267,7 +279,13 @@ class SessionRepository: conn.commit() return count - def cleanup_orphans_on_startup(self) -> int: + def reconcile_orphaned_sessions(self) -> int: + """Mark running sessions with no live worker process as failed. + + This is safe to call both during startup and immediately after a + forced server stop: live workers, including workers owned by another + server instance, are left untouched. + """ try: with db_session(self.db_path) as conn: cursor = conn.cursor() @@ -301,6 +319,10 @@ class SessionRepository: except Exception: return 0 + def cleanup_orphans_on_startup(self) -> int: + """Backward-compatible startup entrypoint for orphan reconciliation.""" + return self.reconcile_orphaned_sessions() + def get_latest_session(self) -> dict[str, Any] | None: try: with db_session(self.db_path) as conn: @@ -309,7 +331,7 @@ class SessionRepository: "SELECT session_id, status FROM sessions ORDER BY start_time DESC LIMIT 1" ) row = cursor.fetchone() - return dict(row) if row else None + return _canonicalize_status(dict(row)) if row else None except Exception: return None diff --git a/apps/admin_console/index.html b/apps/admin_console/index.html index d28f9ea..0d60c4c 100644 --- a/apps/admin_console/index.html +++ b/apps/admin_console/index.html @@ -262,7 +262,7 @@
- +
Loading sessions...
@@ -279,9 +279,9 @@