Files
GUI-Agent/SystemTest-Controller/app/server.py
T

1534 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import json
import html
import ipaddress
import mimetypes
import os
import re
import socket
import ssl
import sys
import threading
import webbrowser
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, urldefrag, urljoin, urlparse
from urllib.request import Request, urlopen
from . import __version__
from .config import ControllerConfig
from .storage import AlreadyRegisteredError, Database
STARTED_AT = datetime.now(timezone.utc).isoformat(timespec="seconds")
DEFAULT_SITE_SCAN_PORTS = [
80,
443,
3000,
4173,
5173,
5174,
8000,
8001,
8080,
8081,
8088,
8765,
9000,
9090,
10000,
]
TLS_SITE_PORTS = {443, 8443, 9443}
MAX_SITE_SCAN_PROBES = 512
MAX_SITE_SCAN_PORTS = 256
DISCOVERY_MAX_LINKS = 24
DISCOVERY_MAX_FORMS = 12
DISCOVERY_MAX_BUTTONS = 24
DISCOVERY_MAX_HTML_BYTES = 1_500_000
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
UNVERIFIED_HTTPS_CONTEXT = ssl._create_unverified_context()
def _browser_autostart_enabled() -> bool:
disabled_values = {"1", "true", "yes", "on"}
return (
os.environ.get("CONTROLLER_NO_BROWSER", "").strip().lower() not in disabled_values
and os.environ.get("AGENT_CONTROLLER_NO_BROWSER", "").strip().lower() not in disabled_values
)
def _open_panel_later(url: str) -> None:
def open_panel() -> None:
try:
webbrowser.open(url, new=2)
except Exception as exc: # Browser launch should not bring the service down.
print(f"Could not open browser automatically: {exc}", file=sys.stderr)
threading.Timer(0.8, open_panel).start()
def _json_bytes(payload: Any) -> bytes:
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
def _chat_completions_url(base_url: str) -> str:
base = base_url.rstrip("/")
if base.endswith("/chat/completions"):
return base
if base.endswith("/v1"):
return f"{base}/chat/completions"
return f"{base}/v1/chat/completions"
def _parse_json_object(text: str) -> dict[str, Any] | None:
raw = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
if fence:
raw = fence.group(1).strip()
start = raw.find("{")
end = raw.rfind("}")
if start >= 0 and end > start:
raw = raw[start : end + 1]
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _completion_content(data: dict[str, Any]) -> str:
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
return ""
choice = choices[0] if isinstance(choices[0], dict) else {}
message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
content = (
message.get("content")
or message.get("reasoning_content")
or message.get("reasoning")
or choice.get("text")
or ""
)
if not isinstance(content, str):
return json.dumps(content, ensure_ascii=False)
return content
def _port_is_available(host: str, port: int) -> bool:
probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
return sock.connect_ex((probe_host, port)) != 0
def _is_blank(value: Any) -> bool:
return value is None or value == [] or (isinstance(value, str) and not value.strip())
def _parse_site_scan_ports(value: Any) -> list[int]:
if _is_blank(value):
return list(DEFAULT_SITE_SCAN_PORTS)
raw_parts = value if isinstance(value, list) else str(value).replace(";", ",").replace(",", ",").split(",")
ports: set[int] = set()
for raw_part in raw_parts:
text = str(raw_part).strip()
if not text:
continue
if "-" in text:
start_text, end_text = [part.strip() for part in text.split("-", 1)]
if not start_text.isdigit() or not end_text.isdigit():
raise ValueError(f"Invalid port range: {text}")
start, end = int(start_text), int(end_text)
if end < start:
raise ValueError(f"Invalid port range: {text}")
if end - start + 1 > MAX_SITE_SCAN_PORTS:
raise ValueError("Port range is too large; narrow it before scanning.")
candidates = range(start, end + 1)
else:
if not text.isdigit():
raise ValueError(f"Invalid port: {text}")
candidates = [int(text)]
for port in candidates:
if port < 1 or port > 65535:
raise ValueError(f"Port out of range: {port}")
ports.add(port)
if not ports:
raise ValueError("No valid ports to scan.")
if len(ports) > MAX_SITE_SCAN_PORTS:
raise ValueError("Too many ports to scan at once; please narrow the port list.")
return sorted(ports)
def _resolve_ipv4(host: str) -> str | None:
try:
infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM)
except socket.gaierror:
return None
for info in infos:
address = info[4][0]
if address:
return address
return None
def _add_local_ipv4(candidates: list[str], address: str | None) -> None:
if not address or address in candidates:
return
try:
parsed = ipaddress.ip_address(address)
except ValueError:
return
if parsed.version != 4 or parsed.is_unspecified or parsed.is_multicast:
return
candidates.append(address)
def _local_site_scan_hosts() -> list[dict[str, str]]:
addresses = ["127.0.0.1"]
try:
for address in socket.gethostbyname_ex(socket.gethostname())[2]:
_add_local_ipv4(addresses, address)
except OSError:
pass
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("8.8.8.8", 80))
_add_local_ipv4(addresses, sock.getsockname()[0])
except OSError:
pass
return [{"host": address, "ip": address} for address in addresses]
def _site_scan_hosts(target: str) -> list[dict[str, str]]:
normalized = target.strip()
if normalized.lower() in {"", "local", "localhost", "127.0.0.1", "::1"}:
return _local_site_scan_hosts()
if any(marker in normalized for marker in ["/", "\\", ",", ";", " "]):
raise ValueError("Please enter one IP address or host name, not a network range.")
ip = _resolve_ipv4(normalized)
if ip is None:
raise ValueError(f"Unable to resolve host: {normalized}")
return [{"host": normalized, "ip": ip}]
def _extract_site_scan_target(payload: dict[str, Any]) -> tuple[str, Any]:
target = str(payload.get("host") or "").strip()
ports = payload.get("ports")
if "://" in target:
parsed = urlparse(target)
if parsed.hostname:
target = parsed.hostname
if _is_blank(ports) and parsed.port:
ports = parsed.port
elif target.count(":") == 1:
host_part, port_part = target.rsplit(":", 1)
if host_part.strip() and port_part.isdigit():
target = host_part.strip()
if _is_blank(ports):
ports = port_part
return target, ports
def _scheme_candidates(port: int) -> list[str]:
return ["https", "http"] if port in TLS_SITE_PORTS else ["http", "https"]
def _decode_body(raw: bytes, content_type: str) -> str:
charset = "utf-8"
match = re.search(r"charset=([^;\s]+)", content_type or "", re.IGNORECASE)
if match:
charset = match.group(1).strip("\"'")
for candidate in [charset, "utf-8", "gbk", "latin-1"]:
try:
return raw.decode(candidate, errors="replace")
except LookupError:
continue
return raw.decode("utf-8", errors="replace")
def _extract_page_title(raw: bytes, content_type: str) -> str:
text = _decode_body(raw, content_type)
match = TITLE_RE.search(text)
if not match:
return ""
return html.unescape(re.sub(r"\s+", " ", match.group(1))).strip()
def _clean_discovery_text(value: Any, max_length: int = 160) -> str:
text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip()
return text[:max_length].strip()
def _normalize_discovery_url(value: Any) -> str:
text = str(value or "").strip()
if not text:
raise ValueError("Missing target system URL.")
if "://" not in text:
text = f"http://{text}"
parsed = urlparse(text)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Target system URL must be an http(s) URL.")
return text
def _origin_tuple(value: str) -> tuple[str, str, int]:
parsed = urlparse(value)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.scheme, (parsed.hostname or "").lower(), port
def _same_origin(base_url: str, candidate_url: str) -> bool:
try:
return _origin_tuple(base_url) == _origin_tuple(candidate_url)
except ValueError:
return False
def _safe_join_page_url(base_url: str, href: str) -> str | None:
text = str(href or "").strip()
if not text or text.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):
return None
joined = urljoin(base_url, text)
joined, _fragment = urldefrag(joined)
parsed = urlparse(joined)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
if not _same_origin(base_url, joined):
return None
return joined
class DiscoveryHtmlParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title_parts: list[str] = []
self.links: list[dict[str, Any]] = []
self.forms: list[dict[str, Any]] = []
self.buttons: list[dict[str, Any]] = []
self._in_title = False
self._anchor_stack: list[dict[str, Any]] = []
self._current_form: dict[str, Any] | None = None
self._current_button: dict[str, Any] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
attr = {name.lower(): value or "" for name, value in attrs}
if tag == "title":
self._in_title = True
return
if tag == "a":
self._anchor_stack.append({"href": attr.get("href", ""), "text_parts": []})
return
if tag == "form":
self._current_form = {
"action": attr.get("action", ""),
"method": (attr.get("method") or "get").upper(),
"id": attr.get("id", ""),
"name": attr.get("name", ""),
"class": attr.get("class", ""),
"text_parts": [],
"fields": [],
}
return
if self._current_form is not None and tag in {"input", "select", "textarea"}:
self._current_form["fields"].append(
{
"tag": tag,
"type": (attr.get("type") or tag).lower(),
"name": attr.get("name", ""),
"id": attr.get("id", ""),
"placeholder": attr.get("placeholder", ""),
"aria_label": attr.get("aria-label", ""),
}
)
if tag == "button":
self._current_button = {
"type": attr.get("type", ""),
"id": attr.get("id", ""),
"name": attr.get("name", ""),
"class": attr.get("class", ""),
"text_parts": [],
"in_form": self._current_form is not None,
}
def handle_data(self, data: str) -> None:
if self._in_title:
self.title_parts.append(data)
if self._anchor_stack:
self._anchor_stack[-1]["text_parts"].append(data)
if self._current_form is not None:
self._current_form["text_parts"].append(data)
if self._current_button is not None:
self._current_button["text_parts"].append(data)
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag == "title":
self._in_title = False
elif tag == "a" and self._anchor_stack:
anchor = self._anchor_stack.pop()
self.links.append(
{
"href": anchor.get("href", ""),
"text": _clean_discovery_text(" ".join(anchor.get("text_parts") or []), 120),
}
)
elif tag == "button" and self._current_button is not None:
button = self._current_button
button["text"] = _clean_discovery_text(" ".join(button.get("text_parts") or []), 120)
self.buttons.append(button)
self._current_button = None
elif tag == "form" and self._current_form is not None:
form = self._current_form
form["text"] = _clean_discovery_text(" ".join(form.get("text_parts") or []), 160)
self.forms.append(form)
self._current_form = None
def _fetch_discovery_page(url: str, timeout: float) -> dict[str, Any]:
request = Request(
url,
headers={
"Accept": "text/html,application/xhtml+xml,*/*",
"User-Agent": f"SystemTestController/{__version__} discovery",
},
)
started = datetime.now(timezone.utc)
try:
with urlopen(request, timeout=timeout, context=UNVERIFIED_HTTPS_CONTEXT) as response:
raw = response.read(DISCOVERY_MAX_HTML_BYTES)
content_type = response.headers.get("Content-Type", "")
final_url = response.geturl()
status = response.status
except HTTPError as exc:
raw = exc.read(DISCOVERY_MAX_HTML_BYTES)
content_type = exc.headers.get("Content-Type", "")
final_url = exc.geturl()
status = exc.code
except (URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", exc)
if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError):
reason = f"timed out after {timeout:g}s"
raise ValueError(f"Target system is not reachable: {reason}") from exc
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
return {
"url": url,
"final_url": final_url,
"status": status,
"content_type": content_type,
"html": _decode_body(raw, content_type),
"elapsed_seconds": round(elapsed, 3),
}
def _candidate_id(seed: str, *parts: Any) -> str:
raw = "|".join([seed, *[str(part or "") for part in parts]])
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12]
return f"auto-{digest}"
def _risk_for_text(text: str) -> str:
lowered = text.lower()
high = ["delete", "remove", "drop", "支付", "付款", "删除", "注销", "清空", "退款"]
medium = ["save", "submit", "create", "update", "edit", "upload", "保存", "提交", "新增", "修改", "上传"]
if any(word in lowered for word in high):
return "high"
if any(word in lowered for word in medium):
return "medium"
return "low"
def _looks_like_login_form(form: dict[str, Any]) -> bool:
fields = form.get("fields") or []
text = " ".join(
[
str(form.get("id") or ""),
str(form.get("name") or ""),
str(form.get("class") or ""),
str(form.get("text") or ""),
*[
" ".join(
[
str(field.get("type") or ""),
str(field.get("name") or ""),
str(field.get("id") or ""),
str(field.get("placeholder") or ""),
str(field.get("aria_label") or ""),
]
)
for field in fields
],
]
).lower()
return "password" in text or any(keyword in text for keyword in ["login", "signin", "sign in", "登录", "账号", "密码"])
def _build_discovery_candidates(target_url: str, page: dict[str, Any]) -> dict[str, Any]:
parser = DiscoveryHtmlParser()
try:
parser.feed(page.get("html") or "")
except Exception:
pass
page_url = page.get("final_url") or target_url
page_title = _clean_discovery_text(" ".join(parser.title_parts), 120) or _extract_page_title(
str(page.get("html") or "").encode("utf-8", errors="ignore"),
"text/html; charset=utf-8",
)
display_name = page_title or urlparse(page_url).netloc or page_url
root_id = _candidate_id(page_url, "root", display_name)
candidates: list[dict[str, Any]] = [
{
"id": root_id,
"parent_id": None,
"root_id": root_id,
"depth": 0,
"level": "site",
"title": f"站点冒烟检查:{display_name}",
"objective": "确认待测站点可以访问,并记录页面标题、状态码和基础元信息。",
"target_url": page_url,
"module": "站点入口",
"risk_level": "low",
"priority": 20,
"max_retries": 2,
"failure_policy": "pause",
"preconditions": [],
"source": "page",
"tags": ["smart-discovery", "p0", "site"],
"selected": True,
}
]
seen_links: set[str] = set()
for link in parser.links:
if len(seen_links) >= DISCOVERY_MAX_LINKS:
break
href = _safe_join_page_url(page_url, str(link.get("href") or ""))
if not href or href in seen_links:
continue
seen_links.add(href)
label = _clean_discovery_text(link.get("text"), 80) or urlparse(href).path.strip("/") or href
risk = _risk_for_text(label)
candidate_id = _candidate_id(page_url, "link", href, label)
candidates.append(
{
"id": candidate_id,
"parent_id": root_id,
"root_id": root_id,
"depth": 1,
"level": "module",
"title": f"探索页面:{label}",
"objective": f"访问并检查页面“{label}”是否可达,记录页面标题和返回状态。",
"target_url": href,
"module": label,
"risk_level": risk,
"priority": 40 if risk == "low" else 70,
"max_retries": 2,
"failure_policy": "pause" if risk == "high" else "skip",
"preconditions": [],
"source": "html_link",
"tags": ["smart-discovery", "p0", "navigation"],
"selected": risk != "high",
}
)
for index, form in enumerate(parser.forms[:DISCOVERY_MAX_FORMS], start=1):
action_url = _safe_join_page_url(page_url, str(form.get("action") or "")) or page_url
is_login = _looks_like_login_form(form)
field_names = [
_clean_discovery_text(
field.get("placeholder") or field.get("aria_label") or field.get("name") or field.get("id") or field.get("type"),
40,
)
for field in form.get("fields") or []
]
field_names = [name for name in field_names if name][:8]
label = "登录表单" if is_login else (_clean_discovery_text(form.get("name") or form.get("id") or form.get("text"), 60) or f"表单 {index}")
risk = "medium" if is_login else _risk_for_text(label)
candidate_id = _candidate_id(page_url, "form", index, action_url, label)
preconditions = ["需要测试账号/密码"] if is_login else []
candidates.append(
{
"id": candidate_id,
"parent_id": root_id,
"root_id": root_id,
"depth": 1,
"level": "feature",
"title": f"识别功能:{label}",
"objective": f"分析{label}的输入项与提交入口,P0 阶段先确认页面元素存在,不执行真实提交。",
"target_url": action_url,
"module": label,
"risk_level": risk,
"priority": 35 if is_login else 55,
"max_retries": 2,
"failure_policy": "pause" if is_login else "skip",
"preconditions": preconditions,
"source": "html_form",
"method": form.get("method") or "GET",
"fields": field_names,
"tags": ["smart-discovery", "p0", "form"],
"selected": True,
}
)
seen_buttons: set[str] = set()
for button in parser.buttons:
if len(seen_buttons) >= DISCOVERY_MAX_BUTTONS:
break
label = _clean_discovery_text(button.get("text") or button.get("name") or button.get("id"), 60)
if not label or label in seen_buttons:
continue
seen_buttons.add(label)
risk = _risk_for_text(label)
candidate_id = _candidate_id(page_url, "button", label)
candidates.append(
{
"id": candidate_id,
"parent_id": root_id,
"root_id": root_id,
"depth": 1,
"level": "feature",
"title": f"识别入口:{label}",
"objective": f"确认入口“{label}”在页面中存在;高风险入口仅记录,不自动执行破坏性动作。",
"target_url": page_url,
"module": "页面入口",
"risk_level": risk,
"priority": 60 if risk == "low" else 85,
"max_retries": 1,
"failure_policy": "pause" if risk == "high" else "skip",
"preconditions": [],
"source": "html_button",
"tags": ["smart-discovery", "p0", "entry"],
"selected": risk != "high",
}
)
if len(candidates) == 1:
for label, objective in [
("单页应用基础冒烟测试", "确认单页应用根页面可达,并为后续 GUI 探索提供初始页面。"),
("页面导航与菜单发现", "后续由 GUI Agent 在页面内识别菜单、按钮和可交互区域。"),
]:
candidate_id = _candidate_id(page_url, "fallback", label)
candidates.append(
{
"id": candidate_id,
"parent_id": root_id,
"root_id": root_id,
"depth": 1,
"level": "module",
"title": label,
"objective": objective,
"target_url": page_url,
"module": "页面探索",
"risk_level": "low",
"priority": 50,
"max_retries": 2,
"failure_policy": "skip",
"preconditions": [],
"source": "fallback",
"tags": ["smart-discovery", "p0", "spa"],
"selected": True,
}
)
return {
"summary": {
"target_url": target_url,
"final_url": page_url,
"page_title": page_title,
"http_status": page.get("status"),
"elapsed_seconds": page.get("elapsed_seconds"),
"link_count": len(seen_links),
"form_count": min(len(parser.forms), DISCOVERY_MAX_FORMS),
"button_count": min(len(seen_buttons), DISCOVERY_MAX_BUTTONS),
"candidate_count": len(candidates),
},
"candidates": candidates,
}
def _probe_site_url(host_entry: dict[str, str], port: int, scheme: str, timeout: float) -> dict[str, Any] | None:
host = host_entry["host"]
url = f"{scheme}://{host}:{port}/"
try:
with socket.create_connection((host, port), timeout=timeout):
pass
except OSError:
return None
started = datetime.now(timezone.utc)
request = Request(
url,
headers={
"Accept": "text/html,application/xhtml+xml,application/json;q=0.8,*/*;q=0.5",
"User-Agent": f"SystemTestController/{__version__} SiteScanner",
},
)
try:
with urlopen(
request,
timeout=timeout,
context=UNVERIFIED_HTTPS_CONTEXT if scheme == "https" else None,
) as response:
raw = response.read(256_000)
headers = response.headers
status = getattr(response, "status", 200)
final_url = response.geturl()
except HTTPError as exc:
raw = exc.read(256_000)
headers = exc.headers
status = exc.code
final_url = url
except (URLError, TimeoutError, OSError, ssl.SSLError):
return None
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
content_type = headers.get("Content-Type", "")
title = _extract_page_title(raw, content_type)
server_name = headers.get("Server", "")
name = title or server_name or f"{host_entry['ip']}:{port}"
return {
"name": name,
"title": title,
"url": url,
"final_url": final_url,
"ip": host_entry["ip"],
"host": host,
"port": port,
"scheme": scheme,
"status": status,
"content_type": content_type,
"elapsed_seconds": round(elapsed, 3),
"address": f"{host_entry['ip']}:{port}",
}
def _probe_site(host_entry: dict[str, str], port: int, timeout: float) -> dict[str, Any] | None:
for scheme in _scheme_candidates(port):
result = _probe_site_url(host_entry, port, scheme, timeout)
if result is not None:
return result
return None
def run_preflight(config: ControllerConfig, db: Database) -> list[dict[str, Any]]:
checks: list[dict[str, Any]] = []
db.init()
database_health = db.health()
checks.append(
{
"name": "database",
"ok": database_health["ok"],
"detail": database_health,
}
)
index_path = config.static_dir / "index.html"
checks.append(
{
"name": "static_panel",
"ok": index_path.exists(),
"detail": {"path": str(index_path)},
}
)
checks.append(
{
"name": "port",
"ok": _port_is_available(config.host, config.port),
"detail": {"host": config.host, "port": config.port},
}
)
return checks
def make_handler(config: ControllerConfig, db: Database):
class ControllerRequestHandler(BaseHTTPRequestHandler):
server_version = f"SystemTestController/{__version__}"
def log_message(self, fmt: str, *args: Any) -> None:
sys.stdout.write(
"%s - - [%s] %s\n"
% (self.client_address[0], self.log_date_time_string(), fmt % args)
)
def _send_json(self, status: int, payload: Any) -> None:
body = _json_bytes(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers()
self.wfile.write(body)
def _send_error_json(self, status: int, message: str, **extra: Any) -> None:
payload = {"ok": False, "error": message}
payload.update(extra)
self._send_json(status, payload)
def _read_json_body(self) -> dict[str, Any] | None:
length = int(self.headers.get("Content-Length") or "0")
if length <= 0:
return {}
raw_body = self.rfile.read(length)
try:
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body")
return None
if not isinstance(payload, dict):
self._send_error_json(HTTPStatus.BAD_REQUEST, "JSON body must be an object")
return None
return payload
def _auth_agent(self, agent_id: int) -> dict[str, Any] | None:
auth = self.headers.get("Authorization") or ""
prefix = "Bearer "
if not auth.startswith(prefix):
self._send_error_json(HTTPStatus.UNAUTHORIZED, "Missing Bearer token")
return None
token = auth[len(prefix) :].strip()
agent = db.validate_agent(agent_id, token)
if agent is None:
self._send_error_json(HTTPStatus.UNAUTHORIZED, "Invalid Bearer token")
return None
return agent
def _health_payload(self) -> dict[str, Any]:
db_health = db.health()
panel_path = config.static_dir / "index.html"
return {
"ok": db_health["ok"] and panel_path.exists(),
"service": "controller",
"version": __version__,
"name": config.controller_name,
"deployment_mode": config.deployment_mode,
"started_at": STARTED_AT,
"public_base_url": config.public_base_url,
"checks": {
"database": db_health,
"port": {"host": config.host, "port": config.port, "ok": True},
"static_panel": {"ok": panel_path.exists(), "path": str(panel_path)},
},
}
def _discovery_payload(self) -> dict[str, Any]:
host = self.headers.get("Host")
base_url = f"http://{host}" if host else config.public_base_url
return {
"service": "Agent-S-Controller",
"version": 1,
"base_url": base_url.rstrip("/"),
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"deployment_mode": config.deployment_mode,
}
def do_OPTIONS(self) -> None:
self.send_response(HTTPStatus.NO_CONTENT)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers()
def do_GET(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
if path == "/health":
self._send_json(HTTPStatus.OK, self._health_payload())
return
if path == "/api/v1/discovery":
self._send_json(HTTPStatus.OK, self._discovery_payload())
return
if path == "/api/dashboard":
self._send_json(HTTPStatus.OK, {"ok": True, **db.dashboard()})
return
if path == "/api/agents":
self._send_json(HTTPStatus.OK, {"ok": True, "agents": db.list_agents()})
return
if path in {"/api/tasks", "/api/v1/tasks"}:
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"tasks": db.list_tasks(
status=self._first_query(query, "status"),
plan_name=self._first_query(query, "plan_name"),
),
},
)
return
if path == "/api/config/model":
self._send_json(HTTPStatus.OK, {"ok": True, **db.get_model_config()})
return
if path == "/api/tools":
self._send_json(HTTPStatus.OK, {"ok": True, **db.get_tool_config()})
return
if path == "/api/metrics":
self._send_json(HTTPStatus.OK, {"ok": True, "metrics": db.metrics()})
return
if path == "/api/reports/summary":
self._send_json(HTTPStatus.OK, {"ok": True, "report": db.report_summary()})
return
if path in {"/", "/index.html"}:
self._send_static_file(config.static_dir / "index.html")
return
if path.startswith("/static/"):
requested = path.removeprefix("/static/").replace("/", "\\")
file_path = (config.static_dir / requested).resolve()
try:
file_path.relative_to(config.static_dir.resolve())
except ValueError:
self._send_error_json(HTTPStatus.NOT_FOUND, "Static file not found")
return
self._send_static_file(file_path)
return
self._send_error_json(HTTPStatus.NOT_FOUND, "Route not found")
def do_POST(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
payload = self._read_json_body()
if payload is None:
return
if path == "/api/agents/register":
self._handle_register(payload)
return
if path in {"/api/tasks", "/api/v1/tasks"}:
created = db.create_task(payload)
self._send_json(HTTPStatus.CREATED, {"ok": True, "task": created})
return
if path == "/api/tasks/import":
self._handle_import_tasks(payload)
return
if path == "/api/config/model":
try:
saved = db.save_model_config(payload)
except ValueError as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.OK, {"ok": True, **saved})
return
if path == "/api/models/test":
self._send_json(HTTPStatus.OK, {"ok": True, "test": self._test_model(payload)})
return
if path == "/api/sites/scan":
try:
result = self._scan_sites(payload)
except ValueError as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.OK, {"ok": True, **result})
return
if path == "/api/system-tests/start":
try:
result = self._start_system_test(payload)
except ValueError as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.CREATED, {"ok": True, **result})
return
if path == "/api/discovery/preview":
try:
result = self._preview_discovery(payload)
except ValueError as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.OK, {"ok": True, **result})
return
if path == "/api/discovery/import":
try:
result = self._import_discovery_tasks(payload)
except ValueError as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.CREATED, {"ok": True, **result})
return
if path == "/api/tools":
saved = db.save_tool_config(payload)
self._send_json(HTTPStatus.OK, {"ok": True, **saved})
return
parts = [part for part in path.split("/") if part]
if len(parts) == 4 and parts[:2] == ["api", "agents"] and parts[3] == "heartbeat":
self._handle_heartbeat(parts[2], payload)
return
if len(parts) == 5 and parts[:2] == ["api", "agents"] and parts[3:] == ["tasks", "claim"]:
self._handle_claim(parts[2])
return
if len(parts) == 5 and parts[:2] == ["api", "agents"] and parts[3:] == ["model", "chat-json"]:
self._handle_agent_model_chat(parts[2], payload)
return
if len(parts) == 6 and parts[:2] == ["api", "agents"] and parts[3] == "tasks" and parts[5] == "result":
self._handle_agent_task_result(parts[2], parts[4], payload)
return
if len(parts) == 4 and parts[:2] == ["api", "tasks"]:
self._handle_task_action(parts[2], parts[3], payload)
return
self._send_error_json(HTTPStatus.NOT_FOUND, "Route not found")
def _first_query(self, query: dict[str, list[str]], name: str) -> str | None:
values = query.get(name) or []
return values[0] if values else None
def _handle_register(self, payload: dict[str, Any]) -> None:
required = ["public_id", "machine_fingerprint"]
missing = [key for key in required if not payload.get(key)]
if missing:
self._send_error_json(
HTTPStatus.BAD_REQUEST,
"Missing required registration fields",
missing=missing,
)
return
try:
registration = db.register_agent(payload)
except AlreadyRegisteredError as exc:
self._send_error_json(
HTTPStatus.CONFLICT,
str(exc)
or "Agent public_id is already registered by another machine.",
public_id=payload.get("public_id"),
)
return
status = HTTPStatus.CREATED if registration.get("registered") else HTTPStatus.OK
self._send_json(status, {"ok": True, **registration})
def _handle_import_tasks(self, payload: dict[str, Any]) -> None:
try:
result = db.import_tasks(payload)
except (ValueError, json.JSONDecodeError) as exc:
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self._send_json(HTTPStatus.CREATED, {"ok": True, **result})
def _handle_heartbeat(self, agent_id_text: str, payload: dict[str, Any]) -> None:
try:
agent_id = int(agent_id_text)
except ValueError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id")
return
if self._auth_agent(agent_id) is None:
return
receipt = db.record_heartbeat(agent_id, payload)
self._send_json(HTTPStatus.OK, receipt)
def _handle_claim(self, agent_id_text: str) -> None:
try:
agent_id = int(agent_id_text)
except ValueError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id")
return
if self._auth_agent(agent_id) is None:
return
task = db.claim_task(agent_id)
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
def _handle_agent_model_chat(self, agent_id_text: str, payload: dict[str, Any]) -> None:
try:
agent_id = int(agent_id_text)
except ValueError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id")
return
if self._auth_agent(agent_id) is None:
return
messages = payload.get("messages")
if not isinstance(messages, list) or not messages:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing model messages")
return
if len(messages) > 20:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Too many model messages")
return
normalized_messages: list[dict[str, str]] = []
for message in messages:
if not isinstance(message, dict):
self._send_error_json(HTTPStatus.BAD_REQUEST, "Each model message must be an object")
return
role = str(message.get("role") or "").strip()
content = str(message.get("content") or "")
if role not in {"system", "user", "assistant"}:
self._send_error_json(HTTPStatus.BAD_REQUEST, f"Invalid model message role: {role}")
return
normalized_messages.append({"role": role, "content": content})
result = self._proxy_model_chat_json(
{
"messages": normalized_messages,
"temperature": payload.get("temperature"),
}
)
self._send_json(HTTPStatus.OK, result)
def _handle_agent_task_result(
self,
agent_id_text: str,
task_id_text: str,
payload: dict[str, Any],
) -> None:
try:
agent_id = int(agent_id_text)
task_id = int(task_id_text)
except ValueError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid id")
return
if self._auth_agent(agent_id) is None:
return
task = db.record_task_result(task_id, payload)
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
def _handle_task_action(
self,
task_id_text: str,
action: str,
payload: dict[str, Any],
) -> None:
try:
task_id = int(task_id_text)
except ValueError:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid task id")
return
if action == "assign":
agent_id = payload.get("agent_id")
task = db.assign_task(
task_id,
int(agent_id) if agent_id not in {None, ""} else None,
reason=payload.get("reason"),
)
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
return
if action == "abort":
task = db.update_task_status(task_id, "cancelled", reason=payload.get("reason") or "aborted by operator")
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
return
if action == "retry":
task = db.retry_task(task_id, reason=payload.get("reason"))
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
return
if action == "status":
status = str(payload.get("status") or "")
if not status:
self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing status")
return
task = db.update_task_status(task_id, status, reason=payload.get("reason"), payload=payload.get("result"))
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
return
if action == "result":
task = db.record_task_result(task_id, payload)
self._send_json(HTTPStatus.OK, {"ok": True, "task": task})
return
self._send_error_json(HTTPStatus.NOT_FOUND, "Task action not found")
def _scan_sites(self, payload: dict[str, Any]) -> dict[str, Any]:
target, port_payload = _extract_site_scan_target(payload)
ports = _parse_site_scan_ports(port_payload)
timeout = float(payload.get("timeout_seconds") or 0.8)
timeout = max(0.2, min(timeout, 5.0))
hosts = _site_scan_hosts(target)
probe_count = len(hosts) * len(ports)
if probe_count > MAX_SITE_SCAN_PROBES:
raise ValueError("Scan range is too large; reduce hosts or ports and try again.")
sites: list[dict[str, Any]] = []
worker_count = min(32, max(1, probe_count))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = [
executor.submit(_probe_site, host_entry, port, timeout)
for host_entry in hosts
for port in ports
]
for future in as_completed(futures):
result = future.result()
if result is not None:
sites.append(result)
sites.sort(key=lambda site: (site["ip"], site["port"], site["scheme"]))
return {
"target": target or "local",
"hosts": hosts,
"ports": ports,
"scanned": probe_count,
"count": len(sites),
"sites": sites,
}
def _start_system_test(self, payload: dict[str, Any]) -> dict[str, Any]:
stored = db.get_model_config(public=False)["value"]
target_url = _normalize_discovery_url(
payload.get("target_url")
or payload.get("url")
or stored.get("target_system_url")
)
parsed = urlparse(target_url)
display_name = str(payload.get("site_name") or parsed.netloc or target_url).strip()
plan_name = str(payload.get("plan_name") or f"系统测试 - {display_name}").strip()
if not plan_name:
plan_name = "系统测试"
try:
max_depth = int(payload.get("max_depth") or payload.get("discovery_max_depth") or 2)
except (TypeError, ValueError):
max_depth = 2
max_depth = max(1, min(max_depth, 5))
assigned_agent_id: int | None = None
raw_agent_id = payload.get("assigned_agent_id") or payload.get("agent_id")
if raw_agent_id not in {None, ""}:
try:
assigned_agent_id = int(raw_agent_id)
except (TypeError, ValueError) as exc:
raise ValueError("Invalid controlled-agent id.") from exc
root_id = str(
payload.get("external_id")
or _candidate_id(target_url, "system-test-root", datetime.now(timezone.utc).isoformat())
)
objective = (
"进入待测系统首页,观察页面可访问性、标题、链接、表单和主要入口;"
"发现可拆分的功能后自动创建层级化子任务并加入主控端任务队列。"
)
created = db.create_task(
{
"external_id": root_id,
"title": f"启动系统测试:{display_name}",
"plan_name": plan_name,
"module": "系统入口",
"parent_external_id": None,
"root_external_id": root_id,
"depth": 0,
"task_level": "site",
"risk_level": "low",
"priority": 10,
"failure_policy": "pause",
"max_retries": 2,
"timeout_seconds": int(payload.get("timeout_seconds") or 90),
"assigned_agent_id": assigned_agent_id,
"tags": ["system-test", "agent-discovery", "p0"],
"payload": {
"task_type": "smart_discovery_p0",
"execution_mode": "web",
"instruction": objective,
"objective": objective,
"target_url": target_url,
"source": "system_test_start",
"level": "site",
"depth": 0,
"risk_level": "low",
"allow_child_discovery": True,
"discovery_max_depth": max_depth,
"open_in_browser": True,
"capture_screenshot": True,
"hierarchy": {
"id": root_id,
"parent_id": None,
"root_id": root_id,
"depth": 0,
"level": "site",
},
},
}
)
try:
db.save_model_config({**stored, "target_system_url": target_url})
except ValueError:
pass
active_agents = [
agent
for agent in db.list_agents()
if agent.get("status") in {"online", "idle", "running", "registered"}
]
return {
"task": created,
"target_url": target_url,
"plan_name": plan_name,
"assigned_agent_id": assigned_agent_id,
"active_agent_count": len(active_agents),
"message": "System test root task created. A controlled agent will claim it from the queue.",
}
def _preview_discovery(self, payload: dict[str, Any]) -> dict[str, Any]:
stored = db.get_model_config(public=False)["value"]
target_url = _normalize_discovery_url(
payload.get("target_url")
or payload.get("url")
or stored.get("target_system_url")
)
timeout = float(payload.get("timeout_seconds") or stored.get("request_timeout_seconds") or 6)
timeout = max(1.0, min(timeout, 20.0))
page = _fetch_discovery_page(target_url, timeout)
return _build_discovery_candidates(target_url, page)
def _import_discovery_tasks(self, payload: dict[str, Any]) -> dict[str, Any]:
candidates = payload.get("tasks") or payload.get("candidates") or []
if not isinstance(candidates, list) or not candidates:
raise ValueError("No discovery tasks were selected.")
plan_name = str(payload.get("plan_name") or "智能探索 P0").strip() or "智能探索 P0"
target_url = str(payload.get("target_url") or "").strip()
created: list[dict[str, Any]] = []
id_map: dict[str, str] = {}
selected_parent_ids = {
str(item.get("parent_id"))
for item in candidates
if isinstance(item, dict) and item.get("parent_id")
}
for index, candidate in enumerate(candidates, start=1):
if not isinstance(candidate, dict):
continue
source_id = str(candidate.get("id") or _candidate_id(target_url, "selected", index))
parent_id = candidate.get("parent_id")
root_id = candidate.get("root_id") or source_id
level = str(candidate.get("level") or "case")
try:
depth = int(candidate.get("depth") or 0)
except (TypeError, ValueError):
depth = 0
allow_child_discovery = (
source_id not in selected_parent_ids
and level in {"site", "module"}
and depth < 2
)
mapped_parent_id = id_map.get(str(parent_id), parent_id) if parent_id else None
mapped_root_id = id_map.get(str(root_id), root_id)
task_payload = {
"task_type": "smart_discovery_p0",
"instruction": candidate.get("objective") or candidate.get("title"),
"objective": candidate.get("objective") or "",
"target_url": candidate.get("target_url") or target_url,
"source": candidate.get("source") or "discovery",
"level": level,
"depth": depth,
"risk_level": candidate.get("risk_level") or "low",
"allow_child_discovery": allow_child_discovery,
"discovery_max_depth": 2,
"preconditions": candidate.get("preconditions") or [],
"discovery_candidate": candidate,
"hierarchy": {
"id": source_id,
"parent_id": parent_id,
"root_id": root_id,
"depth": depth,
"level": level,
},
}
if candidate.get("fields"):
task_payload["fields"] = candidate.get("fields")
if candidate.get("method"):
task_payload["method"] = candidate.get("method")
created_task = db.create_task(
{
"external_id": source_id,
"title": candidate.get("title") or f"智能探索任务 {index}",
"plan_name": plan_name,
"module": candidate.get("module") or "",
"parent_external_id": mapped_parent_id,
"root_external_id": mapped_root_id,
"depth": depth,
"task_level": level,
"risk_level": candidate.get("risk_level") or "low",
"priority": candidate.get("priority") or 100,
"failure_policy": candidate.get("failure_policy") or "skip",
"max_retries": candidate.get("max_retries") or 2,
"timeout_seconds": candidate.get("timeout_seconds") or 60,
"tags": candidate.get("tags") or ["smart-discovery", "p0"],
"payload": task_payload,
}
)
id_map[source_id] = str(created_task.get("external_id") or source_id)
created.append(created_task)
if not created:
raise ValueError("No valid discovery tasks were selected.")
return {"count": len(created), "tasks": created}
def _proxy_model_chat_json(self, payload: dict[str, Any]) -> dict[str, Any]:
stored = db.get_model_config(public=False)["value"]
base_url = str(stored.get("vllm_base_url") or "").strip()
model = str(stored.get("vllm_model") or "").strip()
api_key = str(stored.get("api_key") or "")
if not base_url or not model:
return {
"ok": False,
"source": "controller_proxy",
"error": "Model config is incomplete on controller.",
"duration_seconds": 0,
}
try:
timeout = float(stored.get("request_timeout_seconds") or 30)
except (TypeError, ValueError):
timeout = 30
timeout = max(1.0, min(timeout, 300.0))
temperature = payload.get("temperature")
try:
temperature_value = float(temperature) if temperature is not None else float(stored.get("temperature") or 0)
except (TypeError, ValueError):
temperature_value = 0
url = _chat_completions_url(base_url)
request_payload = {
"model": model,
"messages": payload.get("messages") or [],
"temperature": temperature_value,
}
body = json.dumps(request_payload, ensure_ascii=False).encode("utf-8")
headers = {
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = Request(url, data=body, headers=headers, method="POST")
started = datetime.now(timezone.utc)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read(4_000_000)
except HTTPError as exc:
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
error_body = exc.read().decode("utf-8", errors="replace")
return {
"ok": False,
"source": "controller_proxy",
"url": url,
"duration_seconds": round(elapsed, 3),
"error": f"{url} -> HTTP {exc.code}: {error_body or exc.reason}",
}
except (URLError, TimeoutError, OSError) as exc:
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
return {
"ok": False,
"source": "controller_proxy",
"url": url,
"duration_seconds": round(elapsed, 3),
"error": f"{url} -> {exc}",
}
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except json.JSONDecodeError as exc:
return {
"ok": False,
"source": "controller_proxy",
"url": url,
"duration_seconds": round(elapsed, 3),
"error": f"Invalid model response: {exc}",
}
if not isinstance(data, dict):
return {
"ok": False,
"source": "controller_proxy",
"url": url,
"duration_seconds": round(elapsed, 3),
"error": "Invalid model response: root JSON is not an object.",
}
content = _completion_content(data)
return {
"ok": True,
"source": "controller_proxy",
"url": url,
"duration_seconds": round(elapsed, 3),
"content": content,
"parsed": _parse_json_object(content),
}
def _test_model(self, payload: dict[str, Any]) -> dict[str, Any]:
stored = db.get_model_config(public=False)["value"]
base_url = str(payload.get("vllm_base_url") or stored.get("vllm_base_url") or "").rstrip("/")
api_key = str(payload.get("api_key") or stored.get("api_key") or "")
timeout = float(payload.get("request_timeout_seconds") or stored.get("request_timeout_seconds") or 5)
if not base_url:
return {"ok": False, "message": "Missing vLLM/OpenAI-compatible base URL"}
url = base_url
if url.endswith("/v1"):
url = f"{url}/models"
elif not url.endswith("/models"):
url = f"{url}/v1/models"
headers = {"Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = Request(url, headers=headers)
started = datetime.now(timezone.utc)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read(512_000)
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
data = json.loads(raw.decode("utf-8")) if raw else {}
models = data.get("data") if isinstance(data, dict) else None
return {
"ok": True,
"url": url,
"elapsed_seconds": round(elapsed, 3),
"models": models[:20] if isinstance(models, list) else data,
}
except HTTPError as exc:
return {"ok": False, "url": url, "message": f"HTTP {exc.code}: {exc.reason}"}
except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
return {"ok": False, "url": url, "message": str(exc)}
def _send_static_file(self, file_path: Path) -> None:
if not file_path.exists() or not file_path.is_file():
self._send_error_json(HTTPStatus.NOT_FOUND, "Static file not found")
return
body = file_path.read_bytes()
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return ControllerRequestHandler
def main() -> None:
config = ControllerConfig.from_env()
db = Database(config.database_path, config.agent_stale_after_seconds)
checks = run_preflight(config, db)
print("System Test Controller preflight:")
for check in checks:
status = "OK" if check["ok"] else "FAIL"
print(f" [{status}] {check['name']}: {check['detail']}")
failed = [check for check in checks if not check["ok"]]
if failed:
print("Controller did not start because preflight checks failed.", file=sys.stderr)
raise SystemExit(2)
handler = make_handler(config, db)
server = ThreadingHTTPServer((config.host, config.port), handler)
print(f"Controller started at {config.public_base_url}")
print(f"Health: {config.public_base_url}/health")
print(f"Panel: {config.public_base_url}/")
if _browser_autostart_enabled():
print("Opening controller panel in browser...")
_open_panel_later(config.public_base_url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nController stopping...")
finally:
server.server_close()