Skip to content

Investigate speaking CDP (V8's debugger protocol) so Chrome DevTools and VS Code can drive Ironhorse/Endor #939

Description

@kriscendobot

Preparation for a proposal, per kriskowal's 2026-07-28 directive: investigate
"converting the debugger protocol to v8's debugger protocol such that it can be
driven from Chrome's devtools or VSCode."

Research only — no engine code was written or changed for this. Every protocol
claim below is checked against the published specification rather than recall;
every engine claim cites a file and line read at the commit named.

Read at: endojs/endo-but-for-bots PR #600, branch xs2rust-endor, head
33c68104b3 (2026-07-30). Moddable submodule at pin 23b4d6b0a65f.


0. The ground has moved: the xsbug crate is not on the branch

The directive picks up from "the endor-debug crate at rust/engine/endor-debug,
speaking xsbug's XML protocol". That crate is not present at the current PR
head.
Verified three ways:

  • rust/engine/Cargo.toml lists seven members — ironhorse-vm, xs-oracle,
    ironhorse-262, ironhorse-fuzz, ironhorse-regexp, ironhorse-compile,
    ironhorse-snapshot. No debug crate.
  • No path under rust/ matches *debug*.
  • The PR's own file list (GET /repos/endojs/endo-but-for-bots/pulls/600/files,
    1834 files, 1812 under rust/engine/) contains no path matching debug.

The engine history on this branch was rebuilt on 2026-07-29git log -- rust/engine/Cargo.toml
on the branch shows only five commits, the earliest being b59ca35c9f
"engine: stage-1 thin slice", and the debugger slices are not among them. A
garden press report from 2026-07-21 records nine engine crates including
endor-debug, so the crate existed and was dropped in that restage, not
reverted by a reviewed commit.

This is consistent with the current roadmap in designs/ironhorse-engine.md,
where Debugger is stage 7 of 9 and is not yet landed (the landed-stage
records in that document run to stage 2b; ironhorse-compile (stage 5) and
ironhorse-snapshot (stage 6) are present in the tree, so the document lags the
tree, but no debug crate is present under any name).

Why this matters more than a bookkeeping note: the choice is no longer
"replace a landed xsbug implementation". Stage 7 has not been written. The
decision can be made before the protocol layer is ported, which removes the
single largest cost item from the CDP option — the sunk xsbug port — and changes
the recommendation below.

Someone should confirm whether the drop was deliberate deferral or loss in
the restack
. If the slice-1/2/3 work still exists somewhere it is worth
recovering as a reference implementation even if the wire protocol changes,
because the DebugTransport seam and the VM-side break hooks are protocol-agnostic.


1. The protocol landscape, stated plainly

These three are routinely conflated. They are distinct.

CDP — the Chrome DevTools Protocol. JSON messages, conventionally over a
WebSocket, with an HTTP discovery surface. V8 exposes it via the V8 Inspector
Protocol (the js_protocol.json subset of CDP). Node's inspector "supports all
the Chrome DevTools Protocol domains declared by V8"
(nodejs.org/api/inspector.html). This is
what Chrome DevTools speaks to a JavaScript target.

DAP — the Debug Adapter Protocol. The protocol between an editor and a
debug adapter. Three layers: development tool ↔ debug adapter ↔ debugger/runtime.
The adapter's job is to "adapt existing debugger APIs to the standardized
protocol"
(microsoft.github.io/debug-adapter-protocol/overview).
DAP is not CDP; the two share no wire format and no message vocabulary.

js-debug. VS Code's JavaScript debugger speaks DAP to the editor and CDP to
the target. So the question "can VS Code drive it" resolves to "does js-debug
attach to it as a CDP target".

Verified: it does, and there is a documented configuration for exactly this
case.
vscode-js-debug's OPTIONS.md documents an attach-mode option
websocketAddress"Exact websocket address to attach to. If unspecified, it
will be discovered from the address and port."

(microsoft/vscode-js-debug OPTIONS.md).
So the VS Code attach path is:

{
  "type": "node",
  "request": "attach",
  "name": "Attach to Endor worker",
  "websocketAddress": "ws://127.0.0.1:PORT/UUID",
  "localRoot": "${workspaceFolder}",
  "remoteRoot": "..."   // maps CDP script URLs to on-disk files
}

That form bypasses the /json/list discovery step entirely. The plain
{"port": 9229} form instead discovers via HTTP, which requires implementing the
discovery endpoints (§5).

