DOC CCR-001 REV 1.0 FECHA 2026-06-21

Dossier técnico · Reverse engineering · CLI 2.1.185

El transporte fantasma
reversing WS→CCR en --sdk-url

Binary
claude 2.1.185
Proyecto
mks-agentics gateway
Sesión
diagnóstico CLI crash
Estado
causa + fix validados
Round-trip validado Fix probado en binary real 4 fuentes trianguladas
01

Resumen ejecutivo

El harness de mks-agentics spawnea el CLI claude --sdk-url para controlarlo como backend de sesión. Tras actualizar el binary a 2.1.185, todo CLI spawneado moría con exit code 1 en menos de 500 ms — crash-relaunch loop infinito. El handoff previo dejó cinco hipótesis abiertas (TTY, stdin, channels, --print, PTY) y ninguna era la causa.

La causa real: Anthropic cambió el transporte de --sdk-url de WebSocket a un protocolo CCR (SSE downstream + REST upstream). El wss:// se reescribe internamente a https://, el CLI nunca abre un WebSocket y cuelga sub-paths REST/SSE sobre el path del sdk-url. El harness sólo servía un WebSocket en /ws/cli/:id → el primer GET .../worker/events/stream recibía 404 → exit 1. diagnóstico cerrado fix validado end-to-end

exit 1
síntoma
<500ms
time-to-crash
WS→CCR
causa raíz
PONG
round-trip validado
02

La columna del proyecto

Todo el harness se sostiene sobre un descubrimiento: el flag oculto --sdk-url del CLI (marcado .hideHelp() en Commander). Reverseado en su día desde el binary, ese flag hace que el CLI actúe como cliente de un servidor que tú controlas, hablando el mismo protocolo NDJSON que usa por stdin/stdout — pero por un canal de red.

Eso fue el leak que se convirtió en columna vertebral: permitió envolver el CLI real (claude --print --sdk-url …) y construir encima un control plane — sesiones, multi-provider, agent2agent, recordings — sin tocar el binary, sin Agent SDK, sin tmux/PTY hacks. El reversing original quedó documentado en companion-CC/WEBSOCKET_PROTOCOL_REVERSED.md (CLI v2.1.37).

principio

Control plane sobre el CLI nativo, no translation layer. Se spawnea el proceso claude real y se le habla su protocolo. Lo que cambia entre versiones es cómo viajan los bytes (el transporte), no qué se dice (el protocolo NDJSON de mensajes). Esa distinción es la clave de todo este dossier.

03

El síntoma y las cinco hipótesis muertas

El CLI conectaba (TCP/TLS verde) pero moría en milisegundos. El stderr interno decía SDKStartup: exiting without result: zero messages drained. El handoff de diagnóstico previo acumuló cinco hipótesis — todas plausibles, todas falsas.

H1

CLAUDE_CODE_ENVIRONMENT_KIND=bridge rompe el startup refutada

Resultó cosmético — sólo apaga las líneas SDKStartup en stderr.

FALSO No cambia el transporte ni el exit code.
H2

channels en modo no-TTY refutada

El flag de channels no toca el arranque del transporte.

FALSO Irrelevante para el crash.
H3

--append-system-prompt en no-TTY refutada

El system prompt no participa en la conexión.

FALSO Irrelevante.
H4

el binary requiere un PTY real refutada

isTTY no afecta el exit code; el crash ocurre antes de tocar stdin.

FALSO El transporte muere por 404, no por falta de TTY.
H5

--print incompatible con --sdk-url refutada

Al contrario — --sdk-url auto-habilita --print + stream-json.

FALSO Combinación esperada, no el problema.

El transporte cambió de WebSocket a CCR confirmada

La causa real — ni siquiera estaba en la lista de hipótesis.

VERDADERO wss:// nunca abre WebSocket; el CLI hace SSE+REST a /worker/* → 404 → exit 1.
04

La investigación

Diagnóstico por triangulación: fan-out de agentes sobre el bundle real, análisis byte-level y un capture server que reproduce el harness. Nada se aceptó por fe — el comentario del propio código (stdin EOF) resultó ser una teoría incorrecta del autor del handoff, descartada al ir al binary.

Verificado (binary + comportamiento)certeza
  • wss:// → https:// rewrite factory @209233524 + grep propio del bundle
  • GET /worker/events/stream (SSE) capture server: accept=text/event-stream, upgrade=-
  • 404 del harness → permanentCloseCode → exit 1 reproducido con TLS + /etc/hosts
  • round-trip completo PONG CCR server completo, opus-4-8 respondió
