| # 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: |
|
|
| ```sh |
| 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: |
|
|
| ```sh |
| 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: |
|
|
| ```text |
| 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: |
|
|
| ```text |
| seq = 0, 1, 2, 3, ... |
| ``` |
|
|
| Use contiguous segment sequence ranges to identify and stitch a segmented |
| application message: |
|
|
| ```text |
| 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: |
|
|
| ```text |
| 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: |
|
|
| ```json |
| { |
| "clientName": "my-client" |
| } |
| ``` |
|
|
| Response: |
|
|
| ```json |
| { |
| "sessionId": "00000000-0000-4000-8000-000000000001", |
| "environmentInfo": { |
| "shell": { "name": "bash", "path": "/bin/bash" }, |
| "executorVersion": "1.2.3-alpha.4", |
| "providerId": "sha256:fb4f62da3e84f6864dcec8ede7bc66f1c96ecaeaf55f8a786b85df994057c8ac", |
| "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. |
|
|
| The executor caches optional `providerId` at startup using |
| `codex_build_info::build_id(commit, target)`, which CI can also call for an |
| explicit build target. This opaque compatibility key excludes package version |
| and requires no manifest. It identifies a standard build configuration, not exact |
| executable bytes. Unstamped and legacy executors may omit it. |
|
|
| 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: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "chunks": [], |
| "nextSeq": 1, |
| "exited": false, |
| "exitCode": null, |
| "closed": false, |
| "failure": null |
| } |
| ``` |
|
|
| ### `process/write` |
|
|
| Writes raw bytes to a running process stdin. |
|
|
| Request params: |
|
|
| ```json |
| { |
| "processId": "proc-1", |
| "chunk": "aGVsbG8K" |
| } |
| ``` |
|
|
| `chunk` is base64-encoded raw bytes. In the example above it is `hello\n`. |
|
|
| Response: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "processId": "proc-1" |
| } |
| ``` |
|
|
| Response: |
|
|
| ```json |
| { |
| "running": true |
| } |
| ``` |
|
|
| If the process is already unknown or already removed, the server responds with: |
|
|
| ```json |
| { |
| "running": false |
| } |
| ``` |
|
|
| ## Notifications |
|
|
| ### `process/output` |
|
|
| Streaming output chunk from a running process. |
|
|
| Params: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| { |
| "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: |
|
|
| ```json |
| {"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: |
|
|
| ```json |
| {"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: |
|
|
| ```json |
| {"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: |
|
|
| ```json |
| {"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}} |
| ``` |
|
|