Conclusion: a DAP adapter is the wrong tool here. Writing DAP buys VS Code
only, and buys it by writing a second protocol implementation on top of
whatever the engine already speaks — the adapter still has to talk to the engine
somehow. Implementing CDP buys Chrome DevTools and VS Code and every other
CDP client (Edge DevTools, chrome-remote-interface, Playwright's CDP session,
the cdp MCP servers) from one implementation. The only scenario where DAP wins
is if CDP fidelity turns out to be unreachable and a bespoke adapter over the
xsbug seam is cheaper — and even then, a CDP bridge over the xsbug seam
(option (b) in §6) costs about the same and serves more clients.


2. Minimum viable domain and method set

CDP has no conformance profile and no normative "minimum" — the protocol
definition is a surface, not a contract, and 57 domains are documented for the
V8 build of the viewer
(chromedevtools.github.io/devtools-protocol/v8/).
So the floor cannot be derived from the specification. It can be derived
empirically from a non-V8 engine that already does this, which is the strongest
available evidence.

Hermes (React Native's engine) implements a CDP subset, tracked
method-by-method at
cdpstatus.reactnative.dev.
Its implemented set is the honest floor:

Debuggerenable, disable, setBreakpoint, setBreakpointByUrl,
removeBreakpoint, setBreakpointsActive, setPauseOnExceptions, resume,
pause, stepInto, stepOver, stepOut, evaluateOnCallFrame, plus the
experimental setBlackboxedRanges / setBlackboxPatterns.
Events: paused, resumed, scriptParsed, breakpointResolved.

Runtimeenable, disable, evaluate, callFunctionOn, compileScript,
getProperties, globalLexicalScopeNames, releaseObject, releaseObjectGroup,
discardConsoleEntries, getHeapUsage. Event: consoleAPICalled.

Mapped onto the capabilities the directive asks about:

Capability CDP surface In Hermes' set
Script registration Debugger.scriptParsed yes
Breakpoint set / clear Debugger.setBreakpointByUrl / setBreakpoint / removeBreakpoint yes
Pause / resume events Debugger.paused / Debugger.resumed yes
Stepping stepInto / stepOver / stepOut yes
Call frames with scope chains Debugger.paused.callFrames[].scopeChain[] yes
Expression evaluation in a frame Debugger.evaluateOnCallFrame yes
Pause on exceptions Debugger.setPauseOnExceptions yes
Lazy object expansion Runtime.getProperties + objectId yes
Source retrieval Debugger.getScriptSource not in the tracked set
Console output Runtime.consoleAPICalled yes

Two things to read off that table.

Debugger.getScriptSource is absent from Hermes' tracked set. React Native
serves source to DevTools out of band (Metro, over the script url, plus source
maps via scriptParsed.sourceMapURL). This is suggestive but not conclusive —
the status site tracks "referenced in Hermes CDPAgent" and may simply be
incomplete. Treat it as evidence that DevTools degrades rather than refuses
when source is unavailable, and confirm before relying on it.

What DevTools refuses to start without could not be established from
specification.
There is no normative statement of it, and I did not have a
browser available to observe the handshake. The concrete experiment that settles
it, and which should be step one of any implementation:

Stand up a WebSocket server that speaks CDP framing ({id, method, params}
{id, result} / {method, params}) and answers nothing — log every inbound
message and return {} for each. Point Chrome DevTools at it (via
chrome://inspect target discovery) and point js-debug at it via
websocketAddress. The recorded message sequence is the required floor for
each client, and it is a day of work, not a week.

My working expectation, to be confirmed by that experiment rather than trusted:
Runtime.enable (which must emit Runtime.executionContextCreated or the
console has no context to evaluate in), Debugger.enable, Runtime.runIfWaitingForDebugger,
and Debugger.setPauseOnExceptions are the ones a client will send before it
renders anything. Degrades gracefully without: Profiler, HeapProfiler,
Network, Page, DOM, Overlay, source maps, blackboxing,
getPossibleBreakpoints (breakpoints just resolve less precisely),
setScriptSource (live edit greys out), restartFrame.


3. The object model gap — the substance of the work

This is where xsbug and CDP differ in kind, not in spelling.

What xsbug does

At every break, fxDebugLoop (c/moddable/xs/sources/xsDebug.c:608) pushes the
entire world
: it calls fxListFrames, fxListLocal, fxListGlobal, and
fxListModules unconditionally before emitting <break>, then blocks in a
while (fxIsConnected(the)) { fxReceive; fxDebugParse; } loop until the client
sends <go/>.

Object identity on the wire is a raw slot address. fxEchoAddress
(xsDebug.c:1257) formats the txSlot* pointer itself as hex:

void fxEchoAddress(txMachine* the, txSlot* theSlot)
{
    uintptr_t aValue = (uintptr_t)theSlot;
    fxEcho(the, " value=\"@");
    /* ... hex nibbles of the pointer ... */
}

And the client hands that address straight back. <toggle id="@..."> is parsed
into the->idValue as hex (xsDebug.c:814–817) and then cast back to a
pointer
(xsDebug.c:1151):

case XS_TOGGLE_TAG:
    fxToggle(the, (txSlot*)the->idValue);

The same cast appears for <select> (:1123) and <eval> (:1113).

fxToggle (xsDebug.c:2738) does not return anything. It flips membership in
the machine's mxInstanceInspectors list — a set of instances whose properties
should be expanded — and the next full echo of frames/locals/globals includes
that instance's children inline. Expansion is server-side view state, and the
whole tree is re-serialized on every break.

So the xsbug model is: push everything, address by raw pointer, expansion is a
sticky server-side toggle, no handle lifetime at all.
There is nothing to
invalidate because there is nothing being held.

What CDP does

Runtime.RemoteObject carries an objectId"Unique identifier for non-primitive
values"
— and the specification states the lifetime explicitly: "Objects remain
in memory within their object group unless explicitly released via releaseObject
or when the group is released via releaseObjectGroup"

(Runtime domain).
The client pulls children on demand with Runtime.getProperties(objectId).
The debugger is therefore holding strong references on the client's behalf, and
the client is responsible for dropping them.

This inverts three things at once: push→pull, view-state→handle-state, and
"nothing is retained" → "the debugger is a GC root".

How this lands on ironhorse-vm

Three findings, all from code read at head.

(a) A SlotIndex is not a safe handle. value.rs:27pub struct SlotIndex(pub u32),
a bare index with no generation tag. SlotArena has a free list (value.rs:450)
and alloc (value.rs:325) reuses it. So an index handed to a client and then
swept can be re-issued for a different object: a released objectId replayed
by a buggy or hostile client silently aliases an unrelated object. This is an ABA
hazard, and it is worse than the xsbug pointer cast in one specific way — the
pointer cast crashes on garbage, the index aliases plausibly.

The mitigation is cheap and should be non-negotiable: objectId is an opaque
string minted from a monotonic serial in a side table, never the SlotIndex
itself, and the table stores (serial, SlotIndex) so a stale serial fails closed
rather than resolving.

(b) The objectId table must be a GC root. gc.rs:57
pub fn collect(&mut self, roots: &[SlotIndex]) -> GcStats, an exact mark-sweep
from an explicit root set. Anything the debugger holds an objectId for must be
in that root set or it will be swept while the client still holds the handle. So
CDP's releaseObject / releaseObjectGroup are not optional politeness in this
engine — they are the debugger's only way to stop pinning the guest heap. A
client that forgets to release leaks the guest heap for the life of the session.
This is a real, and I think acceptable, cost: it is exactly the deal Chrome and
Node already make, and the leak ends when the session ends.

The engine's own note on this is reassuring: because the heap is index-based, "a
stale index reaching a kind-checked accessor is a deterministic panic (a crashed
crank), never undefined behavior"
(gc.rs:14–17). A handle bug is a crash, not a
memory-safety hole. That is a meaningfully better failure mode than the C-XS
(txSlot*)the->idValue cast, which is straightforwardly exploitable by a client
that can choose the pointer.

(c) The table must not allocate VM slots on a metered path. meter.rs:45
SLOT_ALLOCATION_METERING, "added by fxNewSlot on every slot allocation during
a run"
. If the objectId table were implemented as guest-heap slots (as
mxInstanceInspectors is in XS — fxNewSlot inside fxToggle), then attaching a
debugger changes the computron count of the program being debugged. Under Agoric
consensus that is a consensus fault. The table must live in host memory
(a Vec/HashMap on the session), contributing only its SlotIndex entries to
the GC root slice.

Is <toggle> the right seam?

No. It is the wrong seam and should not be extended.

<toggle> is a view-state command with no reply — it mutates which instances
get inlined into the next full echo. Runtime.getProperties is a request/response
over a handle. They are not the same operation at different fidelities; they are
opposite architectures. Building getProperties on <toggle> would mean
"toggle the instance on, force a full re-echo of frames+locals+globals, parse
the whole tree, extract the subtree that grew, toggle it back off" — quadratic
in tree depth, and racy if two expansions are in flight.

The right seam is one level down, at the VM: a properties(SlotIndex) -> Vec<(key, Slot)>
query against the slot arena, which is the same walk fxEchoInstance /
fxEchoProperty already perform, minus the serialization. Both protocols can
then be written over it — xsbug's echo and CDP's getProperties — which is
precisely what makes the "keep both protocols" option in §6 cheap.


4. Script identity and source, with compartments taken seriously

CDP's Debugger.scriptParsed carries scriptId, url, hash, executionContextId,
sourceMapURL, hasSourceURL, isModule, startLine/endLine, length
(verified against the Debugger domain).
Debugger.setBreakpointByUrl matches on url or urlRegex — that is DevTools'
primary breakpoint path, because it survives reloads, and it is URL-keyed.
Debugger.setBreakpoint takes a Location (scriptId + line + column) and is
script-keyed.

What Endor can honestly supply, per code read:

  • A stable per-script id: yes, easily. Mint a monotonic string per compiled
    unit at compile time. This is new bookkeeping but trivial.
  • Line and column: yes. XS's environment slot already carries a path key and
    a line (xsDebug.c:2391 fxListLocal, and the path/line walk in fxDebugLoop).
    Columns are the open question — CDP is column-precise and XS's debug records
    are line-granular. Column-less breakpoints resolve to line starts, which
    DevTools tolerates but renders imprecisely on minified or multi-statement lines.
    This is the largest under-appreciated gap in the port and deserves its own
    scoping.
  • Source text: only if retained. Nothing in the engine currently keeps source
    after compilation — the compile path produces bytecode (Compartment::evaluate(bytecode),
    compartment.rs:294). Serving Debugger.getScriptSource means either
    retaining source per script (memory cost, and a confidentiality question — see
    §7) or serving it out of band by URL, which is what React Native does.
  • Source maps: not yet, and mostly not our problem. sourceMapURL is a
    string the engine passes through; producing it is the bundler's job.

Compartments and SES

This is a genuine impedance mismatch, not a footnote.

compartment.rs:81pub struct CompartmentId(pub usize); compartment.rs:225
— each Compartment owns its own ModuleGraph; module.rs:276
ModuleGraph::resolve(&self, specifier: &str) keys modules by specifier within
that graph
. So module identity in Endor is (CompartmentId, specifier). A
specifier is not a file path and need not be URL-shaped; two compartments can
resolve the same specifier to different sources, or different specifiers to the
same source.

The consequences for CDP:

  1. The same source text instantiated in two compartments is two scripts.
    Two scriptParsed events, two scriptIds, and — importantly — a
    setBreakpointByUrl on the shared URL should arm both, which is exactly
    what setBreakpointByUrl is designed to do (it returns a locations array,
    plural, and Chrome already uses it for the same script in multiple frames).
    The URL-keyed path is therefore not an accident of the browser; it is the
    right fit for compartments. This is a happy result.

  2. executionContextId is the compartment. CDP already has the concept —
    ExecutionContextDescription carries id, origin, name, auxData. The
    honest mapping is one CDP execution context per compartment, with the
    compartment name (Compartment::name(), compartment.rs:188) as the context
    name and the compartment id in auxData. DevTools renders a context
    picker, so the user gets a compartment picker for free. This is the single
    most valuable thing CDP gives Endo that xsbug cannot express at all
    — xsbug
    has no notion of multiple realms; its <global> is the global.

  3. The URL is synthetic and must be admitted as such. A specifier like
    ./util.js resolved inside a compartment named guest-7 has no honest file
    URL. The workable scheme is a synthetic hierarchical URL that round-trips —
    e.g. endo://<compartment-name>/<specifier> — with the localRoot/remoteRoot
    mapping in the js-debug config doing the on-disk correspondence where one
    exists. This is not cleanly reconcilable in the general case: a compartment
    with an importHook that synthesizes modules has no on-disk source, and
    DevTools will show "source unavailable" for it. That is an honest degradation,
    not a blocker.

  4. SES freezing is not a problem for CDP itself, but is for evaluation.
    Debugger.evaluateOnCallFrame is arbitrary evaluation in the frame's scope. In
    a locked-down compartment that is still confined by the compartment's own
    globals — evaluation happens inside the compartment, so it gets the
    compartment's authority and no more. That is a good story, and it should be
    stated explicitly in any proposal because it is the thing a reader will worry
    about. The thing to be careful about is the debugger's own evaluation
    perturbing the guest: XS guards this with the->debugEval (checked first in
    fxDebugThrow, xsDebug.c:1220, to suppress recursive break-on-throw). Any
    port needs the equivalent, and CDP's silent parameter on evaluateOnCallFrame
    is the client-facing half of it.


5. Transport and discovery

What exists today. The debug seam is byte-oriented over the daemon envelope
bus, with three verbs:

  • rust/endo/xsnap/src/lib.rs:1039–1054debug-attach (calls debug_enable(),
    runs the debugger, answers debug-attached), debug-detach (debug_reset(),
    answers debug-detached), and debug (pushes payload inbound, pumps, flushes
    outbound).
  • rust/endo/xsnap/src/powers/debug.rs — the whole transport: a thread-local
    DebugState { enabled, connected, outbound: VecDeque<u8>, inbound: VecDeque<u8> }
    with #[no_mangle] externs (rust_debug_connect, rust_debug_recv,
    rust_debug_send, …) that XS's five C platform hooks call.
  • packages/daemon/src/bus-manager-rust-xs.js:932/943/963 — daemon-side routing
    of debug / debug-attached / debug-detached into a per-worker
    DebugSession (packages/daemon/src/debug-session.js, a hand-written SAX
    parser) exposed as a Debugger exo (packages/daemon/src/debugger.js).

What has to exist for CDP. Three pieces, none of which touch the engine:

  1. JSON-RPC framing. CDP messages are {id, method, params}{id, result}
    or {id, error}, with events as {method, params}. Trivial over the existing
    byte transport.
  2. A WebSocket endpoint. Chrome DevTools and js-debug both connect over
    WebSocket. The daemon already runs one: packages/daemon/src/ws-gateway.js
    creates a node:http server plus a ws WebSocketServer (ws-gateway.js:248),
    bound by default to 127.0.0.1:8920 (packages/daemon/src/manager-node.js:176,
    ENDO_ADDR-overridable). A CDP endpoint would be a separate listener, not
    a path on the gateway — see §7 for why that separation is load-bearing.
  3. Discovery, only if the ergonomic path is wanted. GET /json/version and
    GET /json/list returning webSocketDebuggerUrl per target
    (HTTP endpoints). This
    is what makes chrome://inspect list your workers and what makes
    {"port": 9229} work in VS Code without hand-copying a URL. It is also the
    surface with the worst security history (§7). Recommendation: implement it,
    but make it opt-in and loopback-only, with the websocketAddress form as the
    documented default path.

Does DebugTransport survive? Yes — as the lower half. The trait's job is
"move opaque bytes between a VM and a peer", and that is exactly as true for CDP
JSON as for xsbug XML. What does not survive is the assumption baked into the
debug verb's usage: xsbug is a synchronous stop-the-world loop (fxDebugLoop
blocks in fxReceive until <go/>, xsDebug.c:608), whereas CDP expects the
target to keep answering Runtime.getProperties and Debugger.evaluateOnCallFrame
while paused and to accept Debugger.pause while running. The pump therefore
needs two properties xsbug's does not: it must service protocol messages from
inside the paused state (which the blocking loop already structurally does — it
is a message loop), and it must poll for inbound messages during normal execution
so Debugger.pause is not ignored until the next natural break. That second one
is the real new requirement and it is where the "cost when disarmed" question
bites (§8).


6. Strategy — three shapes and a recommendation

(a) Native CDP inside the engine

Implement CDP directly in a Rust crate against the VM, as stage 7, instead of
or alongside the xsbug port.

  • For: one implementation, full fidelity, executionContextId maps to
    compartments honestly, objectId lifetime handled where the GC roots live,
    no translation impedance. Every CDP client works.
  • Against: it contradicts a stated design decision. designs/ironhorse-engine.md
    decision 7 is "Debugger protocol byte-compatibility over protocol
    modernization"
    , with the stage-7 acceptance bar "the existing 11 Rust
    debug-protocol tests and 16 CapTP debugger tests pass unmodified against
    Ironhorse; xsbug connects"
    . Choosing CDP means amending that decision and
    restating that bar.
  • Cost: the object-model work in §3 is the bulk. Ballpark: the objectId
    table + GC rooting + getProperties is one substantial slice; the
    Debugger domain over the existing break/step hooks is another; framing,
    transport, and discovery a third; column-precision a fourth if wanted.

(b) External bridge: CDP ⇄ xsbug

A separate process (or a daemon-side JS module) that speaks CDP to the client and
xsbug to the existing seam, on top of DebugSession.

  • For: engine untouched; provable in days rather than weeks; keeps the C-XS
    path working identically since it drives the same wire protocol; can be
    thrown away.
  • Against: it inherits every xsbug limitation it cannot invent its way out
    of. Specifically: (i) Runtime.getProperties becomes the quadratic
    toggle-and-re-echo dance described in §3, (ii) there is no compartment/execution-context
    concept to map, (iii) objectId stability depends on raw slot addresses that
    change under GC compaction — and gc.rs explicitly slide-compacts the chunk
    arena, so addresses are not stable across a collection, (iv) evaluateOnCallFrame
    maps onto <eval>/<script> which the current Rust port lists as parsed-but-inert.
  • Verdict: viable as a demo and as a way to de-risk the §2 handshake
    experiment against a real engine. Not viable as the answer.

(c) DAP adapter for VS Code only

Rejected on the analysis in §1: strictly less client coverage for comparable
work, and it still needs a protocol to talk to the engine.

Recommendation

Do (a) — native CDP as stage 7 — and reduce or drop xsbug, but decide that
deliberately.
The reasoning, in order of weight:

  1. The sunk cost is gone. §0 establishes that the xsbug port is not on the
    branch and stage 7 is unwritten. The decision that decision 7 was making —
    "don't throw away working xsbug code" — no longer has an object. The
    comparison is now CDP-from-scratch versus xsbug-from-scratch, and CDP wins on
    client coverage by a wide margin: xsbug has exactly one client and it is a
    macOS app; CDP has Chrome DevTools, VS Code, and a large tool ecosystem.

  2. CDP models things xsbug cannot express. Compartments as execution
    contexts (§4), caught-vs-uncaught as a first-class enum (§9), lazy object
    graphs that do not re-serialize the world on every break. These are not
    conveniences; the first one is arguably required for a compartment-centric
    platform to be debuggable at all.

  3. The handle model is safer. Opaque serial → side table (§3) versus
    (txSlot*)the->idValue (xsDebug.c:1151). For an engine whose headline claim
    is forbid(unsafe_code), shipping a protocol whose C ancestor casts
    client-supplied hex to a pointer is a bad look even though the Rust port would
    necessarily do something else.

  4. The VM-side work is common to both. Break/step hooks, the handler chain
    predicate, frame and scope enumeration, property walks — none of that is
    protocol-specific. Only serialization differs. So if xsbug support is later
    wanted for parity, it is a serializer over the same seam, not a second engine
    integration.

On xsbug: recommend deprecate, do not drop, and reframe stage 7's
acceptance bar. Keep the debug/debug-attach/debug-detach envelope verbs and
the Debugger exo — those are the Endo-facing ocap surface and they should
survive protocol choice. The 16 CapTP debugger tests
(packages/daemon/test/debugger-captp.test.js) test the exo, not the wire, and
should mostly survive. The 11 Rust debug-protocol tests
(rust/endo/xsnap/src/debug_protocol_tests.rs) test the C-XS xsbug path and
should stay exactly as they are — they guard the C-XS path, which this work
must not perturb, and they already skip on a default build (they require
--features debug). Parity with the C-XS path then means: C-XS keeps speaking
xsbug to xsbug; Ironhorse speaks CDP; the exo API is the same on both, with
setExceptionBreakMode and friends implemented differently underneath. If
byte-level xsbug parity on Ironhorse is later required by a real consumer, add it
as a second serializer.


7. Security — first-class, and the reason to be careful

A debugger endpoint is total authority over the vat. Debugger.evaluateOnCallFrame
is arbitrary code execution in any frame; Runtime.getProperties walks the entire
reachable heap; Debugger.setBreakpoint + evaluateOnCallFrame together are a
general-purpose implant. There is no attenuated form of "attached CDP session".
Anyone who can open the WebSocket owns the worker, and through the worker, every
capability the worker holds.

Node states the consequence plainly: "If the debugger is bound to a public IP
address, or to 0.0.0.0, any clients that can reach your IP address will be able to
connect to the debugger without any restriction and will be able to run arbitrary
code"
, and "Any applications running locally on your machine will have
unrestricted access. This is by design"

(nodejs.org/en/learn/getting-started/debugging).
Node's mitigations are exactly two: bind 127.0.0.1:9229 by default, and verify
the Host header is precisely an IP or localhost to defeat DNS rebinding. Node
also recommends SSH tunnelling (ssh -L 9221:localhost:9229 user@host) over ever
binding publicly.

That is the floor. Endo should clear it and then some, because the ambient-authority
model CDP assumes is the exact opposite of the model this platform is built on.

The composition problem, stated precisely. Endo's existing surfaces are
ocap-disciplined: the WebSocket gateway hands a browser client only an attenuated
gateway() bootstrap and everything else must be granted
(packages/daemon/src/ws-gateway.js), and the primary CapTP transport is a unix
domain socket
(makePrivatePathService(… sockPath …),
packages/daemon/src/manager-node.js:166) — filesystem-permission-gated, not
network-reachable. A CDP endpoint is ambient authority on connect. Bolting one
onto the gateway would put an unauthenticated total-authority surface behind the
same listener that browser clients reach, which is a category error.

Proposed shape:

  1. The Debugger exo remains the authority. Attaching stays what it is today:
    E(daemon).attachDebugger(worker) returns a capability, and holding that
    capability is what authorizes debugging. No capability, no attach. This is the
    ocap-correct answer and it already exists.
  2. The CDP listener is minted by an attach, not standing. A CDP endpoint
    exists only for the lifetime of a Debugger exo, is created on demand, and
    dies with it. There is no daemon-wide --inspect port. This is the key
    difference from Node and it is what keeps the ocap story intact: the network
    endpoint is a materialization of a capability someone already holds, not a
    source of authority.
  3. Default transport is a unix domain socket, not TCP. Same as the daemon's
    own CapTP path. Filesystem permissions do the authentication. Both Chrome
    DevTools and js-debug want ws://, so a TCP loopback listener is needed for
    the ergonomic path — mint it per-session on an ephemeral port bound to
    127.0.0.1 with a long unguessable path segment (Node's UUID pattern,
    ws://127.0.0.1:PORT/<uuid>), which is a capability URL and should be treated
    as secret.
  4. Never bind non-loopback. No env-var escape hatch. The gateway's
    ENDO_ADDR precedent (manager-node.js:176) — a plain env var that can move
    the listener to 0.0.0.0 — must not be replicated for the debugger. If
    remote debugging is wanted, the answer is an SSH tunnel, as Node recommends.
  5. Enforce Host and Origin on the upgrade. Reject any Host that is not
    loopback (Node's DNS-rebinding defence) and reject any request carrying an
    Origin header at all — a genuine DevTools/js-debug client sends none, and a
    browser page attempting the connection sends one. Note the existing gateway
    does neither today; do not inherit that.
  6. Discovery is opt-in. /json/list is the classic exposure: it enumerates
    targets and hands out the WebSocket URLs, defeating mitigation 3's
    unguessability for anyone who can reach it. Gate it behind explicit
    configuration, loopback-only, and document websocketAddress as the default
    path.
  7. Consider what source retention leaks. If getScriptSource retains guest
    source in the worker for the session's benefit, a heap dump or a later
    compromise reads it. Prefer serving source out of band from a place that
    already has it.

Failure mode if someone ships it enabled by default: every Endo daemon on the
network with the endpoint reachable is a remote code execution primitive with no
authentication step, and — because Endo workers hold capabilities — RCE that
starts with a live capability set rather than an empty one. On a loopback-only
binding the residual risk is any local process and any local browser page that
can guess or discover the URL
, which is precisely why mitigations 5 and 6 matter
and why the endpoint should not outlive the session. The proposal should state
that the default configuration has no listening debugger endpoint at all,
and
that the endpoint's existence is evidence someone deliberately attached.


8. Cost when disarmed

The standing bar is metering neutrality and "always compiled, dormant by
default". The proposal keeps both, with one item needing care.

  • Dormant transport cost is unchanged. debug_is_active()
    (powers/debug.rs:71) is a thread-local bool read; the debug verb arm in
    lib.rs:1051 is guarded on it. Protocol choice does not touch this.
  • Metering: the sharp edge is allocation, and it is avoidable.
    meter.rs:45SLOT_ALLOCATION_METERING is added on every slot allocation
    during a run, so any debugger structure allocated in the guest slot arena
    changes computrons. XS's fxToggle does exactly that (it calls fxNewSlot).
    The CDP objectId table must therefore live in host memory, outside the slot
    arena
    , contributing only root SlotIndexes to Heap::collect's root slice.
    Do that and the disarmed cost is zero and the armed cost is zero-on-the-meter
    too, which is strictly better than the XS behaviour being ported from. This
    should be an explicit acceptance bar: the same program run with and without a
    debugger attached reports identical computrons.
    Under Agoric consensus that is
    not a nicety.
  • The one new cost: Debugger.pause responsiveness. CDP lets a client pause a
    running target. Honouring that needs an inbound-message check on some
    periodic path. The honest options are (i) check only at existing debug points
    (breakpoint checks / line boundaries) — zero new cost, but pause does nothing
    in a tight loop with no debug points; (ii) fold the check into the metering
    check, which already fires at loop-closing points (meter.rs:12–16: backward
    branches, calls, returns, catches) — near-zero cost since that branch is
    already taken, and it makes pause work in exactly the places a hung program
    actually is. (ii) is the right answer and it is a nice piece of
    serendipity: the meter's check points are already the "is this program
    looping" points.
  • The C-XS path is not perturbed. Nothing proposed here touches
    c/moddable, xsnap-platform.c, or the debug cargo feature. The 11 Rust
    debug-protocol tests keep guarding it and keep skipping on a default build.

9. Does adopting CDP subsume the caught-vs-uncaught investigation?

Largely yes on the protocol half, and no on the VM half. The two jobs converge
but do not merge.

Protocol half — subsumed, verified. Debugger.setPauseOnExceptions takes a
state parameter with allowed values none, caught, uncaught, all
(verified against the
Debugger domain reference:
"Defines pause on exceptions state. Can be set to stop on all exceptions,
uncaught exceptions, or caught exceptions, no exceptions"
). That is exactly the
four-way choice the sibling job asks for, already standardized, already
understood by every client, with no wire extension and no
uncaughtExceptions-pseudo-breakpoint invention needed. Adopting CDP deletes the
sibling job's entire §3 (protocol shape: reuse the pseudo-breakpoint / add a new
one / build a general condition mechanism), and with it the "is this compatible
with an unmodified xsbug client" constraint that was going to be the hardest
thing to satisfy.

Related: Debugger.paused.reason distinguishes exception from
promiseRejection — a distinction the sibling job identifies as an awkward case
and which xsbug cannot express at all.

VM half — not subsumed, and it is the interesting half. "Can the live handler
chain tell, at throw time, that a catch sits above the throw" is an engine
question that CDP does not answer. What I found reading interp.rs at head, which
the sibling job should be told:

  • The chain is jumps: Vec<CatchJump> (interp.rs:3098), innermost last. The
    comment at interp.rs:3096 and the definition at :3144 state the structural
    invariant: "every ironhorse jump is a JS jump, XS's jump->flag = 1; the host
    is the absence of a jump"
    , and unwind_to_jump (:15705) returns None when
    the chain is empty, meaning the throw escapes every JS handler to the host.
  • So the predicate the maintainer's intuition asks for is not a walk at all —
    it is a length comparison.
    XS needs to traverse firstJump checking
    jump->flag; Ironhorse re-expressed the flag as a structural invariant, so
    "is there a JS catch above this throw" is self.jumps.len() > base — O(1),
    zero allocation, metering-neutral. This is strictly better than the C design
    in designs/daemon-xs-worker-debugger.md §"Analysis of XS internals", and it
    is better because of a porting decision already made.
  • The base matters. The generator and async run stacks record a jumps_base
    (interp.rs:7035, :7124), so inside a generator resume the relevant host
    boundary is that base, not zero.
  • Two live caveats. (i) yield and await inside a live try are currently
    unimplementedHalt::Unsupported("generator:yield-in-try") (interp.rs:7042)
    and Halt::Unsupported("await:await-in-try") (interp.rs:7129), both flagged as
    needing "the jump chain snapshotted and rebased". So the predicate is trivially
    correct today and must be re-examined when that lands (stage 4). (ii) An empty
    chain does not reliably mean "uncaught": interp.rs carries a
    meter-reversal path for "a throw that reached the host boundary of a re-entrant
    run_callback was actually caught by a native mxTry (a promise reaction
    handler or a thenable then)"
    (:15735–15737). A throw inside a promise
    reaction has an empty JS chain and is nonetheless caught — it becomes a
    rejection. This defeats the naive intuition precisely where the sibling job
    predicted it might, and it is the reason CDP's separate promiseRejection
    pause reason exists.

Recommendation for the two jobs: keep them separate but retarget the sibling.
Its protocol section should be replaced by "adopt Debugger.setPauseOnExceptions";
its VM section — which is the part with real content — should be sharpened onto
the jumps vector, the generator/async jumps_base, and the promise-reaction
host-boundary case above.


10. What I could not establish

Stated as open rather than guessed:

  • The exact handshake Chrome DevTools and js-debug require before they render.
    No specification states it and no browser was available. §2 names the
    experiment that settles it definitively in about a day; that experiment should
    gate any implementation estimate.
  • Whether Debugger.getScriptSource is genuinely optional. Evidence is
    Hermes' tracked set not listing it; the tracker may be incomplete.
  • Column precision. Whether Ironhorse's compiled output can carry column
    information at all, and at what cost, is not something I could determine from
    the code I read; ironhorse-compile is mid-port (stage 5). This is the item
    most likely to surprise an estimate.
  • Whether the endor-debug drop (§0) was deliberate. Worth a direct answer
    from whoever restaged the branch on 2026-07-29.
  • Profiling. CDP's Profiler domain and .cpuprofile export were out of
    scope here; xsbug's <start-profiling>/<pr>/<ps>/<pt> records have a
    natural CDP counterpart and the original design doc lists profiling as a use
    case. Someone should scope it separately.

11. Rough sizing

Not an estimate — a shape, for whoever writes the proposal.

Slice Content Risk
0 The §2 handshake experiment: stub CDP server, record what DevTools and js-debug actually send low, high value, gates everything
1 Framing + transport + per-session loopback listener + the §7 security posture low
2 Debugger domain over VM break/step hooks: scriptParsed, breakpoints, stepping, paused/resumed, setPauseOnExceptions medium
3 The object model: objectId side table, GC rooting, getProperties, RemoteObject shaping, releaseObject/releaseObjectGroup highest — §3 is the substance
4 evaluateOnCallFrame + Runtime.evaluate + the debugEval re-entrancy guard medium
5 Compartments as execution contexts; synthetic URLs; getScriptSource or the out-of-band decision medium, design-heavy
6 Discovery endpoints, opt-in; VS Code launch-config documentation low
Column precision unknown — scope before committing

Filed by the garden fleet under job endor-debugger-cdp-devtools-investigation.
Research and proposal preparation only; no engine code was changed and no pull
request is opened by this job.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions