Files
codex/codex-rs/exec-server
ostepanian 9e680a52e7 Support host-accepted exec-server WebSockets (#39786)
## What changed

- Add `EnvironmentManager::from_accepted_websocket` so embedding hosts can
  construct a remote environment from an already accepted and authenticated
  Axum WebSocket.
- Add `replace_accepted_websocket` to retire the current transport and resume
  the same exec-server session on a host-supplied replacement connection.
- Serialize replacement handoffs, reject overlapping replacements, and release
  the handoff claim when a replacement attempt is cancelled or fails.

## Testing

- Cover initial connection validation and immediate environment readiness.
- Verify replacement retry behavior and recovery of a running process and its
  output after reconnecting.

GitOrigin-RevId: 1f2ab7bcf7b5abbbece5c101801432dc84a8058d
2026-08-20 19:33:48 +00:00
..

codex-exec-server

codex-exec-server is the library backing codex exec-server, a small JSON-RPC server for spawning and controlling subprocesses through codex-utils-pty.

It provides:

  • a CLI entrypoint: codex exec-server
  • a Rust client: ExecServerClient
  • a small protocol module with shared request/response types

This crate owns the transport, protocol, and filesystem/process handlers. The top-level codex binary owns hidden helper dispatch for sandboxed filesystem operations and codex-linux-sandbox.

Transport

The server speaks the exec-specific codex-exec-server-protocol message envelope on the wire.

The CLI entrypoint supports:

  • ws://IP:PORT (default)
  • --remote URL --environment-id ID [--name NAME]
  • forward --connect ws://HOST:PORT --remote URL --environment-id ID

Remote mode registers the local exec-server with the environment registry, then reconnects to the service-provided rendezvous websocket as the environment. Remote communication uses the Noise relay contract; the registry and harness must support it. Forward mode uses the same registration and Noise relay, but opens an independent WebSocket connection to the destination exec-server for each authenticated harness stream. Complete message payloads pass unchanged in both directions; the forwarder does not parse RPCs, initialize sessions, or execute requests. The destination owns session IDs, processes, and session resumption. Disconnecting either side closes its peer and resets the remote stream. The existing harness reconnect flow can then resume a retained destination session. The forwarder does not replay requests or persist execution state, so recovery is limited by the destination's session and process-output retention. It uses the standard Codex ChatGPT sign-in state; run codex login first when remote registration needs authentication. Containerized callers that receive an Agent Identity JWT in CODEX_ACCESS_TOKEN can opt into that auth path with --use-agent-identity-auth; Codex then registers an Agent task and sends the derived AgentAssertion headers on the registry request.

Alternatively, API users can instead use CODEX_API_KEY; Codex sends it as a bearer token on the registration request. For example:

CODEX_API_KEY="$OPENAI_API_KEY" \
codex exec-server \
  --remote ... \
  --environment-id "$ENVIRONMENT_ID"

Wire framing:

  • local websocket: one JSON-RPC message per websocket message
  • Noise remote websocket: binary protobuf relay frames carrying encrypted payloads

Remote Relay Message Format

In remote mode, the harness and environment communicate through rendezvous using codex.exec_server.relay.v1.RelayMessageFrame; the checked-in schema is in src/proto/codex.exec_server.relay.v1.proto. The relay frame carries stream identity plus endpoint-owned reliability metadata:

version
stream_id
traceparent       // optional W3C parent on the first frame of a traced request
tracestate        // optional W3C vendor state paired with traceparent
body              // handshake | data | ack_frame | resume | reset | heartbeat
ack               // highest contiguous peer segment seq received
ack_bits          // bitset for peer segment seqs after ack
seq               // data only: segment sequence number
segment_index     // data only: 0-based index within message
segment_count     // data only: number of segments in message
payload           // handshake bytes or encrypted data record
next_seq          // resume only: next sender seq
reason            // reset only: reset reason

stream_id identifies one virtual harness/environment JSON-RPC session on the environment websocket. The harness generates a UUIDv4 stream_id; the environment demuxes frames by stream_id and runs an independent ConnectionProcessor per stream.

Use segment-level sequence numbers for reliability:

seq = 0, 1, 2, 3, ...

Use contiguous segment sequence ranges to identify and stitch a segmented application message:

message_start_seq = seq - segment_index
segment_index = 0
segment_count = 1

message_start_seq is derived by the receiver, not sent on the wire. For unsplit messages, message_start_seq == seq, segment_index == 0, and segment_count == 1.

Use cumulative ack plus fixed-size ack_bits instead of variable ack ranges:

ack = highest contiguous received segment seq
bit i in ack_bits acknowledges seq = ack + 1 + i

Send ack and ack_bits redundantly on every outbound frame. Acks are not themselves acked. Acks, retries, duplicate suppression, segmentation, and reassembly are endpoint responsibilities; rendezvous only routes relay frames by stream_id.

Lifecycle

Each connection follows this sequence:

  1. Send initialize.
  2. Wait for the initialize response.
  3. Send initialized.
  4. Call process or filesystem RPCs.

Requests run sequentially by default. Pass --concurrent-requests <COUNT> to enable concurrent processing.

If the server receives any notification other than initialized, it replies with an error using request id -1.

If the websocket connection closes, the server terminates any remaining managed processes for that client connection.

API

initialize

Initial handshake request.

Request params:

{
  "clientName": "my-client"
}

Response:

{}

initialized

Handshake acknowledgement notification sent by the client after a successful initialize response.

Params are currently ignored. Sending any other notification method is treated as an invalid request.

process/start

Starts a new managed process.

Request params:

{
  "processId": "proc-1",
  "argv": ["bash", "-lc", "printf 'hello\\n'"],
  "cwd": "file:///absolute/working/directory",
  "env": {
    "PATH": "/usr/bin:/bin"
  },
  "tty": true,
  "pipeStdin": false,
  "arg0": null
}

Field definitions:

  • processId: caller-chosen stable id for this process within the connection.
  • argv: command vector. It must be non-empty.
  • cwd: file: URI for the child process working directory.
  • env: environment variables passed to the child process.
  • tty: when true, spawn a PTY-backed interactive process.
  • pipeStdin: when true, keep non-PTY stdin writable via process/write.
  • arg0: optional argv0 override forwarded to codex-utils-pty.

Response:

{
  "processId": "proc-1"
}

Behavior notes:

  • Reusing an existing processId is rejected.
  • PTY-backed processes accept later writes through process/write.
  • Non-PTY processes reject writes unless pipeStdin is true.
  • Output is streamed asynchronously via process/output.
  • Exit is reported asynchronously via process/exited.

process/read

Reads buffered output and terminal state for a managed process.

Request params:

{
  "processId": "proc-1",
  "afterSeq": null,
  "maxBytes": 65536,
  "waitMs": 1000
}

Field definitions:

  • processId: managed process id returned by process/start.
  • afterSeq: optional sequence number cursor; when present, only newer chunks are returned.
  • maxBytes: optional response byte budget.
  • waitMs: optional long-poll timeout in milliseconds.

Response:

{
  "chunks": [],
  "nextSeq": 1,
  "exited": false,
  "exitCode": null,
  "closed": false,
  "failure": null
}

process/write

Writes raw bytes to a running process stdin.

Request params:

{
  "processId": "proc-1",
  "chunk": "aGVsbG8K"
}

chunk is base64-encoded raw bytes. In the example above it is hello\n.

Response:

{
  "status": "accepted"
}

Behavior notes:

  • Writes to an unknown processId are rejected.
  • Writes to a non-PTY process are rejected unless it started with pipeStdin.

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",
  "seq": 1,
  "stream": "stdout",
  "chunk": "aGVsbG8K"
}

