feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -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})
|
||||
@@ -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
|
||||
|
||||
|
||||
+395
-179
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user