Reverseado del bundlealta confianza
  • envelope SSE: event_id, sequence_num, event_type, payload handleSSEFrame @206428738
  • gate event: client_event e!=='client_event' → drop
  • worker handshake PUT/GET /worker + worker_epoch OWt.initialize @206411998
Pendiente validaciónproducción
  • shape exacto de control_request por SSE (permisos) no ejercitado en el smoke PONG
  • reconexión con from_sequence_num + Last-Event-ID documentado, no probado
05

El root cause

El factory de transporte del bundle 2.1.185 es inequívoco: con sdkUrl siempre instancia el cliente CCR, sin rama WebSocket ni flag de escape.

bundle 2.1.185 — factory de transporte (desofuscado)
# @209233524 — selección de transporte
$ return t.sdkUrl ? new xht(t.sdkUrl, ...) : new v2t(...)
# xht = cliente CCR (SSE+REST) · v2t = stdin/stream-json
→ con --sdk-url SIEMPRE xht. No hay rama WebSocket.

# @206433678 — construcción del transporte
$ let l = new URL(t)
$ l.pathname = l.pathname.replace(/\/$/,"") + "/worker/events/stream"
$ let c = new MWt(l, ...)   # MWt = SSE transport
→ cuelga /worker/events/stream encima del path del sdk-url

# @206428738 — parser del evento SSE entrante
$ handleSSEFrame(e, t) {
$   if (e !== "client_event") { warn cli_sse_unexpected_event_type; return }
$   n = JSON.parse(t); r = n.payload
$   if (r && typeof r === "object" && "type" in r)
$     this.onData(JSON.stringify(r) + "\n")   # emite el payload como NDJSON
$ }
el 404

El primer GET <base>/worker/events/stream golpea el propio harness (127.0.0.1), no api.anthropic.com. El gateway sólo servía un WebSocket en /ws/cli/:id → esa ruta SSE no existe → 404 → el transporte marca permanentCloseCodeexit(1). El CLI muere antes de tocar Anthropic.

06

La evolución del transporte

El reversing previo (WEBSOCKET_PROTOCOL_REVERSED.md, v2.1.37) ya documentaba una jerarquía de transportes. Cruzándolo con 2.1.185 emerge una migración en tres etapas — el harness se quedó congelado en la primera.

etapa 1

WebSocket puro (sd1)

  • CLI = cliente WS bidi en /ws/cli/:id el leak original · base del harness
  • frames NDJSON sueltos 1 mensaje por frame WS
etapa 2

HybridTransport (kQA)

  • upstream HTTP POST + downstream WS opt-in con CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2
  • documentado en v2.1.37 nunca llegó a producción del harness
etapa 3

CCR full (xht/MWt/OWt)

  • SSE downstream + REST upstream, sin WS default con --sdk-url en 2.1.185
  • worker handshake + envelope event_id + delivery acks lo que reverseamos en esta sesión
07

El reversing del handshake CCR

Cerrar el round-trip fue iterativo: cada smoke contra el capture server destapó el siguiente gate. Cuatro saltos hasta el PONG.

smoke 1

404 → SSE confirmado

  • el CLI pide GET /worker/events/stream accept=text/event-stream, jamás WS upgrade
smoke 2

no_auth_headers

  • faltaba CLAUDE_CODE_SESSION_ACCESS_TOKEN el worker exige Authorization: Bearer
smoke 3

missing_epoch

  • inyectar CLAUDE_CODE_WORKER_EPOCH=1 o implementar /worker/register
smoke 4

heartbeat 404 + framing \r\n

  • añadir POST /worker/heartbeat → 200
  • SSE en LF puro, no CRLF un \r residual corrompe el event name → drop silencioso

round-trip PONG

  • user → assistant → result/success opus-4-8 respondió a través del CCR reverseado
smoke final — round-trip completo (logs del capture server)
$ GET .../worker/events/stream            # SSE abierto, 15ms
$ PUT .../worker  {worker_status:idle}     # registro
# → inyectamos user_message por SSE (event: client_event)
→ delivery ACK {status:"received"}         # el CLI recibió el input
$ PUT .../worker  {worker_status:running}  # arranca el turno
→ delivery ACK {status:"processing"}
→ POST .../worker/events  [system/init]
$ PUT .../worker  {last_served_model:claude-opus-4-8}
→ POST .../worker/events  [assistant]      # PONG
→ POST .../worker/events  [result/success] # result=PONG
→ delivery ACK {status:"processed"}
$ PUT .../worker  {worker_status:idle}      # vuelve a idle
08

El protocolo CCR completo

Todo cuelga del path base del sdk-url, que pasa de ser un WebSocket a un prefijo HTTP con SSE + REST.

