[codex] Add unsandboxed process exec API (#19040)

## Why

App-server clients sometimes need argv-based local process execution
while sandbox policy is controlled outside Codex. Those environments can
reject sandbox-disabling paths before a command ever starts, even when
the caller intentionally wants unsandboxed execution.

This PR adds a distinct `process/*` API for that use case instead of
extending `command/exec` with another sandbox-disabling shape. Keeping
the new surface separate also makes the future removal of `command/exec`
simpler: clients that need explicit process lifecycle control can move
to the newer handle-based API without depending on `command/exec`
business logic.

## What changed

- Added v2 process lifecycle methods: `process/spawn`,
`process/writeStdin`, `process/resizePty`, and `process/kill`.
- Added process notifications: `process/outputDelta` for streamed
stdout/stderr chunks and `process/exited` for final exit status and
buffered output.
- Made `process/spawn` intentionally unsandboxed and omitted
sandbox-selection fields such as `sandboxPolicy` and
`permissionProfile`.
- Added client-supplied, connection-scoped `processHandle` values for
follow-up control requests and notification routing.
- Supported cwd, environment overrides, PTY mode and size, stdin
streaming, stdout/stderr streaming, per-stream output caps, and timeout
controls.
- Killed active process sessions when the originating app-server
connection closes.
- Wired the implementation through the modular `request_processors/`
app-server layout, with process-handle request serialization for
follow-up control calls.
- Updated generated JSON/TypeScript schema fixtures and documented the
new API in `codex-rs/app-server/README.md`.
- Added v2 app-server integration coverage in
`codex-rs/app-server/tests/suite/v2/process_exec.rs` for spawn
acknowledgement before exit, buffered output caps, and process
termination.

## Verification

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server`

---------

Co-authored-by: Owen Lin <owen@openai.com>
This commit is contained in:
Ruslan Nigmatullin
2026-05-04 16:43:58 -07:00
committed by GitHub
Unverified
parent a8db4af5c3
commit 4950e7d8a6
25 changed files with 2223 additions and 2 deletions
@@ -80,6 +80,7 @@ pub enum ClientRequestSerializationScope {
Thread { thread_id: String },
ThreadPath { path: PathBuf },
CommandExecProcess { process_id: String },
Process { process_handle: String },
FuzzyFileSearchSession { session_id: String },
FsWatch { watch_id: String },
McpOauth { server_name: String },
@@ -127,6 +128,11 @@ macro_rules! serialization_scope_expr {
process_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, process_handle($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::Process {
process_handle: $actual_params.$field.clone(),
})
};
($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
session_id: $actual_params.$field.clone(),
@@ -900,6 +906,34 @@ client_request_definitions! {
serialization: command_process_id(params.process_id),
response: v2::CommandExecResizeResponse,
},
#[experimental("process/spawn")]
/// Spawn a standalone process (argv vector) without a Codex sandbox.
ProcessSpawn => "process/spawn" {
params: v2::ProcessSpawnParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessSpawnResponse,
},
#[experimental("process/writeStdin")]
/// Write stdin bytes to a running `process/spawn` session or close stdin.
ProcessWriteStdin => "process/writeStdin" {
params: v2::ProcessWriteStdinParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessWriteStdinResponse,
},
#[experimental("process/kill")]
/// Terminate a running `process/spawn` session by client-supplied `processHandle`.
ProcessKill => "process/kill" {
params: v2::ProcessKillParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessKillResponse,
},
#[experimental("process/resizePty")]
/// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`.
ProcessResizePty => "process/resizePty" {
params: v2::ProcessResizePtyParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessResizePtyResponse,
},
ConfigRead => "config/read" {
params: v2::ConfigReadParams,
@@ -1401,6 +1435,12 @@ server_notification_definitions! {
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
/// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
/// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.
#[experimental("process/outputDelta")]
ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification),
/// Final exit notification for a `process/spawn` session.
#[experimental("process/exited")]
ProcessExited => "process/exited" (v2::ProcessExitedNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
/// Deprecated legacy apply_patch output stream notification.
@@ -3558,6 +3558,204 @@ pub enum CommandExecOutputStream {
Stderr,
}
/// PTY size in character cells for `process/spawn` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessTerminalSize {
/// Terminal height in character cells.
pub rows: u16,
/// Terminal width in character cells.
pub cols: u16,
}
/// Spawn a standalone process (argv vector) without a Codex sandbox on the host
/// where the app server is running.
///
/// `process/spawn` returns after the process has started and the connection-scoped
/// `processHandle` has been registered. Process output and exit are reported via
/// `process/outputDelta` and `process/exited` notifications.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessSpawnParams {
/// Command argv vector. Empty arrays are rejected.
pub command: Vec<String>,
/// Client-supplied, connection-scoped process handle.
///
/// Duplicate active handles are rejected on the same connection. The same
/// handle can be reused after the prior process exits.
pub process_handle: String,
/// Absolute working directory for the process.
pub cwd: AbsolutePathBuf,
/// Enable PTY mode.
///
/// This implies `streamStdin` and `streamStdoutStderr`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tty: bool,
/// Allow follow-up `process/writeStdin` requests to write stdin bytes.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdin: bool,
/// Stream stdout/stderr via `process/outputDelta` notifications.
///
/// Streamed bytes are not duplicated into the `process/exited` notification.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdout_stderr: bool,
/// Optional per-stream stdout/stderr capture cap in bytes.
///
/// When omitted, the server default applies. Set to `null` to disable the
/// cap.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub output_bytes_cap: Option<Option<usize>>,
/// Optional timeout in milliseconds.
///
/// When omitted, the server default applies. Set to `null` to disable the
/// timeout.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub timeout_ms: Option<Option<i64>>,
/// Optional environment overrides merged into the app-server process
/// environment.
///
/// Matching names override inherited values. Set a key to `null` to unset
/// an inherited variable.
#[ts(optional = nullable)]
pub env: Option<HashMap<String, Option<String>>>,
/// Optional initial PTY size in character cells. Only valid when `tty` is
/// true.
#[ts(optional = nullable)]
pub size: Option<ProcessTerminalSize>,
}
/// Successful response for `process/spawn`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessSpawnResponse {}
/// Write stdin bytes to a running `process/spawn` session, close stdin, or
/// both.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessWriteStdinParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Optional base64-encoded stdin bytes to write.
#[ts(optional = nullable)]
pub delta_base64: Option<String>,
/// Close stdin after writing `deltaBase64`, if present.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub close_stdin: bool,
}
/// Empty success response for `process/writeStdin`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessWriteStdinResponse {}
/// Terminate a running `process/spawn` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessKillParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
}
/// Empty success response for `process/kill`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessKillResponse {}
/// Resize a running PTY-backed `process/spawn` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessResizePtyParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// New PTY size in character cells.
pub size: ProcessTerminalSize,
}
/// Empty success response for `process/resizePty`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessResizePtyResponse {}
/// Stream label for `process/outputDelta` notifications.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ProcessOutputStream {
/// stdout stream. PTY mode multiplexes terminal output here.
Stdout,
/// stderr stream.
Stderr,
}
/// Base64-encoded output chunk emitted for a streaming `process/spawn` request.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessOutputDeltaNotification {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Output stream this chunk belongs to.
pub stream: ProcessOutputStream,
/// Base64-encoded output bytes.
pub delta_base64: String,
/// True on the final streamed chunk for this stream when output was
/// truncated by `outputBytesCap`.
pub cap_reached: bool,
}
/// Final process exit notification for `process/spawn`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessExitedNotification {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Process exit code.
pub exit_code: i32,
/// Buffered stdout capture.
///
/// Empty when stdout was streamed via `process/outputDelta`.
pub stdout: String,
/// Whether stdout reached `outputBytesCap`.
///
/// In streaming mode, stdout is empty and cap state is also reported on the
/// final stdout `process/outputDelta` notification.
pub stdout_cap_reached: bool,
/// Buffered stderr capture.
///
/// Empty when stderr was streamed via `process/outputDelta`.
pub stderr: String,
/// Whether stderr reached `outputBytesCap`.
///
/// In streaming mode, stderr is empty and cap state is also reported on the
/// final stderr `process/outputDelta` notification.
pub stderr_cap_reached: bool,
}
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(
@@ -9147,6 +9345,97 @@ mod tests {
assert_eq!(decoded, params);
}
#[test]
fn process_spawn_params_round_trips_without_sandbox_policy() {
let params = ProcessSpawnParams {
command: vec!["sleep".to_string(), "30".to_string()],
process_handle: "sleep-1".to_string(),
cwd: test_absolute_path(),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
timeout_ms: None,
env: None,
size: None,
};
let value = serde_json::to_value(&params).expect("serialize process/spawn params");
assert_eq!(
value,
json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"env": null,
"size": null,
})
);
let decoded =
serde_json::from_value::<ProcessSpawnParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn process_spawn_params_distinguish_omitted_null_and_value_limits() {
let base = json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
});
let expected_omitted = ProcessSpawnParams {
command: vec!["sleep".to_string(), "30".to_string()],
process_handle: "sleep-1".to_string(),
cwd: test_absolute_path(),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
timeout_ms: None,
env: None,
size: None,
};
let decoded =
serde_json::from_value::<ProcessSpawnParams>(base).expect("deserialize omitted limits");
assert_eq!(decoded, expected_omitted);
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"outputBytesCap": null,
"timeoutMs": null,
}))
.expect("deserialize disabled limits");
assert_eq!(
decoded,
ProcessSpawnParams {
output_bytes_cap: Some(None),
timeout_ms: Some(None),
..expected_omitted.clone()
}
);
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"outputBytesCap": 123,
"timeoutMs": 456,
}))
.expect("deserialize explicit limits");
assert_eq!(
decoded,
ProcessSpawnParams {
output_bytes_cap: Some(Some(123)),
timeout_ms: Some(Some(456)),
..expected_omitted
}
);
}
#[test]
fn command_exec_params_round_trips_disable_output_cap() {
let params = CommandExecParams {
@@ -9379,6 +9668,110 @@ mod tests {
assert_eq!(decoded, notification);
}
#[test]
fn process_control_params_round_trip() {
let write = ProcessWriteStdinParams {
process_handle: "proc-7".to_string(),
delta_base64: None,
close_stdin: true,
};
let value = serde_json::to_value(&write).expect("serialize process/writeStdin params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
"deltaBase64": null,
"closeStdin": true,
})
);
let decoded = serde_json::from_value::<ProcessWriteStdinParams>(value)
.expect("deserialize process/writeStdin params");
assert_eq!(decoded, write);
let resize = ProcessResizePtyParams {
process_handle: "proc-7".to_string(),
size: ProcessTerminalSize {
rows: 50,
cols: 160,
},
};
let value = serde_json::to_value(&resize).expect("serialize process/resizePty params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
"size": {
"rows": 50,
"cols": 160,
},
})
);
let decoded = serde_json::from_value::<ProcessResizePtyParams>(value)
.expect("deserialize process/resizePty params");
assert_eq!(decoded, resize);
let kill = ProcessKillParams {
process_handle: "proc-7".to_string(),
};
let value = serde_json::to_value(&kill).expect("serialize process/kill params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
})
);
let decoded =
serde_json::from_value::<ProcessKillParams>(value).expect("deserialize process/kill");
assert_eq!(decoded, kill);
}
#[test]
fn process_notifications_round_trip() {
let delta = ProcessOutputDeltaNotification {
process_handle: "proc-1".to_string(),
stream: ProcessOutputStream::Stdout,
delta_base64: "AQI=".to_string(),
cap_reached: false,
};
let value = serde_json::to_value(&delta).expect("serialize process/outputDelta");
assert_eq!(
value,
json!({
"processHandle": "proc-1",
"stream": "stdout",
"deltaBase64": "AQI=",
"capReached": false,
})
);
let decoded = serde_json::from_value::<ProcessOutputDeltaNotification>(value)
.expect("deserialize process/outputDelta");
assert_eq!(decoded, delta);
let exited = ProcessExitedNotification {
process_handle: "proc-1".to_string(),
exit_code: 0,
stdout: "out".to_string(),
stdout_cap_reached: false,
stderr: "err".to_string(),
stderr_cap_reached: true,
};
let value = serde_json::to_value(&exited).expect("serialize process/exited");
assert_eq!(
value,
json!({
"processHandle": "proc-1",
"exitCode": 0,
"stdout": "out",
"stdoutCapReached": false,
"stderr": "err",
"stderrCapReached": true,
})
);
let decoded = serde_json::from_value::<ProcessExitedNotification>(value)
.expect("deserialize process/exited");
assert_eq!(decoded, exited);
}
#[test]
fn command_execution_output_delta_round_trips() {
let notification = CommandExecutionOutputDeltaNotification {