Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # | |
| # CLI: hit GhostExec HTTP endpoints (live URL or --local in-process app). | |
| # | |
| # uv run python scripts/http_endpoint_smoke.py --local | |
| # uv run python scripts/http_endpoint_smoke.py --url http://127.0.0.1:8000 | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| import urllib.error | |
| import urllib.request | |
| from typing import Any | |
| from urllib.parse import urljoin | |
| ROOT = __import__("pathlib").Path(__file__).resolve().parents[1] | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| def _print_curl(base: str) -> None: | |
| print("# --- copy/paste (bash) ---") | |
| for method, path in [ | |
| ("GET", "/health"), | |
| ("GET", "/metadata"), | |
| ("GET", "/state"), | |
| ("GET", "/schema"), | |
| ("GET", "/openapi.json"), | |
| ]: | |
| print(f"curl -sS -X {method} '{base.rstrip('/')}{path}' | head -c 200 && echo") | |
| print( | |
| "curl -sS -X POST '{base}/reset' -H 'Content-Type: application/json' -d '{{}}' | head -c 300 && echo".format( | |
| base=base.rstrip("/") | |
| ) | |
| ) | |
| print( | |
| "curl -sS -X POST '{base}/step' -H 'Content-Type: application/json' " | |
| "-d '{{\"action\":{{\"action_type\":\"do_nothing\"}}}}' | head -c 300 && echo".format(base=base.rstrip("/")) | |
| ) | |
| print( | |
| "# Note: HTTP uses a new env per request — not one multi-step episode; use WebSocket /ws for that." | |
| ) | |
| class LiveClient: | |
| def __init__(self, base: str) -> None: | |
| self.base = base.rstrip("/") | |
| def request( | |
| self, | |
| method: str, | |
| path: str, | |
| *, | |
| data: bytes | None = None, | |
| headers: dict[str, str] | None = None, | |
| ) -> tuple[int, str]: | |
| url = urljoin(self.base + "/", path.lstrip("/")) | |
| req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) | |
| try: | |
| with urllib.request.urlopen(req, timeout=20) as resp: | |
| return resp.status, resp.read().decode(errors="replace") | |
| except urllib.error.HTTPError as e: | |
| return e.code, e.read().decode(errors="replace") | |
| class LocalClient: | |
| def __init__(self) -> None: | |
| from fastapi.testclient import TestClient | |
| from ghostexec.server.app import app | |
| self._client = TestClient(app, raise_server_exceptions=True) | |
| def request( | |
| self, | |
| method: str, | |
| path: str, | |
| *, | |
| data: bytes | None = None, | |
| headers: dict[str, str] | None = None, | |
| ) -> tuple[int, str]: | |
| hdrs = headers or {} | |
| kwargs: dict[str, Any] = {} | |
| if data is not None: | |
| kwargs["content"] = data | |
| kwargs["headers"] = hdrs | |
| r = self._client.request(method, path, **kwargs) | |
| return r.status_code, r.text | |
| def main() -> int: | |
| p = argparse.ArgumentParser(description="GhostExec HTTP endpoint smoke (CLI).") | |
| p.add_argument( | |
| "--url", | |
| default="http://127.0.0.1:8000", | |
| help="Live server base URL (ignored with --local).", | |
| ) | |
| p.add_argument( | |
| "--local", | |
| action="store_true", | |
| help="Use in-process FastAPI TestClient (no server required).", | |
| ) | |
| p.add_argument( | |
| "--print-curl", | |
| action="store_true", | |
| help="Print example curl commands and exit 0.", | |
| ) | |
| args = p.parse_args() | |
| if args.print_curl: | |
| _print_curl(args.url) | |
| return 0 | |
| client: LiveClient | LocalClient | |
| label: str | |
| if args.local: | |
| client = LocalClient() | |
| label = "local TestClient" | |
| else: | |
| client = LiveClient(args.url) | |
| label = args.url | |
| def check_get(path: str) -> None: | |
| code, body = client.request("GET", path) | |
| ok = 200 <= code < 300 | |
| status = "OK" if ok else "FAIL" | |
| print(f"[{status}] GET {path} -> HTTP {code} (body ~{len(body)} chars)") | |
| if not ok: | |
| raise SystemExit(1) | |
| print(f"GhostExec HTTP smoke ({label})\n") | |
| for path in ( | |
| "/health", | |
| "/metadata", | |
| "/state", | |
| "/schema", | |
| "/openapi.json", | |
| "/docs", | |
| "/redoc", | |
| ): | |
| check_get(path) | |
| body = json.dumps({}).encode() | |
| hdrs = {"Content-Type": "application/json"} | |
| code, txt = client.request("POST", "/reset", data=body, headers=hdrs) | |
| print(f"[{'OK' if code == 200 else 'FAIL'}] POST /reset -> HTTP {code}") | |
| if code != 200: | |
| raise SystemExit(1) | |
| j = json.loads(txt) | |
| em = (j.get("observation") or {}).get("echoed_message", "")[:50] | |
| print(f" briefing prefix: {em!r}") | |
| step_payload = json.dumps({"action": {"action_type": "do_nothing"}}).encode() | |
| code2, txt2 = client.request("POST", "/step", data=step_payload, headers=hdrs) | |
| print(f"[{'OK' if code2 == 200 else 'FAIL'}] POST /step do_nothing -> HTTP {code2}") | |
| if code2 != 200: | |
| raise SystemExit(1) | |
| print( | |
| "\nNote: OpenEnv HTTP may use a new env per request, so separate POSTs do not advance " | |
| "one long episode; each POST /step runs a single action on a fresh instance. " | |
| "Multi-step learning on one episode: WebSocket /ws (see ghostexec/README.md)." | |
| ) | |
| code3, _ = client.request("POST", "/mcp", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode(), headers=hdrs) | |
| print(f"[{'OK' if code3 == 200 else 'FAIL'}] POST /mcp tools/list -> HTTP {code3}") | |
| if code3 != 200: | |
| raise SystemExit(1) | |
| code4, _ = client.request("GET", "/reset") | |
| print(f"[{'OK' if code4 == 405 else 'FAIL'}] GET /reset (expect 405) -> HTTP {code4}") | |
| if code4 != 405: | |
| raise SystemExit(1) | |
| print("\nAll checks passed.") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |