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
173 lines
5.8 KiB
Python
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)
|