Fields:

  • processId: process identifier
  • seq: per-process output sequence number
  • stream: "stdout", "stderr", or "pty"
  • chunk: base64-encoded output bytes

process/exited

Final process exit notification.

Params:

{
  "processId": "proc-1",
  "seq": 2,
  "exitCode": 0,
  "sandboxDenied": false
}

sandboxDenied lets streaming clients preserve executor-side sandbox denial detection without issuing a final process/read request. Clients recover it with process/read when an older server omits the field.

process/closed

Notification emitted after process output is closed and the process handle is removed.

Params:

{
  "processId": "proc-1",
  "seq": 3
}

Filesystem RPCs

Filesystem methods require valid file: URI strings and return JSON-RPC errors for invalid or unavailable paths. Native absolute path strings are rejected; callers must convert them to file: URIs before sending requests:

  • fs/readFile
  • fs/open, fs/readBlock, and fs/close (internal transport for ExecutorFileSystem::read_file_stream)
  • fs/writeFile
  • fs/createDirectory
  • fs/getMetadata
  • fs/canonicalize
  • fs/readDirectory
  • fs/remove
  • fs/copy

Each filesystem request accepts an optional sandbox object. When sandbox contains a ReadOnly or WorkspaceWrite policy, the operation runs in a hidden helper process launched from the top-level codex executable and prepared through the shared sandbox transform path. Helper requests and responses are passed over stdin/stdout.

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
  • sandbox-denied filesystem operations

Rust surface

The crate exports:

  • ExecServerClient
  • ExecServerError
  • ExecServerClientConnectOptions
  • RemoteExecServerConnectArgs
  • protocol request/response structs for process and filesystem RPCs
  • DEFAULT_LISTEN_URL and ExecServerListenUrlParseError
  • ExecServerRuntimePaths
  • run_main() for embedding the websocket server
  • RemoteEnvironmentConfig and run_remote_environment() for embedding remote registration mode

Callers must pass ExecServerRuntimePaths and an explicitly configured HttpClientFactory to run_main(). The top-level codex exec-server command builds these paths from the codex arg0 dispatch state and resolves its HTTP client factory from the effective Codex configuration. RemoteEnvironmentConfig::new(...) also takes the auth provider and HTTP client factory that remote registration mode should use; the CLI builds the auth provider from Codex auth state before starting remote mode.

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":"file:///tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"pipeStdin":false,"arg0":null}}
{"id":2,"result":{"processId":"proc-1"}}
{"method":"process/output","params":{"processId":"proc-1","seq":1,"stream":"stdout","chunk":"cmVhZHkK"}}

Write to the process:

{"id":3,"method":"process/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
{"id":3,"result":{"status":"accepted"}}
{"method":"process/output","params":{"processId":"proc-1","seq":2,"stream":"stdout","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","seq":3,"exitCode":0,"sandboxDenied":false}}
{"method":"process/closed","params":{"processId":"proc-1","seq":4}}