Skip to content

feat(desktop): codex app-server transport, correcting L4a's dead daemon rung (L4b) - #569

Merged
physercoe merged 2 commits into
mainfrom
feat-vision-l4b-codex-transport
Aug 16, 2026
Merged

feat(desktop): codex app-server transport, correcting L4a's dead daemon rung (L4b)#569
physercoe merged 2 commits into
mainfrom
feat-vision-l4b-codex-transport

Conversation

@physercoe

Copy link
Copy Markdown
Owner

What

The transport a local codex session uses to reach an app-server — and a
correction to the rung #568 (L4a) shipped two commits ago.

The bug this fixes

L4a shipped codex app-server proxy --sock <path> as the daemon data path,
on the strength of that subcommand's help text: "Proxy stdio bytes to the
running app-server control socket"
. It cannot carry the protocol, and it
fails in the worst possible way — exit 0, no stdout, no stderr. A dead
channel that reads exactly like a quiet agent.

Measured with a logging relay placed between the CLI and its own socket:

what it puts on the wire
daemon version (the vendor's own client) GET / HTTP/1.1 + Upgrade: websocket101 Switching Protocols, then JSON-RPC inside frames
app-server proxy --sock our stdin verbatim, no upgrade

The daemon closes on the first byte of anything that is not an HTTP upgrade —
valid JSON-RPC and pure garbage close identically, so it is not a parse error.

So the control socket is a WebSocket server bound to a Unix domain socket
(the daemon's own argv is app-server --listen unix://). The plan's original
"WebSocket attach" was right and L4a's correction of it was wrong; what the
plan actually got wrong was the auth — there is no bearer scheme, the socket is
srw------- and filesystem permissions are the auth.

Both causes L4a guessed at are now disproven: not a framing question
(Content-Length and half-close fail identically), and not
enable-remote-control — the live round trip succeeds with remote control
reporting disabled throughout, so that security-posture toggle was never
needed and stays off.

As built

  • CodexAttachPlan is now a discriminated union. A daemon plan carries a
    socketPath and cannot carry an argv — the shipped bug is unrepresentable,
    not merely fixed.
  • codexchannel.ts opens either rung behind one send/onFrame surface.
    The spawn rung reassembles newline-delimited JSON across chunk boundaries; the
    WebSocket rung must not buffer, since a message with no trailing newline is
    already complete and holding it would stall the channel forever.
  • perMessageDeflate: false is load-bearing. ws offers permessage-deflate
    by default and the daemon hangs up on the handshake rather than declining
    the extension. Isolated by varying one option at a time against the live
    daemon.
  • A daemon that will not come up falls back to spawn, and says so. "Shared
    with your codex TUI" and "dies with this window" are different promises; a
    silent downgrade leaves the user holding the wrong one. And close() on a
    daemon channel closes our socket only — the rung exists so the session
    outlives us.
  • codexBinary searches the well-known install dirs, because the official
    installer writes ~/.local/bin/codex with its PATH line in .bashrc — which
    a GUI-launched Electron app never sources.

Verification

  • 26 unit tests; 13 mutations, all killed.
  • An opt-in live e2e (TERMIPOD_CODEX_ATTACH_E2E=1) round-trips
    initialize against a real daemon and asserts the daemon survives a client
    disconnect. It restores the daemon's prior state afterwards.
  • The deflate mutation is killed only by the e2e, and it fails with the real
    symptom (socket hang up). No unit test can see that handshake — which is the
    whole reason L4a's error survived review.
  • Full electron suite 658 tests / 0 fail, typecheck clean, CI lint set + desktop
    token ratchet + openapi all pass.

Scope

Not yet reachable from main.ts, like L4a — this is the transport. L4c is
the driver that wires it in, so no user-visible behaviour changes yet. The
translation half already exists: the hub's codex frame profile ships to the
desktop in agent_families.generated.json, so L4c reads that table rather than
writing a second one.

Ubuntu and others added 2 commits August 16, 2026 13:02
…on rung (L4b)

L4a shipped `codex app-server proxy --sock <path>` as the daemon data
path, on the strength of that subcommand's help text ("Proxy stdio bytes
to the running app-server control socket"). It cannot carry the protocol,
and it fails in the worst possible way: exit 0, no stdout, no stderr.

Measured with a logging relay between the CLI and its own control socket:

  - `daemon version` — the vendor's OWN client for this socket — opens
    with `GET / HTTP/1.1` + `Upgrade: websocket`, takes `101 Switching
    Protocols`, and only then speaks JSON-RPC inside frames.
  - `app-server proxy --sock` forwards stdin verbatim with no upgrade.
    The daemon closes on the first byte of anything that is not an HTTP
    upgrade — valid JSON-RPC and pure garbage close identically, so it is
    not a parse error.

So the control socket is a WebSocket server bound to a Unix domain socket
(the daemon's own argv is `app-server --listen unix://`). The plan's
original "WebSocket attach" was right and L4a's correction of it was
wrong; what the plan actually got wrong was the auth — there is no bearer
scheme, the socket is `srw-------` and filesystem permissions are the
auth. Both causes L4a guessed at are disproven: not a framing question
(Content-Length and half-close fail identically), and not
`enable-remote-control` — the live round trip succeeds with remote
control reporting `disabled` throughout, so that toggle stays off.

  - `CodexAttachPlan` becomes a discriminated union: a `daemon` plan
    carries a `socketPath` and cannot carry an argv, so the shipped bug
    is now unrepresentable rather than merely fixed.
  - `codexchannel.ts` opens either rung behind one send/onFrame surface.
    The spawn rung reassembles newline-delimited JSON across chunk
    boundaries; the WebSocket rung must NOT buffer, since a message with
    no trailing newline is already complete and holding it would stall
    the channel forever.
  - `perMessageDeflate: false` is load-bearing: `ws` offers
    permessage-deflate by default and the daemon hangs up on that
    handshake rather than declining the extension. Isolated by varying
    one option at a time against the live daemon.
  - A daemon that will not come up falls back to spawn and SAYS so —
    "shared with your codex TUI" and "dies with this window" are
    different promises. `close()` on a daemon channel closes our socket
    only; the rung exists so the session outlives us.
  - `codexBinary` resolves through PATH plus the well-known install dirs
    the way kimiweb.ts does, because the official installer writes
    ~/.local/bin/codex with its PATH line in .bashrc — which a
    GUI-launched Electron app never sources.

Verified: 26 unit tests, 13 mutations all killed, and an opt-in live e2e
(TERMIPOD_CODEX_ATTACH_E2E=1) that round-trips `initialize` against a
real daemon. The deflate mutation is killed only by the e2e, with the
real symptom — no unit test can see that handshake.

Not yet reachable from main.ts, like L4a: this is the transport, and L4c
is the driver that wires it in. No user-visible behaviour changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spawn rung piped stderr and never read it. codex writes WARNING lines
there (the PATH-aliases complaint appears on every run against a relocated
CODEX_HOME), and an unread pipe blocks the engine outright once the ~64 KiB
buffer fills — a mid-session hang with no symptom, in the module whose own
contract says swallowing engine output is how features ship invisible.

stderr lines now flow to onJunk, never to onFrame (a JSON-shaped log line is
still a log line), the unterminated tail is flushed on exit, and the drain is
attached even when no onJunk handler is given so the pipe can never back up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@physercoe
physercoe merged commit 8873110 into main Aug 16, 2026
8 checks passed
@physercoe
physercoe deleted the feat-vision-l4b-codex-transport branch August 16, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants