havoc-remaster/tools/havoc_debug_agent_server.py
pyr0ball 7d39ccddf0 fix: harden debug agent for reliable WOW64 watchpoints and multi-run stability
Key fixes applied during Task 6 end-to-end MCP integration testing:

- CONTEXT_ALL (CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS) for Wow64Get/SetThreadContext
  so DR0-DR7 are read and written alongside general-purpose registers
- Native x64 SetThreadContext path in _apply_watchpoints_to_thread: Wow64SetThreadContext
  updates the WOW64 context save area but does not propagate to hardware DR registers;
  the native path is required for hardware watchpoints to actually fire on WOW64 threads
- _REGISTER_NAMES extended to include Dr0-Dr3, Dr6, Dr7 so get_registers returns them
- launch() now terminates and drains the previously attached process before starting a
  new one, preventing stale debug events from a hung game from corrupting a new session
- continue_execution filters events by process ID so late-arriving exit events from a
  previously terminated game do not trigger a false "exited" status for the new process
- AV exception routing restored: EXCEPTION_ACCESS_VIOLATION stops and reports; callers
  skip boot-time AVs in a loop rather than silently continuing them in the agent
- launch_with_breakpoints atomic op in agent server and MCP server
- _DebugWorker dedicated thread: Windows debug API requires all WaitForDebugEvent,
  ContinueDebugEvent, and CreateProcessA calls to originate from the same OS thread;
  ThreadingTCPServer violated this -- replaced with single worker thread + queue
- Gameplay screenshot captured at watchpoint stop (272 KB real frame)

Acceptance checklist results:
- Item 1 PASS: bp@0x40C8E0, eip/eax confirmed
- Item 2 PASS: conditional breakpoint false-skip and true-fire verified
- Item 3 PASS: watchpoint@slot0 fired at 0x40E998 (MOV [DAT_0047c488], EAX in
  player_cooldown_timers_tick -- the DAT write is in the caller, not inside
  compute_frame_delta_ticks_60hz_min1_clamp as the spec assumed)
- Item 4 PASS: screenshot PNG captured (gameplay frame 272 KB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
2026-07-04 14:05:24 -07:00

173 lines
5.8 KiB
Python

"""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 queue
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("launch_with_breakpoints")
def _op_launch_with_breakpoints(agent: DebugAgent, params: dict) -> dict:
"""Launch and immediately plant breakpoints before the WOW64 loader hardens page protection."""
agent.launch(params["path"], params["cwd"])
for addr_hex in params.get("breakpoints", []):
agent.set_breakpoint(int(addr_hex, 16), None)
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 _DebugWorker(threading.Thread):
"""Single dedicated thread that owns ALL debug agent calls.
Windows requires CreateProcessA, WaitForDebugEvent, ContinueDebugEvent, etc. to
all be called from the same OS thread. ThreadingTCPServer violates this by using a
fresh thread per connection. This worker serializes every op on one thread via a queue."""
def __init__(self, agent: DebugAgent) -> None:
super().__init__(daemon=True, name="debug-worker")
self._agent = agent
self._q: queue.Queue = queue.Queue()
def run(self) -> None:
while True:
op_name, params, result_q = self._q.get()
try:
if op_name not in _OPS:
raise ValueError(f"unknown op: {op_name!r}")
result = _OPS[op_name](self._agent, params)
result_q.put(("ok", result))
except WatchpointLimitError as exc:
result_q.put(("err", str(exc)))
except Exception as exc: # noqa: BLE001
result_q.put(("err", f"{type(exc).__name__}: {exc}"))
def call(self, op_name: str, params: dict, timeout: float = 120.0):
result_q: queue.Queue = queue.Queue()
self._q.put((op_name, params, result_q))
return result_q.get(timeout=timeout)
class _Handler(socketserver.StreamRequestHandler):
def handle(self) -> None:
worker: _DebugWorker = self.server.worker # 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", {})
# continue_execution can block for its full timeout plus processing overhead
op_timeout = params.get("timeout", 30) + 10 if op_name == "continue_execution" else 30.0
status, payload = worker.call(op_name, params, timeout=op_timeout)
if status == "ok":
response = {"ok": True, "result": payload}
else:
response = {"ok": False, "error": payload}
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."""
worker = _DebugWorker(agent)
worker.start()
server = _Server(("127.0.0.1", port), _Handler)
server.worker = worker # 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)