Skip to content

Commit e2398d0

Browse files
maxj-oaibolinfest
andauthored
[app-server] expose environment info RPC (openai#30291)
## Why App-server clients that configure named execution environments need to discover an environment's shell and working directory before selecting it for a thread or turn. Because the environment can run on a different operating system than app-server, its working directory is represented as a canonical `file:` URI rather than a host-local path string. The probe also needs a bounded response time: an exec-server that completes initialization but never answers `environment/info` must not hold the environment serialization queue indefinitely. ## What changed - Add an experimental `environment/info` app-server RPC for named environments. - Route the probe through the managed environment connection and return target-native shell metadata plus the default working directory as a `PathUri`. - Return connection and protocol failures as JSON-RPC errors. - Bound the exec-server probe response to 30 seconds and remove timed-out calls from the pending-request table so later environment mutations can proceed. - Cover successful responses, omitted working directories, unknown environments, connection failures, and pending-call cleanup. ## Protocol examples Request: ```json { "id": 42, "method": "environment/info", "params": { "environmentId": "remote-a" } } ``` Successful response: ```json { "id": 42, "result": { "shell": { "name": "zsh", "path": "/bin/zsh" }, "cwd": "file:///workspace" } } ``` If the exec-server initializes but does not answer the probe within 30 seconds: ```json { "id": 42, "error": { "code": -32603, "message": "failed to get info for environment `remote-a`: exec-server protocol error: timed out waiting for exec-server `environment/info` response after 30s" } } ``` ## Testing - App-server integration coverage for successful info (including omitted `cwd`), unknown environments, and connection failures. - Exec-server RPC coverage verifying a timed-out call is removed from the pending-request table. --------- Co-authored-by: Michael Bolin <mbolin@openai.com>
1 parent d2885dc commit e2398d0

13 files changed

Lines changed: 466 additions & 66 deletions

File tree

codex-rs/app-server-protocol/src/export.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY B
4343
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
4444
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
4545
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
46+
"EnvironmentShellInfo",
47+
"PathUri",
4648
"RemoteControlClient",
4749
"RemoteControlClientsListOrder",
4850
"ThreadBackgroundTerminal",

codex-rs/app-server-protocol/src/protocol/common.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,13 @@ client_request_definitions! {
943943
serialization: global("environment"),
944944
response: v2::EnvironmentAddResponse,
945945
},
946+
#[experimental("environment/info")]
947+
/// Reads information from a configured execution environment.
948+
EnvironmentInfo => "environment/info" {
949+
params: v2::EnvironmentInfoParams,
950+
serialization: global_shared_read("environment"),
951+
response: v2::EnvironmentInfoResponse,
952+
},
946953

947954
McpServerOauthLogin => "mcpServer/oauth/login" {
948955
params: v2::McpServerOauthLoginParams,

codex-rs/app-server-protocol/src/protocol/v2/environment.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use codex_utils_path_uri::PathUri;
12
use schemars::JsonSchema;
23
use serde::Deserialize;
34
use serde::Serialize;
@@ -19,3 +20,29 @@ pub struct EnvironmentAddParams {
1920
#[serde(rename_all = "camelCase")]
2021
#[ts(export_to = "v2/")]
2122
pub struct EnvironmentAddResponse {}
23+
24+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
25+
#[serde(rename_all = "camelCase")]
26+
#[ts(export_to = "v2/")]
27+
pub struct EnvironmentInfoParams {
28+
pub environment_id: String,
29+
}
30+
31+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
32+
#[serde(rename_all = "camelCase")]
33+
#[ts(export_to = "v2/")]
34+
pub struct EnvironmentInfoResponse {
35+
pub shell: EnvironmentShellInfo,
36+
/// Default working directory reported by the environment, as a canonical file URI.
37+
pub cwd: Option<PathUri>,
38+
}
39+
40+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
41+
#[serde(rename_all = "camelCase")]
42+
#[ts(export_to = "v2/")]
43+
pub struct EnvironmentShellInfo {
44+
/// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`.
45+
pub name: String,
46+
/// Target-native shell executable path or command name.
47+
pub path: String,
48+
}

codex-rs/app-server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ Example with notification opt-out:
205205
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
206206
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
207207
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment.
208+
- `environment/info` — experimental; connect to a configured environment by `environmentId` and return its detected `shell` plus its default `cwd` as a canonical environment-native `file:` URI. Connection failures are returned as request errors.
208209
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
209210
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
210211
- `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills.

codex-rs/app-server/src/message_processor.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,9 @@ impl MessageProcessor {
10371037
ClientRequest::EnvironmentAdd { params, .. } => {
10381038
self.environment_processor.environment_add(params).await
10391039
}
1040+
ClientRequest::EnvironmentInfo { params, .. } => {
1041+
self.environment_processor.environment_info(params).await
1042+
}
10401043
ClientRequest::FsReadFile { params, .. } => self
10411044
.fs_processor
10421045
.read_file(params)

codex-rs/app-server/src/request_processors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ use codex_app_server_protocol::DynamicToolNamespaceTool;
6262
use codex_app_server_protocol::DynamicToolSpec;
6363
use codex_app_server_protocol::EnvironmentAddParams;
6464
use codex_app_server_protocol::EnvironmentAddResponse;
65+
use codex_app_server_protocol::EnvironmentInfoParams;
66+
use codex_app_server_protocol::EnvironmentInfoResponse;
67+
use codex_app_server_protocol::EnvironmentShellInfo;
6568
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
6669
use codex_app_server_protocol::ExperimentalFeatureListParams;
6770
use codex_app_server_protocol::ExperimentalFeatureListResponse;

codex-rs/app-server/src/request_processors/environment_processor.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,30 @@ impl EnvironmentRequestProcessor {
2626
.map_err(|err| invalid_request(err.to_string()))?;
2727
Ok(Some(EnvironmentAddResponse {}.into()))
2828
}
29+
30+
pub(crate) async fn environment_info(
31+
&self,
32+
params: EnvironmentInfoParams,
33+
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
34+
let environment_id = params.environment_id;
35+
let environment = self
36+
.environment_manager
37+
.get_environment(&environment_id)
38+
.ok_or_else(|| invalid_request(format!("unknown environment id `{environment_id}`")))?;
39+
let info = environment.info().await.map_err(|err| {
40+
internal_error(format!(
41+
"failed to get info for environment `{environment_id}`: {err}"
42+
))
43+
})?;
44+
Ok(Some(
45+
EnvironmentInfoResponse {
46+
shell: EnvironmentShellInfo {
47+
name: info.shell.name,
48+
path: info.shell.path,
49+
},
50+
cwd: info.cwd,
51+
}
52+
.into(),
53+
))
54+
}
2955
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
use std::time::Duration;
2+
3+
use anyhow::Result;
4+
use app_test_support::TestAppServer;
5+
use app_test_support::to_response;
6+
use codex_app_server_protocol::EnvironmentAddResponse;
7+
use codex_app_server_protocol::EnvironmentInfoResponse;
8+
use codex_app_server_protocol::EnvironmentShellInfo;
9+
use codex_app_server_protocol::JSONRPCError;
10+
use codex_app_server_protocol::JSONRPCErrorError;
11+
use codex_app_server_protocol::JSONRPCResponse;
12+
use codex_app_server_protocol::RequestId;
13+
use codex_utils_path_uri::PathUri;
14+
use pretty_assertions::assert_eq;
15+
use serde_json::json;
16+
use tempfile::TempDir;
17+
use tokio::net::TcpListener;
18+
use tokio::time::timeout;
19+
20+
use super::exec_server_test_support::accept_exec_server_environment;
21+
22+
const RPC_TIMEOUT: Duration = Duration::from_secs(10);
23+
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
24+
const INTERNAL_ERROR_CODE: i64 = -32603;
25+
26+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
27+
async fn environment_info_returns_remote_environment_info() -> Result<()> {
28+
let listener = TcpListener::bind("127.0.0.1:0").await?;
29+
let exec_server_url = format!("ws://{}", listener.local_addr()?);
30+
let exec_server = tokio::spawn(async move {
31+
accept_exec_server_environment(
32+
listener,
33+
json!({
34+
"shell": {"name": "zsh", "path": "/bin/zsh"},
35+
"cwd": "file:///workspace",
36+
}),
37+
)
38+
.await?;
39+
Ok::<_, anyhow::Error>(())
40+
});
41+
42+
let codex_home = TempDir::new()?;
43+
let mut app_server = TestAppServer::new(codex_home.path()).await?;
44+
timeout(RPC_TIMEOUT, app_server.initialize()).await??;
45+
add_environment(
46+
&mut app_server,
47+
&exec_server_url,
48+
/*connect_timeout_ms*/ None,
49+
)
50+
.await?;
51+
52+
let request_id = app_server
53+
.send_raw_request(
54+
"environment/info",
55+
Some(json!({"environmentId": "remote-a"})),
56+
)
57+
.await?;
58+
let response: JSONRPCResponse = timeout(
59+
RPC_TIMEOUT,
60+
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
61+
)
62+
.await??;
63+
assert_eq!(
64+
to_response::<EnvironmentInfoResponse>(response)?,
65+
EnvironmentInfoResponse {
66+
shell: EnvironmentShellInfo {
67+
name: "zsh".to_string(),
68+
path: "/bin/zsh".to_string(),
69+
},
70+
cwd: Some(PathUri::parse("file:///workspace")?),
71+
}
72+
);
73+
timeout(RPC_TIMEOUT, exec_server).await???;
74+
Ok(())
75+
}
76+
77+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
78+
async fn environment_info_accepts_missing_cwd() -> Result<()> {
79+
let listener = TcpListener::bind("127.0.0.1:0").await?;
80+
let exec_server_url = format!("ws://{}", listener.local_addr()?);
81+
let exec_server = tokio::spawn(async move {
82+
accept_exec_server_environment(
83+
listener,
84+
json!({"shell": {"name": "zsh", "path": "/bin/zsh"}}),
85+
)
86+
.await?;
87+
Ok::<_, anyhow::Error>(())
88+
});
89+
90+
let codex_home = TempDir::new()?;
91+
let mut app_server = TestAppServer::new(codex_home.path()).await?;
92+
timeout(RPC_TIMEOUT, app_server.initialize()).await??;
93+
add_environment(
94+
&mut app_server,
95+
&exec_server_url,
96+
/*connect_timeout_ms*/ None,
97+
)
98+
.await?;
99+
100+
let request_id = app_server
101+
.send_raw_request(
102+
"environment/info",
103+
Some(json!({"environmentId": "remote-a"})),
104+
)
105+
.await?;
106+
let response: JSONRPCResponse = timeout(
107+
RPC_TIMEOUT,
108+
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
109+
)
110+
.await??;
111+
assert_eq!(
112+
to_response::<EnvironmentInfoResponse>(response)?,
113+
EnvironmentInfoResponse {
114+
shell: EnvironmentShellInfo {
115+
name: "zsh".to_string(),
116+
path: "/bin/zsh".to_string(),
117+
},
118+
cwd: None,
119+
}
120+
);
121+
timeout(RPC_TIMEOUT, exec_server).await???;
122+
Ok(())
123+
}
124+
125+
#[tokio::test]
126+
async fn environment_info_rejects_unknown_environment() -> Result<()> {
127+
let codex_home = TempDir::new()?;
128+
let mut app_server = TestAppServer::new(codex_home.path()).await?;
129+
timeout(RPC_TIMEOUT, app_server.initialize()).await??;
130+
131+
let request_id = app_server
132+
.send_raw_request(
133+
"environment/info",
134+
Some(json!({"environmentId": "missing"})),
135+
)
136+
.await?;
137+
let error = timeout(
138+
RPC_TIMEOUT,
139+
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
140+
)
141+
.await??;
142+
assert_eq!(
143+
error,
144+
JSONRPCError {
145+
id: RequestId::Integer(request_id),
146+
error: JSONRPCErrorError {
147+
code: INVALID_REQUEST_ERROR_CODE,
148+
message: "unknown environment id `missing`".to_string(),
149+
data: None,
150+
},
151+
}
152+
);
153+
Ok(())
154+
}
155+
156+
#[tokio::test]
157+
async fn environment_info_reports_connection_failure() -> Result<()> {
158+
let listener = TcpListener::bind("127.0.0.1:0").await?;
159+
let exec_server_url = format!("ws://{}", listener.local_addr()?);
160+
let codex_home = TempDir::new()?;
161+
let mut app_server = TestAppServer::new(codex_home.path()).await?;
162+
timeout(RPC_TIMEOUT, app_server.initialize()).await??;
163+
add_environment(&mut app_server, &exec_server_url, Some(50)).await?;
164+
165+
let request_id = app_server
166+
.send_raw_request(
167+
"environment/info",
168+
Some(json!({"environmentId": "remote-a"})),
169+
)
170+
.await?;
171+
let error = timeout(
172+
RPC_TIMEOUT,
173+
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
174+
)
175+
.await??;
176+
assert_eq!(error.error.code, INTERNAL_ERROR_CODE);
177+
assert!(
178+
error
179+
.error
180+
.message
181+
.contains("failed to get info for environment `remote-a`")
182+
);
183+
Ok(())
184+
}
185+
186+
async fn add_environment(
187+
app_server: &mut TestAppServer,
188+
exec_server_url: &str,
189+
connect_timeout_ms: Option<u64>,
190+
) -> Result<()> {
191+
let request_id = app_server
192+
.send_raw_request(
193+
"environment/add",
194+
Some(json!({
195+
"environmentId": "remote-a",
196+
"execServerUrl": exec_server_url,
197+
"connectTimeoutMs": connect_timeout_ms,
198+
})),
199+
)
200+
.await?;
201+
let response: JSONRPCResponse = timeout(
202+
RPC_TIMEOUT,
203+
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
204+
)
205+
.await??;
206+
let _: EnvironmentAddResponse = to_response(response)?;
207+
Ok(())
208+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use anyhow::Result;
2+
use futures::SinkExt;
3+
use futures::StreamExt;
4+
use serde_json::Value;
5+
use serde_json::json;
6+
use tokio::net::TcpListener;
7+
use tokio::net::TcpStream;
8+
use tokio_tungstenite::WebSocketStream;
9+
use tokio_tungstenite::accept_async;
10+
use tokio_tungstenite::tungstenite::Message;
11+
12+
pub(crate) async fn accept_exec_server_environment(
13+
listener: TcpListener,
14+
environment_info: Value,
15+
) -> Result<WebSocketStream<TcpStream>> {
16+
let mut websocket = accept_initialized_exec_server(listener).await?;
17+
18+
let request = read_exec_server_json(&mut websocket).await?;
19+
assert_eq!(request["method"], "environment/info");
20+
websocket
21+
.send(Message::Text(
22+
json!({
23+
"id": request["id"],
24+
"result": environment_info,
25+
})
26+
.to_string()
27+
.into(),
28+
))
29+
.await?;
30+
31+
Ok(websocket)
32+
}
33+
34+
pub(crate) async fn accept_initialized_exec_server(
35+
listener: TcpListener,
36+
) -> Result<WebSocketStream<TcpStream>> {
37+
let (stream, _) = listener.accept().await?;
38+
let mut websocket = accept_async(stream).await?;
39+
40+
let initialize = read_exec_server_json(&mut websocket).await?;
41+
assert_eq!(initialize["method"], "initialize");
42+
websocket
43+
.send(Message::Text(
44+
json!({
45+
"id": initialize["id"],
46+
"result": {"sessionId": "test-session"},
47+
})
48+
.to_string()
49+
.into(),
50+
))
51+
.await?;
52+
let initialized = read_exec_server_json(&mut websocket).await?;
53+
assert_eq!(initialized["method"], "initialized");
54+
55+
Ok(websocket)
56+
}
57+
58+
pub(crate) async fn read_exec_server_json(
59+
websocket: &mut WebSocketStream<TcpStream>,
60+
) -> Result<Value> {
61+
loop {
62+
match websocket
63+
.next()
64+
.await
65+
.ok_or_else(|| anyhow::anyhow!("exec-server websocket closed"))??
66+
{
67+
Message::Text(text) => return Ok(serde_json::from_str(text.as_ref())?),
68+
Message::Binary(bytes) => return Ok(serde_json::from_slice(bytes.as_ref())?),
69+
Message::Ping(_) | Message::Pong(_) => {}
70+
message => anyhow::bail!("expected JSON-RPC message, got {message:?}"),
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)