Run exec-server fs operations through sandbox helper (#17294)

## Summary
- run exec-server filesystem RPCs requiring sandboxing through a
`codex-fs` arg0 helper over stdin/stdout
- keep direct local filesystem execution for `DangerFullAccess` and
external sandbox policies
- remove the standalone exec-server binary path in favor of top-level
arg0 dispatch/runtime paths
- add sandbox escape regression coverage for local and remote filesystem
paths

## Validation
- `just fmt`
- `git diff --check`
- remote devbox: `cd codex-rs && bazel test --bes_backend=
--bes_results_url= //codex-rs/exec-server:all` (6/6 passed)

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-12 18:36:03 -07:00
committed by GitHub
parent 7c1e41c8b6
commit d626dc3895
52 changed files with 2313 additions and 895 deletions

View File

@@ -1,27 +1,25 @@
# codex-exec-server
`codex-exec-server` is a small standalone JSON-RPC server for spawning
and controlling subprocesses through `codex-utils-pty`.
`codex-exec-server` is the library backing `codex exec-server`, a small
JSON-RPC server for spawning and controlling subprocesses through
`codex-utils-pty`.
This PR intentionally lands only the standalone binary, client, wire protocol,
and docs. Exec and filesystem methods are stubbed server-side here and are
implemented in follow-up PRs.
It provides:
It currently provides:
- a standalone binary: `codex-exec-server`
- a CLI entrypoint: `codex exec-server`
- a Rust client: `ExecServerClient`
- 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.
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 shared `codex-app-server-protocol` message envelope on
the wire.
The standalone binary supports:
The CLI entrypoint supports:
- `ws://IP:PORT` (default)
@@ -36,7 +34,7 @@ Each connection follows this sequence:
1. Send `initialize`.
2. Wait for the `initialize` response.
3. Send `initialized`.
4. Call exec or filesystem RPCs once the follow-up implementation PRs land.
4. Call process or filesystem RPCs.
If the server receives any notification other than `initialized`, it replies
with an error using request id `-1`.
@@ -72,7 +70,7 @@ Handshake acknowledgement notification sent by the client after a successful
Params are currently ignored. Sending any other notification method is treated
as an invalid request.
### `command/exec`
### `process/start`
Starts a new managed process.
@@ -87,7 +85,6 @@ Request params:
"PATH": "/usr/bin:/bin"
},
"tty": true,
"outputBytesCap": 16384,
"arg0": null
}
```
@@ -100,31 +97,61 @@ Field definitions:
- `env`: environment variables passed to the child process.
- `tty`: when `true`, spawn a PTY-backed interactive process; when `false`,
spawn a pipe-backed process with closed stdin.
- `outputBytesCap`: maximum retained stdout/stderr bytes per stream for the
in-memory buffer. Defaults to `codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP`.
- `arg0`: optional argv0 override forwarded to `codex-utils-pty`.
Response:
```json
{
"processId": "proc-1",
"running": true,
"exitCode": null,
"stdout": null,
"stderr": null
"processId": "proc-1"
}
```
Behavior notes:
- Reusing an existing `processId` is rejected.
- PTY-backed processes accept later writes through `command/exec/write`.
- 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 `command/exec/outputDelta`.
- Exit is reported asynchronously via `command/exec/exited`.
- Output is streamed asynchronously via `process/output`.
- Exit is reported asynchronously via `process/exited`.
### `command/exec/write`
### `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 PTY-backed process stdin.
@@ -143,7 +170,7 @@ Response:
```json
{
"accepted": true
"status": "accepted"
}
```
@@ -152,7 +179,7 @@ Behavior notes:
- Writes to an unknown `processId` are rejected.
- Writes to a non-PTY process are rejected because stdin is already closed.
### `command/exec/terminate`
### `process/terminate`
Terminates a running managed process.
@@ -182,7 +209,7 @@ If the process is already unknown or already removed, the server responds with:
## Notifications
### `command/exec/outputDelta`
### `process/output`
Streaming output chunk from a running process.
@@ -191,6 +218,7 @@ Params:
```json
{
"processId": "proc-1",
"seq": 1,
"stream": "stdout",
"chunk": "aGVsbG8K"
}
@@ -199,10 +227,11 @@ Params:
Fields:
- `processId`: process identifier
- `stream`: `"stdout"` or `"stderr"`
- `seq`: per-process output sequence number
- `stream`: `"stdout"`, `"stderr"`, or `"pty"`
- `chunk`: base64-encoded output bytes
### `command/exec/exited`
### `process/exited`
Final process exit notification.
@@ -211,10 +240,43 @@ Params:
```json
{
"processId": "proc-1",
"seq": 2,
"exitCode": 0
}
```
### `process/closed`
Notification emitted after process output is closed and the process handle is
removed.
Params:
```json
{
"processId": "proc-1"
}
```
## Filesystem RPCs
Filesystem methods use absolute paths and return JSON-RPC errors for invalid
or unavailable paths:
- `fs/readFile`
- `fs/writeFile`
- `fs/createDirectory`
- `fs/getMetadata`
- `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:
@@ -231,6 +293,7 @@ Typical error cases:
- duplicate `processId`
- writes to unknown processes
- writes to non-PTY processes
- sandbox-denied filesystem operations
## Rust surface
@@ -240,10 +303,14 @@ The crate exports:
- `ExecServerError`
- `ExecServerClientConnectOptions`
- `RemoteExecServerConnectArgs`
- protocol structs `InitializeParams` and `InitializeResponse`
- protocol request/response structs for process and filesystem RPCs
- `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError`
- `run_main_with_listen_url()`
- `run_main()` for embedding the websocket server in a binary
- `ExecServerRuntimePaths`
- `run_main()` for embedding the websocket server
Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level
`codex exec-server` command builds these paths from the `codex` arg0 dispatch
state.
## Example session
@@ -258,23 +325,24 @@ Initialize:
Start a process:
```json
{"id":2,"method":"command/exec","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,"outputBytesCap":4096,"arg0":null}}
{"id":2,"result":{"processId":"proc-1","running":true,"exitCode":null,"stdout":null,"stderr":null}}
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"cmVhZHkK"}}
{"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","seq":1,"stream":"stdout","chunk":"cmVhZHkK"}}
```
Write to the process:
```json
{"id":3,"method":"command/exec/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
{"id":3,"result":{"accepted":true}}
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"ZWNobzpoZWxsbwo="}}
{"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":"command/exec/terminate","params":{"processId":"proc-1"}}
{"id":4,"method":"process/terminate","params":{"processId":"proc-1"}}
{"id":4,"result":{"running":true}}
{"method":"command/exec/exited","params":{"processId":"proc-1","exitCode":0}}
{"method":"process/exited","params":{"processId":"proc-1","seq":3,"exitCode":0}}
{"method":"process/closed","params":{"processId":"proc-1"}}
```