2026-08-26 17:16:02 +08:00
|
|
|
|
"""ModelHub XC 适配智能体安全探针 v1.0.3。
|
2026-08-24 13:13:11 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
默认只做一次只读鉴权检查。只有显式设置 ALLOW_SUBMIT=1,且鉴权检查成功后,
|
|
|
|
|
|
才会调用 build-config 和 task/add;每个进程生命周期最多提交一次。
|
2026-08-24 11:12:07 +08:00
|
|
|
|
"""
|
2026-08-26 17:16:02 +08:00
|
|
|
|
|
|
|
|
|
|
import copy
|
2026-08-24 11:12:07 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import signal
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
import urllib.error
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
2026-08-26 17:16:02 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Callable, Mapping, Optional, Tuple
|
|
|
|
|
|
|
2026-08-24 11:12:07 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
VERSION = "1.0.3"
|
|
|
|
|
|
MAIN = os.getenv("MAIN_HOST", "https://modelhub.org.cn").rstrip("/")
|
|
|
|
|
|
STRATEGY_ID = os.getenv("STRATEGY_ID", "").strip()
|
2026-08-24 11:12:07 +08:00
|
|
|
|
PORT = int(os.getenv("PORT", "8080"))
|
2026-08-26 17:16:02 +08:00
|
|
|
|
ALLOW_SUBMIT = os.getenv("ALLOW_SUBMIT", "").strip().lower() in {"1", "true", "yes", "on"}
|
2026-08-24 11:12:07 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
TEST_MODEL = os.getenv(
|
|
|
|
|
|
"TEST_MODEL",
|
|
|
|
|
|
"https://www.modelscope.cn/models/mradermacher/TrialSpace-1225-GGUF",
|
2026-08-24 13:13:11 +08:00
|
|
|
|
)
|
2026-08-26 17:16:02 +08:00
|
|
|
|
GPU = os.getenv("GPU", "MetaX_c-500")
|
|
|
|
|
|
FW = os.getenv("FW", "vllm")
|
|
|
|
|
|
TT = os.getenv("TT", "text-generation")
|
2026-08-24 13:13:11 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
_TOKEN_PLACEHOLDERS = {"tmp", "placeholder", "changeme", "change-me", "example", "test"}
|
2026-08-24 11:12:07 +08:00
|
|
|
|
shutdown = threading.Event()
|
2026-08-26 17:16:02 +08:00
|
|
|
|
STATE_LOCK = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _initial_state() -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"version": VERSION,
|
|
|
|
|
|
"phase": "starting",
|
|
|
|
|
|
"done": False,
|
|
|
|
|
|
"auth_ready": False,
|
|
|
|
|
|
"submit_enabled": ALLOW_SUBMIT,
|
|
|
|
|
|
"token_source": "none",
|
|
|
|
|
|
"results": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
STATE = _initial_state()
|
2026-08-24 11:12:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
|
|
|
|
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
def _set_state(**changes) -> None:
|
|
|
|
|
|
with STATE_LOCK:
|
|
|
|
|
|
STATE.update(changes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snapshot() -> dict:
|
|
|
|
|
|
with STATE_LOCK:
|
|
|
|
|
|
return copy.deepcopy(STATE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reset_state() -> None:
|
|
|
|
|
|
"""Reset process state; kept separate to make the probe deterministically testable."""
|
|
|
|
|
|
with STATE_LOCK:
|
|
|
|
|
|
STATE.clear()
|
|
|
|
|
|
STATE.update(_initial_state())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _valid_token(value: str) -> bool:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
return bool(value) and value.lower() not in _TOKEN_PLACEHOLDERS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_token(
|
|
|
|
|
|
env: Optional[Mapping[str, str]] = None,
|
|
|
|
|
|
read_text: Optional[Callable[[str], str]] = None,
|
|
|
|
|
|
) -> Tuple[str, str, Optional[str]]:
|
|
|
|
|
|
"""Resolve a credential without exposing its value or length.
|
2026-08-24 12:28:57 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
Precedence is XC_TOKEN_FILE, XC_TOKEN, then the legacy
|
|
|
|
|
|
EXTERNAL_SERVICE_TOKEN. An explicitly configured but unreadable token file
|
|
|
|
|
|
is treated as an error instead of silently falling back.
|
|
|
|
|
|
"""
|
|
|
|
|
|
values = os.environ if env is None else env
|
|
|
|
|
|
file_reader = read_text or (lambda path: Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
|
token_file = values.get("XC_TOKEN_FILE", "").strip()
|
|
|
|
|
|
if token_file:
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = file_reader(token_file).strip()
|
|
|
|
|
|
except (OSError, UnicodeError):
|
|
|
|
|
|
return "", "XC_TOKEN_FILE", "token_file_unreadable"
|
|
|
|
|
|
if _valid_token(value):
|
|
|
|
|
|
return value, "XC_TOKEN_FILE", None
|
|
|
|
|
|
return "", "XC_TOKEN_FILE", "token_missing_or_placeholder"
|
2026-08-24 12:28:57 +08:00
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
for name in ("XC_TOKEN", "EXTERNAL_SERVICE_TOKEN"):
|
|
|
|
|
|
value = values.get(name, "").strip()
|
|
|
|
|
|
if _valid_token(value):
|
|
|
|
|
|
return value, name, None
|
|
|
|
|
|
if value:
|
|
|
|
|
|
return "", name, "token_missing_or_placeholder"
|
|
|
|
|
|
return "", "none", "token_missing_or_placeholder"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _req(method: str, path: str, token: str, body=None, timeout: int = 30) -> dict:
|
|
|
|
|
|
headers = {"Accept": "application/json", "Xc-Token": token}
|
|
|
|
|
|
data = body.encode("utf-8") if isinstance(body, str) else body
|
|
|
|
|
|
if data is not None:
|
|
|
|
|
|
headers["Content-Type"] = "application/json"
|
2026-08-24 13:13:11 +08:00
|
|
|
|
req = urllib.request.Request(MAIN + path, data=data, method=method, headers=headers)
|
2026-08-24 11:12:07 +08:00
|
|
|
|
try:
|
2026-08-26 17:16:02 +08:00
|
|
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
|
|
|
|
text = response.read().decode("utf-8", "ignore")
|
|
|
|
|
|
status = response.status
|
|
|
|
|
|
except urllib.error.HTTPError as error:
|
|
|
|
|
|
text = error.read().decode("utf-8", "ignore")
|
|
|
|
|
|
status = error.code
|
|
|
|
|
|
except Exception as error: # noqa: BLE001 - network diagnostics are returned safely
|
|
|
|
|
|
return {"http": -1, "error": type(error).__name__}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return {"http": status, "code": None, "message": "non_json_response"}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"http": status,
|
|
|
|
|
|
"code": payload.get("code"),
|
|
|
|
|
|
"message": str(payload.get("message") or "")[:100],
|
|
|
|
|
|
"data": payload.get("data"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _api_ok(result: dict) -> bool:
|
2026-08-24 11:12:07 +08:00
|
|
|
|
try:
|
2026-08-26 17:16:02 +08:00
|
|
|
|
status = int(result.get("http", -1))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return 200 <= status < 300 and result.get("code") in {0, "0"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _result_summary(result: dict) -> dict:
|
|
|
|
|
|
summary = {"http": result.get("http"), "code": result.get("code")}
|
|
|
|
|
|
if result.get("message"):
|
|
|
|
|
|
summary["message"] = result["message"]
|
|
|
|
|
|
if result.get("error"):
|
|
|
|
|
|
summary["error"] = result["error"]
|
|
|
|
|
|
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task_id(data) -> Optional[str]:
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
for key in ("id", "taskId", "task_id"):
|
|
|
|
|
|
if data.get(key) is not None:
|
|
|
|
|
|
return str(data[key])
|
|
|
|
|
|
return None
|
2026-08-24 11:12:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def probe() -> None:
|
2026-08-26 17:16:02 +08:00
|
|
|
|
"""Run one guarded probe; no retry loop and at most one task/add call."""
|
|
|
|
|
|
token, source, token_error = _resolve_token()
|
|
|
|
|
|
_set_state(token_source=source, submit_enabled=ALLOW_SUBMIT)
|
|
|
|
|
|
|
|
|
|
|
|
if not STRATEGY_ID:
|
|
|
|
|
|
_set_state(phase="blocked_missing_strategy_id", done=True)
|
|
|
|
|
|
log("probe blocked: STRATEGY_ID is missing")
|
|
|
|
|
|
return
|
|
|
|
|
|
if token_error:
|
|
|
|
|
|
_set_state(phase="blocked_missing_auth", done=True, results={"auth": token_error})
|
|
|
|
|
|
log(f"probe blocked: usable credential is unavailable (source={source})")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
_set_state(phase="checking_auth")
|
|
|
|
|
|
page = _req("GET", "/api/adapt/task/page?current=1&pageSize=1", token)
|
|
|
|
|
|
results = {"task_page": _result_summary(page)}
|
|
|
|
|
|
if not _api_ok(page):
|
|
|
|
|
|
_set_state(phase="auth_failed", done=True, results=results)
|
|
|
|
|
|
log("read-only auth check failed: " + json.dumps(results["task_page"], ensure_ascii=False))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
_set_state(auth_ready=True, results=results)
|
|
|
|
|
|
log(f"read-only auth check passed (source={source})")
|
|
|
|
|
|
if not ALLOW_SUBMIT:
|
|
|
|
|
|
_set_state(phase="read_only_complete", done=True)
|
|
|
|
|
|
log("probe complete in read-only mode; task/add was not called")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
_set_state(phase="preparing_submission")
|
|
|
|
|
|
build_path = f"/api/adapt/task/build-config?gpuType={GPU}&framework={FW}&taskType={TT}"
|
|
|
|
|
|
build_config = _req("POST", build_path, token)
|
|
|
|
|
|
results["build_config"] = _result_summary(build_config)
|
|
|
|
|
|
config = build_config.get("data")
|
|
|
|
|
|
if not _api_ok(build_config) or not isinstance(config, str) or not config:
|
|
|
|
|
|
_set_state(phase="build_config_failed", done=True, results=results)
|
|
|
|
|
|
log("build-config failed; task/add was not called")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
body = json.dumps(
|
|
|
|
|
|
{
|
|
|
|
|
|
"modelAddress": TEST_MODEL,
|
|
|
|
|
|
"taskType": TT,
|
|
|
|
|
|
"targetGpu": GPU,
|
|
|
|
|
|
"framework": FW,
|
|
|
|
|
|
"strategyId": STRATEGY_ID,
|
|
|
|
|
|
"configParams": config,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
task_add = _req("POST", "/api/adapt/task/add", token, body)
|
|
|
|
|
|
results["task_add"] = _result_summary(task_add)
|
|
|
|
|
|
task_id = _task_id(task_add.get("data"))
|
|
|
|
|
|
if task_id:
|
|
|
|
|
|
results["task_add"]["task_id"] = task_id
|
|
|
|
|
|
|
|
|
|
|
|
phase = "submit_complete" if _api_ok(task_add) else "submit_failed"
|
|
|
|
|
|
_set_state(phase=phase, done=True, results=results)
|
|
|
|
|
|
log("single task/add attempt finished: " + json.dumps(results["task_add"], ensure_ascii=False))
|
2026-08-24 11:12:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
|
def _json(self, obj: dict, status: int = 200) -> None:
|
2026-08-26 17:16:02 +08:00
|
|
|
|
payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
2026-08-24 11:12:07 +08:00
|
|
|
|
self.send_response(status)
|
2026-08-26 17:16:02 +08:00
|
|
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
2026-08-24 11:12:07 +08:00
|
|
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
|
|
|
|
self.end_headers()
|
|
|
|
|
|
self.wfile.write(payload)
|
|
|
|
|
|
|
|
|
|
|
|
def do_GET(self) -> None:
|
|
|
|
|
|
if self.path == "/health":
|
2026-08-26 17:16:02 +08:00
|
|
|
|
self._json({"status": "ok", "version": VERSION})
|
2026-08-24 11:12:07 +08:00
|
|
|
|
return
|
2026-08-26 17:16:02 +08:00
|
|
|
|
if self.path in {"/", "/status"}:
|
|
|
|
|
|
self._json({"name": "huni-probe-agent", "probe": _snapshot()})
|
2026-08-24 11:12:07 +08:00
|
|
|
|
return
|
|
|
|
|
|
self._json({"error": "not found"}, 404)
|
|
|
|
|
|
|
2026-08-26 17:16:02 +08:00
|
|
|
|
def log_message(self, *_args) -> None:
|
2026-08-24 11:12:07 +08:00
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_signal(signum: int, _frame) -> None:
|
|
|
|
|
|
shutdown.set()
|
|
|
|
|
|
log(f"received signal {signum}, shutting down")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
|
|
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
|
|
|
|
threading.Thread(target=probe, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
|
|
|
|
|
server.timeout = 1
|
2026-08-26 17:16:02 +08:00
|
|
|
|
log(f"probe agent v{VERSION} listening on 0.0.0.0:{PORT} (submit_enabled={ALLOW_SUBMIT})")
|
2026-08-24 11:12:07 +08:00
|
|
|
|
while not shutdown.is_set():
|
|
|
|
|
|
server.handle_request()
|
|
|
|
|
|
server.server_close()
|
|
|
|
|
|
log("stopped")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|