Skip to content

feat: DAP mirror — read-only IDE attach to agent-owned debug sessions (#217) - #301

Merged
debugmcpdev merged 8 commits into
mainfrom
feat/217-dap-mirror
Aug 11, 2026
Merged

feat: DAP mirror — read-only IDE attach to agent-owned debug sessions (#217)#301
debugmcpdev merged 8 commits into
mainfrom
feat/217-dap-mirror

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Implements the v1 (read-only) DAP mirror from the design spike in #217: an agent debugs a process no IDE owns — CI, a container, a plain terminal — parks it at an interesting point, and a human attaches VS Code (or any DAP client) to the live, paused session and inspects real state. Execution control stays with the MCP session.

What's in the box

Two new MCP tools (26 → 28)

  • expose_session {sessionId}{host: "127.0.0.1", port, token} — starts a loopback-only, token-gated DAP server endpoint for the session; idempotent (same endpoint + token on re-call). The result message carries a ready-to-paste VS Code config ("debugServer": <port> + "mirrorToken"), with a container-networking note appended in container mode.
  • unexpose_session {sessionId} — closes the endpoint and disconnects IDE clients (terminated event); no-op success when not exposed.

Worker-hosted mirror (src/proxy/dap-mirror-server.ts) — lives in the proxy worker, the sole owner of the live adapter connection (one worker = one session = one mirror):

  • initialize answered from the adapter's cached capabilities with control affordances masked off (restart/step-back/setVariable/etc. forced false, exceptionBreakpointFilters: []), so IDE UIs degrade before the user can click.
  • Token checked timing-safely at attach/launch (crypto.randomBytes(24) base64url); bad token → user-visible error + socket close; 30s auth timeout; max 4 clients.
  • Forwarded (via the worker's IDapClient.sendRequest, so js-debug child routing comes free): threads, stackTrace, scopes, variables, source, evaluate, exceptionInfo, loadedSources, modules.
  • Soft-succeeded so VS Code's attach handshake survives: set*Breakpoints (reported verified:false with an explanatory message), setExceptionBreakpoints.
  • Rejected (default-deny, quiet showUser:false errors): continue, next, stepIn/Out, pause, setVariable, restart, terminate, and everything unlisted.
  • Per-client seq spaces with correct request_seq correlation under out-of-order completion.
  • Late join: on attach while paused, the mirror synthesizes a stopped event from the worker's recorded last stop, so the IDE lands directly on the paused frame (with a 1s fallback for clients that skip configurationDone).

Event plumbingstopped/continued fan out from the worker's own handlers (after the threadId backfill for Delve/JDI; the generic 'event' channel would race the async backfill and ship a threadId-less body), everything else rides MinimalDapClient's generic 'event' feed. A successful continue/step response also synthesizes continued for mirror clients when no event arrives — DAP clients are entitled to infer resumption from the response, and stale paused frames in the IDE were the alternative.

Transport: zero new IPC surfacemirrorExpose/mirrorUnexpose ride the existing correlated dap envelope (precedent: redefineClasses), intercepted at the top of handleDapCommand before the connectivity bail and policy queueing.

Shared framing codecMinimalDapClient's battle-tested Content-Length decoder is extracted verbatim into src/proxy/dap-framing.ts (DapFrameDecoder + encodeDapMessage) and reused by client and mirror; the existing framing property suite now runs against the extraction as a regression net, plus a new property suite drives the decoder directly.

Security

  • Bind hard-coded to 127.0.0.1; token required on every client.
  • The shared payload sanitizer now redacts string token/mirrorToken values, and ProxyManager's raw received-message debug log is sanitized (it previously dumped every IPC message verbatim — the dapResponse carrying the token would have hit debug logs).
  • SECURITY.md + tool-reference call out that the token is a debuggee-execution capability (evaluate is forwarded), not a view-only credential.
  • list_debug_sessions shows exposure: {host, port} — never the token — gated on a running proxy so stale records can't surface after teardown paths that skip cleanup.

Testing

  • tests/proxy/dap-mirror-server.test.ts — 46 hermetic cases (fake sockets feeding the real decoder): handshake + capability mask, token gate, auth timeout, full reject-table sweep, allowlist forwarding with seq correlation, out-of-order completions, two-client fan-out with independent seq spaces, late-join synthesis + fallback timer, terminated dedupe, max clients, malformed frames.
  • tests/proxy/dap-proxy-worker.test.ts — 14 new worker cases: interception before queueing, idempotent expose, host reflects live worker state, resume inference, shutdown notification, capability retention through the [FEATURE] Capture adapter initialize capabilities + best-effort exceptionInfo enrichment on exception stops #243 once-only guard.
  • tests/core/unit/{server,session} — tool dispatch/result shaping (incl. container note) and exposeSession/unexposeSession logic (stale-record cleanup, relaunch reset, token absent from projections).
  • tests/integration/dap-mirror/ — live-socket check of the production net wiring.
  • tests/e2e/mcp-server-dap-mirror.test.ts — mock-adapter full stack with a scripted TCP DAP client: full inspection surface, control rejection leaves the session paused, token rejection, two concurrent clients, idempotency, endpoint closed after unexpose/close.
  • Full suite: 3300 passed, 0 failed; lint + strict tsc clean.
  • Live dogfood against a real debugpy session via the dev proxy: exposed a paused Python session, attached a scripted IDE client with the token — masked capabilities, late-join stopped, real locals, evaluate forwarded, continue rejected with the session still agent-controlled, unexpose → ECONNREFUSED, and the token appears in zero log files (proxy log, DAP trace, debugpy logs).

Out of scope (per the spike's phasing)

v2 breakpoint union and opt-in execution control; per-language polish (js-debug child-session guarantees, JDI bridge notes) — the mirror routes through sendRequest, so js-debug child routing already works, but v1 makes no per-adapter guarantees beyond the tested mock + Python paths.

Closes #217

🤖 Generated with Claude Code

cynarlab and others added 6 commits August 11, 2026 10:25
Move MinimalDapClient's decode loop verbatim into DapFrameDecoder and add
encodeDapMessage, so the upcoming DAP mirror server reuses the
property-fuzzed framing instead of hand-rolling a second parser.
handleData/cleanup delegate to the decoder; the existing framing property
suite now doubles as a regression net over the extraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ents (#217)

Loopback-only, token-gated TCP DAP server with per-client seq spaces,
default-deny dispatch (forward reads, soft-succeed breakpoint config,
reject control/mutation with quiet errors), a capability mask that hides
control affordances, and late-join stopped synthesis so an IDE attaching
to a paused session lands directly on the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mirrorExpose/mirrorUnexpose pseudo-commands ride the existing dap
envelope, intercepted before the connectivity bail and policy queueing.
The worker now retains adapter capabilities and the last stopped-event
body (post threadId-backfill) for the mirror's initialize response and
late-join replay, fans stopped/continued out from its own choke points,
infers resumption from successful continue/step responses, and closes
the mirror with client notification during shutdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parent-side plumbing for the DAP mirror: exposeSession/unexposeSession on
SessionManagerOperations riding the mirrorExpose/mirrorUnexpose
pseudo-commands, a SessionExposure record on ManagedSession projected into
list_debug_sessions as host/port only (gated on a running proxy), eager
exposure cleanup on relaunch and proxy stop, and token redaction — the
shared payload sanitizer now redacts token/mirrorToken strings and
ProxyManager's raw received-message debug log is sanitized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Server tool dispatch and session-manager expose/unexpose units, sanitizer
token-redaction cases, a live-socket check of the production net wiring,
and a mock-adapter e2e driving a scripted TCP DAP client (shared
TcpDapClient helper) through the mirror: late-join stop replay, the full
inspection surface, control rejection, token rejection, two concurrent
clients, idempotency, and lifecycle teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tool counts 26→28, README table + comparison row flip, a full IDE Mirror
section in tool-reference (VS Code debugServer+mirrorToken recipe,
language→debug-type table, request dispositions, container note), the
SECURITY.md trust-model call-out that the mirror token is a
debuggee-execution capability, server-instruction/prompt/skill guidance,
api-reference entries, and the CHANGELOG entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.17759% with 37 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/dap-mirror-server.ts 92.68% 18 Missing ⚠️
src/server.ts 75.51% 12 Missing ⚠️
src/proxy/dap-proxy-worker.ts 96.10% 3 Missing ⚠️
src/session/session-manager-operations.ts 90.90% 3 Missing ⚠️
src/proxy/dap-proxy-dependencies.ts 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

… redaction regression (#217)

Four gaps found by review of what the suites actually drive:
- Worker stopped/continued fan-out now tested through the REAL
  setupDapEventHandlers closures (captured via a connection-manager stub)
  including the threadId-backfill path, replacing a hand-rolled simulation
  that would have survived deletion of the production insertions.
- New e2e: live mirroring — an attached IDE client observes MCP-side
  stepping (synthesized continued + fresh stopped) and debuggee exit
  (exactly one terminated via the dedupe, then socket close + port
  refused), covering the shared-pause-state story end to end.
- e2e lifecycle now proves restart_debugging kills the old endpoint,
  clears the exposure projection, and a fresh expose gets a new port.
- Regression test that ProxyManager's received-message debug log redacts
  the mirror token (the confirmed leak fixed in this PR).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Added a coverage pass before merge (commit c068714) closing four gaps a review of the suites turned up:

  1. Real handler wiring — the worker's stopped/continued mirror fan-out is now tested through the actual setupDapEventHandlers closures (incl. the Delve/JDI threadId-backfill path), replacing a simulation that would have survived deletion of the production code.
  2. Live mirroring e2e — an attached IDE client now observes MCP-side stepping (synthesized continued + fresh stopped(step)) and debuggee exit (exactly one terminated through the terminal-signal dedupe, then socket close + port refused). This is the shared-pause-state promise itself, previously only tested against a static pause.
  3. restart_debugging lifecycle — e2e now proves a restart kills the old endpoint, clears the exposure projection, and a fresh expose binds a new port.
  4. Token-redaction regression test — ProxyManager's received-message debug log is asserted to redact the mirror token (the leak this PR fixes).

Full suite green locally via the pre-push hook; CI re-running.

…oot cause #143)

The full-suite failure of the js-attach smoke was the documented #143
mode — the default 5s thread-verification window hard-failing a healthy
attach while js-debug's child session adopts the inspector under heavy
machine load — not a mirror regression: the attach path is untouched by
this branch and the test passed 10/10 reproduction attempts (isolation,
under CPU load, and paired with the new mirror e2e in both orders). Use
the caller-configurable verifyTimeout that #147 added for exactly this;
the poll exits as soon as threads appear, so a responsive machine pays
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev
debugmcpdev merged commit c5a47ae into main Aug 11, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the feat/217-dap-mirror branch August 11, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] DAP mirror: let an IDE attach to an agent-owned debug session with shared pause state

2 participants