feat(desktop): codex app-server transport, correcting L4a's dead daemon rung (L4b) - #569
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
daemon version(the vendor's own client)GET / HTTP/1.1+Upgrade: websocket→101 Switching Protocols, then JSON-RPC inside framesapp-server proxy --sockThe 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-Lengthand half-close fail identically), and notenable-remote-control— the live round trip succeeds with remote controlreporting
disabledthroughout, so that security-posture toggle was neverneeded and stays off.
As built
CodexAttachPlanis now a discriminated union. Adaemonplan carries asocketPathand cannot carry an argv — the shipped bug is unrepresentable,not merely fixed.
codexchannel.tsopens either rung behind onesend/onFramesurface.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: falseis load-bearing.wsoffers permessage-deflateby 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.
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 adaemon channel closes our socket only — the rung exists so the session
outlives us.
codexBinarysearches the well-known install dirs, because the officialinstaller writes
~/.local/bin/codexwith its PATH line in.bashrc— whicha GUI-launched Electron app never sources.
Verification
TERMIPOD_CODEX_ATTACH_E2E=1) round-tripsinitializeagainst a real daemon and asserts the daemon survives a clientdisconnect. It restores the daemon's prior state afterwards.
symptom (
socket hang up). No unit test can see that handshake — which is thewhole reason L4a's error survived review.
token ratchet + openapi all pass.
Scope
Not yet reachable from
main.ts, like L4a — this is the transport. L4c isthe driver that wires it in, so no user-visible behaviour changes yet. The
translation half already exists: the hub's
codexframe profile ships to thedesktop in
agent_families.generated.json, so L4c reads that table rather thanwriting a second one.