feat: add screenshot capture and local JSON-over-TCP server to debug agent

Adds DebugAgent.screenshot() using Pillow/ImageGrab -- locates the game window
via FindWindowA, grabs its rect, returns PNG bytes. Adds tools/havoc_debug_agent_server.py:
a 127.0.0.1-only ThreadingTCPServer that serializes all ops behind a single lock and
dispatches NDJSON requests to DebugAgent. Covers all debug-agent ops (launch,
breakpoints, watchpoints, continue_execution, read/write_memory, get_registers,
screenshot, get_status). Acceptance test confirmed: get_status returns
{"ok": true, "result": {"status": "not_launched"}} from a fresh server. Adds
tools/requirements-windows.txt (pillow).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
This commit is contained in:
pyr0ball 2026-07-04 09:30:07 -07:00
parent 12ec33b41a
commit 6baefe8e8a
3 changed files with 152 additions and 0 deletions

View file

@ -318,6 +318,21 @@ class DebugAgent:
return None
def screenshot(self) -> bytes:
"""Capture the game window as PNG bytes. Requires Pillow (pip install pillow)."""
from PIL import ImageGrab # local import: only needed on Caspian, not for pure-logic tests
import io
hwnd = ctypes.windll.user32.FindWindowA(None, b"HAVOC(tm) by Reality Bytes")
if not hwnd:
raise OSError("game window not found -- is HAVOC_NOCD.EXE running and visible?")
rect = wintypes.RECT()
ctypes.windll.user32.GetWindowRect(hwnd, byref(rect))
img = ImageGrab.grab(bbox=(rect.left, rect.top, rect.right, rect.bottom))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def get_status(self) -> dict:
if self._exited is not None:
return {"status": "exited", "exit_code": self._exited}

View file

@ -0,0 +1,136 @@
"""Local-only (127.0.0.1) JSON-over-TCP server wrapping a DebugAgent. Never bind 0.0.0.0
here -- the MCP server is the only LAN-reachable piece (see spec Security section)."""
from __future__ import annotations
import base64
import json
import socketserver
import threading
from tools.havoc_debug_agent import DebugAgent
from tools.havoc_debug_protocol import decode_message, WatchpointLimitError
_OPS = {}
def _op(name):
def register(fn):
_OPS[name] = fn
return fn
return register
@_op("launch")
def _op_launch(agent: DebugAgent, params: dict) -> dict:
agent.launch(params["path"], params["cwd"])
return {}
@_op("set_breakpoint")
def _op_set_breakpoint(agent: DebugAgent, params: dict) -> dict:
agent.set_breakpoint(int(params["addr"], 16), params.get("condition"))
return {}
@_op("remove_breakpoint")
def _op_remove_breakpoint(agent: DebugAgent, params: dict) -> dict:
agent.remove_breakpoint(int(params["addr"], 16))
return {}
@_op("list_breakpoints")
def _op_list_breakpoints(agent: DebugAgent, params: dict) -> dict:
return {"breakpoints": [hex(addr) for addr in agent.list_breakpoints()]}
@_op("set_watchpoint")
def _op_set_watchpoint(agent: DebugAgent, params: dict) -> dict:
slot = agent.set_watchpoint(int(params["addr"], 16), params["size"], params["mode"])
return {"slot": slot}
@_op("remove_watchpoint")
def _op_remove_watchpoint(agent: DebugAgent, params: dict) -> dict:
agent.remove_watchpoint(params["slot"])
return {}
@_op("list_watchpoints")
def _op_list_watchpoints(agent: DebugAgent, params: dict) -> dict:
return {"watchpoints": {slot: hex(addr) for slot, addr in agent.list_watchpoints().items()}}
@_op("continue_execution")
def _op_continue_execution(agent: DebugAgent, params: dict) -> dict:
return agent.continue_execution(params["timeout"])
@_op("read_memory")
def _op_read_memory(agent: DebugAgent, params: dict) -> dict:
data = agent.read_memory(int(params["addr"], 16), params["length"])
return {"data": base64.b64encode(data).decode("ascii")}
@_op("write_memory")
def _op_write_memory(agent: DebugAgent, params: dict) -> dict:
agent.write_memory(int(params["addr"], 16), base64.b64decode(params["data"]))
return {}
@_op("get_registers")
def _op_get_registers(agent: DebugAgent, params: dict) -> dict:
return {"registers": {name: hex(value) for name, value in agent.get_registers().items()}}
@_op("screenshot")
def _op_screenshot(agent: DebugAgent, params: dict) -> dict:
png_bytes = agent.screenshot()
return {"png_base64": base64.b64encode(png_bytes).decode("ascii")}
@_op("get_status")
def _op_get_status(agent: DebugAgent, params: dict) -> dict:
return agent.get_status()
class _Handler(socketserver.StreamRequestHandler):
def handle(self) -> None:
agent: DebugAgent = self.server.agent # type: ignore[attr-defined]
lock: threading.Lock = self.server.lock # type: ignore[attr-defined]
for line in self.rfile:
if not line.strip():
continue
request = decode_message(line)
op_name = request["op"]
params = request.get("params", {})
try:
with lock:
if op_name not in _OPS:
raise ValueError(f"unknown op: {op_name!r}")
result = _OPS[op_name](agent, params)
response = {"ok": True, "result": result}
except WatchpointLimitError as exc:
response = {"ok": False, "error": str(exc)}
except Exception as exc: # noqa: BLE001
response = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
class _Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def run_agent_server(agent: DebugAgent, port: int) -> None:
"""Blocks forever, serving the local-only debug agent API on 127.0.0.1:port."""
server = _Server(("127.0.0.1", port), _Handler)
server.agent = agent # type: ignore[attr-defined]
server.lock = threading.Lock() # type: ignore[attr-defined]
server.serve_forever()
if __name__ == "__main__":
import sys
port = int(sys.argv[1]) if len(sys.argv) > 1 else 47474
run_agent_server(DebugAgent(), port)

View file

@ -0,0 +1 @@
pillow