Files
codex/codex-rs/exec-server
ostepanian b3f5e45cc1 Add direct SigV4 transport to exec-server (#42781)
## Why

Allow remote exec servers to connect directly to AWS-hosted registries that
authenticate registry requests and WebSocket handshakes with AWS SigV4.

## What changed

- Add `--remote-transport direct` with SigV4 profile, region, and service
  options while keeping Noise as the default transport.
- Register the `direct_jsonrpc_v1` transport and carry plain exec-server
  JSON-RPC messages over the authenticated WebSocket.
- Reuse direct registrations across transient disconnects, refresh them after
  a `409 Conflict`, and require TLS for non-loopback endpoints.

## Testing

- Cover CLI validation and SigV4 request signing.
- Exercise direct registration, handshake retry behavior, JSON-RPC
  interoperability, and process recovery after reconnecting.

GitOrigin-RevId: 0755df330ba3abe5db0a516fdaa49338d9bbe2d2
2026-09-04 14:49:46 +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"

AWS-hosted registries can use SigV4 for registry requests and the executor WebSocket handshake. Select the transport and authentication with executor arguments rather than config.toml settings:

codex exec-server \
  --remote https://example.com \
  --environment-id "$ENVIRONMENT_ID" \
  --remote-transport direct \
  --aws-sigv4 \
  --aws-profile development \
  --aws-region us-west-2 \
  --aws-service bedrock-mantle

Noise remains the default transport. Direct requires --aws-sigv4, which conflicts with --use-agent-identity-auth and is not supported for Noise. The AWS options require --aws-sigv4; Direct forwarding remains unsupported. The AWS SDK default credential and region chains are used when --aws-profile or --aws-region is omitted. The signing service defaults to execute-api. Direct mode registers direct_jsonrpc_v1 through the AWS-owned /cloud/environment/{environment_id}/direct/register endpoint and carries plain exec-server JSON-RPC over the authenticated WebSocket. The existing Codex Noise registration endpoint remains unchanged. Production deployments must use TLS (https/wss).

Direct registration URLs must remain reusable across disconnects and temporary connection failures. The executor only refreshes its registration when the WebSocket handshake returns 409 Conflict. Handshake 408, 429, and 5xx responses retry with backoff using the current registration; other 4xx responses stop the executor. A backend that issues single-use connection URLs must adapt to this contract. If the initial registration or a registration refresh fails, the executor returns the error without retrying registration, matching Noise.

Wire framing:

  • local websocket: one JSON-RPC message per websocket message
  • direct remote 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:

{
  "sessionId": "00000000-0000-4000-8000-000000000001",
  "environmentInfo": {
    "shell": { "name": "bash", "path": "/bin/bash" },
    "executorVersion": "1.2.3-alpha.4",
    "cwd": "file:///workspace"
  }
}

environmentInfo contains the same executor metadata returned by environment/info, so clients can use it without a second request.

executorVersion is the executor's package release version, or 0.0.0 when unknown.

Rust clients cache this metadata for the client's lifetime, including session resumption. If initialization omits it, the first metadata request fetches and caches environment/info.

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":{"sessionId":"00000000-0000-4000-8000-000000000001","environmentInfo":{"shell":{"name":"bash","path":"/bin/bash"},"cwd":"file:///tmp"}}}
{"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}}