Remove the stale sandbox field from the exec-server params construction and align the README wording with the simplified handshake. Co-authored-by: Codex <noreply@openai.com>
8.6 KiB
codex-exec-server
codex-exec-server is a small standalone JSON-RPC server for spawning and
controlling subprocesses through codex-utils-pty.
It currently provides:
- a standalone binary:
codex-exec-server - a transport-agnostic server runtime with stdio and websocket entrypoints
- a Rust client:
ExecServerClient - a direct in-process client mode:
ExecServerClient::connect_in_process - a separate local launch helper:
spawn_local_exec_server - a small protocol module with shared request/response types
This crate is intentionally narrow. It is not wired into the main Codex CLI or unified-exec in this PR; it is only the standalone transport layer.
The internal shape is intentionally closer to app-server than the first cut:
- transport adapters are separate from the per-connection request processor
- JSON-RPC route matching is separate from the stateful exec handler
- the client only speaks the protocol; it does not spawn a server subprocess
- the client can also bypass the JSON-RPC transport/routing layer in local in-process mode and call the typed handler directly
- local child-process launch is handled by a separate helper/factory layer
That split is meant to leave reusable seams if exec-server and app-server later share transport or JSON-RPC connection utilities. It also keeps the core handler testable without the RPC server implementation itself.
Design notes for a likely future integration with unified exec, including rough call flow, buffering, and sandboxing boundaries, live in DESIGN.md.
Transport
The server speaks the shared JSON-RPC message shapes over multiple transports.
The standalone binary supports:
stdio://(default)ws://IP:PORT
Wire framing:
- stdio: one newline-delimited JSON message per line on stdin/stdout
- websocket: one JSON-RPC message per websocket text frame
Messages on the wire use the shared codex-app-server-protocol envelope types.
Lifecycle
Each connection follows this sequence:
- Send
initialize. - Wait for the
initializeresponse. - Send
initialized. - Start and manage processes with
process/start,process/read,process/write, andprocess/terminate. - Read streaming notifications from
process/outputandprocess/exited.
If the client sends exec methods before completing the initialize /
initialized handshake, the server rejects them.
If a connection closes, the server terminates any remaining managed processes for that connection.
TODO: add authentication to the initialize setup before this is used across a
trust boundary.
API
initialize
Initial handshake request.
Request params:
{
"clientName": "my-client"
}
Response:
{}
initialized
Handshake acknowledgement notification sent by the client after a successful
initialize response. Exec methods are rejected until this arrives.
Params are currently ignored. Sending any other client notification method is a protocol error.
process/start
Starts a new managed process.
Request params:
{
"processId": "proc-1",
"argv": ["bash", "-lc", "printf 'hello\\n'"],
"cwd": "/absolute/working/directory",
"env": {
"PATH": "/usr/bin:/bin"
},
"tty": true,
"arg0": null
}
Field definitions:
argv: command vector. It must be non-empty.cwd: absolute working directory used for the child process.env: environment variables passed to the child process.tty: whentrue, spawn a PTY-backed interactive process; whenfalse, spawn a pipe-backed process with closed stdin.arg0: optional argv0 override forwarded tocodex-utils-pty.
Response:
{
"processId": "proc-1"
}
Behavior notes:
processIdis chosen by the client and must be unique for the connection.- PTY-backed processes accept later writes through
process/write. - Pipe-backed processes are launched with stdin closed and reject writes.
- Output is streamed asynchronously via
process/output. - Exit is reported asynchronously via
process/exited.
process/write
Writes raw bytes to a running PTY-backed process stdin.
Request params:
{
"processId": "proc-1",
"chunk": "aGVsbG8K"
}
chunk is base64-encoded raw bytes. In the example above it is hello\n.
Response:
{
"accepted": true
}
Behavior notes:
- Writes to an unknown
processIdare rejected. - Writes to a non-PTY process are rejected because stdin is already closed.
process/read
Reads retained output from a managed process by sequence number.
Request params:
{
"processId": "proc-1",
"afterSeq": 0,
"maxBytes": 65536,
"waitMs": 250
}
Response:
{
"chunks": [
{
"seq": 1,
"stream": "pty",
"chunk": "aGVsbG8K"
}
],
"nextSeq": 2,
"exited": false,
"exitCode": null
}
Behavior notes:
- Output is retained in bounded server memory so callers can poll without relying only on notifications.
afterSeqis exclusive:0reads from the beginning of the retained buffer.waitMswaits briefly for new output or exit if nothing is currently available.- Once retained output exceeds the per-process cap, oldest chunks are dropped.
process/terminate
Terminates a running managed process.
Request params:
{
"processId": "proc-1"
}
Response:
{
"running": true
}
If the process is already unknown or already removed, the server responds with:
{
"running": false
}
Notifications
process/output
Streaming output chunk from a running process.
Params:
{
"processId": "proc-1",
"stream": "stdout",
"chunk": "aGVsbG8K"
}
Fields:
processId: process identifierstream:"stdout","stderr", or"pty"for PTY-backed processeschunk: base64-encoded output bytes
process/exited
Final process exit notification.
Params:
{
"processId": "proc-1",
"exitCode": 0
}
Errors
The server returns JSON-RPC errors with these codes:
-32600: invalid request-32602: invalid params-32603: internal error
Typical error cases:
- unknown method
- malformed params
- empty
argv - duplicate
processId - writes to unknown processes
- writes to non-PTY processes
Rust surface
The crate exports:
ExecServerClientExecServerClientConnectOptionsRemoteExecServerConnectArgsExecServerLaunchCommandExecServerEventSpawnedExecServerExecServerErrorExecServerTransportspawn_local_exec_server(...)- protocol structs such as
ExecParams,ExecResponse,WriteParams,TerminateParams,ExecOutputDeltaNotification, andExecExitedNotification run_main()andrun_main_with_transport(...)
Binary
Run over stdio:
codex-exec-server
Run as a websocket server:
codex-exec-server --listen ws://127.0.0.1:8080
Client
Connect the client to an existing server transport:
ExecServerClient::connect_stdio(...)ExecServerClient::connect_websocket(...)ExecServerClient::connect_in_process(...)for a local no-transport mode backed directly by the typed handler
Timeout behavior:
- stdio and websocket clients both enforce an initialize-handshake timeout
- websocket clients also enforce a connect timeout before the handshake begins
Events:
ExecServerClient::event_receiver()yieldsExecServerEvent- output events include both
stream(stdout,stderr, orpty) and raw bytes - process lifetime is tracked by server notifications such as
process/exited, not by a client-side process registry
Spawning a local child process is deliberately separate:
spawn_local_exec_server(...)
Example session
Initialize:
{"id":1,"method":"initialize","params":{"clientName":"example-client"}}
{"id":1,"result":{}}
{"method":"initialized","params":{}}
Start a process:
{"id":2,"method":"process/start","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\\n'; while IFS= read -r line; do printf 'echo:%s\\n' \"$line\"; done"],"cwd":"/tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"arg0":null}}
{"id":2,"result":{"processId":"proc-1"}}
{"method":"process/output","params":{"processId":"proc-1","stream":"pty","chunk":"cmVhZHkK"}}
Write to the process:
{"id":3,"method":"process/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
{"id":3,"result":{"accepted":true}}
{"method":"process/output","params":{"processId":"proc-1","stream":"pty","chunk":"ZWNobzpoZWxsbwo="}}
Terminate it:
{"id":4,"method":"process/terminate","params":{"processId":"proc-1"}}
{"id":4,"result":{"running":true}}
{"method":"process/exited","params":{"processId":"proc-1","exitCode":0}}