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

+178
View File
@@ -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})
+2 -2
View File
@@ -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")
}
@@ -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
File diff suppressed because it is too large. Load diff
+58 -27
View File
@@ -12,11 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
try:
from admin_console.database.repositories.session_repository import session_repo
@@ -27,6 +28,48 @@ except ImportError:
router = APIRouter(tags=["media"])
_VIDEO_MEDIA_TYPES = {
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
def _allowed_media_roots() -> list[Path]:
"""Directories the media endpoints are permitted to serve from."""
roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
roots.append(Path(root).resolve())
except OSError:
continue
return roots
def _resolve_media_path(raw_path: str, allowed_suffixes: set[str]) -> Path:
"""Resolve a client-supplied path strictly inside the allowed media roots.
Every candidate is fully resolved (symlinks, `..`, mixed separators) and
then re-checked against the allowed roots and an extension allowlist, so a
crafted URL can never address source code, databases, or dotfiles.
"""
roots = _allowed_media_roots()
candidate = Path(raw_path)
attempts = [candidate] if candidate.is_absolute() else [root / candidate for root in roots]
for attempt in attempts:
try:
resolved = attempt.resolve(strict=True)
except (OSError, ValueError):
continue
if not resolved.is_file():
continue
if not any(resolved.is_relative_to(root) for root in roots):
continue
if resolved.suffix.lower() not in allowed_suffixes:
raise HTTPException(status_code=403, detail="File type is not served by the media API.")
return resolved
raise HTTPException(status_code=404, detail="Media file not found")
@router.get("/admin", response_class=HTMLResponse)
@router.get("/debug", response_class=HTMLResponse)
@@ -47,8 +90,12 @@ async def get_admin_index():
async def get_image(image_name: str):
if not image_name.endswith(".jpg"):
image_name += ".jpg"
image_path = IMAGES_DIR / image_name
if not image_path.exists():
try:
images_root = IMAGES_DIR.resolve()
image_path = (images_root / image_name).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="Image not found")
if not image_path.is_file() or not image_path.is_relative_to(images_root):
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(image_path, media_type="image/jpeg")
@@ -56,34 +103,18 @@ async def get_image(image_name: str):
@router.get("/videos/{video_path:path}")
async def get_video(video_path: str):
path = Path(video_path)
if not path.exists():
if not path.is_absolute():
candidate = WORKSPACE_ROOT / path
if candidate.exists():
path = candidate
else:
candidate_root = Path("/" + video_path.lstrip("/"))
if candidate_root.exists():
path = candidate_root
else:
path = candidate
if not path.exists():
raise HTTPException(status_code=404, detail=f"Video not found at {path}")
suffix = path.suffix.lower()
media_type = "video/mp4"
if suffix == ".webm":
media_type = "video/webm"
elif suffix == ".mkv":
media_type = "video/x-matroska"
return FileResponse(path, media_type=media_type)
path = _resolve_media_path(video_path, set(_VIDEO_MEDIA_TYPES))
return FileResponse(path, media_type=_VIDEO_MEDIA_TYPES[path.suffix.lower()])
@router.get("/api/sessions/{session_id}/video")
async def get_session_video(session_id: str):
# Blocking work (sqlite, filesystem scan, possible ffmpeg conversion) runs
# off the event loop.
return await asyncio.to_thread(_get_session_video_sync, session_id)
def _get_session_video_sync(session_id: str):
video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id)
+123 -114
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import time
from uuid import UUID
@@ -38,125 +39,133 @@ router = APIRouter(tags=["sessions"])
@router.get("/api/sessions")
async def list_sessions():
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg
# conversion on first sight of a new recording), so run it off the event
# loop — the frontend polls this endpoint and it must not stall other
# requests.
try:
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
return await asyncio.to_thread(_list_sessions_sync)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def _list_sessions_sync():
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
@router.get("/api/sessions/{session_id}")
async def get_session_details(session_id: str):
"""Retrieve details for a single automation session."""
+174 -35
View File
@@ -14,15 +14,84 @@
"""System Readiness & Diagnostics Router for Artemis Admin Console."""
from fastapi import APIRouter, HTTPException
import ipaddress
import os
import secrets
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
from artemis.core.diagnostics.adb_server_connection import (
InvalidAdbServerEndpoint,
adb_server_connection,
)
from artemis.core.diagnostics.schema import SystemReadinessReport
router = APIRouter(prefix="/api/system", tags=["system"])
def _require_local_admin_request(request: Request) -> None:
"""Keep endpoint probing and mutation on the local administration boundary."""
client_host = request.client.host if request.client else None
allow_remote = os.getenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", "").lower() in {
"1",
"true",
"yes",
}
if client_host and not allow_remote:
try:
is_loopback = ipaddress.ip_address(client_host).is_loopback
except ValueError:
is_loopback = client_host.lower() == "localhost"
if not is_loopback:
raise HTTPException(
status_code=403,
detail=(
"ADB server settings are local-only. Set "
"ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=true to manage them from another "
"computer."
),
)
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return
origin_host = urlsplit(origin).netloc.lower()
if origin_host != host.lower():
raise HTTPException(
status_code=403,
detail="ADB server settings can only be changed from the Artemis console.",
)
def _require_loopback_request(request: Request, detail: str) -> None:
"""Reject requests whose TCP peer is not the local machine."""
client_host = request.client.host if request.client else None
try:
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
except ValueError:
is_loopback = bool(client_host and client_host.lower() == "localhost")
if not is_loopback:
raise HTTPException(status_code=403, detail=detail)
def _require_local_lifecycle_request(request: Request) -> None:
"""Authorize a process-lifecycle request from the local CLI only."""
_require_loopback_request(request, "Server lifecycle controls are local-only.")
expected = getattr(request.app.state, "lifecycle_token", None)
supplied = request.headers.get("x-artemis-lifecycle-token")
if not (
isinstance(expected, str)
and isinstance(supplied, str)
and secrets.compare_digest(expected, supplied)
):
raise HTTPException(status_code=403, detail="Invalid server lifecycle token.")
class SelectDeviceRequest(BaseModel):
"""Payload to select an active target Android device."""
@@ -94,6 +163,66 @@ async def connect_wireless_adb(request: ConnectAdbRequest):
}
class ConnectAdbServerRequest(BaseModel):
"""Payload to select an ADB server endpoint accessible from this computer."""
host: str = Field(description="Host name or IP address of the ADB server")
port: int = Field(default=5037, ge=1, le=65535, description="ADB server port")
persist: bool = Field(default=True, description="Persist the endpoint for future launches")
@router.get("/adb/server")
async def get_adb_server_status():
"""Return the process-wide ADB server endpoint currently used by Artemis."""
return adb_server_connection.status()
@router.post("/adb/server/connect")
async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Validate and activate an ADB server endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.connect(
payload.host,
payload.port,
persist=payload.persist,
)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response: dict[str, object] = {"connection_result": connection_result}
if connection_result["success"]:
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
response["report"] = await readiness_engine.run_all(force_refresh=True)
return response
@router.post("/adb/server/probe")
async def probe_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Test an ADB server endpoint without changing the active endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.probe(payload.host, payload.port)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"connection_result": connection_result}
@router.post("/adb/server/local")
async def use_local_adb_server(request: Request, persist: bool = True):
"""Restore the standard local ADB server without touching a remote daemon."""
_require_local_admin_request(request)
connection_result = await adb_server_connection.use_local_server(persist=persist)
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
updated_report = await readiness_engine.run_all(force_refresh=True)
return {
"connection_result": connection_result,
"report": updated_report,
}
class LaunchEmulatorRequest(BaseModel):
"""Payload to launch a local Android Virtual Device (AVD)."""
@@ -157,22 +286,18 @@ class ValidateCredentialsRequest(BaseModel):
@router.get("/credentials")
async def get_credentials():
"""Retrieve current configured API keys for management."""
"""Report which providers have an API key configured.
Secret values never leave the process: this endpoint intentionally returns
presence booleans only. Keys are written via POST /credentials and used
server-side.
"""
from artemis.config import settings
gemini_k = settings.get_api_key("google")
openai_k = settings.get_api_key("openai")
anthropic_k = settings.get_api_key("anthropic")
openrouter_k = settings.get_api_key("openrouter")
ocr_k = settings.get_api_key("ocr")
return {
"google": gemini_k.get_secret_value() if gemini_k else None,
"gemini": gemini_k.get_secret_value() if gemini_k else None,
"openai": openai_k.get_secret_value() if openai_k else None,
"anthropic": anthropic_k.get_secret_value() if anthropic_k else None,
"openrouter": openrouter_k.get_secret_value() if openrouter_k else None,
"ocr": ocr_k.get_secret_value() if ocr_k else None,
}
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
status = {name: bool(settings.get_api_key(name)) for name in providers}
status["gemini"] = status["google"]
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
@router.post("/credentials/test")
@@ -278,11 +403,13 @@ async def get_model_config_and_env():
return None
def mask_key(k: str | None) -> str | None:
# Expose only enough to recognize which key is active, never a usable
# fragment of the secret itself.
if not k:
return None
if len(k) <= 8:
return "****"
return f"{k[:4]}...{k[-4:]}"
return f"****{k[-4:]}"
gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value(
"GOOGLE_API_KEY", "google"
@@ -375,18 +502,30 @@ async def get_server_runtime_status():
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
status["current_pid"] = os.getpid()
return status
# Explicit DTO: the raw metadata file additionally holds the lifecycle
# token, cmdline, and filesystem paths, none of which belong on the wire.
return {
"running": status["running"],
"port": status["port"],
"pids": status["pids"],
"active_pid": status["active_pid"],
"uptime_seconds": status["uptime_seconds"],
"url": status["url"],
"admin_url": status["admin_url"],
"current_pid": os.getpid(),
}
@router.post("/restart")
async def restart_server_endpoint():
async def restart_server_endpoint(request: Request):
"""Request a graceful restart of the Artemis server from thin clients/UI."""
import asyncio
import os
import sys
import threading
_require_loopback_request(request, "Server lifecycle controls are local-only.")
try:
from apps.admin_console.core.state import state
except ImportError:
@@ -423,33 +562,33 @@ async def restart_server_endpoint():
}
@router.post("/shutdown")
async def shutdown_server_endpoint():
@router.post("/shutdown", status_code=202)
async def shutdown_server_endpoint(request: Request):
"""Request a graceful shutdown of the Artemis server."""
import os
import signal
import threading
import asyncio
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
current_pid = os.getpid()
_require_local_lifecycle_request(request)
server = getattr(request.app.state, "uvicorn_server", None)
if server is None:
raise HTTPException(status_code=503, detail="Server lifecycle controller is unavailable.")
def _shutdown_worker():
import time
async def _shutdown_after_response() -> None:
# Let Starlette flush the accepted response before Uvicorn leaves its
# main loop and invokes the FastAPI shutdown lifecycle.
await asyncio.sleep(0.05)
state.is_shutting_down = True
state.shutdown_event.set()
server.should_exit = True
time.sleep(0.5)
try:
os.kill(current_pid, signal.SIGTERM)
except Exception:
os._exit(0)
threading.Thread(target=_shutdown_worker, daemon=True).start()
asyncio.create_task(_shutdown_after_response())
return {
"status": "shutting_down",
"message": "Artemis server is shutting down.",
"pid": current_pid,
"pid": os.getpid(),
}
+56
View File
@@ -80,6 +80,62 @@ async def run_task(request: RunRequest):
detail="Either 'goal' or 'goals' list must be provided.",
)
# Idempotent SDK retries must never re-run device readiness checks. A task
# can hold the device while its admission response is lost in transit; in
# that state, probing the same device again may fail or block even though
# the original task was accepted successfully.
if request.session_id and len(incoming_goals) == 1:
requested_sid = str(request.session_id)
existing_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == requested_sid
),
None,
)
persisted_session = session_repo.get_session_by_id(requested_sid)
is_active = (
str(state.active_session_id) == requested_sid
or requested_sid in state.active_connections
)
if existing_item or persisted_session or is_active:
task_payload = dict(existing_item or persisted_session or {})
task_payload.setdefault("session_id", requested_sid)
task_payload.setdefault("goal", incoming_goals[0])
task_payload.setdefault("profile", request.profile or "flash")
task_payload.setdefault("device_serial", request.device_serial)
task_payload.setdefault("status", "running" if is_active else "queued")
return {
"status": task_payload["status"],
"tasks": [task_payload],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Reject an explicit unknown/offline target before running the more
# expensive readiness probe. Besides producing a stable SDK response,
# this avoids probing the currently active device for a serial that can
# never be selected. Only a successful, non-empty enumeration may reject:
# an indeterminate one (adb blip, startup) lets the submission queue and
# fail downstream with a clear error instead.
if request.device_serial:
try:
rejection = await device_pool.validate_explicit_serial_async(
request.device_serial
)
except Exception:
rejection = None
if rejection:
return {
"status": "rejected",
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Re-check immediately before enqueueing so a device locked between UI
# polling intervals cannot start through a stale Ready state. Use the
# bounded submission probe: the full diagnostics path also scans packages,
+79 -22
View File
@@ -17,9 +17,11 @@
Modular entrypoint for full trace inspection, step replay, and task execution management.
"""
import argparse
import asyncio
import os
from pathlib import Path
import secrets
import signal
import sys
from types import FrameType
@@ -43,13 +45,13 @@ for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_c
sys.path.insert(0, _p)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
import uvicorn
from artemis.runtime import (
DeviceExecutionLock,
clear_server_info,
device_pool,
shutdown_awake_service,
start_awake_service,
write_server_info,
@@ -67,6 +69,7 @@ from artemis.config import (
)
try:
from admin_console.core.security import SameOriginBoundaryMiddleware
from admin_console.core.state import ServerState, state
from admin_console.database.connection import db_session, get_db
from admin_console.database.repositories.session_repository import session_repo
@@ -79,6 +82,7 @@ try:
from admin_console.services.model_service import model_service
from admin_console.services.task_queue_service import task_queue_service
except ImportError:
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
@@ -92,15 +96,13 @@ init_ls_address()
# Initialize FastAPI application
app = FastAPI(title="Artemis Admin & Trace Console")
LIFECYCLE_TOKEN = os.environ.get("ARTEMIS_LIFECYCLE_TOKEN") or secrets.token_urlsafe(32)
app.state.lifecycle_token = LIFECYCLE_TOKEN
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# The console UI is served same-origin from this process, so no CORS grants
# exist at all; the boundary middleware rejects cross-origin browser traffic
# and unrecognized Host headers (DNS rebinding) instead.
app.add_middleware(SameOriginBoundaryMiddleware)
@app.on_event("startup")
@@ -108,11 +110,29 @@ async def on_startup():
"""Startup lifecycle hooks."""
state.is_shutting_down = False
state.shutdown_event.clear()
write_server_info(port=getattr(state, "port", 8000), host=getattr(state, "host", "0.0.0.0"))
write_server_info(
port=getattr(state, "port", 8000),
host=getattr(state, "host", "127.0.0.1"),
lifecycle_token=LIFECYCLE_TOKEN,
)
await asyncio.to_thread(start_awake_service)
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
if cleaned_device_locks:
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
# Warm the adb server and complete one enumeration before requests are
# served, so submissions in the first seconds never race an adb cold start
# and get a false "no devices attached" rejection.
try:
adb_warmed = await device_pool.warm_up_async()
except Exception as exc:
adb_warmed = False
print(f"[ServerStartup] ADB warm-up failed: {exc}")
if not adb_warmed:
print(
"[ServerStartup] ADB warm-up did not confirm a responding adb server; "
"device enumeration will retry on demand."
)
asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch))
asyncio.create_task(
asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager)
@@ -178,7 +198,10 @@ async def on_shutdown():
await ipc_service.stop_server()
state.ipc_subscribers.clear()
await asyncio.to_thread(shutdown_awake_service)
clear_server_info()
clear_server_info(
port=getattr(state, "port", 8000),
lifecycle_token=LIFECYCLE_TOKEN,
)
# Mount modular routers
@@ -222,6 +245,18 @@ def _get_showcase_dist() -> Path:
return base_dist / "frontend" / "browser"
def _resolve_static_file(root: Path, relative_path: str) -> Path | None:
"""Return the requested file only when it truly lives under ``root``."""
try:
root = root.resolve()
target = (root / relative_path).resolve()
except (OSError, ValueError):
return None
if target.is_file() and target.is_relative_to(root):
return target
return None
@app.get("/", include_in_schema=False)
async def serve_showcase_root():
"""Explicitly serve the Showcase UI at the root path by default."""
@@ -258,15 +293,18 @@ async def serve_showcase_spa(full_path: str):
showcase_dist = _get_showcase_dist()
# Exact static file match (js, css, images, fonts, favicon, logo)
# Exact static file match (js, css, images, fonts, favicon, logo).
# Resolve and re-anchor under the static root so encoded traversal
# sequences cannot address files outside the build directories.
if clean_path:
target_file = showcase_dist / clean_path
if target_file.is_file():
target_file = _resolve_static_file(showcase_dist, clean_path)
if target_file is not None:
return FileResponse(target_file)
# Fallback for icons/logos if showcase UI hasn't been built yet
public_file = _workspace_root / "apps" / "showcase_ui" / "public" / clean_path
if public_file.is_file():
public_root = _workspace_root / "apps" / "showcase_ui" / "public"
public_file = _resolve_static_file(public_root, clean_path)
if public_file is not None:
return FileResponse(public_file)
# Serve Showcase UI Angular SPA index.html
@@ -403,7 +441,7 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
"""Run the UI server with bounded, signal-aware graceful shutdown."""
state.host = host
state.port = port
write_server_info(port=port, host=host)
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
try:
if reload:
@@ -422,14 +460,33 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
port=port,
timeout_graceful_shutdown=5,
)
ArtemisUvicornServer(config).run()
server = ArtemisUvicornServer(config)
app.state.uvicorn_server = server
server.run()
finally:
clear_server_info()
app.state.uvicorn_server = None
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
def main():
port = int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", 8000))
run_ui_server(host="0.0.0.0", port=port)
def main(argv: list[str] | None = None) -> None:
"""Parse the daemon/standalone server bind address and run Uvicorn."""
parser = argparse.ArgumentParser(description="Run the Artemis Admin Console server")
parser.add_argument(
"--host",
default=os.environ.get("ARTEMIS_SERVER_HOST", "127.0.0.1"),
help=(
"Address to bind the HTTP server to. Defaults to loopback; expose "
"remotely via a Tailscale/SSH tunnel rather than a wide bind."
),
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", "8000")),
help="TCP port for the HTTP server.",
)
args = parser.parse_args(argv)
run_ui_server(host=args.host, port=args.port)
if __name__ == "__main__":
+114 -57
View File
@@ -18,6 +18,7 @@ import json
import os
from pathlib import Path
import subprocess
import threading
from typing import Any
import urllib.parse
from fastapi import HTTPException
@@ -28,6 +29,12 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
class MediaService:
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
# Conversion results keyed by source path -> (mtime, size, resolved path).
# Failed conversions are cached too (resolved path == source path) so a broken
# file doesn't re-run ffmpeg (up to 45s) on every /api/sessions poll.
_playable_cache: dict[str, tuple[float, int, str]] = {}
_playable_cache_lock = threading.Lock()
@classmethod
def ensure_browser_playable_video(cls, p: Path) -> Path:
"""Ensure the video file is in a browser-supported container (MP4).
@@ -41,59 +48,88 @@ class MediaService:
if suffix == ".mp4":
return p
if suffix in (".mkv", ".webm"):
src_stat = p.stat()
cache_key = str(p)
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
mp4_cand = p.with_suffix(".mp4")
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
)
return mp4_cand
with cls._playable_cache_lock:
# Another thread may have converted this file while we waited.
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
try:
result = cls._convert_to_mp4(p, mp4_cand)
except Exception:
result = p
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(result),
)
return result
except Exception:
pass
return p
@staticmethod
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
return p
@staticmethod
def path_to_video_url(p: Path) -> str:
resolved = p.resolve()
@@ -287,30 +323,51 @@ class MediaService:
return [MediaService.unwrap_payload(x) for x in obj]
return obj
@staticmethod
def get_safe_local_file(path_str: str) -> tuple[Path, str]:
_LOCAL_FILE_MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
@classmethod
def get_safe_local_file(cls, path_str: str) -> tuple[Path, str]:
"""Serve trace media only: resolved path must sit inside the workspace
or traces tree AND carry a media extension, so this endpoint can never
hand out .env, databases, or source files."""
decoded_path = urllib.parse.unquote(path_str)
if decoded_path.startswith("file://"):
decoded_path = decoded_path[len("file://") :]
p = Path(decoded_path).resolve()
try:
p = Path(decoded_path).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="File not found")
if not p.is_file():
raise HTTPException(status_code=404, detail="File not found")
if not str(p).startswith(str(WORKSPACE_ROOT)):
allowed_roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
allowed_roots.append(Path(root).resolve())
except OSError:
continue
if not any(p.is_relative_to(root) for root in allowed_roots):
raise HTTPException(
status_code=403,
detail="Access denied. Path is outside workspace root.",
)
ext = p.suffix.lower()
media_type = "application/octet-stream"
if ext in (".jpg", ".jpeg"):
media_type = "image/jpeg"
elif ext == ".png":
media_type = "image/png"
elif ext == ".mp4":
media_type = "video/mp4"
media_type = cls._LOCAL_FILE_MEDIA_TYPES.get(p.suffix.lower())
if media_type is None:
raise HTTPException(
status_code=403,
detail="Access denied. Only media files are served.",
)
return p, media_type
+22 -5
View File
@@ -13,18 +13,27 @@
# limitations under the License.
import json
import time
from typing import Any
class ModelService:
"""Service handling agent architecture profile detection and metadata formatting."""
@staticmethod
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config
model_id = "gemini-3.7-flash"
# parse_llm_config re-reads and re-validates the config file on every call;
# /api/sessions calls this once per session row, so cache (provider, model_id)
# for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds.
_LLM_INFO_TTL = 10.0
_llm_info_cache: tuple[float, str, str] | None = None
@classmethod
def _get_llm_provider_and_model(cls) -> tuple[str, str]:
cached = cls._llm_info_cache
now = time.monotonic()
if cached and now - cached[0] < cls._LLM_INFO_TTL:
return cached[1], cached[2]
provider = "google"
model_id = "gemini-3.7-flash"
try:
from artemis.config import parse_llm_config
@@ -37,6 +46,14 @@ class ModelService:
model_id = str(llm_cfg.default.model)
except Exception:
pass
cls._llm_info_cache = (now, provider, model_id)
return provider, model_id
@classmethod
def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config (cached)
provider, model_id = cls._get_llm_provider_and_model()
# 2. Determine agent architecture name (Flash vs Pro)
arch_name = "Flash"
@@ -16,6 +16,7 @@ import asyncio
import codecs
from datetime import datetime
import os
import psutil
import shutil
import subprocess
import sys
@@ -38,7 +39,13 @@ from artemis.config import (
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from artemis.runtime import DeviceExecutionLock, process_supervisor
from artemis.runtime import (
AdbEndpoint,
AdbTarget,
DeviceExecutionLock,
current_adb_endpoint,
process_supervisor,
)
class TaskQueueService:
@@ -50,6 +57,17 @@ class TaskQueueService:
# keeps weak references to running tasks).
_run_tasks: set[asyncio.Task] = set()
@staticmethod
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
endpoint_data = task_item.get("adb_endpoint")
endpoint = (
AdbEndpoint.from_mapping(endpoint_data)
if isinstance(endpoint_data, dict)
else current_adb_endpoint()
)
serial = task_item.get("device_serial")
return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None)
@classmethod
def _broadcast_event(cls, event_type: str, data: Any):
"""Broadcasts an event safely to all registered subscribers."""
@@ -241,8 +259,6 @@ class TaskQueueService:
pid = getattr(proc, "pid", None)
if pid:
try:
import psutil
p = psutil.Process(pid)
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
return proc.returncode if proc.returncode is not None else -15
@@ -345,7 +361,7 @@ class TaskQueueService:
if isinstance(i, dict) and i.get("status") == "running"
]
busy_devices = state.busy_device_ids | {
str(i.get("device_serial")) for i in in_flight if i.get("device_serial")
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
}
dispatched_any = False
loop = asyncio.get_running_loop()
@@ -358,19 +374,20 @@ class TaskQueueService:
continue
device = item.get("device_serial")
target = cls._task_target(item)
if mode == "per_device":
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
# then allocates freely without contending against active runs.
if device is None and (state.active_runs or in_flight or dispatched_any):
continue
if device is not None and str(device) in busy_devices:
if device is not None and target.lock_key in busy_devices:
continue
item["status"] = "running"
dispatched_any = True
if device is not None:
busy_devices.add(str(device))
busy_devices.add(target.lock_key)
run_task = loop.create_task(cls._execute_task_item(item))
# Hold a strong reference: asyncio keeps only weak refs to running
# tasks, and a collected run would strand its queue item forever.
@@ -389,6 +406,8 @@ class TaskQueueService:
proc: asyncio.subprocess.Process | None = None
output_task: asyncio.Task[None] | None = None
try:
if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.")
expected_output = task_item.get("expected_output")
enable_outputter = task_item.get("enable_outputter")
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
@@ -436,6 +455,9 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
target = cls._task_target(task_item)
target.endpoint.apply_to_environment(env)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -479,6 +501,8 @@ class TaskQueueService:
state.active_runs[run_key] = {
"process": proc,
"device_id": str(device_serial) if device_serial else None,
"lock_key": target.lock_key if device_serial else None,
"adb_endpoint": target.endpoint.to_dict(),
"goal": goal,
"profile": profile,
}
@@ -503,6 +527,7 @@ class TaskQueueService:
device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None,
ingress=ingress_type,
lock_scope=target.lock_scope,
)
# Forward the worker's combined output and tee it into the trace's
# stdout.log so the log paths advertised by the MCP API exist.
@@ -667,6 +692,7 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
endpoint = current_adb_endpoint()
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
if session_id and len(goals) == 1:
@@ -705,6 +731,7 @@ class TaskQueueService:
and item.get("status") == "pending"
and item.get("goal") == first_goal
and (not device_serial or item.get("device_serial") == device_serial)
and item.get("adb_endpoint", {}).get("identity") == endpoint.identity
and (now - float(item.get("created_at", 0))) < 1.0
),
None,
@@ -719,31 +746,19 @@ class TaskQueueService:
# Strict device binding: reject an explicitly requested serial that is not
# attached and authorized, instead of silently running on another device.
# The shared validator fails open on an indeterminate/empty enumeration so
# the task can proceed and fail downstream with a clear no-device error.
if device_serial:
try:
from artemis.runtime import device_pool
norm = DeviceExecutionLock._normalize_device_id
attached = {
norm(d.serial): d.state
for d in await device_pool.list_devices_async()
}
rejection = await device_pool.validate_explicit_serial_async(device_serial)
except Exception:
attached = None
# Empty enumeration (no adb / transient failure) skips validation so the
# task can proceed and fail downstream with a clear no-device error,
# mirroring mcp_server.tools.task_runner._validate_device_serial.
if attached and attached.get(norm(device_serial)) != "device":
dev_state = attached.get(norm(device_serial))
error_detail = (
f"Device '{device_serial}' is not connected. "
f"Attached devices: {sorted(attached) if attached else 'none'}."
if dev_state is None
else f"Device '{device_serial}' is attached but not ready (state: '{dev_state}')."
)
rejection = None
if rejection:
return {
"status": "rejected",
"error": error_detail,
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
@@ -772,6 +787,7 @@ class TaskQueueService:
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress=ingress,
lock_scope=endpoint.identity,
)
task_item = {
"session_id": sess_id,
@@ -782,6 +798,7 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"adb_endpoint": endpoint.to_dict(),
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending",