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-29 — git 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:
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:
Debugger — enable, disable, setBreakpoint, setBreakpointByUrl,
removeBreakpoint, setBreakpointsActive, setPauseOnExceptions, resume,
pause, stepInto, stepOver, stepOut, evaluateOnCallFrame, plus the
experimental setBlackboxedRanges / setBlackboxPatterns.
Events: paused, resumed, scriptParsed, breakpointResolved.
Runtime — enable, 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:27 — pub 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:81 — pub 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:
-
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.
-
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.
-
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.
-
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–1054 — debug-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:
- 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.
- 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.
- 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:
-
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.
-
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.
-
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.
-
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:45 — SLOT_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
unimplemented — Halt::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.
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-botsPR #600, branchxs2rust-endor, head33c68104b3(2026-07-30). Moddable submodule at pin23b4d6b0a65f.0. The ground has moved: the xsbug crate is not on the branch
The directive picks up from "the
endor-debugcrate atrust/engine/endor-debug,speaking xsbug's XML protocol". That crate is not present at the current PR
head. Verified three ways:
rust/engine/Cargo.tomllists seven members —ironhorse-vm,xs-oracle,ironhorse-262,ironhorse-fuzz,ironhorse-regexp,ironhorse-compile,ironhorse-snapshot. No debug crate.rust/matches*debug*.GET /repos/endojs/endo-but-for-bots/pulls/600/files,1834 files, 1812 under
rust/engine/) contains no path matchingdebug.The engine history on this branch was rebuilt on 2026-07-29 —
git log -- rust/engine/Cargo.tomlon 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, notreverted 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) andironhorse-snapshot(stage 6) are present in the tree, so the document lags thetree, 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
DebugTransportseam 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.jsonsubset of CDP). Node's inspector "supports allthe 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'sOPTIONS.mddocuments an attach-mode optionwebsocketAddress— "Exact websocket address to attach to. If unspecified, itwill 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/listdiscovery step entirely. The plain{"port": 9229}form instead discovers via HTTP, which requires implementing thediscovery 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
cdpMCP servers) from one implementation. The only scenario where DAP winsis 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:
Debugger —
enable,disable,setBreakpoint,setBreakpointByUrl,removeBreakpoint,setBreakpointsActive,setPauseOnExceptions,resume,pause,stepInto,stepOver,stepOut,evaluateOnCallFrame, plus theexperimental
setBlackboxedRanges/setBlackboxPatterns.Events:
paused,resumed,scriptParsed,breakpointResolved.Runtime —
enable,disable,evaluate,callFunctionOn,compileScript,getProperties,globalLexicalScopeNames,releaseObject,releaseObjectGroup,discardConsoleEntries,getHeapUsage. Event:consoleAPICalled.Mapped onto the capabilities the directive asks about:
Debugger.scriptParsedDebugger.setBreakpointByUrl/setBreakpoint/removeBreakpointDebugger.paused/Debugger.resumedstepInto/stepOver/stepOutDebugger.paused.callFrames[].scopeChain[]Debugger.evaluateOnCallFrameDebugger.setPauseOnExceptionsRuntime.getProperties+objectIdDebugger.getScriptSourceRuntime.consoleAPICalledTwo things to read off that table.
Debugger.getScriptSourceis absent from Hermes' tracked set. React Nativeserves source to DevTools out of band (Metro, over the script
url, plus sourcemaps 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:
My working expectation, to be confirmed by that experiment rather than trusted:
Runtime.enable(which must emitRuntime.executionContextCreatedor theconsole has no context to evaluate in),
Debugger.enable,Runtime.runIfWaitingForDebugger,and
Debugger.setPauseOnExceptionsare the ones a client will send before itrenders 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 theentire world: it calls
fxListFrames,fxListLocal,fxListGlobal, andfxListModulesunconditionally before emitting<break>, then blocks in awhile (fxIsConnected(the)) { fxReceive; fxDebugParse; }loop until the clientsends
<go/>.Object identity on the wire is a raw slot address.
fxEchoAddress(
xsDebug.c:1257) formats thetxSlot*pointer itself as hex:And the client hands that address straight back.
<toggle id="@...">is parsedinto
the->idValueas hex (xsDebug.c:814–817) and then cast back to apointer (
xsDebug.c:1151):The same cast appears for
<select>(:1123) and<eval>(:1113).fxToggle(xsDebug.c:2738) does not return anything. It flips membership inthe machine's
mxInstanceInspectorslist — a set of instances whose propertiesshould 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.RemoteObjectcarries anobjectId— "Unique identifier for non-primitivevalues" — and the specification states the lifetime explicitly: "Objects remain
in memory within their object group unless explicitly released via
releaseObjector 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-vmThree findings, all from code read at head.
(a) A
SlotIndexis not a safe handle.value.rs:27—pub struct SlotIndex(pub u32),a bare index with no generation tag.
SlotArenahas a free list (value.rs:450)and
alloc(value.rs:325) reuses it. So an index handed to a client and thenswept can be re-issued for a different object: a released
objectIdreplayedby 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:
objectIdis an opaquestring minted from a monotonic serial in a side table, never the
SlotIndexitself, and the table stores
(serial, SlotIndex)so a stale serial fails closedrather than resolving.
(b) The
objectIdtable must be a GC root.gc.rs:57—pub fn collect(&mut self, roots: &[SlotIndex]) -> GcStats, an exact mark-sweepfrom an explicit root set. Anything the debugger holds an
objectIdfor must bein that root set or it will be swept while the client still holds the handle. So
CDP's
releaseObject/releaseObjectGroupare not optional politeness in thisengine — 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 amemory-safety hole. That is a meaningfully better failure mode than the C-XS
(txSlot*)the->idValuecast, which is straightforwardly exploitable by a clientthat can choose the pointer.
(c) The table must not allocate VM slots on a metered path.
meter.rs:45—SLOT_ALLOCATION_METERING, "added byfxNewSloton every slot allocation duringa run". If the
objectIdtable were implemented as guest-heap slots (asmxInstanceInspectorsis in XS —fxNewSlotinsidefxToggle), then attaching adebugger 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/HashMapon the session), contributing only itsSlotIndexentries tothe 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 instancesget inlined into the next full echo.
Runtime.getPropertiesis a request/responseover a handle. They are not the same operation at different fidelities; they are
opposite architectures. Building
getPropertieson<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/fxEchoPropertyalready perform, minus the serialization. Both protocols canthen be written over it — xsbug's echo and CDP's
getProperties— which isprecisely what makes the "keep both protocols" option in §6 cheap.
4. Script identity and source, with compartments taken seriously
CDP's
Debugger.scriptParsedcarriesscriptId,url,hash,executionContextId,sourceMapURL,hasSourceURL,isModule,startLine/endLine,length(verified against the Debugger domain).
Debugger.setBreakpointByUrlmatches onurlorurlRegex— that is DevTools'primary breakpoint path, because it survives reloads, and it is URL-keyed.
Debugger.setBreakpointtakes aLocation(scriptId + line + column) and isscript-keyed.
What Endor can honestly supply, per code read:
unit at compile time. This is new bookkeeping but trivial.
a line (
xsDebug.c:2391fxListLocal, and the path/line walk infxDebugLoop).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.
after compilation — the compile path produces bytecode (
Compartment::evaluate(bytecode),compartment.rs:294). ServingDebugger.getScriptSourcemeans eitherretaining 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.
sourceMapURLis astring 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:81—pub struct CompartmentId(pub usize);compartment.rs:225— each
Compartmentowns its ownModuleGraph;module.rs:276—ModuleGraph::resolve(&self, specifier: &str)keys modules by specifier withinthat graph. So module identity in Endor is
(CompartmentId, specifier). Aspecifier 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:
The same source text instantiated in two compartments is two scripts.
Two
scriptParsedevents, twoscriptIds, and — importantly — asetBreakpointByUrlon the shared URL should arm both, which is exactlywhat
setBreakpointByUrlis designed to do (it returns alocationsarray,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.
executionContextIdis the compartment. CDP already has the concept —ExecutionContextDescriptioncarriesid,origin,name,auxData. Thehonest mapping is one CDP execution context per compartment, with the
compartment name (
Compartment::name(),compartment.rs:188) as the contextnameand the compartment id inauxData. DevTools renders a contextpicker, 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.The URL is synthetic and must be admitted as such. A specifier like
./util.jsresolved inside a compartment namedguest-7has no honest fileURL. The workable scheme is a synthetic hierarchical URL that round-trips —
e.g.
endo://<compartment-name>/<specifier>— with thelocalRoot/remoteRootmapping 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
importHookthat synthesizes modules has no on-disk source, andDevTools will show "source unavailable" for it. That is an honest degradation,
not a blocker.
SES freezing is not a problem for CDP itself, but is for evaluation.
Debugger.evaluateOnCallFrameis arbitrary evaluation in the frame's scope. Ina 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 infxDebugThrow,xsDebug.c:1220, to suppress recursive break-on-throw). Anyport needs the equivalent, and CDP's
silentparameter onevaluateOnCallFrameis 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–1054—debug-attach(callsdebug_enable(),runs the debugger, answers
debug-attached),debug-detach(debug_reset(),answers
debug-detached), anddebug(pushes payload inbound, pumps, flushesoutbound).
rust/endo/xsnap/src/powers/debug.rs— the whole transport: a thread-localDebugState { 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 routingof
debug/debug-attached/debug-detachedinto a per-workerDebugSession(packages/daemon/src/debug-session.js, a hand-written SAXparser) exposed as a
Debuggerexo (packages/daemon/src/debugger.js).What has to exist for CDP. Three pieces, none of which touch the engine:
{id, method, params}→{id, result}or
{id, error}, with events as{method, params}. Trivial over the existingbyte transport.
WebSocket. The daemon already runs one:
packages/daemon/src/ws-gateway.jscreates a
node:httpserver plus awsWebSocketServer(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, nota path on the gateway — see §7 for why that separation is load-bearing.
GET /json/versionandGET /json/listreturningwebSocketDebuggerUrlper target(HTTP endpoints). This
is what makes
chrome://inspectlist your workers and what makes{"port": 9229}work in VS Code without hand-copying a URL. It is also thesurface with the worst security history (§7). Recommendation: implement it,
but make it opt-in and loopback-only, with the
websocketAddressform as thedocumented default path.
Does
DebugTransportsurvive? 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
debugverb's usage: xsbug is a synchronous stop-the-world loop (fxDebugLoopblocks in
fxReceiveuntil<go/>,xsDebug.c:608), whereas CDP expects thetarget to keep answering
Runtime.getPropertiesandDebugger.evaluateOnCallFramewhile paused and to accept
Debugger.pausewhile running. The pump thereforeneeds 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.pauseis not ignored until the next natural break. That second oneis 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.
executionContextIdmaps tocompartments honestly,
objectIdlifetime handled where the GC roots live,no translation impedance. Every CDP client works.
designs/ironhorse-engine.mddecision 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.
objectIdtable + GC rooting +
getPropertiesis one substantial slice; theDebuggerdomain 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.path working identically since it drives the same wire protocol; can be
thrown away.
of. Specifically: (i)
Runtime.getPropertiesbecomes the quadratictoggle-and-re-echo dance described in §3, (ii) there is no compartment/execution-context
concept to map, (iii)
objectIdstability depends on raw slot addresses thatchange under GC compaction — and
gc.rsexplicitly slide-compacts the chunkarena, so addresses are not stable across a collection, (iv)
evaluateOnCallFramemaps onto
<eval>/<script>which the current Rust port lists as parsed-but-inert.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:
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.
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.
The handle model is safer. Opaque serial → side table (§3) versus
(txSlot*)the->idValue(xsDebug.c:1151). For an engine whose headline claimis
forbid(unsafe_code), shipping a protocol whose C ancestor castsclient-supplied hex to a pointer is a bad look even though the Rust port would
necessarily do something else.
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-detachenvelope verbs andthe
Debuggerexo — those are the Endo-facing ocap surface and they shouldsurvive protocol choice. The 16 CapTP debugger tests
(
packages/daemon/test/debugger-captp.test.js) test the exo, not the wire, andshould mostly survive. The 11 Rust debug-protocol tests
(
rust/endo/xsnap/src/debug_protocol_tests.rs) test the C-XS xsbug path andshould 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 speakingxsbug to xsbug; Ironhorse speaks CDP; the exo API is the same on both, with
setExceptionBreakModeand friends implemented differently underneath. Ifbyte-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.evaluateOnCallFrameis arbitrary code execution in any frame;
Runtime.getPropertieswalks the entirereachable heap;
Debugger.setBreakpoint+evaluateOnCallFrametogether are ageneral-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:9229by default, and verifythe
Hostheader is precisely an IP orlocalhostto defeat DNS rebinding. Nodealso recommends SSH tunnelling (
ssh -L 9221:localhost:9229 user@host) over everbinding 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 unixdomain socket (
makePrivatePathService(… sockPath …),packages/daemon/src/manager-node.js:166) — filesystem-permission-gated, notnetwork-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:
Debuggerexo remains the authority. Attaching stays what it is today:E(daemon).attachDebugger(worker)returns a capability, and holding thatcapability is what authorizes debugging. No capability, no attach. This is the
ocap-correct answer and it already exists.
exists only for the lifetime of a
Debuggerexo, is created on demand, anddies with it. There is no daemon-wide
--inspectport. This is the keydifference 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.
own CapTP path. Filesystem permissions do the authentication. Both Chrome
DevTools and js-debug want
ws://, so a TCP loopback listener is needed forthe ergonomic path — mint it per-session on an ephemeral port bound to
127.0.0.1with a long unguessable path segment (Node's UUID pattern,ws://127.0.0.1:PORT/<uuid>), which is a capability URL and should be treatedas secret.
ENDO_ADDRprecedent (manager-node.js:176) — a plain env var that can movethe listener to
0.0.0.0— must not be replicated for the debugger. Ifremote debugging is wanted, the answer is an SSH tunnel, as Node recommends.
HostandOriginon the upgrade. Reject anyHostthat is notloopback (Node's DNS-rebinding defence) and reject any request carrying an
Originheader at all — a genuine DevTools/js-debug client sends none, and abrowser page attempting the connection sends one. Note the existing gateway
does neither today; do not inherit that.
/json/listis the classic exposure: it enumeratestargets 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
websocketAddressas the defaultpath.
getScriptSourceretains guestsource 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.
debug_is_active()(
powers/debug.rs:71) is a thread-local bool read; thedebugverb arm inlib.rs:1051is guarded on it. Protocol choice does not touch this.meter.rs:45—SLOT_ALLOCATION_METERINGis added on every slot allocationduring a run, so any debugger structure allocated in the guest slot arena
changes computrons. XS's
fxToggledoes exactly that (it callsfxNewSlot).The CDP
objectIdtable must therefore live in host memory, outside the slotarena, contributing only root
SlotIndexes toHeap::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.
Debugger.pauseresponsiveness. CDP lets a client pause arunning 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
pausedoes nothingin 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: backwardbranches, calls, returns, catches) — near-zero cost since that branch is
already taken, and it makes
pausework in exactly the places a hung programactually 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.
c/moddable,xsnap-platform.c, or thedebugcargo feature. The 11 Rustdebug-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.setPauseOnExceptionstakes astateparameter with allowed valuesnone,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 thesibling 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.reasondistinguishesexceptionfrompromiseRejection— a distinction the sibling job identifies as an awkward caseand 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.rsat head, whichthe sibling job should be told:
jumps: Vec<CatchJump>(interp.rs:3098), innermost last. Thecomment at
interp.rs:3096and the definition at:3144state the structuralinvariant: "every ironhorse jump is a JS jump, XS's
jump->flag = 1; the hostis the absence of a jump", and
unwind_to_jump(:15705) returnsNonewhenthe chain is empty, meaning the throw escapes every JS handler to the host.
it is a length comparison. XS needs to traverse
firstJumpcheckingjump->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 itis better because of a porting decision already made.
basematters. The generator and async run stacks record ajumps_base(
interp.rs:7035,:7124), so inside a generator resume the relevant hostboundary is that base, not zero.
yieldandawaitinside a livetryare currentlyunimplemented —
Halt::Unsupported("generator:yield-in-try")(interp.rs:7042)and
Halt::Unsupported("await:await-in-try")(interp.rs:7129), both flagged asneeding "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.rscarries ameter-reversal path for "a throw that reached the host boundary of a re-entrant
run_callbackwas actually caught by a nativemxTry(a promise reactionhandler or a thenable
then)" (:15735–15737). A throw inside a promisereaction 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
promiseRejectionpause 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
jumpsvector, the generator/asyncjumps_base, and the promise-reactionhost-boundary case above.
10. What I could not establish
Stated as open rather than guessed:
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.
Debugger.getScriptSourceis genuinely optional. Evidence isHermes' tracked set not listing it; the tracker may be incomplete.
information at all, and at what cost, is not something I could determine from
the code I read;
ironhorse-compileis mid-port (stage 5). This is the itemmost likely to surprise an estimate.
endor-debugdrop (§0) was deliberate. Worth a direct answerfrom whoever restaged the branch on 2026-07-29.
Profilerdomain and.cpuprofileexport were out ofscope here; xsbug's
<start-profiling>/<pr>/<ps>/<pt>records have anatural 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.
Debuggerdomain over VM break/step hooks:scriptParsed, breakpoints, stepping,paused/resumed,setPauseOnExceptionsobjectIdside table, GC rooting,getProperties,RemoteObjectshaping,releaseObject/releaseObjectGroupevaluateOnCallFrame+Runtime.evaluate+ thedebugEvalre-entrancy guardgetScriptSourceor the out-of-band decisionFiled 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.