Endpoints CCR (relativos a <base> = /ws/cli/:id)
MétodoPathRolSi falla
GET.../worker/events/streamSSE downstream (server→CLI)404 → exit 1
PUT.../workerregister + worker_epoch401/403/404 → exit 1
GET.../workerrestorewarning
POST.../worker/eventsupstream (CLI→server)retry/backoff
POST.../worker/events/deliveryacks: received/processing/processeddrop 4xx
POST.../worker/heartbeatlivenessbloquea procesamiento
POST.../worker/internal-eventseventos internosdrop 4xx
envelope SSE downstream (server → CLI) — framing exacto
# gate: el campo SSE event DEBE ser client_event (otro valor = drop)
# framing LF puro (\n), nunca CRLF (\r\n)
$ event: client_event
$ id: 1
$ data: {"event_id":"<uuid>","sequence_num":1,"event_type":"user","payload":{...}}
$ (línea en blanco)
# payload = mensaje NDJSON de siempre:
$ {"type":"user","session_id":"","parent_tool_use_id":null,"message":{"role":"user","content":"..."}}
09

¿Qué se reusa, qué se reescribe?

La pregunta que dimensiona el fix. El protocolo de mensajes es invariante; sólo cambia la cañería.

Las 4 capas — impacto del cambio
CapaAntes (WS)Ahora (CCR)Veredicto
Mensajes (NDJSON)user/assistant/result/control_*idénticosREUSAR adapter entero
TransporteWS frames bidiSSE-recv + REST-POSTREESCRIBIR
Envelopeframes sueltosevent_id + batch + delivery acksNUEVO
HandshakeningunoPUT /worker + epoch + authNUEVO

Se reusa tal cual

  • claude.adapter — mapeo NDJSON ↔ BrowserMessage
  • el modelo de mensajes completo (13 control subtypes, permission flow)
  • la lógica de sesión / state machine
  • el LLM call es local al CLI (CCR es sólo control plane)

Hay que reescribir / añadir

  • capa de transporte: WS → SSE downstream + REST upstream
  • envelope CCR: des/envolver event_id + payload
  • worker handshake + state machine (idle/running/idle)
  • delivery acks + heartbeat + reconexión por sequence_num
10

La receta del fix

Para la sesión de implementación. El capture server de diagnóstico (/tmp/ccr-server.ts) es la referencia funcional mínima.

1

Rutas CCR en el harness

gateway-server
  • GET/PUT .../worker + GET .../worker/events/stream (SSE)
  • POST .../worker/events + /delivery + /heartbeat + /internal-events
  • archivos: runner.ws.ts + broker.ws.ts
2

Capa de transporte

ws-bridge
  • downstream: emitir frames como event: client_event LF
  • upstream: recibir POST /worker/events, desenvolver payload
  • acks: received → processing → processed
3

Env vars en el spawn

cli-launcher
  • CLAUDE_CODE_SESSION_ACCESS_TOKEN (auth worker)
  • CLAUDE_CODE_WORKER_EPOCH o implementar /worker/register
4

Reusar

sin cambios
  • claude.adapter intacto — sólo un unwrap extra del envelope
  • el modelo de mensajes NDJSON no se toca
workaround alternativo

Verificado que no hay flag de escape para forzar WS en 2.1.185 (el factory no lo expone). La única alternativa a implementar CCR es pinear el binary a una versión pre-CCR — deuda: pierde updates, frágil con auto-update, insostenible para spawn-plane en workspaces VPS. El fix correcto es servir CCR.

11

Verificación y honestidad

Probado en binary realverde
  • el CLI 2.1.185 deja de crashear con CCR servido exit 142 = vivo en query loop
  • turno completo user→assistant→result result=PONG, opus-4-8
  • worker state machine + delivery acks observados idle→running→idle, received→processing→processed
No ejercitado en este smokea validar en impl
  • permission flow (control_request can_use_tool) por SSE el PONG no usó tools
  • reconexión / replay con sequence_num documentado del bundle, no probado
  • multi-turn largo + compaction un solo turno validado

Evidencia primaria

  • bundle claude 2.1.185 (factory + handleSSEFrame + OWt.initialize)verificado
  • capture server /tmp/ccr-server.ts (round-trip PONG)verificado
  • companion-CC/WEBSOCKET_PROTOCOL_REVERSED.md (v2.1.37)verificado

Contexto del proyecto

  • docs/jarvis/next-prompt-phase-0.18-cli-crash-diagnosis.md (handoff previo)research
  • docs/references/BRIDGE-PROTOCOL-DEEP-DIVE.mdresearch
  • ADR 0002 (sdk-url hostname check) + ADR 0004 (este transporte)verificado