| |
| """Run a local browser UI for scanning AI agent skills with Vigil.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import base64 |
| import hashlib |
| import json |
| import mimetypes |
| import os |
| import platform |
| import shutil |
| import subprocess |
| import tempfile |
| import time |
| import tarfile |
| import webbrowser |
| import zipfile |
| from dataclasses import dataclass |
| from http import HTTPStatus |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| from pathlib import Path, PurePosixPath |
| from typing import Any |
| from urllib.parse import urlparse |
| from urllib.request import Request, urlopen |
|
|
|
|
| ROOT = Path(__file__).resolve().parent |
| REPOSITORY_URL = os.environ.get("VIGIL_REPOSITORY_URL", "https://huggingface.co/turenlabs/Vigil") |
| MODEL_URL = f"{REPOSITORY_URL}/resolve/main/compact-model.onnx" |
| METADATA_URL = MODEL_URL + ".json" |
| MODEL_SHA256 = "a56667baba56811b35dd7dffc75270f8c0d3f42de88f35dd198666a663e17f1e" |
| METADATA_SHA256 = "9c90f3bc1869f77452ed4f1cec1cdb17ffac5e5a20b229060432d6c9596f26db" |
| RUNTIME_VERSION = "v0.9.0-beta.3" |
| RUNTIMES = { |
| ("darwin", "arm64"): ( |
| "vigil-compact-darwin-arm64.tar.gz", |
| "b47164c9e7db7cdc199f5212e90c9865202f0c54fd05fe9225bd9dda4d170436", |
| ), |
| ("linux", "amd64"): ( |
| "vigil-compact-linux-amd64.tar.gz", |
| "d4479903615788ebd1c1217318070cbb202639d5993099d9f9eece3d7a256af6", |
| ), |
| ("linux", "arm64"): ( |
| "vigil-compact-linux-arm64.tar.gz", |
| "a344dac51a6a2691061494ae5ea24e5f8edab7a468d3ee9fad5efd14986b95d2", |
| ), |
| ("windows", "amd64"): ( |
| "vigil-compact-windows-amd64.zip", |
| "2c5e65696b6704416b9fdbbd63d1079a6af5c3da49e7242541da17c5d412164f", |
| ), |
| ("windows", "arm64"): ( |
| "vigil-compact-windows-arm64.zip", |
| "064d7243b71e735a1e9169a25bdda1e08039bb1080e343d19dec500c1e9ceff5", |
| ), |
| } |
| MAX_REQUEST = 32 * 1024 * 1024 |
| MAX_FILE = 4 * 1024 * 1024 |
| MAX_FILES = 512 |
|
|
|
|
| def sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def download(url: str, destination: Path, expected_hash: str) -> None: |
| if destination.is_file() and sha256(destination) == expected_hash: |
| return |
| destination.parent.mkdir(parents=True, exist_ok=True) |
| temporary = destination.with_suffix(destination.suffix + ".download") |
| request = Request(url, headers={"User-Agent": "vigil-local-harness/1"}) |
| try: |
| with urlopen(request, timeout=60) as response, temporary.open("wb") as output: |
| shutil.copyfileobj(response, output) |
| actual = sha256(temporary) |
| if actual != expected_hash: |
| raise RuntimeError(f"download hash mismatch: {actual}") |
| temporary.replace(destination) |
| finally: |
| temporary.unlink(missing_ok=True) |
|
|
|
|
| def runtime_library_name(system: str | None = None) -> str: |
| system = system or platform.system().lower() |
| if system == "darwin": |
| return "libonnxruntime.dylib" |
| if system == "windows": |
| return "onnxruntime.dll" |
| return "libonnxruntime.so" |
|
|
|
|
| def device() -> tuple[str, str]: |
| system = platform.system().lower() |
| machine = platform.machine().lower() |
| architectures = { |
| "x86_64": "amd64", |
| "amd64": "amd64", |
| "aarch64": "arm64", |
| "arm64": "arm64", |
| } |
| architecture = architectures.get(machine, machine) |
| if (system, architecture) not in RUNTIMES: |
| raise RuntimeError( |
| f"This device is not supported yet: {platform.system()} {platform.machine()}" |
| ) |
| return system, architecture |
|
|
|
|
| def safe_archive_path(value: str) -> Path: |
| path = PurePosixPath(value.replace("\\", "/")) |
| if path.is_absolute() or not path.parts or ".." in path.parts: |
| raise RuntimeError(f"unsafe runtime archive path: {value}") |
| return Path(*path.parts) |
|
|
|
|
| def extract_runtime(archive: Path, destination: Path) -> None: |
| temporary = destination.with_name(destination.name + ".installing") |
| if temporary.exists(): |
| shutil.rmtree(temporary) |
| temporary.mkdir(parents=True) |
| try: |
| if archive.suffix == ".zip": |
| with zipfile.ZipFile(archive) as bundle: |
| for member in bundle.infolist(): |
| relative = safe_archive_path(member.filename) |
| if member.is_dir(): |
| (temporary / relative).mkdir(parents=True, exist_ok=True) |
| continue |
| target = temporary / relative |
| target.parent.mkdir(parents=True, exist_ok=True) |
| with bundle.open(member) as source, target.open("wb") as output: |
| shutil.copyfileobj(source, output) |
| else: |
| with tarfile.open(archive, "r:gz") as bundle: |
| for member in bundle.getmembers(): |
| relative = safe_archive_path(member.name) |
| if member.issym() or member.islnk() or member.isdev(): |
| raise RuntimeError(f"unsupported runtime archive entry: {member.name}") |
| if member.isdir(): |
| (temporary / relative).mkdir(parents=True, exist_ok=True) |
| continue |
| if not member.isfile(): |
| continue |
| source = bundle.extractfile(member) |
| if source is None: |
| raise RuntimeError(f"could not extract runtime file: {member.name}") |
| target = temporary / relative |
| target.parent.mkdir(parents=True, exist_ok=True) |
| with source, target.open("wb") as output: |
| shutil.copyfileobj(source, output) |
| target.chmod(member.mode & 0o777) |
| if destination.exists(): |
| shutil.rmtree(destination) |
| temporary.replace(destination) |
| finally: |
| if temporary.exists(): |
| shutil.rmtree(temporary) |
|
|
|
|
| def install_runtime(runtime_dir: Path) -> tuple[Path, Path]: |
| system, architecture = device() |
| archive_name, archive_hash = RUNTIMES[(system, architecture)] |
| package_name = archive_name.removesuffix(".tar.gz").removesuffix(".zip") |
| install_root = runtime_dir / RUNTIME_VERSION / package_name |
| binary_name = "vigil-compact.exe" if system == "windows" else "vigil-compact" |
| binary = install_root / package_name / binary_name |
| library = install_root / package_name / runtime_library_name(system) |
| if binary.is_file() and library.is_file(): |
| return binary, library |
|
|
| runtime_dir.mkdir(parents=True, exist_ok=True) |
| archive = runtime_dir / RUNTIME_VERSION / archive_name |
| url = f"{REPOSITORY_URL}/resolve/main/runtime/{RUNTIME_VERSION}/{archive_name}" |
| print(f"Installing the Vigil runtime for {system} {architecture}...", flush=True) |
| download(url, archive, archive_hash) |
| extract_runtime(archive, install_root) |
| if not binary.is_file() or not library.is_file(): |
| raise RuntimeError("the installed runtime package is incomplete") |
| if system != "windows": |
| binary.chmod(binary.stat().st_mode | 0o111) |
| return binary, library |
|
|
|
|
| @dataclass(frozen=True) |
| class Config: |
| binary: Path |
| runtime_library: Path |
| model: Path |
| metadata: Path |
| timeout: float |
|
|
| def missing(self) -> list[str]: |
| pairs = ( |
| ("vigil-compact binary", self.binary), |
| ("ONNX Runtime library", self.runtime_library), |
| ("ONNX model", self.model), |
| ("model metadata", self.metadata), |
| ) |
| return [label for label, path in pairs if not path.is_file()] |
|
|
|
|
| def resolve_config(args: argparse.Namespace) -> Config: |
| model_dir = Path(args.model_dir).expanduser().resolve() |
| model = model_dir / "compact-model.onnx" |
| metadata = model_dir / "compact-model.onnx.json" |
| download(MODEL_URL, model, MODEL_SHA256) |
| download(METADATA_URL, metadata, METADATA_SHA256) |
|
|
| binary_value = args.binary or os.environ.get("VIGIL_COMPACT_BIN") |
| library_value = ( |
| args.runtime_lib |
| or os.environ.get("VIGIL_COMPACT_RUNTIME_LIB") |
| or os.environ.get("ONNXRUNTIME_LIB") |
| ) |
| if binary_value and library_value: |
| binary = Path(binary_value).expanduser().resolve() |
| library = Path(library_value).expanduser().resolve() |
| else: |
| binary, library = install_runtime(Path(args.runtime_dir).expanduser().resolve()) |
|
|
| metadata_value = json.loads(metadata.read_text(encoding="utf-8")) |
| if metadata_value["model"]["sha256"] != MODEL_SHA256: |
| raise RuntimeError("metadata does not bind the downloaded model") |
| return Config(binary, library, model, metadata, max(1.0, args.timeout)) |
|
|
|
|
| def safe_relative_path(value: str) -> Path: |
| path = PurePosixPath(value.replace("\\", "/")) |
| if path.is_absolute() or not path.parts or ".." in path.parts: |
| raise ValueError("invalid uploaded path") |
| clean = [part for part in path.parts if part not in ("", ".")] |
| if not clean: |
| raise ValueError("invalid uploaded path") |
| return Path(*clean) |
|
|
|
|
| def normalize_files(files: list[tuple[Path, bytes]]) -> list[tuple[Path, bytes]]: |
| if len(files) == 1 and files[0][0].name.casefold() == "skill.md": |
| return [(Path("SKILL.md"), files[0][1])] |
| while not any(path.as_posix().casefold() == "skill.md" for path, _ in files): |
| parts = [path.parts for path, _ in files] |
| if any(len(value) < 2 for value in parts): |
| break |
| prefix = parts[0][0] |
| if any(value[0] != prefix for value in parts): |
| break |
| files = [(Path(*path.parts[1:]), data) for path, data in files] |
| if not any(path.as_posix().casefold() == "skill.md" for path, _ in files): |
| raise ValueError("the package must contain a top-level SKILL.md") |
| return files |
|
|
|
|
| def decode_files(body: bytes) -> list[tuple[Path, bytes]]: |
| payload = json.loads(body.decode("utf-8")) |
| items = payload.get("files") if isinstance(payload, dict) else None |
| if not isinstance(items, list) or not items or len(items) > MAX_FILES: |
| raise ValueError("choose between 1 and 512 files") |
| decoded: list[tuple[Path, bytes]] = [] |
| seen: set[str] = set() |
| for item in items: |
| if not isinstance(item, dict) or not isinstance(item.get("path"), str): |
| raise ValueError("invalid uploaded file") |
| path = safe_relative_path(item["path"]) |
| key = path.as_posix().casefold() |
| if key in seen: |
| raise ValueError("duplicate uploaded path") |
| seen.add(key) |
| try: |
| data = base64.b64decode(item.get("data", ""), validate=True) |
| except Exception as exc: |
| raise ValueError("invalid uploaded file data") from exc |
| if len(data) > MAX_FILE: |
| raise ValueError("an uploaded file exceeds 4 MiB") |
| decoded.append((path, data)) |
| return normalize_files(decoded) |
|
|
|
|
| def scan(config: Config, files: list[tuple[Path, bytes]]) -> tuple[dict[str, Any], float]: |
| if config.missing(): |
| raise RuntimeError("runtime_not_ready") |
| started = time.perf_counter() |
| with tempfile.TemporaryDirectory(prefix="vigil-harness-") as temporary: |
| root = Path(temporary) |
| for relative, data in files: |
| destination = root / relative |
| destination.parent.mkdir(parents=True, exist_ok=True) |
| destination.write_bytes(data) |
| command = [ |
| str(config.binary), "--require-model", |
| "--model", str(config.model), |
| "--metadata", str(config.metadata), |
| "--runtime-lib", str(config.runtime_library), |
| "--format", "json", str(root), |
| ] |
| completed = subprocess.run( |
| command, stdin=subprocess.DEVNULL, capture_output=True, text=True, |
| timeout=config.timeout, check=False, |
| ) |
| output = completed.stdout if completed.returncode == 0 else completed.stderr |
| lines = [line for line in output.splitlines() if line.strip()] |
| if not lines: |
| raise RuntimeError("scanner_returned_no_result") |
| result = json.loads(lines[-1]) |
| if completed.returncode != 0 or result.get("schema_version") != "vigil.compact-score.v1": |
| raise RuntimeError(str(result.get("error_code", "scan_failed"))) |
| return result, (time.perf_counter() - started) * 1000 |
|
|
|
|
| class Handler(BaseHTTPRequestHandler): |
| server_version = "VigilLocalHarness/1" |
|
|
| @property |
| def config(self) -> Config: |
| return self.server.config |
|
|
| def send_json(self, value: Any, status: HTTPStatus = HTTPStatus.OK) -> None: |
| data = json.dumps(value, separators=(",", ":")).encode() |
| self.send_response(status) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| self.send_header("Content-Length", str(len(data))) |
| self.send_header("Cache-Control", "no-store") |
| self.end_headers() |
| self.wfile.write(data) |
|
|
| def do_GET(self) -> None: |
| route = urlparse(self.path).path |
| if route == "/api/config": |
| self.send_json({ |
| "ready": not self.config.missing(), |
| "missing": self.config.missing(), |
| "model_sha256": sha256(self.config.model), |
| }) |
| return |
| filename = "index.html" if route in ("/", "/index.html") else route.lstrip("/") |
| if filename not in {"index.html", "app.js", "styles.css"}: |
| self.send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND) |
| return |
| data = (ROOT / filename).read_bytes() |
| content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" |
| self.send_response(HTTPStatus.OK) |
| self.send_header("Content-Type", content_type) |
| self.send_header("Content-Length", str(len(data))) |
| self.send_header("Cache-Control", "no-store") |
| self.end_headers() |
| self.wfile.write(data) |
|
|
| def do_POST(self) -> None: |
| if urlparse(self.path).path != "/api/scan": |
| self.send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND) |
| return |
| try: |
| length = int(self.headers.get("Content-Length", "-1")) |
| if length < 0 or length > MAX_REQUEST: |
| raise ValueError("request exceeds 32 MiB") |
| result, elapsed = scan(self.config, decode_files(self.rfile.read(length))) |
| result["elapsed_ms"] = round(elapsed, 1) |
| self.send_json({"ok": True, "result": result}) |
| except RuntimeError as exc: |
| self.send_json({"error": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE) |
| except (ValueError, OSError, json.JSONDecodeError) as exc: |
| self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--host", default="127.0.0.1") |
| parser.add_argument("--port", type=int, default=8787) |
| parser.add_argument("--bin", dest="binary", help="path to vigil-compact") |
| parser.add_argument("--runtime-lib", help="path to the ONNX Runtime shared library") |
| parser.add_argument("--model-dir", default=str(ROOT / ".model")) |
| parser.add_argument("--runtime-dir", default=str(ROOT / ".runtime")) |
| parser.add_argument("--timeout", type=float, default=30.0) |
| parser.add_argument("--no-open", action="store_true", help="do not open a browser") |
| args = parser.parse_args() |
| config = resolve_config(args) |
| server = ThreadingHTTPServer((args.host, args.port), Handler) |
| server.config = config |
| print(f"Vigil local harness: http://{args.host}:{args.port}", flush=True) |
| if config.missing(): |
| print("Runtime setup needed: " + ", ".join(config.missing()), flush=True) |
| if not args.no_open: |
| webbrowser.open(f"http://{args.host}:{args.port}") |
| try: |
| server.serve_forever() |
| except KeyboardInterrupt: |
| pass |
| finally: |
| server.server_close() |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|