Merge 9475d37b83 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-01 11:16:52 -07:00
committed by GitHub
74 changed files with 4419 additions and 1809 deletions
+11
View File
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "codex"
[setup]
script = ""
[[actions]]
name = "Run"
icon = "run"
command = "cargo +1.93.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file"
+40 -7
View File
@@ -1857,8 +1857,8 @@ dependencies = [
"chrono",
"clap",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
"codex-app-server-transport",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
@@ -1891,23 +1891,17 @@ dependencies = [
"codex-state",
"codex-thread-store",
"codex-tools",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-pty",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"core_test_support",
"flate2",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"opentelemetry",
"opentelemetry_sdk",
"owo-colors",
"pretty_assertions",
"reqwest",
"rmcp",
@@ -2005,6 +1999,45 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-app-server-transport"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"codex-api",
"codex-app-server-protocol",
"codex-config",
"codex-core",
"codex-login",
"codex-model-provider",
"codex-state",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"owo-colors",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tracing",
"url",
"uuid",
]
[[package]]
name = "codex-apply-patch"
version = "0.0.0"
+2
View File
@@ -8,6 +8,7 @@ members = [
"ansi-escape",
"async-utils",
"app-server",
"app-server-transport",
"app-server-client",
"app-server-protocol",
"app-server-test-client",
@@ -127,6 +128,7 @@ codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-aws-auth = { path = "aws-auth" }
codex-app-server = { path = "app-server" }
codex-app-server-transport = { path = "app-server-transport" }
codex-app-server-client = { path = "app-server-client" }
codex-app-server-protocol = { path = "app-server-protocol" }
codex-app-server-test-client = { path = "app-server-test-client" }
+1 -1
View File
@@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
The legacy `notify` setting is deprecated and will be removed in a future release. Existing configurations still work, but new automation should use lifecycle hooks instead. The [notify documentation](../docs/config.md#notify) explains the remaining compatibility behavior. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
### `codex exec` to run Codex programmatically/non-interactively
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "app-server-transport",
crate_name = "codex_app_server_transport",
)
+58
View File
@@ -0,0 +1,58 @@
[package]
name = "codex-app-server-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_app_server_transport"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-state = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"rt-multi-thread",
] }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true, features = ["log"] }
url = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
chrono = { workspace = true }
codex-config = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+20
View File
@@ -0,0 +1,20 @@
mod outgoing_message;
mod transport;
pub use outgoing_message::ConnectionId;
pub use outgoing_message::OutgoingError;
pub use outgoing_message::OutgoingMessage;
pub use outgoing_message::OutgoingResponse;
pub use outgoing_message::QueuedOutgoingMessage;
pub use transport::AppServerTransport;
pub use transport::AppServerTransportParseError;
pub use transport::CHANNEL_CAPACITY;
pub use transport::ConnectionOrigin;
pub use transport::RemoteControlHandle;
pub use transport::TransportEvent;
pub use transport::app_server_control_socket_path;
pub use transport::auth;
pub use transport::start_control_socket_acceptor;
pub use transport::start_remote_control;
pub use transport::start_stdio_connection;
pub use transport::start_websocket_acceptor;
@@ -0,0 +1,58 @@
use std::fmt;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use serde::Serialize;
use tokio::sync::oneshot;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ConnectionId(pub u64);
impl fmt::Display for ConnectionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Outgoing message from the server to the client.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum OutgoingMessage {
Request(ServerRequest),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
#[derive(Debug)]
pub struct QueuedOutgoingMessage {
pub message: OutgoingMessage,
pub write_complete_tx: Option<oneshot::Sender<()>>,
}
impl QueuedOutgoingMessage {
pub fn new(message: OutgoingMessage) -> Self {
Self {
message,
write_complete_tx: None,
}
}
}
@@ -86,7 +86,7 @@ pub enum AppServerWebsocketCapabilityTokenSource {
}
#[derive(Clone, Debug, Default)]
pub(crate) struct WebsocketAuthPolicy {
pub struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
}
@@ -219,7 +219,7 @@ impl AppServerWebsocketAuthArgs {
}
}
pub(crate) fn policy_from_settings(
pub fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
@@ -0,0 +1,478 @@
pub mod auth;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::QueuedOutgoingMessage;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::config::find_codex_home;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::warn;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage - 128 messages should be
/// plenty for an interactive CLI.
pub const CHANNEL_CAPACITY: usize = 128;
mod remote_control;
mod stdio;
mod unix_socket;
#[cfg(test)]
mod unix_socket_tests;
mod websocket;
pub use remote_control::RemoteControlHandle;
pub use remote_control::start_remote_control;
pub use stdio::start_stdio_connection;
pub use unix_socket::start_control_socket_acceptor;
pub use websocket::start_websocket_acceptor;
const OVERLOADED_ERROR_CODE: i64 = -32001;
const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control";
const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock";
pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(
codex_home
.join(APP_SERVER_CONTROL_SOCKET_DIR_NAME)
.join(APP_SERVER_CONTROL_SOCKET_FILE_NAME),
)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AppServerTransport {
Stdio,
UnixSocket { socket_path: AbsolutePathBuf },
WebSocket { bind_address: SocketAddr },
Off,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AppServerTransportParseError {
UnsupportedListenUrl(String),
InvalidUnixSocketPath { listen_url: String, message: String },
InvalidWebSocketListenUrl(String),
}
impl std::fmt::Display for AppServerTransportParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`"
),
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url,
message,
} => write!(
f,
"invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}"
),
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
),
}
}
}
impl std::error::Error for AppServerTransportParseError {}
impl AppServerTransport {
pub const DEFAULT_LISTEN_URL: &'static str = "stdio://";
pub fn from_listen_url(listen_url: &str) -> Result<Self, AppServerTransportParseError> {
if listen_url == Self::DEFAULT_LISTEN_URL {
return Ok(Self::Stdio);
}
if let Some(raw_socket_path) = listen_url.strip_prefix("unix://") {
let socket_path = if raw_socket_path.is_empty() {
let codex_home = find_codex_home().map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: format!("failed to resolve CODEX_HOME: {err}"),
}
})?;
app_server_control_socket_path(&codex_home).map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: err.to_string(),
}
})?
} else {
AbsolutePathBuf::relative_to_current_dir(raw_socket_path).map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: err.to_string(),
}
})?
};
return Ok(Self::UnixSocket { socket_path });
}
if listen_url == "off" {
return Ok(Self::Off);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
})?;
return Ok(Self::WebSocket { bind_address });
}
Err(AppServerTransportParseError::UnsupportedListenUrl(
listen_url.to_string(),
))
}
}
impl FromStr for AppServerTransport {
type Err = AppServerTransportParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_listen_url(s)
}
}
#[derive(Debug)]
pub enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
origin: ConnectionOrigin,
writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
connection_id: ConnectionId,
},
IncomingMessage {
connection_id: ConnectionId,
message: JSONRPCMessage,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionOrigin {
Stdio,
InProcess,
WebSocket,
RemoteControl,
}
impl ConnectionOrigin {
pub fn allows_device_key_requests(self) -> bool {
// Device-key endpoints are only for local connections that own the app-server instance.
// Do not include remote transports such as SSH or remote-control websocket connections.
matches!(self, Self::Stdio | Self::InProcess)
}
}
static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
fn next_connection_id() -> ConnectionId {
ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
}
async fn forward_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
payload: &str,
) -> bool {
match serde_json::from_str::<JSONRPCMessage>(payload) {
Ok(message) => {
enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await
}
Err(err) => {
error!("Failed to deserialize JSONRPCMessage: {err}");
true
}
}
}
async fn enqueue_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
message: JSONRPCMessage,
) -> bool {
let event = TransportEvent::IncomingMessage {
connection_id,
message,
};
match transport_event_tx.try_send(event) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage {
connection_id,
message: JSONRPCMessage::Request(request),
})) => {
let overload_error = OutgoingMessage::Error(OutgoingError {
id: request.id,
error: JSONRPCErrorError {
code: OVERLOADED_ERROR_CODE,
message: "Server overloaded; retry later.".to_string(),
data: None,
},
});
match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
Err(mpsc::error::TrySendError::Full(_overload_error)) => {
warn!(
"dropping overload response for connection {:?}: outbound queue is full",
connection_id
);
true
}
}
}
Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(),
}
}
fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option<String> {
let value = match serde_json::to_value(outgoing_message) {
Ok(value) => value,
Err(err) => {
error!("Failed to convert OutgoingMessage to JSON value: {err}");
return None;
}
};
match serde_json::to_string(&value) {
Ok(json) => Some(json),
Err(err) => {
error!("Failed to serialize JSONRPCMessage: {err}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::Duration;
use tokio::time::timeout;
#[test]
fn listen_off_parses_as_off_transport() {
assert_eq!(
AppServerTransport::from_listen_url("off"),
Ok(AppServerTransport::Off)
);
}
#[tokio::test]
async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: first_message.clone(),
})
.await
.expect("queue should accept first message");
let request = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
assert!(
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await
);
let queued_event = transport_event_rx
.recv()
.await
.expect("first event should stay queued");
match queued_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message,
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(message, first_message);
}
_ => panic!("expected queued incoming message"),
}
let overload = writer_rx
.recv()
.await
.expect("request should receive overload error");
let overload_json =
serde_json::to_value(overload.message).expect("serialize overload error");
assert_eq!(
overload_json,
json!({
"id": 7,
"error": {
"code": OVERLOADED_ERROR_CODE,
"message": "Server overloaded; retry later."
}
})
);
}
#[tokio::test]
async fn enqueue_incoming_response_waits_instead_of_dropping_when_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
let (writer_tx, _writer_rx) = mpsc::channel(1);
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: first_message.clone(),
})
.await
.expect("queue should accept first message");
let response = JSONRPCMessage::Response(JSONRPCResponse {
id: RequestId::Integer(7),
result: json!({"ok": true}),
});
let transport_event_tx_for_enqueue = transport_event_tx.clone();
let writer_tx_for_enqueue = writer_tx.clone();
let enqueue_handle = tokio::spawn(async move {
enqueue_incoming_message(
&transport_event_tx_for_enqueue,
&writer_tx_for_enqueue,
connection_id,
response,
)
.await
});
let queued_event = transport_event_rx
.recv()
.await
.expect("first event should be dequeued");
match queued_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message,
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(message, first_message);
}
_ => panic!("expected queued incoming message"),
}
let enqueue_result = enqueue_handle.await.expect("enqueue task should not panic");
assert!(enqueue_result);
let forwarded_event = transport_event_rx
.recv()
.await
.expect("response should be forwarded instead of dropped");
match forwarded_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message: JSONRPCMessage::Response(JSONRPCResponse { id, result }),
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(id, RequestId::Integer(7));
assert_eq!(result, json!({"ok": true}));
}
_ => panic!("expected forwarded response message"),
}
}
#[tokio::test]
async fn enqueue_incoming_request_does_not_block_when_writer_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, _transport_event_rx) = mpsc::channel(1);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
}),
})
.await
.expect("transport queue should accept first message");
writer_tx
.send(QueuedOutgoingMessage::new(
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
},
)),
))
.await
.expect("writer queue should accept first message");
let request = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
let enqueue_result = timeout(
Duration::from_millis(100),
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request),
)
.await
.expect("enqueue should not block while writer queue is full");
assert!(enqueue_result);
let queued_outgoing = writer_rx
.recv()
.await
.expect("writer queue should still contain original message");
let queued_json =
serde_json::to_value(queued_outgoing.message).expect("serialize queued message");
assert_eq!(
queued_json,
json!({
"method": "configWarning",
"params": {
"summary": "queued",
"details": null,
},
})
);
}
}
@@ -36,14 +36,14 @@ pub(super) struct QueuedServerEnvelope {
}
#[derive(Clone)]
pub(crate) struct RemoteControlHandle {
pub struct RemoteControlHandle {
enabled_tx: Arc<watch::Sender<bool>>,
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
state_db_available: bool,
}
impl RemoteControlHandle {
pub(crate) fn set_enabled(&self, enabled: bool) {
pub fn set_enabled(&self, enabled: bool) {
let requested_enabled = enabled;
let enabled = enabled && self.state_db_available;
if requested_enabled && !self.state_db_available {
@@ -56,14 +56,12 @@ impl RemoteControlHandle {
});
}
pub(crate) fn status_receiver(
&self,
) -> watch::Receiver<RemoteControlStatusChangedNotification> {
pub fn status_receiver(&self) -> watch::Receiver<RemoteControlStatusChangedNotification> {
self.status_tx.subscribe()
}
}
pub(crate) async fn start_remote_control(
pub async fn start_remote_control(
remote_control_url: String,
state_db: Option<Arc<StateRuntime>>,
auth_manager: Arc<AuthManager>,
@@ -21,7 +21,7 @@ use tracing::debug;
use tracing::error;
use tracing::info;
pub(crate) async fn start_stdio_connection(
pub async fn start_stdio_connection(
transport_event_tx: mpsc::Sender<TransportEvent>,
stdio_handles: &mut Vec<JoinHandle<()>>,
initialize_client_name_tx: oneshot::Sender<String>,
@@ -20,7 +20,7 @@ use tracing::warn;
#[cfg(unix)]
const CONTROL_SOCKET_MODE: u32 = 0o600;
pub(crate) async fn start_control_socket_acceptor(
pub async fn start_control_socket_acceptor(
socket_path: AbsolutePathBuf,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
@@ -128,7 +128,7 @@ async fn websocket_upgrade_handler(
.into_response()
}
pub(crate) async fn start_websocket_acceptor(
pub async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
+2 -9
View File
@@ -30,7 +30,6 @@ axum = { workspace = true, default-features = false, features = [
"ws",
] }
codex-analytics = { workspace = true }
codex-api = { workspace = true }
codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
@@ -58,6 +57,7 @@ codex-model-provider = { workspace = true }
codex-models-manager = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-transport = { workspace = true }
codex-feedback = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-rollout = { workspace = true }
@@ -65,18 +65,11 @@ codex-sandboxing = { workspace = true }
codex-state = { workspace = true }
codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
@@ -93,7 +86,6 @@ tokio = { workspace = true, features = [
"signal",
] }
tokio-util = { workspace = true }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
url = { workspace = true }
@@ -111,6 +103,7 @@ core_test_support = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
flate2 = { workspace = true }
hmac = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
@@ -225,6 +225,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
@@ -273,7 +274,6 @@ use codex_core::find_thread_name_by_id;
use codex_core::find_thread_path_by_id_str;
use codex_core::path_utils;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
@@ -2528,28 +2528,13 @@ impl CodexMessageProcessor {
.await;
return;
}
let environments = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect::<Vec<_>>()
});
if let Some(environments) = environments.as_ref()
&& let Err(err) = self
.thread_manager
.validate_environment_selections(environments)
{
self.outgoing
.send_error(
request_id,
invalid_request(environment_selection_error_message(err)),
)
.await;
return;
}
let environment_selections = match self.parse_environment_selections(environments) {
Ok(environment_selections) => environment_selections,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
@@ -2588,7 +2573,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
dynamic_tools,
session_start_source,
environments,
environment_selections,
persist_extended_history,
service_name,
experimental_raw_events,
@@ -3013,6 +2998,27 @@ impl CodexMessageProcessor {
overrides
}
fn parse_environment_selections(
&self,
environments: Option<Vec<TurnEnvironmentParams>>,
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
let environment_selections = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect::<Vec<_>>()
});
if let Some(environment_selections) = environment_selections.as_ref() {
self.thread_manager
.validate_environment_selections(environment_selections)
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
}
Ok(environment_selections)
}
async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) {
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
@@ -3947,16 +3953,48 @@ impl CodexMessageProcessor {
include_turns: bool,
) -> Result<Thread, ThreadReadViewError> {
let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok();
let mut thread = if let Some(thread) = self
let mut thread = if include_turns {
if let Some(loaded_thread) = loaded_thread.as_ref() {
// Loaded thread with turns: use persisted metadata when it exists,
// but reconstruct turns from the live ThreadStore history.
let persisted_thread = self
.load_persisted_thread_for_read(thread_id, /*include_turns*/ false)
.await?;
self.load_live_thread_view(
thread_id,
include_turns,
loaded_thread,
persisted_thread,
)
.await?
} else if let Some(thread) = self
.load_persisted_thread_for_read(thread_id, include_turns)
.await?
{
// Unloaded thread with turns: load metadata and history together
// from the ThreadStore.
thread
} else {
return Err(ThreadReadViewError::InvalidRequest(format!(
"thread not loaded: {thread_id}"
)));
}
} else if let Some(thread) = self
.load_persisted_thread_for_read(thread_id, include_turns)
.await?
{
// Persisted metadata-only read: no live thread state is needed.
thread
} else if let Some(thread) = self
.load_live_thread_view(thread_id, include_turns, loaded_thread.as_ref())
} else if let Some(loaded_thread) = loaded_thread.as_ref() {
// Loaded metadata-only read before persistence is materialized: build
// the response from the live thread snapshot.
self.load_live_thread_view(
thread_id,
include_turns,
loaded_thread,
/*persisted_thread*/ None,
)
.await?
{
thread
} else {
return Err(ThreadReadViewError::InvalidRequest(format!(
"thread not loaded: {thread_id}"
@@ -4022,65 +4060,51 @@ impl CodexMessageProcessor {
}
}
/// Builds a `thread/read` view from a loaded thread plus optional persisted metadata.
async fn load_live_thread_view(
&self,
thread_id: ThreadId,
include_turns: bool,
loaded_thread: Option<&Arc<CodexThread>>,
) -> Result<Option<Thread>, ThreadReadViewError> {
let Some(thread) = loaded_thread else {
return Ok(None);
};
let config_snapshot = thread.config_snapshot().await;
let loaded_rollout_path = thread.rollout_path();
if include_turns && loaded_rollout_path.is_none() {
loaded_thread: &CodexThread,
persisted_thread: Option<Thread>,
) -> Result<Thread, ThreadReadViewError> {
let config_snapshot = loaded_thread.config_snapshot().await;
if include_turns && config_snapshot.ephemeral {
return Err(ThreadReadViewError::InvalidRequest(
"ephemeral threads do not support includeTurns".to_string(),
));
}
let mut thread =
build_thread_from_snapshot(thread_id, &config_snapshot, loaded_rollout_path.clone());
self.apply_thread_read_rollout_fields(
thread_id,
&mut thread,
loaded_rollout_path.as_deref(),
include_turns,
)
.await?;
Ok(Some(thread))
let fallback_thread =
build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread);
let mut thread = if let Some(mut thread) = persisted_thread {
if thread.path.is_none() {
thread.path = fallback_thread.path.clone();
}
thread.ephemeral = fallback_thread.ephemeral;
thread
} else {
fallback_thread
};
self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread)
.await?;
Ok(thread)
}
async fn apply_thread_read_rollout_fields(
async fn apply_thread_read_store_fields(
&self,
thread_id: ThreadId,
thread: &mut Thread,
rollout_path: Option<&Path>,
include_turns: bool,
loaded_thread: &CodexThread,
) -> Result<(), ThreadReadViewError> {
if thread.forked_from_id.is_none()
&& let Some(rollout_path) = rollout_path
{
thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await;
}
self.attach_thread_name(thread_id, thread).await;
if include_turns && let Some(rollout_path) = rollout_path {
match read_rollout_items_from_rollout(rollout_path).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(ThreadReadViewError::InvalidRequest(format!(
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
)));
}
Err(err) => {
return Err(ThreadReadViewError::Internal(format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
)));
}
}
if include_turns {
let history = loaded_thread
.load_history(/*include_archived*/ true)
.await
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
thread.turns = build_turns_from_rollout_items(&history.items);
}
Ok(())
@@ -6669,21 +6693,7 @@ impl CodexMessageProcessor {
let collaboration_mode = params
.collaboration_mode
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
let environments: Option<Vec<TurnEnvironmentSelection>> =
params.environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect()
});
if let Some(environments) = environments.as_ref() {
self.thread_manager
.validate_environment_selections(environments)
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
}
let environment_selections = self.parse_environment_selections(params.environments)?;
// Map v2 input items to core input items.
let mapped_items: Vec<CoreInputItem> = params
@@ -6792,7 +6802,7 @@ impl CodexMessageProcessor {
let turn_op = if has_any_overrides {
Op::UserInputWithTurnContext {
items: mapped_items,
environments,
environments: environment_selections,
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
cwd,
@@ -6812,7 +6822,7 @@ impl CodexMessageProcessor {
} else {
Op::UserInput {
items: mapped_items,
environments,
environments: environment_selections,
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
}
@@ -9145,6 +9155,32 @@ fn thread_turns_list_history_load_error(
}
}
fn thread_read_history_load_error(
thread_id: ThreadId,
err: ThreadStoreError,
) -> ThreadReadViewError {
match err {
ThreadStoreError::InvalidRequest { message }
if message.starts_with("failed to resolve rollout path `") =>
{
ThreadReadViewError::InvalidRequest(format!(
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
))
}
ThreadStoreError::ThreadNotFound {
thread_id: missing_thread_id,
} if missing_thread_id == thread_id => ThreadReadViewError::InvalidRequest(format!(
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
)),
ThreadStoreError::InvalidRequest { message } => {
ThreadReadViewError::InvalidRequest(message)
}
err => ThreadReadViewError::Internal(format!(
"failed to load thread history for thread {thread_id}: {err}"
)),
}
}
fn conversation_summary_thread_id_read_error(
conversation_id: ThreadId,
err: ThreadStoreError,
@@ -9564,8 +9600,9 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
}
}
#[cfg(test)]
async fn forked_from_id_from_rollout(path: &Path) -> Option<String> {
read_session_meta_line(path)
codex_core::read_session_meta_line(path)
.await
.ok()
.and_then(|meta_line| meta_line.meta.forked_from_id)
@@ -9743,6 +9780,14 @@ fn build_thread_from_snapshot(
}
}
fn build_thread_from_loaded_snapshot(
thread_id: ThreadId,
config_snapshot: &ThreadConfigSnapshot,
loaded_thread: &CodexThread,
) -> Thread {
build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path())
}
fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification {
thread.turns.clear();
ThreadStartedNotification { thread }
+5 -51
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -15,7 +14,6 @@ use codex_app_server_protocol::ServerRequestPayload;
use codex_otel::span_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::protocol::W3cTraceContext;
use serde::Serialize;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -26,22 +24,17 @@ use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::internal_error;
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
pub(crate) use codex_app_server_transport::ConnectionId;
pub(crate) use codex_app_server_transport::OutgoingError;
pub(crate) use codex_app_server_transport::OutgoingMessage;
pub(crate) use codex_app_server_transport::OutgoingResponse;
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
#[cfg(test)]
use codex_protocol::account::PlanType;
pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId(pub(crate) u64);
impl fmt::Display for ConnectionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Stable identifier for a client request scoped to a transport connection.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionRequestId {
@@ -96,21 +89,6 @@ pub(crate) enum OutgoingEnvelope {
},
}
#[derive(Debug)]
pub(crate) struct QueuedOutgoingMessage {
pub(crate) message: OutgoingMessage,
pub(crate) write_complete_tx: Option<oneshot::Sender<()>>,
}
impl QueuedOutgoingMessage {
pub(crate) fn new(message: OutgoingMessage) -> Self {
Self {
message,
write_complete_tx: None,
}
}
}
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_server_request_id: AtomicI64,
@@ -665,30 +643,6 @@ impl OutgoingMessageSender {
}
}
/// Outgoing message from the server to the client.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum OutgoingMessage {
Request(ServerRequest),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
#[cfg(test)]
mod tests {
use std::time::Duration;
+232
View File
@@ -0,0 +1,232 @@
use crate::message_processor::ConnectionSessionState;
use crate::outgoing_message::OutgoingEnvelope;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ServerRequest;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::warn;
pub use codex_app_server_transport::AppServerTransport;
pub(crate) use codex_app_server_transport::CHANNEL_CAPACITY;
pub(crate) use codex_app_server_transport::ConnectionId;
pub(crate) use codex_app_server_transport::ConnectionOrigin;
pub(crate) use codex_app_server_transport::OutgoingMessage;
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
pub(crate) use codex_app_server_transport::RemoteControlHandle;
pub(crate) use codex_app_server_transport::TransportEvent;
pub use codex_app_server_transport::app_server_control_socket_path;
pub use codex_app_server_transport::auth;
pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
pub(crate) use codex_app_server_transport::start_remote_control;
pub(crate) use codex_app_server_transport::start_stdio_connection;
pub(crate) use codex_app_server_transport::start_websocket_acceptor;
pub(crate) struct ConnectionState {
pub(crate) outbound_initialized: Arc<AtomicBool>,
pub(crate) outbound_experimental_api_enabled: Arc<AtomicBool>,
pub(crate) outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) session: Arc<ConnectionSessionState>,
}
impl ConnectionState {
pub(crate) fn new(
origin: ConnectionOrigin,
outbound_initialized: Arc<AtomicBool>,
outbound_experimental_api_enabled: Arc<AtomicBool>,
outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
) -> Self {
Self {
outbound_initialized,
outbound_experimental_api_enabled,
outbound_opted_out_notification_methods,
session: Arc::new(ConnectionSessionState::new(origin)),
}
}
}
pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
}
impl OutboundConnectionState {
pub(crate) fn new(
writer: mpsc::Sender<QueuedOutgoingMessage>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
disconnect_sender: Option<CancellationToken>,
) -> Self {
Self {
initialized,
experimental_api_enabled,
opted_out_notification_methods,
writer,
disconnect_sender,
}
}
fn can_disconnect(&self) -> bool {
self.disconnect_sender.is_some()
}
pub(crate) fn request_disconnect(&self) {
if let Some(disconnect_sender) = &self.disconnect_sender {
disconnect_sender.cancel();
}
}
}
fn should_skip_notification_for_connection(
connection_state: &OutboundConnectionState,
message: &OutgoingMessage,
) -> bool {
let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read()
else {
warn!("failed to read outbound opted-out notifications");
return false;
};
match message {
OutgoingMessage::AppServerNotification(notification) => {
if notification.experimental_reason().is_some()
&& !connection_state
.experimental_api_enabled
.load(Ordering::Acquire)
{
return true;
}
let method = notification.to_string();
opted_out_notification_methods.contains(method.as_str())
}
_ => false,
}
}
fn disconnect_connection(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
connection_id: ConnectionId,
) -> bool {
if let Some(connection_state) = connections.remove(&connection_id) {
connection_state.request_disconnect();
return true;
}
false
}
async fn send_message_to_connection(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
connection_id: ConnectionId,
message: OutgoingMessage,
write_complete_tx: Option<tokio::sync::oneshot::Sender<()>>,
) -> bool {
let Some(connection_state) = connections.get(&connection_id) else {
warn!("dropping message for disconnected connection: {connection_id:?}");
return false;
};
let message = filter_outgoing_message_for_connection(connection_state, message);
if should_skip_notification_for_connection(connection_state, &message) {
return false;
}
let writer = connection_state.writer.clone();
let queued_message = QueuedOutgoingMessage {
message,
write_complete_tx,
};
if connection_state.can_disconnect() {
match writer.try_send(queued_message) {
Ok(()) => false,
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(
"disconnecting slow connection after outbound queue filled: {connection_id:?}"
);
disconnect_connection(connections, connection_id)
}
Err(mpsc::error::TrySendError::Closed(_)) => {
disconnect_connection(connections, connection_id)
}
}
} else if writer.send(queued_message).await.is_err() {
disconnect_connection(connections, connection_id)
} else {
false
}
}
fn filter_outgoing_message_for_connection(
connection_state: &OutboundConnectionState,
message: OutgoingMessage,
) -> OutgoingMessage {
let experimental_api_enabled = connection_state
.experimental_api_enabled
.load(Ordering::Acquire);
match message {
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id,
mut params,
}) => {
if !experimental_api_enabled {
params.strip_experimental_fields();
}
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id,
params,
})
}
_ => message,
}
}
pub(crate) async fn route_outgoing_envelope(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
envelope: OutgoingEnvelope,
) {
match envelope {
OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx,
} => {
let _ =
send_message_to_connection(connections, connection_id, message, write_complete_tx)
.await;
}
OutgoingEnvelope::Broadcast { message } => {
let target_connections: Vec<ConnectionId> = connections
.iter()
.filter_map(|(connection_id, connection_state)| {
if connection_state.initialized.load(Ordering::Acquire)
&& !should_skip_notification_for_connection(connection_state, &message)
{
Some(*connection_id)
} else {
None
}
})
.collect();
for connection_id in target_connections {
let _ = send_message_to_connection(
connections,
connection_id,
message.clone(),
/*write_complete_tx*/ None,
)
.await;
}
}
}
}
#[cfg(test)]
#[path = "transport_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+532
View File
@@ -0,0 +1,532 @@
use super::*;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::Duration;
use tokio::time::timeout;
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
}
fn thread_goal_updated_notification() -> ServerNotification {
ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "ship goal mode".to_string(),
status: ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 1,
updated_at: 1,
},
})
}
#[tokio::test]
async fn to_connection_notification_respects_opt_out_filters() {
let connection_id = ConnectionId(7);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let initialized = Arc::new(AtomicBool::new(true));
let opted_out_notification_methods =
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()])));
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
initialized,
Arc::new(AtomicBool::new(true)),
opted_out_notification_methods,
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "task_started".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"opted-out notification should be dropped"
);
}
#[tokio::test]
async fn to_connection_notifications_are_dropped_for_opted_out_clients() {
let connection_id = ConnectionId(10);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "task_started".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"opted-out notifications should not reach clients"
);
}
#[tokio::test]
async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() {
let connection_id = ConnectionId(11);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "task_started".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("notification should reach non-opted-out clients");
assert!(matches!(
message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "task_started"
));
}
#[tokio::test]
async fn experimental_notifications_are_dropped_without_capability() {
let connection_id = ConnectionId(12);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
write_complete_tx: None,
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"experimental notifications should not reach clients without capability"
);
}
#[tokio::test]
async fn experimental_notifications_are_preserved_with_capability() {
let connection_id = ConnectionId(13);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("experimental notification should reach opted-in client");
assert!(matches!(
message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_))
));
}
#[tokio::test]
async fn command_execution_request_approval_strips_additional_permissions_without_capability() {
let connection_id = ConnectionId(8);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id: RequestId::Integer(1),
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
item_id: "call_123".to_string(),
approval_id: None,
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(absolute_path("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
write: None,
glob_scan_max_depth: None,
entries: None,
},
),
},
),
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
}),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message.message).expect("request should serialize");
assert_eq!(json["params"].get("additionalPermissions"), None);
}
#[tokio::test]
async fn command_execution_request_approval_keeps_additional_permissions_with_capability() {
let connection_id = ConnectionId(9);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id: RequestId::Integer(1),
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
item_id: "call_123".to_string(),
approval_id: None,
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(absolute_path("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
write: None,
glob_scan_max_depth: None,
entries: None,
},
),
},
),
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
}),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message.message).expect("request should serialize");
let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned();
assert_eq!(
json["params"]["additionalPermissions"],
json!({
"network": null,
"fileSystem": {
"read": [allowed_path],
"write": null,
},
})
);
}
#[tokio::test]
async fn broadcast_does_not_block_on_slow_connection() {
let fast_connection_id = ConnectionId(1);
let slow_connection_id = ConnectionId(2);
let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1);
let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1);
let fast_disconnect_token = CancellationToken::new();
let slow_disconnect_token = CancellationToken::new();
let mut connections = HashMap::new();
connections.insert(
fast_connection_id,
OutboundConnectionState::new(
fast_writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(fast_disconnect_token.clone()),
),
);
connections.insert(
slow_connection_id,
OutboundConnectionState::new(
slow_writer_tx.clone(),
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(slow_disconnect_token.clone()),
),
);
let queued_message = OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "already-buffered".to_string(),
details: None,
path: None,
range: None,
},
));
slow_writer_tx
.try_send(QueuedOutgoingMessage::new(queued_message))
.expect("channel should have room");
let broadcast_message = OutgoingMessage::AppServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "test".to_string(),
details: None,
path: None,
range: None,
}),
);
timeout(
Duration::from_millis(100),
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::Broadcast {
message: broadcast_message,
},
),
)
.await
.expect("broadcast should return even when one connection is slow");
assert!(!connections.contains_key(&slow_connection_id));
assert!(slow_disconnect_token.is_cancelled());
assert!(!fast_disconnect_token.is_cancelled());
let fast_message = fast_writer_rx
.try_recv()
.expect("fast connection should receive the broadcast notification");
assert!(matches!(
fast_message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "test"
));
let slow_message = slow_writer_rx
.try_recv()
.expect("slow connection should retain its original buffered message");
assert!(matches!(
slow_message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "already-buffered"
));
}
#[tokio::test]
async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() {
let connection_id = ConnectionId(3);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
writer_tx
.send(QueuedOutgoingMessage::new(
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
},
)),
))
.await
.expect("channel should accept the first queued message");
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
let route_task = tokio::spawn(async move {
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "second".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await
});
let first = timeout(Duration::from_millis(100), writer_rx.recv())
.await
.expect("first queued message should be readable")
.expect("first queued message should exist");
timeout(Duration::from_millis(100), route_task)
.await
.expect("routing should finish after the first queued message is drained")
.expect("routing task should succeed");
assert!(matches!(
first.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "queued"
));
let second = writer_rx
.try_recv()
.expect("second notification should be delivered once the queue has room");
assert!(matches!(
second.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "second"
));
}
@@ -340,6 +340,89 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
Ok(())
}
#[tokio::test]
async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let store_id = Uuid::new_v4().to_string();
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
},
})
.await?
.expect("thread/start should succeed");
let ThreadStartResponse { thread, .. } = serde_json::from_value(result)?;
assert_eq!(thread.path, None);
let thread_id = codex_protocol::ThreadId::from_string(&thread.id)?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: store_history_items(),
})
.await?;
let result = client
.request(ClientRequest::ThreadRead {
request_id: RequestId::Integer(2),
params: ThreadReadParams {
thread_id: thread.id,
include_turns: true,
},
})
.await?
.expect("thread/read should succeed");
let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?;
assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]);
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
+1 -1
View File
@@ -146,7 +146,7 @@ pub struct ConfigToml {
#[serde(default)]
pub permissions: Option<PermissionsToml>,
/// Optional external command to spawn for end-user notifications.
/// Deprecated optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option<Vec<String>>,
+1 -1
View File
@@ -4268,7 +4268,7 @@
},
"notify": {
"default": null,
"description": "Optional external command to spawn for end-user notifications.",
"description": "Deprecated optional external command to spawn for end-user notifications.",
"items": {
"type": "string"
},
+4 -6
View File
@@ -30,6 +30,7 @@ use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
@@ -47,7 +48,6 @@ use crate::session::SUBMISSION_CHANNEL_CAPACITY;
use crate::session::emit_subagent_session_started;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use codex_login::AuthManager;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::error::CodexErr;
@@ -94,11 +94,9 @@ pub(crate) async fn run_codex_thread_interactive(
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
parent_trace: None,
environments: parent_ctx
.environments
.iter()
.map(TurnEnvironment::selection)
.collect(),
environment_selections: ResolvedTurnEnvironments {
turn_environments: parent_ctx.environments.clone(),
},
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
thread_store: Arc::clone(&parent_session.services.thread_store),
}))
+2 -2
View File
@@ -480,7 +480,7 @@ pub struct Config {
/// - `Some("...")`: use the provided attribution text verbatim
pub commit_attribution: Option<String>,
/// Optional external notifier command. When set, Codex will spawn this
/// Deprecated optional external notifier command. When set, Codex will spawn this
/// program after each completed *turn* (i.e. when the agent finishes
/// processing a user submission). The value must be the full command
/// broken into argv tokens **without** the trailing JSON argument - Codex
@@ -499,7 +499,7 @@ pub struct Config {
/// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}'
/// ```
///
/// If unset the feature is disabled.
/// If unset the feature is disabled. Use lifecycle hooks for new automation.
pub notify: Option<Vec<String>>,
/// TUI notification settings, including enabled events, delivery method, and focus condition.
+97 -28
View File
@@ -1,12 +1,15 @@
use std::collections::HashSet;
use std::sync::Arc;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::session::turn_context::TurnEnvironment;
pub(crate) fn default_thread_environment_selections(
environment_manager: &EnvironmentManager,
cwd: &AbsolutePathBuf,
@@ -21,42 +24,61 @@ pub(crate) fn default_thread_environment_selections(
.collect()
}
pub(crate) fn validate_environment_selections(
#[derive(Clone, Debug)]
pub(crate) struct ResolvedTurnEnvironments {
pub(crate) turn_environments: Vec<TurnEnvironment>,
}
impl ResolvedTurnEnvironments {
pub(crate) fn to_selections(&self) -> Vec<TurnEnvironmentSelection> {
self.turn_environments
.iter()
.map(TurnEnvironment::selection)
.collect()
}
pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> {
self.turn_environments.first()
}
pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {
self.primary_turn_environment()
.map(|environment| Arc::clone(&environment.environment))
}
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
self.primary_turn_environment()
.map(|environment| environment.environment.get_filesystem())
}
}
pub(crate) fn resolve_environment_selections(
environment_manager: &EnvironmentManager,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<()> {
) -> CodexResult<ResolvedTurnEnvironments> {
let mut seen_environment_ids = HashSet::with_capacity(environments.len());
let mut turn_environments = Vec::with_capacity(environments.len());
for selected_environment in environments {
if environment_manager
.get_environment(&selected_environment.environment_id)
.is_none()
{
if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) {
return Err(CodexErr::InvalidRequest(format!(
"unknown turn environment id `{}`",
"duplicate turn environment id `{}`",
selected_environment.environment_id
)));
}
let environment_id = selected_environment.environment_id.clone();
let environment = environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`"))
})?;
turn_environments.push(TurnEnvironment {
environment_id,
environment,
cwd: selected_environment.cwd.clone(),
});
}
Ok(())
}
pub(crate) fn selected_primary_environment(
environment_manager: &EnvironmentManager,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<Option<Arc<Environment>>> {
environments
.first()
.map(|selected_environment| {
environment_manager
.get_environment(&selected_environment.environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{}`",
selected_environment.environment_id
))
})
})
.transpose()
Ok(ResolvedTurnEnvironments { turn_environments })
}
#[cfg(test)]
@@ -105,4 +127,51 @@ mod tests {
Vec::<TurnEnvironmentSelection>::new()
);
}
#[tokio::test]
async fn resolve_environment_selections_rejects_duplicate_ids() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::default_for_tests();
let err = resolve_environment_selections(
&manager,
&[
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: cwd.join("other"),
},
],
)
.expect_err("duplicate environment id should fail");
assert!(err.to_string().contains("duplicate"));
}
#[tokio::test]
async fn resolved_environment_selections_use_first_selection_as_primary() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let selected_cwd = cwd.join("selected");
let manager = EnvironmentManager::default_for_tests();
let resolved = resolve_environment_selections(
&manager,
&[TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: selected_cwd,
}],
)
.expect("environment selections should resolve");
assert_eq!(
resolved
.primary_turn_environment()
.expect("primary environment")
.environment_id,
"local"
);
}
}
+14 -7
View File
@@ -221,6 +221,19 @@ impl Session {
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.primary_environment() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_environment()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
turn_context.cwd.to_path_buf(),
),
};
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
guard.cancel();
@@ -234,13 +247,7 @@ impl Session {
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.permission_profile(),
McpRuntimeEnvironment::new(
turn_context
.environment
.clone()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
turn_context.cwd.to_path_buf(),
),
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,
+7 -13
View File
@@ -30,8 +30,7 @@ use crate::context::NetworkRuleSaved;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::default_skill_metadata_budget;
use crate::environment_selection::selected_primary_environment;
use crate::environment_selection::validate_environment_selections;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
@@ -113,7 +112,6 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
@@ -410,7 +408,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) parent_rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environments: Vec<TurnEnvironmentSelection>,
pub(crate) environment_selections: ResolvedTurnEnvironments,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
}
@@ -467,18 +465,13 @@ impl Codex {
inherited_exec_policy,
parent_rollout_thread_trace,
parent_trace: _,
environments,
environment_selections,
analytics_events_client,
thread_store,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
validate_environment_selections(environment_manager.as_ref(), &environments)?;
let environment =
selected_primary_environment(environment_manager.as_ref(), &environments)?;
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let fs = environment_selections.primary_filesystem();
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
@@ -501,8 +494,9 @@ impl Codex {
let _ = config.features.disable(Feature::Collab);
}
let primary_environment = environment_selections.primary_environment();
let user_instructions = AgentsMdManager::new(&config)
.user_instructions(environment.as_deref())
.user_instructions(primary_environment.as_deref())
.await;
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
@@ -614,7 +608,7 @@ impl Codex {
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments,
environments: environment_selections.to_selections(),
original_config_do_not_use: Arc::clone(&config),
metrics_service_name,
app_server_client_name: None,
-1
View File
@@ -123,7 +123,6 @@ pub(super) async fn spawn_review_thread(
reasoning_effort,
reasoning_summary,
session_source,
environment: parent_turn_context.environment.clone(),
environments: parent_turn_context.environments.clone(),
tools_config,
features: parent_turn_context.features.clone(),
+50 -13
View File
@@ -1,7 +1,9 @@
use super::*;
use crate::goals::GoalRuntimeState;
use codex_otel::LEGACY_NOTIFY_CONFIGURED_METRIC;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnEnvironmentSelection;
use tokio::sync::Semaphore;
/// Context for an initialized model agent
@@ -206,12 +208,7 @@ impl SessionConfiguration {
.unwrap_or_else(|| self.cwd.clone());
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
next_configuration.cwd = absolute_cwd.clone();
if cwd_changed
&& let Some(primary_environment) = next_configuration.environments.first_mut()
{
primary_environment.cwd = absolute_cwd;
}
next_configuration.cwd = absolute_cwd;
if let Some(permission_profile) = updates.permission_profile.clone() {
let active_permission_profile =
@@ -576,6 +573,24 @@ impl Session {
}),
});
}
let legacy_notify_configured = config
.notify
.as_ref()
.is_some_and(|argv| !argv.is_empty() && !argv[0].is_empty());
if legacy_notify_configured {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent {
summary:
"`notify` is deprecated and will be removed in a future release."
.to_string(),
details: Some(
"Switch to a `Stop` hook for end-of-turn automation. See https://developers.openai.com/codex/hooks."
.to_string(),
),
}),
});
}
for message in &config.startup_warnings {
post_session_configured_events.push(Event {
id: "".to_owned(),
@@ -633,6 +648,9 @@ impl Session {
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
}
if legacy_notify_configured {
session_telemetry.counter(LEGACY_NOTIFY_CONFIGURED_METRIC, /*inc*/ 1, &[]);
}
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
@@ -940,6 +958,31 @@ impl Session {
cancel_guard.cancel();
*cancel_guard = CancellationToken::new();
}
let turn_environment = crate::environment_selection::resolve_environment_selections(
sess.services.environment_manager.as_ref(),
&session_configuration.environments,
)
.map_err(|err| {
CodexErr::InvalidRequest(err.to_string().replace(
"unknown turn environment id",
"unknown stored MCP environment id",
))
})?
.primary_turn_environment()
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
sess.services
.environment_manager
.default_environment()
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
session_configuration.cwd.to_path_buf(),
),
};
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
@@ -948,13 +991,7 @@ impl Session {
INITIAL_SUBMIT_ID.to_owned(),
tx_event.clone(),
session_configuration.permission_profile(),
McpRuntimeEnvironment::new(
sess.services
.environment_manager
.default_environment()
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
session_configuration.cwd.to_path_buf(),
),
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
+186 -59
View File
@@ -47,6 +47,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use tracing::Span;
@@ -3289,7 +3290,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
}
#[tokio::test]
async fn session_update_settings_keeps_runtime_cwds_absolute() {
async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() {
let (session, turn_context) = make_session_and_context().await;
let updated_cwd = turn_context.cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
@@ -3315,6 +3316,91 @@ async fn session_update_settings_keeps_runtime_cwds_absolute() {
assert_eq!(next_turn.config.cwd, updated_cwd);
}
#[tokio::test]
async fn relative_cwd_update_without_environments_resolves_under_session_cwd() {
let (session, _turn_context) = make_session_and_context().await;
let original_cwd = {
let mut state = session.state.lock().await;
state.session_configuration.environments = Vec::new();
state.session_configuration.cwd.clone()
};
let updated_cwd = original_cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
.update_settings(SessionSettingsUpdate {
cwd: Some(PathBuf::from("project")),
..Default::default()
})
.await
.expect("cwd update should succeed");
let state = session.state.lock().await;
assert_eq!(state.session_configuration.cwd, updated_cwd);
assert!(state.session_configuration.environments.is_empty());
}
#[tokio::test]
async fn cwd_update_does_not_rewrite_sticky_environment_cwd() {
let (session, _turn_context) = make_session_and_context().await;
let (original_cwd, environment_cwd) = {
let mut state = session.state.lock().await;
let original_cwd = state.session_configuration.cwd.clone();
let environment_cwd = original_cwd.join("environment");
state.session_configuration.environments = vec![TurnEnvironmentSelection {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: environment_cwd.clone(),
}];
(original_cwd, environment_cwd)
};
let updated_cwd = original_cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
.update_settings(SessionSettingsUpdate {
cwd: Some(PathBuf::from("project")),
..Default::default()
})
.await
.expect("cwd update should succeed");
let state = session.state.lock().await;
assert_eq!(state.session_configuration.cwd, updated_cwd);
assert_eq!(
state.session_configuration.environments[0].cwd,
environment_cwd
);
}
#[tokio::test]
async fn absolute_cwd_update_with_turn_environment_is_allowed() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let absolute_cwd = {
let state = session.state.lock().await;
state.session_configuration.cwd.join("absolute-turn")
};
std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir");
let turn_context = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
cwd: Some(absolute_cwd.to_path_buf()),
environments: Some(vec![TurnEnvironmentSelection {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: absolute_cwd.clone(),
}]),
..Default::default()
},
)
.await
.expect("absolute cwd with explicit environments should succeed");
assert_eq!(turn_context.cwd, absolute_cwd);
assert_eq!(turn_context.config.cwd, absolute_cwd);
assert_eq!(turn_context.environments.len(), 1);
}
#[tokio::test]
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -3594,7 +3680,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_info,
&models_manager,
/*network*/ None,
Some(environment),
turn_environments,
session_configuration.cwd.clone(),
"turn_id".to_string(),
@@ -4333,23 +4418,24 @@ async fn turn_environments_set_primary_environment() {
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environment.environment,
&turn_environments[0].environment
));
assert!(!turn_context.environments.is_empty());
assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path());
assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path());
}
#[tokio::test]
async fn default_turn_uses_stored_thread_environments() {
async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let session_cwd = session.get_config().await.cwd.clone();
let selected_cwd =
AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected"))
.expect("absolute path");
AbsolutePathBuf::try_from(session_cwd.as_path().join("selected")).expect("absolute path");
{
let mut state = session.state.lock().await;
@@ -4363,15 +4449,15 @@ async fn default_turn_uses_stored_thread_environments() {
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environment.environment,
&turn_environments[0].environment
));
assert_eq!(turn_context.cwd, selected_cwd);
assert_eq!(turn_context.config.cwd, selected_cwd);
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
}
#[tokio::test]
@@ -4386,54 +4472,42 @@ async fn default_turn_honors_empty_stored_thread_environments() {
let turn_context = session.new_default_turn().await;
assert!(turn_context.environment.is_none());
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
assert_eq!(turn_context.environments.len(), 0);
}
#[tokio::test]
async fn multiple_turn_environments_use_first_as_primary_environment() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let session_cwd = session.get_config().await.cwd.clone();
let first_cwd =
AbsolutePathBuf::try_from(session_cwd.as_path().join("first")).expect("absolute path");
let second_cwd =
AbsolutePathBuf::try_from(session_cwd.as_path().join("second")).expect("absolute path");
async fn primary_environment_uses_first_turn_environment() {
let (_session, mut turn_context) = make_session_and_context().await;
let first_environment = turn_context.environments[0].clone();
let second_cwd = turn_context.cwd.join("second");
turn_context.environments.push(TurnEnvironment {
environment_id: "second".to_string(),
environment: Arc::clone(&first_environment.environment),
cwd: second_cwd.clone(),
});
let turn_context = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
environments: Some(vec![
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: first_cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: second_cwd.clone(),
},
]),
..Default::default()
},
)
.await
.expect("turn should start");
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 2);
assert_eq!(turn_environments[0].cwd, first_cwd);
assert_eq!(turn_environments[1].cwd, second_cwd);
assert!(std::sync::Arc::ptr_eq(
assert_eq!(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environments[0].environment
));
assert_eq!(turn_context.cwd, first_cwd);
assert_eq!(turn_context.config.cwd, first_cwd);
.primary_environment()
.expect("primary environment")
.environment_id,
first_environment.environment_id
);
assert_eq!(
turn_context
.environments
.iter()
.find(|environment| environment.environment_id == "second")
.expect("second environment")
.cwd,
second_cwd
);
assert_eq!(turn_context.environments.len(), 2);
assert_eq!(turn_context.environments[1].cwd, second_cwd);
}
#[tokio::test]
@@ -4451,15 +4525,19 @@ async fn empty_turn_environments_clear_primary_environment() {
.await
.expect("turn should start");
assert!(turn_context.environment.is_none());
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert_eq!(turn_context.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.environments.len(), 0);
}
#[tokio::test]
async fn unknown_turn_environment_returns_error() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let original_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
let err = session
.new_turn_with_sub_id(
@@ -4467,7 +4545,7 @@ async fn unknown_turn_environment_returns_error() {
SessionSettingsUpdate {
environments: Some(vec![TurnEnvironmentSelection {
environment_id: "missing".to_string(),
cwd: session.get_config().await.cwd.clone(),
cwd: original_configuration.cwd.clone(),
}]),
..Default::default()
},
@@ -4475,8 +4553,58 @@ async fn unknown_turn_environment_returns_error() {
.await
.expect_err("unknown environment should fail");
let current_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
assert!(matches!(err, CodexErr::InvalidRequest(_)));
assert!(err.to_string().contains("missing"));
assert_eq!(current_configuration.cwd, original_configuration.cwd);
assert_eq!(
current_configuration.environments,
original_configuration.environments
);
}
#[tokio::test]
async fn duplicate_turn_environment_returns_error_without_mutating_session() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let original_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
let err = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
environments: Some(vec![
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: original_configuration.cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: original_configuration.cwd.join("second"),
},
]),
..Default::default()
},
)
.await
.expect_err("duplicate environment should fail");
let current_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
assert!(matches!(err, CodexErr::InvalidRequest(_)));
assert!(err.to_string().contains("duplicate"));
assert_eq!(current_configuration.cwd, original_configuration.cwd);
assert_eq!(
current_configuration.environments,
original_configuration.environments
);
}
#[tokio::test]
@@ -5033,7 +5161,6 @@ where
model_info,
&models_manager,
/*network*/ None,
Some(environment),
turn_environments,
session_configuration.cwd.clone(),
"turn_id".to_string(),
@@ -1,5 +1,6 @@
use super::*;
use crate::compact::InitialContextInjection;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_policy::ExecPolicyManager;
@@ -754,7 +755,9 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
user_shell_override: None,
parent_trace: None,
environments: Vec::new(),
environment_selections: ResolvedTurnEnvironments {
turn_environments: Vec::new(),
},
analytics_events_client: None,
thread_store,
})
+8
View File
@@ -71,6 +71,7 @@ use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
use codex_hooks::HookResult;
use codex_otel::LEGACY_NOTIFY_RUN_METRIC;
use codex_protocol::config_types::ModeKind;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -575,6 +576,13 @@ pub(crate) async fn run_turn(
},
})
.await;
if !hook_outcomes.is_empty() {
turn_context.session_telemetry.counter(
LEGACY_NOTIFY_RUN_METRIC,
/*inc*/ 1,
&[],
);
}
let mut abort_message = None;
for hook_outcome in hook_outcomes {
+42 -41
View File
@@ -59,7 +59,6 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) environment: Option<Arc<Environment>>,
pub(crate) environments: Vec<TurnEnvironment>,
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
@@ -106,6 +105,10 @@ impl TurnContext {
self.permission_profile.network_sandbox_policy()
}
pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> {
self.environments.first()
}
pub(crate) fn sandbox_policy(&self) -> SandboxPolicy {
let file_system_sandbox_policy = self.file_system_sandbox_policy();
let network_sandbox_policy = self.network_sandbox_policy();
@@ -230,7 +233,6 @@ impl TurnContext {
reasoning_effort,
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
environment: self.environment.clone(),
environments: self.environments.clone(),
cwd: self.cwd.clone(),
current_date: self.current_date.clone(),
@@ -432,7 +434,6 @@ impl Session {
model_info: ModelInfo,
models_manager: &SharedModelsManager,
network: Option<NetworkProxy>,
environment: Option<Arc<Environment>>,
environments: Vec<TurnEnvironment>,
cwd: AbsolutePathBuf,
sub_id: String,
@@ -474,7 +475,7 @@ impl Session {
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_has_environment(environment.is_some())
.with_has_environment(!environments.is_empty())
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
@@ -522,7 +523,6 @@ impl Session {
reasoning_effort,
reasoning_summary,
session_source,
environment,
environments,
cwd,
current_date: Some(current_date),
@@ -564,10 +564,16 @@ impl Session {
let mut state = self.state.lock().await;
match state.session_configuration.clone().apply(&updates) {
Ok(next) => {
let effective_environments = updates
let mut effective_environments = updates
.environments
.clone()
.unwrap_or_else(|| next.environments.clone());
if updates.environments.is_none() {
Self::overlay_runtime_cwd_on_primary_environment(
&mut effective_environments,
&next.cwd,
);
}
let turn_environments =
self.resolve_turn_environments(&effective_environments)?;
let previous_cwd = state.session_configuration.cwd.clone();
@@ -641,27 +647,11 @@ impl Session {
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<Vec<TurnEnvironment>> {
let mut turn_environments = Vec::with_capacity(environments.len());
for selected_environment in environments {
let environment_id = selected_environment.environment_id.clone();
let environment = self
.services
.environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{environment_id}`"
))
})?;
let cwd = selected_environment.cwd.clone();
turn_environments.push(TurnEnvironment {
environment_id,
environment,
cwd,
});
}
Ok(turn_environments)
crate::environment_selection::resolve_environment_selections(
self.services.environment_manager.as_ref(),
environments,
)
.map(|resolved| resolved.turn_environments)
}
async fn new_turn_from_configuration(
@@ -672,8 +662,6 @@ impl Session {
turn_environments: Vec<TurnEnvironment>,
) -> Arc<TurnContext> {
let primary_turn_environment = turn_environments.first();
let environment = primary_turn_environment
.map(|turn_environment| Arc::clone(&turn_environment.environment));
let cwd = primary_turn_environment
.map(|turn_environment| turn_environment.cwd.clone())
.unwrap_or_else(|| session_configuration.cwd.clone());
@@ -700,9 +688,8 @@ impl Session {
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let fs = primary_turn_environment
.map(|turn_environment| turn_environment.environment.get_filesystem());
let skills_outcome = Arc::new(
self.services
.skills_manager
@@ -731,7 +718,6 @@ impl Session {
)
.then(|| started_proxy.proxy())
}),
environment,
turn_environments,
cwd,
sub_id,
@@ -773,14 +759,18 @@ impl Session {
let state = self.state.lock().await;
state.session_configuration.clone()
};
let turn_environments =
match self.resolve_turn_environments(&session_configuration.environments) {
Ok(turn_environments) => turn_environments,
Err(err) => {
warn!("failed to resolve stored session environments: {err}");
Vec::new()
}
};
let mut effective_environments = session_configuration.environments.clone();
Self::overlay_runtime_cwd_on_primary_environment(
&mut effective_environments,
&session_configuration.cwd,
);
let turn_environments = match self.resolve_turn_environments(&effective_environments) {
Ok(turn_environments) => turn_environments,
Err(err) => {
warn!("failed to resolve stored session environments: {err}");
Vec::new()
}
};
self.new_turn_from_configuration(
sub_id,
@@ -790,4 +780,15 @@ impl Session {
)
.await
}
fn overlay_runtime_cwd_on_primary_environment(
environments: &mut [TurnEnvironmentSelection],
runtime_cwd: &AbsolutePathBuf,
) {
if let Some(turn_environment) = environments.first_mut()
&& turn_environment.cwd != *runtime_cwd
{
turn_environment.cwd = runtime_cwd.clone();
}
}
}
+9 -9
View File
@@ -4,8 +4,7 @@ use crate::codex_thread::CodexThread;
use crate::config::Config;
use crate::config::ThreadStoreConfig;
use crate::environment_selection::default_thread_environment_selections;
use crate::environment_selection::selected_primary_environment;
use crate::environment_selection::validate_environment_selections;
use crate::environment_selection::resolve_environment_selections;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::rollout::RolloutRecorder;
@@ -433,7 +432,8 @@ impl ThreadManager {
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<()> {
validate_environment_selections(self.state.environment_manager.as_ref(), environments)
resolve_environment_selections(self.state.environment_manager.as_ref(), environments)
.map(|_| ())
}
pub fn get_models_manager(&self) -> SharedModelsManager {
@@ -1098,16 +1098,16 @@ impl ThreadManagerState {
threads.remove(&resumed.conversation_id);
}
}
let environment =
selected_primary_environment(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment.as_ref() {
Some(environment) if !environment.is_remote() => {
let environment_selections =
resolve_environment_selections(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment_selections.primary_turn_environment() {
Some(turn_environment) if !turn_environment.environment.is_remote() => {
self.skills_watcher
.register_config(
&config,
self.skills_manager.as_ref(),
self.plugins_manager.as_ref(),
Some(environment.get_filesystem()),
Some(turn_environment.environment.get_filesystem()),
)
.await
}
@@ -1139,7 +1139,7 @@ impl ThreadManagerState {
parent_rollout_thread_trace,
user_shell_override,
parent_trace,
environments,
environment_selections,
analytics_events_client: self.analytics_events_client.clone(),
thread_store: Arc::clone(&self.thread_store),
})
@@ -363,13 +363,14 @@ impl ToolHandler for ApplyPatchHandler {
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let sandbox = environment
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
@@ -474,9 +475,8 @@ pub(crate) async fn intercept_apply_patch(
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
let sandbox = turn
.environment
.as_ref()
.filter(|env| env.is_remote())
.primary_environment()
.filter(|env| env.environment.is_remote())
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref())
.await
+2 -2
View File
@@ -412,12 +412,12 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {
@@ -196,12 +196,12 @@ impl ToolHandler for UnifiedExecHandler {
}
};
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
@@ -88,16 +88,18 @@ impl ToolHandler for ViewImageHandler {
};
let abs_path = turn.resolve_path(Some(args.path));
let Some(environment) = turn.environment.as_ref() else {
let Some(environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"view_image is unavailable in this session".to_string(),
));
};
let sandbox = environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
let metadata = environment
.environment
.get_filesystem()
.get_metadata(&abs_path, sandbox.as_ref())
.await
@@ -115,6 +117,7 @@ impl ToolHandler for ViewImageHandler {
)));
}
let file_bytes = environment
.environment
.get_filesystem()
.read_file(&abs_path, sandbox.as_ref())
.await
@@ -191,11 +191,11 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let environment = ctx.turn.environment.as_ref().ok_or_else(|| {
let turn_environment = ctx.turn.primary_environment().ok_or_else(|| {
ToolError::Rejected("apply_patch is unavailable in this session".to_string())
})?;
let started_at = Instant::now();
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
@@ -254,9 +254,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
let environment_is_remote = ctx
.turn
.environment
.as_ref()
.is_some_and(|environment| environment.is_remote());
.primary_environment()
.is_some_and(|turn_environment| turn_environment.environment.is_remote());
let command = if environment_is_remote {
base_command.to_vec()
} else {
@@ -293,12 +292,12 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.await?
{
Some(prepared) => {
let Some(environment) = ctx.turn.environment.as_ref() else {
let Some(turn_environment) = ctx.turn.primary_environment() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
};
if environment.is_remote() {
if turn_environment.environment.is_remote() {
return Err(ToolError::Rejected(
"unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(),
));
@@ -310,7 +309,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&prepared.exec_request,
req.tty,
prepared.spawn_lifecycle,
environment.as_ref(),
turn_environment.environment.as_ref(),
)
.await
.map_err(|err| match err {
@@ -338,7 +337,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(command, options, managed_network)
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(environment) = ctx.turn.environment.as_ref() else {
let Some(turn_environment) = ctx.turn.primary_environment() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
@@ -349,7 +348,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&exec_env,
req.tty,
Box::new(NoopSpawnLifecycle),
environment.as_ref(),
turn_environment.environment.as_ref(),
)
.await
.map_err(|err| match err {
+9 -3
View File
@@ -96,7 +96,10 @@ async fn exec_command_with_tty(
&request,
tty,
Box::new(NoopSpawnLifecycle),
turn.environment.as_ref().expect("turn environment"),
turn.primary_environment()
.expect("turn environment")
.environment
.as_ref(),
)
.await?,
);
@@ -591,7 +594,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
let remote_test_env = remote_test_env().await?;
let (_, mut turn) = make_session_and_context().await;
turn.environment = Some(Arc::new(remote_test_env.environment().clone()));
turn.environments[0].environment = Arc::new(remote_test_env.environment().clone());
let request = test_exec_request(
&turn,
@@ -609,7 +612,10 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
Box::new(TestSpawnLifecycle {
inherited_fds: vec![42],
}),
turn.environment.as_ref().expect("turn environment"),
turn.primary_environment()
.expect("turn environment")
.environment
.as_ref(),
)
.await
.expect_err("expected inherited fd rejection");
@@ -115,6 +115,38 @@ async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_deprecation_notice_for_notify() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.notify = Some(vec!["notify-send".to_string(), "Codex".to_string()]);
});
let TestCodex { codex, .. } = builder.build(&server).await?;
let notice = wait_for_event_match(&codex, |event| match event {
EventMsg::DeprecationNotice(ev) if ev.summary.contains("`notify`") => Some(ev.clone()),
_ => None,
})
.await;
let DeprecationNoticeEvent { summary, details } = notice;
assert_eq!(
summary,
"`notify` is deprecated and will be removed in a future release.".to_string(),
);
assert_eq!(
details.as_deref(),
Some(
"Switch to a `Stop` hook for end-of-turn automation. See https://developers.openai.com/codex/hooks."
),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
-153
View File
@@ -1,153 +0,0 @@
use std::process::Stdio;
use std::sync::Arc;
use serde::Serialize;
use crate::Hook;
use crate::HookEvent;
use crate::HookPayload;
use crate::HookResult;
use crate::command_from_argv;
/// Legacy notify payload appended as the final argv argument for backward compatibility.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
turn_id: String,
cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<String>,
/// Messages that the user sent to the agent to initiate the turn.
input_messages: Vec<String>,
/// The last message sent by the assistant in the turn.
last_assistant_message: Option<String>,
},
}
pub fn legacy_notify_json(payload: &HookPayload) -> Result<String, serde_json::Error> {
match &payload.hook_event {
HookEvent::AfterAgent { event } => {
serde_json::to_string(&UserNotification::AgentTurnComplete {
thread_id: event.thread_id.to_string(),
turn_id: event.turn_id.clone(),
cwd: payload.cwd.display().to_string(),
client: payload.client.clone(),
input_messages: event.input_messages.clone(),
last_assistant_message: event.last_assistant_message.clone(),
})
}
_ => Err(serde_json::Error::io(std::io::Error::other(
"legacy notify payload is only supported for after_agent",
))),
}
}
pub fn notify_hook(argv: Vec<String>) -> Hook {
let argv = Arc::new(argv);
Hook {
name: "legacy_notify".to_string(),
func: Arc::new(move |payload: &HookPayload| {
let argv = Arc::clone(&argv);
Box::pin(async move {
let mut command = match command_from_argv(&argv) {
Some(command) => command,
None => return HookResult::Success,
};
if let Ok(notify_payload) = legacy_notify_json(payload) {
command.arg(notify_payload);
}
// Backwards-compat: match legacy notify behavior (argv + JSON arg, fire-and-forget).
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(_) => HookResult::Success,
Err(err) => HookResult::FailedContinue(err.into()),
}
})
}),
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use super::*;
fn expected_notification_json() -> Value {
let cwd = test_path_buf("/Users/example/project");
json!({
"type": "agent-turn-complete",
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": cwd.display().to_string(),
"client": "codex-tui",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
})
}
#[test]
fn test_user_notification() -> Result<()> {
let notification = UserNotification::AgentTurnComplete {
thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
turn_id: "12345".to_string(),
cwd: test_path_buf("/Users/example/project")
.display()
.to_string(),
client: Some("codex-tui".to_string()),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
};
let serialized = serde_json::to_string(&notification)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
#[test]
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
let payload = HookPayload {
session_id: ThreadId::new(),
cwd: test_path_buf("/Users/example/project").abs(),
client: Some("codex-tui".to_string()),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
event: crate::HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec![
"Rename `foo` to `bar` and update the callsites.".to_string(),
],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
},
},
};
let serialized = legacy_notify_json(&payload)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
}
+2
View File
@@ -32,6 +32,8 @@ pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_syn
pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final";
pub const HOOK_RUN_METRIC: &str = "codex.hooks.run";
pub const HOOK_RUN_DURATION_METRIC: &str = "codex.hooks.run.duration_ms";
pub const LEGACY_NOTIFY_CONFIGURED_METRIC: &str = "codex.notify.configured";
pub const LEGACY_NOTIFY_RUN_METRIC: &str = "codex.notify.run";
/// Total runtime of a startup prewarm attempt until it completes, tagged by final status.
pub const STARTUP_PREWARM_DURATION_METRIC: &str = "codex.startup_prewarm.duration_ms";
/// Age of the startup prewarm attempt when the first real turn resolves it, tagged by outcome.
+5
View File
@@ -131,7 +131,12 @@ libc = { workspace = true }
which = { workspace = true }
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
] }
winsplit = "0.1"
+14 -7
View File
@@ -121,7 +121,6 @@
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
//!
use crate::bottom_pane::footer::goal_status_indicator_line;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
@@ -167,7 +166,6 @@ use super::footer::footer_hint_items_width;
use super::footer::footer_line_width;
use super::footer::inset_footer_hint_area;
use super::footer::max_left_width_for_right;
use super::footer::mode_indicator_line as collaboration_mode_indicator_line;
use super::footer::passive_footer_status_line;
use super::footer::render_context_right;
use super::footer::render_footer_from_props;
@@ -176,6 +174,7 @@ use super::footer::render_footer_line;
use super::footer::reset_mode_after_activity;
use super::footer::side_conversation_context_line;
use super::footer::single_line_footer_layout;
use super::footer::status_line_right_indicator_line;
use super::footer::toggle_shortcut_mode;
use super::footer::uses_passive_footer_status_layout;
use super::paste_burst::CharDecision;
@@ -385,6 +384,7 @@ pub(crate) struct ChatComposer {
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
goal_status_indicator: Option<GoalStatusIndicator>,
ide_context_active: bool,
connectors_enabled: bool,
plugins_command_enabled: bool,
fast_command_enabled: bool,
@@ -565,6 +565,7 @@ impl ChatComposer {
config,
collaboration_mode_indicator: None,
goal_status_indicator: None,
ide_context_active: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
@@ -724,6 +725,10 @@ impl ChatComposer {
self.goal_status_indicator = indicator;
}
pub fn set_ide_context_active(&mut self, active: bool) {
self.ide_context_active = active;
}
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
@@ -1083,14 +1088,16 @@ impl ChatComposer {
if let Some(vim_mode) = self.vim_mode_indicator_span() {
spans.push(vim_mode);
}
if let Some(collab) =
collaboration_mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint)
.or_else(|| goal_status_indicator_line(self.goal_status_indicator.as_ref()))
{
if let Some(indicators) = status_line_right_indicator_line(
self.collaboration_mode_indicator,
self.goal_status_indicator.as_ref(),
self.ide_context_active,
show_cycle_hint,
) {
if !spans.is_empty() {
spans.push(" | ".dim());
}
spans.extend(collab.spans);
spans.extend(indicators.spans);
}
if spans.is_empty() {
None
+68 -2
View File
@@ -566,6 +566,34 @@ pub(crate) fn goal_status_indicator_line(
Some(Line::from(vec![Span::from(label).magenta()]))
}
pub(crate) fn status_line_right_indicator_line(
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
goal_status_indicator: Option<&GoalStatusIndicator>,
ide_context_active: bool,
show_cycle_hint: bool,
) -> Option<Line<'static>> {
let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint)
.or_else(|| goal_status_indicator_line(goal_status_indicator));
let ide_context_indicator = ide_context_active.then(|| Line::from(vec!["IDE context".cyan()]));
let mut line: Option<Line<'static>> = None;
for indicator in [primary_indicator, ide_context_indicator]
.into_iter()
.flatten()
{
if let Some(line) = line.as_mut() {
line.push_span(" · ".dim());
for span in indicator.spans {
line.push_span(span);
}
} else {
line = Some(indicator);
}
}
line
}
pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
if let Some(rest) = label.strip_prefix("Side ") {
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
@@ -1261,6 +1289,7 @@ mod tests {
height: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
ide_context_active: bool,
context_line: Line<'static>,
) {
terminal
@@ -1321,9 +1350,16 @@ mod tests {
)
};
let right_line = if status_line_active {
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(
let full = status_line_right_indicator_line(
collaboration_mode_indicator,
/*goal_status_indicator*/ None,
ide_context_active,
show_cycle_hint,
);
let compact = status_line_right_indicator_line(
collaboration_mode_indicator,
/*goal_status_indicator*/ None,
ide_context_active,
/*show_cycle_hint*/ false,
);
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
@@ -1448,6 +1484,7 @@ mod tests {
height,
props,
collaboration_mode_indicator,
/*ide_context_active*/ false,
context_line,
);
assert_snapshot!(name, terminal.backend());
@@ -1466,11 +1503,32 @@ mod tests {
height,
props,
collaboration_mode_indicator,
/*ide_context_active*/ false,
context_line,
);
terminal.backend().vt100().screen().contents()
}
fn snapshot_footer_with_indicators(
name: &str,
width: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
ide_context_active: bool,
) {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(
&mut terminal,
height,
props,
collaboration_mode_indicator,
ide_context_active,
context_window_line(/*percent*/ None, /*used_tokens*/ None),
);
assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_snapshots() {
snapshot_footer(
@@ -1769,6 +1827,14 @@ mod tests {
context_window_line(Some(50), /*used_tokens*/ None),
);
snapshot_footer_with_indicators(
"footer_status_line_enabled_mode_and_ide_context_right",
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
/*ide_context_active*/ true,
);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
+5
View File
@@ -380,6 +380,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_ide_context_active(&mut self, active: bool) {
self.composer.set_ide_context_active(active);
self.request_redraw();
}
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.composer.set_personality_command_enabled(enabled);
self.request_redraw();
@@ -165,6 +165,7 @@ mod tests {
assert_eq!(
commands,
vec![
SlashCommand::Ide,
SlashCommand::Copy,
SlashCommand::Diff,
SlashCommand::Mention,
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Plan mode (shift+tab to cycle) · IDE context "
+31 -55
View File
@@ -320,6 +320,8 @@ use self::goal_status::GoalStatusState;
#[cfg(test)]
use self::goal_status::goal_status_indicator_from_app_goal;
mod goal_menu;
mod ide_context;
use self::ide_context::IdeContextState;
mod interrupts;
use self::interrupts::InterruptManager;
mod keymap_picker;
@@ -838,6 +840,7 @@ pub(crate) struct ChatWidget {
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
connectors_prefetch_in_flight: bool,
connectors_force_refetch_pending: bool,
ide_context: IdeContextState,
plugins_cache: PluginsCacheState,
plugins_fetch_state: PluginListFetchState,
plugin_install_apps_needing_auth: Vec<AppSummary>,
@@ -1138,6 +1141,7 @@ pub(crate) struct ThreadInputState {
composer: Option<ThreadComposerState>,
pending_steers: VecDeque<UserMessage>,
pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
rejected_steers_queue: VecDeque<UserMessage>,
rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
queued_user_messages: VecDeque<QueuedUserMessage>,
@@ -1451,16 +1455,16 @@ fn user_message_display_for_history(
history_record: &UserMessageHistoryRecord,
) -> UserMessageDisplay {
let message = user_message_for_restore(message, history_record);
UserMessageDisplay {
message: message.text,
remote_image_urls: message.remote_image_urls,
local_images: message
ChatWidget::user_message_display_from_parts(
message.text,
message.text_elements,
message
.local_images
.into_iter()
.map(|image| image.path)
.collect(),
text_elements: message.text_elements,
}
message.remote_image_urls,
)
}
fn merge_user_messages_with_history_record(
@@ -3235,6 +3239,11 @@ impl ChatWidget {
.iter()
.map(|pending| pending.history_record.clone())
.collect(),
pending_steer_compare_keys: self
.pending_steers
.iter()
.map(|pending| pending.compare_key.clone())
.collect(),
rejected_steers_queue: self.rejected_steers_queue.clone(),
rejected_steer_history_records: self.rejected_steer_history_records.clone(),
queued_user_messages: self.queued_user_messages.clone(),
@@ -3288,16 +3297,19 @@ impl ChatWidget {
input_state.pending_steers.len(),
UserMessageHistoryRecord::UserMessageText,
);
let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys;
self.pending_steers = input_state
.pending_steers
.into_iter()
.zip(pending_steer_history_records)
.map(|(user_message, history_record)| PendingSteer {
compare_key: PendingSteerCompareKey {
message: user_message.text.clone(),
image_count: user_message.local_images.len()
+ user_message.remote_image_urls.len(),
},
compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| {
PendingSteerCompareKey {
message: user_message.text.clone(),
image_count: user_message.local_images.len()
+ user_message.remote_image_urls.len(),
}
}),
history_record,
user_message,
})
@@ -4883,6 +4895,7 @@ impl ChatWidget {
connectors_partial_snapshot: None,
connectors_prefetch_in_flight: false,
connectors_force_refetch_pending: false,
ide_context: IdeContextState::default(),
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
@@ -5749,6 +5762,9 @@ impl ChatWidget {
));
return (false, None);
}
self.maybe_apply_ide_context(&mut items);
let collaboration_mode = if self.collaboration_modes_enabled() {
self.active_collaboration_mask
.as_ref()
@@ -5831,7 +5847,7 @@ impl ChatWidget {
// Show replayable user content in conversation history.
let display_user_message = render_in_history.then(|| {
user_message_for_restore(
user_message_display_for_history(
UserMessage {
text,
local_images,
@@ -5842,49 +5858,8 @@ impl ChatWidget {
&history_record,
)
});
if let Some(display_user_message) = display_user_message {
let UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: _,
} = display_user_message;
if !text.is_empty() {
let local_image_paths = local_images
.into_iter()
.map(|img| img.path)
.collect::<Vec<_>>();
self.last_rendered_user_message_display =
Some(Self::user_message_display_from_parts(
text.clone(),
text_elements.clone(),
local_image_paths.clone(),
remote_image_urls.clone(),
));
self.add_to_history(history_cell::new_user_prompt(
text,
text_elements,
local_image_paths,
remote_image_urls,
));
self.record_visible_user_turn_for_copy();
} else if !remote_image_urls.is_empty() {
self.last_rendered_user_message_display =
Some(Self::user_message_display_from_parts(
String::new(),
Vec::new(),
Vec::new(),
remote_image_urls.clone(),
));
self.add_to_history(history_cell::new_user_prompt(
String::new(),
Vec::new(),
Vec::new(),
remote_image_urls,
));
self.record_visible_user_turn_for_copy();
}
if let Some(display) = display_user_message {
self.on_user_message_display(display);
}
self.needs_final_message_separator = false;
@@ -6622,6 +6597,7 @@ impl ChatWidget {
self.last_rendered_user_message_display = Some(display.clone());
if !display.message.trim().is_empty()
|| !display.text_elements.is_empty()
|| !display.local_images.is_empty()
|| !display.remote_image_urls.is_empty()
{
self.record_visible_user_turn_for_copy();
+132
View File
@@ -0,0 +1,132 @@
//! Chat-widget wiring for the `/ide` command and IDE context prompt injection.
use codex_app_server_protocol::UserInput;
use super::ChatWidget;
#[derive(Default)]
pub(super) struct IdeContextState {
enabled: bool,
prompt_fetch_warned: bool,
}
impl IdeContextState {
pub(super) fn is_enabled(&self) -> bool {
self.enabled
}
fn enable(&mut self) {
self.enabled = true;
self.prompt_fetch_warned = false;
}
fn disable(&mut self) {
self.enabled = false;
self.prompt_fetch_warned = false;
}
fn mark_available(&mut self) {
self.prompt_fetch_warned = false;
}
}
impl ChatWidget {
pub(super) fn handle_ide_command(&mut self) {
if self.ide_context.is_enabled() {
self.ide_context.disable();
self.sync_ide_context_status_indicator();
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
} else {
self.ide_context.enable();
self.add_ide_context_status_message();
}
}
pub(super) fn handle_ide_command_args(&mut self, args: &str) {
match args.to_ascii_lowercase().as_str() {
"" => self.handle_ide_command(),
"on" => {
self.ide_context.enable();
self.add_ide_context_status_message();
}
"off" => {
self.ide_context.disable();
self.sync_ide_context_status_indicator();
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
}
"status" => {
self.add_ide_context_status_message();
}
_ => {
self.add_error_message("Usage: /ide [on|off|status]".to_string());
}
}
}
/// Fetches fresh IDE context for the outgoing user turn and folds it into the prompt.
pub(super) fn maybe_apply_ide_context(&mut self, items: &mut Vec<UserInput>) {
if !self.ide_context.is_enabled() {
return;
}
match crate::ide_context::fetch_ide_context(&self.config.cwd) {
Ok(context) => {
self.ide_context.mark_available();
self.sync_ide_context_status_indicator();
crate::ide_context::apply_ide_context_to_user_input(&context, items);
}
Err(err) => {
self.sync_ide_context_status_indicator();
if !self.ide_context.prompt_fetch_warned {
self.ide_context.prompt_fetch_warned = true;
self.add_info_message(
"IDE context was skipped for this message.".to_string(),
Some(err.prompt_skip_hint()),
);
}
}
}
}
fn add_ide_context_status_message(&mut self) {
if !self.ide_context.is_enabled() {
self.sync_ide_context_status_indicator();
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
return;
}
match crate::ide_context::fetch_ide_context(&self.config.cwd) {
Ok(context) => {
self.ide_context.mark_available();
self.sync_ide_context_status_indicator();
if crate::ide_context::has_prompt_context(&context) {
self.add_info_message(
"IDE context is on.".to_string(),
Some(
"Future messages will include your current IDE selection and open tabs."
.to_string(),
),
);
} else {
self.add_info_message(
"IDE context is on.".to_string(),
Some("Connected to your IDE.".to_string()),
);
}
}
Err(err) => {
self.ide_context.disable();
self.sync_ide_context_status_indicator();
self.add_info_message(
"IDE context could not be enabled.".to_string(),
Some(err.user_facing_hint()),
);
}
}
}
pub(super) fn sync_ide_context_status_indicator(&mut self) {
self.bottom_pane
.set_ide_context_active(self.ide_context.is_enabled());
}
}
@@ -363,6 +363,9 @@ impl ChatWidget {
);
}
}
SlashCommand::Ide => {
self.handle_ide_command();
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
@@ -572,6 +575,9 @@ impl ChatWidget {
}
}
}
SlashCommand::Ide => {
self.handle_ide_command_args(trimmed);
}
SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() {
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
@@ -835,6 +841,7 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Fast
| SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
@@ -931,6 +931,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
composer: None,
pending_steers: VecDeque::new(),
pending_steer_history_records: VecDeque::new(),
pending_steer_compare_keys: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
rejected_steer_history_records: VecDeque::new(),
queued_user_messages: VecDeque::new(),
@@ -1180,6 +1181,68 @@ fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
);
}
#[test]
fn user_message_display_from_inputs_hides_prompt_context() {
let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk $figma";
let mention_start = raw_message.find("$figma").expect("mention in raw message");
let rendered = ChatWidget::user_message_display_from_inputs(&[UserInput::Text {
text: raw_message.to_string(),
text_elements: vec![
TextElement::new(
(mention_start..mention_start + "$figma".len()).into(),
Some("$figma".to_string()),
)
.into(),
],
}]);
assert_eq!(
rendered,
ChatWidget::user_message_display_from_parts(
"Ask $figma".to_string(),
vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
Vec::new(),
Vec::new(),
)
);
}
#[tokio::test]
async fn committed_user_message_with_hidden_prompt_context_renders_local_images() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let local_image = PathBuf::from("/tmp/context-image.png");
let raw_message =
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";
complete_user_message_for_inputs(
&mut chat,
"user-1",
vec![
UserInput::Text {
text: raw_message.to_string(),
text_elements: Vec::new(),
},
UserInput::LocalImage {
path: local_image.clone(),
},
],
);
let mut user_cell = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
break;
}
}
let (message, local_images) = user_cell.expect("expected user history cell");
assert_eq!(message, "");
assert_eq!(local_images, vec![local_image]);
}
#[tokio::test]
async fn interrupt_restores_queued_messages_into_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -249,6 +249,7 @@ pub(super) async fn make_chatwidget_manual(
newly_installed_marketplace_tab_id: None,
connectors_prefetch_in_flight: false,
connectors_force_refetch_pending: false,
ide_context: super::super::ide_context::IdeContextState::default(),
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
interrupts: InterruptManager::new(),
@@ -400,7 +400,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
}
#[tokio::test]
async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() {
async fn replayed_user_message_with_only_local_images_renders_history_cell() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
@@ -438,17 +438,20 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce
ReplayKind::ResumeInitialMessages,
);
let mut found_user_history_cell = false;
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& cell.as_any().downcast_ref::<UserHistoryCell>().is_some()
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
found_user_history_cell = true;
user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
break;
}
}
assert!(!found_user_history_cell);
let (stored_message, stored_local_images) =
user_cell.expect("expected a replayed local-image-only user history cell");
assert!(stored_message.is_empty());
assert_eq!(stored_local_images, local_images);
}
#[tokio::test]
@@ -333,6 +333,12 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut pending_steers = VecDeque::new();
pending_steers.push_back(UserMessage::from("pending steer"));
let expected_compare_key = PendingSteerCompareKey {
message: "hidden IDE context\npending steer".to_string(),
image_count: 0,
};
let mut pending_steer_compare_keys = VecDeque::new();
pending_steer_compare_keys.push_back(expected_compare_key.clone());
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
@@ -342,6 +348,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
composer: None,
pending_steers,
pending_steer_history_records: VecDeque::new(),
pending_steer_compare_keys,
rejected_steers_queue,
rejected_steer_history_records: VecDeque::new(),
queued_user_messages,
@@ -362,6 +369,10 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.pending_steers.front().unwrap().user_message.text,
"pending steer"
);
assert_eq!(
chat.pending_steers.front().unwrap().compare_key,
expected_compare_key
);
}
#[tokio::test]
+24 -1
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf;
use codex_app_server_protocol::UserInput;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use super::ChatWidget;
@@ -33,8 +34,30 @@ impl ChatWidget {
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> UserMessageDisplay {
let (message, prompt_request_offset) =
crate::ide_context::extract_prompt_request_with_offset(&message);
let prompt_request_end = prompt_request_offset + message.len();
// Prompt context uses the same delimiter and stripping behavior as the desktop app and IDE
// extension. The raw user message goes to the agent, but every surface renders only the
// request after that delimiter, so keep elements inside the visible request and shift their
// byte ranges to match.
let text_elements = text_elements
.into_iter()
.filter_map(|element| {
let range = element.byte_range;
if range.start < prompt_request_offset || range.end > prompt_request_end {
return None;
}
Some(element.map_range(|range| ByteRange {
start: range.start - prompt_request_offset,
end: range.end - prompt_request_offset,
}))
})
.collect();
UserMessageDisplay {
message,
message: message.to_string(),
remote_image_urls,
local_images,
text_elements,
+117
View File
@@ -0,0 +1,117 @@
//! IDE context data model and public helpers for TUI `/ide` support.
mod ipc;
mod prompt;
#[cfg(windows)]
mod windows_pipe;
pub(crate) use ipc::fetch_ide_context;
pub(crate) use prompt::apply_ide_context_to_user_input;
pub(crate) use prompt::extract_prompt_request_with_offset;
pub(crate) use prompt::has_prompt_context;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct IdeContext {
active_file: Option<ActiveFile>,
#[serde(default)]
open_tabs: Vec<FileDescriptor>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct ActiveFile {
#[serde(flatten)]
descriptor: FileDescriptor,
selection: Range,
#[serde(default)]
active_selection_content: String,
#[serde(default)]
selections: Vec<Range>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct FileDescriptor {
label: String,
path: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct Range {
start: Position,
end: Position,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct Position {
line: u32,
character: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn deserializes_existing_ide_context_shape() {
let value = json!({
"activeFile": {
"label": "lib.rs",
"path": "src/lib.rs",
"fsPath": "/repo/src/lib.rs",
"selection": {
"start": { "line": 1, "character": 2 },
"end": { "line": 3, "character": 4 }
},
"activeSelectionContent": "selected",
"selections": []
},
"openTabs": [
{
"label": "main.rs",
"path": "src/main.rs",
"fsPath": "/repo/src/main.rs",
"startLine": 2,
"endLine": 10
}
],
"processEnv": {
"path": "/usr/bin"
}
});
let context: IdeContext = serde_json::from_value(value).expect("deserialize ide context");
assert_eq!(
context,
IdeContext {
active_file: Some(ActiveFile {
descriptor: FileDescriptor {
label: "lib.rs".to_string(),
path: "src/lib.rs".to_string(),
},
selection: Range {
start: Position {
line: 1,
character: 2,
},
end: Position {
line: 3,
character: 4,
},
},
active_selection_content: "selected".to_string(),
selections: Vec::new(),
}),
open_tabs: vec![FileDescriptor {
label: "main.rs".to_string(),
path: "src/main.rs".to_string(),
}],
}
);
}
}
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
//! Prompt rendering for IDE context injected into TUI user turns.
use codex_app_server_protocol::ByteRange;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::UserInput;
use super::IdeContext;
const MAX_ACTIVE_SELECTION_CHARS: usize = 40_000;
const MAX_OPEN_TABS: usize = 100;
const MAX_OPEN_TABS_CHARS: usize = 20_000;
// Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the
// raw prompt before this marker, then transcript rendering strips back to the request after the last
// marker. Keeping the same marker and stripping semantics lets threads created with IDE context in
// one surface replay cleanly in the others.
const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:";
pub(crate) fn apply_ide_context_to_user_input(
context: &IdeContext,
items: &mut Vec<UserInput>,
) -> bool {
let Some(context_text) = render_prompt_context(context) else {
return false;
};
let prefix = format!("{context_text}\n{PROMPT_REQUEST_BEGIN}\n");
if let Some(text_index) = items
.iter()
.position(|item| matches!(item, UserInput::Text { .. }))
{
// Prefix the existing text item in place so image and text items keep
// the same relative order they had in the user's original submission.
let item = std::mem::replace(
&mut items[text_index],
UserInput::Text {
text: String::new(),
text_elements: Vec::new(),
},
);
let UserInput::Text {
text,
text_elements,
} = item
else {
unreachable!("position matched a text item");
};
items[text_index] = prefixed_text_input(prefix, text, text_elements);
} else {
items.insert(
0,
UserInput::Text {
text: prefix,
text_elements: Vec::new(),
},
);
}
true
}
pub(crate) fn has_prompt_context(context: &IdeContext) -> bool {
render_prompt_context(context).is_some()
}
pub(crate) fn extract_prompt_request_with_offset(message: &str) -> (&str, usize) {
let Some((before_request, request)) = message.rsplit_once(PROMPT_REQUEST_BEGIN) else {
return (message, 0);
};
let request_start = before_request.len() + PROMPT_REQUEST_BEGIN.len();
let trimmed_request = request.trim();
let leading_trimmed_len = request.len() - request.trim_start().len();
(trimmed_request, request_start + leading_trimmed_len)
}
fn prefixed_text_input(prefix: String, text: String, text_elements: Vec<TextElement>) -> UserInput {
let prefix_len = prefix.len();
UserInput::Text {
text: format!("{prefix}{text}"),
text_elements: text_elements
.into_iter()
.map(|element| {
let range = element.byte_range.clone();
TextElement::new(
ByteRange {
start: range.start + prefix_len,
end: range.end + prefix_len,
},
element.placeholder().map(str::to_string),
)
})
.collect(),
}
}
fn render_prompt_context(context: &IdeContext) -> Option<String> {
let mut ide_context_section = String::new();
if let Some(active_file) = &context.active_file {
ide_context_section.push_str(&format!(
"\n## Active file: {}\n",
active_file.descriptor.path
));
}
if let Some(active_file) = &context.active_file {
let selected_ranges = if active_file.selections.is_empty() {
std::slice::from_ref(&active_file.selection)
} else {
active_file.selections.as_slice()
}
.iter()
.filter(|range| range.start != range.end)
.collect::<Vec<_>>();
if !selected_ranges.is_empty()
&& (active_file.active_selection_content.is_empty() || selected_ranges.len() > 1)
{
if selected_ranges.len() == 1 {
ide_context_section.push_str("\n## Active selection range:\n");
} else {
ide_context_section.push_str("\n## Active selection ranges:\n");
}
for range in selected_ranges {
// Render ranges as 1-based positions for the prompt.
let start_line = range.start.line + 1;
let start_column = range.start.character + 1;
let end_line = range.end.line + 1;
let end_column = range.end.character + 1;
ide_context_section.push_str(&format!(
"- {}: line {start_line}, column {start_column} to line {end_line}, column {end_column}\n",
active_file.descriptor.path
));
}
}
}
if let Some(active_file) = &context.active_file
&& !active_file.active_selection_content.is_empty()
{
ide_context_section.push_str("\n## Active selection of the file:\n");
let selection = active_file.active_selection_content.as_str();
if let Some((truncate_at, _)) = selection.char_indices().nth(MAX_ACTIVE_SELECTION_CHARS) {
ide_context_section.push_str(&selection[..truncate_at]);
ide_context_section.push_str(&format!(
"\n[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]\n"
));
} else {
ide_context_section.push_str(selection);
}
}
if !context.open_tabs.is_empty() {
ide_context_section.push_str("\n## Open tabs:\n");
let mut rendered_tabs = 0;
let mut rendered_tab_chars = 0;
for tab in &context.open_tabs {
if rendered_tabs >= MAX_OPEN_TABS {
break;
}
let tab_line = format!("- {}: {}\n", tab.label, tab.path);
if rendered_tab_chars + tab_line.len() > MAX_OPEN_TABS_CHARS {
break;
}
ide_context_section.push_str(&tab_line);
rendered_tabs += 1;
rendered_tab_chars += tab_line.len();
}
let omitted_tabs = context.open_tabs.len() - rendered_tabs;
if omitted_tabs > 0 {
ide_context_section.push_str(&format!("[{omitted_tabs} open tabs omitted.]\n"));
}
}
if ide_context_section.is_empty() {
None
} else {
Some(format!(
"# Context from my IDE setup:\n{ide_context_section}"
))
}
}
#[cfg(test)]
mod tests {
use super::super::ActiveFile;
use super::super::FileDescriptor;
use super::super::IdeContext;
use super::super::Position;
use super::super::Range;
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn descriptor(label: &str, path: &str) -> FileDescriptor {
FileDescriptor {
label: label.to_string(),
path: path.to_string(),
}
}
#[test]
fn render_prompt_context_matches_app_format() {
let context = IdeContext {
active_file: Some(ActiveFile {
descriptor: descriptor("lib.rs", "src/lib.rs"),
selection: Range {
start: Position {
line: 4,
character: 0,
},
end: Position {
line: 6,
character: 1,
},
},
active_selection_content: "fn selected() {}".to_string(),
selections: Vec::new(),
}),
open_tabs: vec![
descriptor("lib.rs", "src/lib.rs"),
descriptor("main.rs", "src/main.rs"),
],
};
assert_eq!(
render_prompt_context(&context),
Some(
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection of the file:\nfn selected() {}\n## Open tabs:\n- lib.rs: src/lib.rs\n- main.rs: src/main.rs\n"
.to_string()
)
);
}
#[test]
fn render_prompt_context_omits_empty_context() {
let context = IdeContext {
active_file: None,
open_tabs: Vec::new(),
};
assert_eq!(render_prompt_context(&context), None);
}
#[test]
fn apply_ide_context_uses_desktop_prompt_request_delimiter() {
let context = IdeContext {
active_file: Some(ActiveFile {
descriptor: descriptor("lib.rs", "src/lib.rs"),
selection: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
active_selection_content: String::new(),
selections: Vec::new(),
}),
open_tabs: Vec::new(),
};
let text = "Ask $figma".to_string();
let mut items = vec![
UserInput::LocalImage {
path: PathBuf::from("/tmp/screenshot.png"),
},
UserInput::Text {
text,
text_elements: vec![TextElement::new(
ByteRange { start: 4, end: 10 },
Some("$figma".to_string()),
)],
},
];
assert!(apply_ide_context_to_user_input(&context, &mut items));
let expected_prefix = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";
let prefix_len = expected_prefix.len();
assert_eq!(
items,
vec![
UserInput::LocalImage {
path: PathBuf::from("/tmp/screenshot.png"),
},
UserInput::Text {
text: format!("{expected_prefix}Ask $figma"),
text_elements: vec![TextElement::new(
ByteRange {
start: prefix_len + 4,
end: prefix_len + 10,
},
Some("$figma".to_string()),
)],
},
]
);
}
#[test]
fn extract_prompt_request_returns_text_after_last_delimiter() {
let message =
"# Context\n## My request for Codex:\nFirst\n## My request for Codex:\n Second\n";
assert_eq!(
extract_prompt_request_with_offset(message),
("Second", message.find("Second").expect("request offset"))
);
}
#[test]
fn render_prompt_context_includes_selection_ranges_without_content() {
let first_range = Range {
start: Position {
line: 1,
character: 2,
},
end: Position {
line: 1,
character: 5,
},
};
let second_range = Range {
start: Position {
line: 3,
character: 0,
},
end: Position {
line: 4,
character: 1,
},
};
let context = IdeContext {
active_file: Some(ActiveFile {
descriptor: descriptor("lib.rs", "src/lib.rs"),
selection: first_range.clone(),
active_selection_content: String::new(),
selections: vec![first_range, second_range],
}),
open_tabs: Vec::new(),
};
assert_eq!(
render_prompt_context(&context),
Some(
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection ranges:\n- src/lib.rs: line 2, column 3 to line 2, column 6\n- src/lib.rs: line 4, column 1 to line 5, column 2\n"
.to_string()
)
);
}
#[test]
fn render_prompt_context_truncates_large_selection() {
let context = IdeContext {
active_file: Some(ActiveFile {
descriptor: descriptor("large.txt", "large.txt"),
selection: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 1,
},
},
active_selection_content: format!("{}tail", "a".repeat(MAX_ACTIVE_SELECTION_CHARS)),
selections: Vec::new(),
}),
open_tabs: Vec::new(),
};
let rendered = render_prompt_context(&context).expect("rendered IDE context");
assert!(rendered.contains(&format!(
"[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]"
)));
assert!(!rendered.contains("tail"));
}
#[test]
fn render_prompt_context_omits_excess_open_tabs() {
let open_tabs = (0..MAX_OPEN_TABS + 2)
.map(|index| descriptor(&format!("file-{index}.rs"), &format!("src/file-{index}.rs")))
.collect::<Vec<_>>();
let context = IdeContext {
active_file: None,
open_tabs,
};
let rendered = render_prompt_context(&context).expect("rendered IDE context");
assert!(rendered.contains("- file-99.rs: src/file-99.rs\n"));
assert!(!rendered.contains("- file-100.rs: src/file-100.rs\n"));
assert!(rendered.contains("[2 open tabs omitted.]\n"));
}
}
@@ -0,0 +1,339 @@
//! Windows named-pipe transport for the IDE context IPC client.
use std::io;
use std::io::Read;
use std::io::Write;
use std::os::windows::ffi::OsStrExt;
use std::path::PathBuf;
use std::ptr;
use std::time::Instant;
use windows_sys::Win32::Foundation::BOOL;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Foundation::ERROR_IO_PENDING;
use windows_sys::Win32::Foundation::ERROR_NOT_FOUND;
use windows_sys::Win32::Foundation::GENERIC_READ;
use windows_sys::Win32::Foundation::GENERIC_WRITE;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Foundation::WAIT_FAILED;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
use windows_sys::Win32::Security::EqualSid;
use windows_sys::Win32::Security::GetTokenInformation;
use windows_sys::Win32::Security::TOKEN_QUERY;
use windows_sys::Win32::Security::TOKEN_USER;
use windows_sys::Win32::Security::TokenUser;
use windows_sys::Win32::Storage::FileSystem::CreateFileW;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE;
use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
use windows_sys::Win32::Storage::FileSystem::ReadFile;
use windows_sys::Win32::Storage::FileSystem::WriteFile;
use windows_sys::Win32::System::IO::CancelIoEx;
use windows_sys::Win32::System::IO::GetOverlappedResult;
use windows_sys::Win32::System::IO::OVERLAPPED;
use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
use windows_sys::Win32::System::Threading::CreateEventW;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::OpenProcessToken;
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
const TRUE: BOOL = 1;
const FALSE: BOOL = 0;
const NULL_HANDLE: HANDLE = 0;
pub(super) struct WindowsPipeStream {
handle: OwnedHandle,
deadline: Instant,
}
impl WindowsPipeStream {
pub(super) fn connect(pipe_path: PathBuf, deadline: Instant) -> io::Result<Self> {
let wide_path = pipe_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let handle = unsafe {
CreateFileW(
wide_path.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
ptr::null(),
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
NULL_HANDLE,
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
let handle = OwnedHandle(handle);
validate_pipe_server_owner(handle.raw())?;
Ok(Self { handle, deadline })
}
pub(super) fn set_deadline(&mut self, deadline: Instant) {
self.deadline = deadline;
}
}
impl Read for WindowsPipeStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let bytes_to_read = u32::try_from(buf.len()).unwrap_or(u32::MAX);
let mut operation = OverlappedOperation::new()?;
let result = unsafe {
ReadFile(
self.handle.raw(),
buf.as_mut_ptr(),
bytes_to_read,
ptr::null_mut(),
operation.as_mut_ptr(),
)
};
operation.complete(self.handle.raw(), result, self.deadline)
}
}
impl Write for WindowsPipeStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let bytes_to_write = u32::try_from(buf.len()).unwrap_or(u32::MAX);
let mut operation = OverlappedOperation::new()?;
let result = unsafe {
WriteFile(
self.handle.raw(),
buf.as_ptr(),
bytes_to_write,
ptr::null_mut(),
operation.as_mut_ptr(),
)
};
operation.complete(self.handle.raw(), result, self.deadline)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct OverlappedOperation {
event: OwnedHandle,
overlapped: OVERLAPPED,
}
impl OverlappedOperation {
fn new() -> io::Result<Self> {
let event = unsafe { CreateEventW(ptr::null(), TRUE, FALSE, ptr::null()) };
if event == 0 {
return Err(io::Error::last_os_error());
}
let mut overlapped = unsafe { std::mem::zeroed::<OVERLAPPED>() };
overlapped.hEvent = event;
Ok(Self {
event: OwnedHandle(event),
overlapped,
})
}
fn as_mut_ptr(&mut self) -> *mut OVERLAPPED {
&mut self.overlapped
}
fn complete(
&mut self,
handle: HANDLE,
initial_result: BOOL,
deadline: Instant,
) -> io::Result<usize> {
if initial_result == 0 {
let error = io::Error::last_os_error();
if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) {
return Err(error);
}
// Use a zero wait after the deadline so pending overlapped I/O still flows through
// cancel_and_timeout instead of returning while the OS operation owns this OVERLAPPED.
match unsafe { WaitForSingleObject(self.event.raw(), remaining_timeout_ms(deadline)) } {
WAIT_OBJECT_0 => {}
WAIT_TIMEOUT => return Err(self.cancel_and_timeout(handle)),
WAIT_FAILED => return Err(io::Error::last_os_error()),
other => {
return Err(io::Error::other(format!(
"unexpected WaitForSingleObject result: {other}"
)));
}
}
}
let mut bytes_transferred = 0;
let result = unsafe {
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE)
};
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(bytes_transferred as usize)
}
fn cancel_and_timeout(&mut self, handle: HANDLE) -> io::Error {
let cancel_result = unsafe { CancelIoEx(handle, self.as_mut_ptr()) };
if cancel_result == 0 {
let cancel_error = io::Error::last_os_error();
if cancel_error.raw_os_error() != Some(ERROR_NOT_FOUND as i32) {
return cancel_error;
}
// ERROR_NOT_FOUND means the operation completed before cancellation was issued. Drain
// it without waiting so the timeout path cannot block past the caller's deadline.
let mut bytes_transferred = 0;
unsafe {
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE)
};
return timeout_io_error();
}
let mut bytes_transferred = 0;
unsafe {
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, TRUE);
}
timeout_io_error()
}
}
struct OwnedHandle(HANDLE);
impl OwnedHandle {
fn raw(&self) -> HANDLE {
self.0
}
}
impl Drop for OwnedHandle {
fn drop(&mut self) {
if self.0 != 0 && self.0 != INVALID_HANDLE_VALUE {
unsafe {
CloseHandle(self.0);
}
}
}
}
struct TokenUserBuffer {
buffer: Vec<u8>,
}
impl TokenUserBuffer {
fn sid(&self) -> io::Result<windows_sys::Win32::Foundation::PSID> {
if self.buffer.len() < std::mem::size_of::<TOKEN_USER>() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"token user buffer is too small",
));
}
// GetTokenInformation writes TOKEN_USER into a byte buffer. Vec<u8> has
// no TOKEN_USER alignment guarantee, so copy the fixed header out with
// an unaligned read before using its SID pointer.
let token_user =
unsafe { std::ptr::read_unaligned(self.buffer.as_ptr() as *const TOKEN_USER) };
Ok(token_user.User.Sid)
}
}
fn validate_pipe_server_owner(pipe_handle: HANDLE) -> io::Result<()> {
let mut server_process_id = 0;
let result = unsafe { GetNamedPipeServerProcessId(pipe_handle, &mut server_process_id) };
if result == 0 {
return Err(io::Error::last_os_error());
}
let server_process =
unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, server_process_id) };
if server_process == 0 {
return Err(io::Error::last_os_error());
}
let server_process = OwnedHandle(server_process);
let server_token = open_process_token(server_process.raw())?;
let current_token = open_process_token(unsafe { GetCurrentProcess() })?;
let server_user = token_user(server_token.raw())?;
let current_user = token_user(current_token.raw())?;
if unsafe { EqualSid(server_user.sid()?, current_user.sid()?) } == 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"IDE context provider is not owned by the current user",
));
}
Ok(())
}
fn open_process_token(process: HANDLE) -> io::Result<OwnedHandle> {
let mut token = 0;
let result = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) };
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(OwnedHandle(token))
}
fn token_user(token: HANDLE) -> io::Result<TokenUserBuffer> {
let mut return_length = 0;
unsafe {
GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut return_length);
}
if return_length == 0 {
return Err(io::Error::last_os_error());
}
let mut buffer = vec![0_u8; return_length as usize];
let result = unsafe {
GetTokenInformation(
token,
TokenUser,
buffer.as_mut_ptr() as *mut _,
return_length,
&mut return_length,
)
};
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(TokenUserBuffer { buffer })
}
fn remaining_timeout_ms(deadline: Instant) -> u32 {
let now = Instant::now();
if now >= deadline {
return 0;
}
let millis = deadline.duration_since(now).as_millis().max(1);
u32::try_from(millis).unwrap_or(u32::MAX)
}
fn timeout_io_error() -> io::Error {
io::Error::new(io::ErrorKind::TimedOut, "timed out waiting for IDE context")
}
+1
View File
@@ -125,6 +125,7 @@ mod frames;
mod get_git_diff;
mod goal_display;
mod history_cell;
mod ide_context;
pub(crate) mod insert_history;
pub use insert_history::insert_history_lines;
mod key_hint;
+12 -1
View File
@@ -14,6 +14,7 @@ pub enum SlashCommand {
// more frequently used commands should be listed first.
Model,
Fast,
Ide,
Approvals,
Permissions,
Keymap,
@@ -105,6 +106,9 @@ impl SlashCommand {
SlashCommand::Fast => {
"toggle Fast mode to enable fastest inference with increased plan usage"
}
SlashCommand::Ide => {
"include current selection, open files, and other context from your IDE"
}
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
@@ -148,6 +152,7 @@ impl SlashCommand {
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Fast
| SlashCommand::Ide
| SlashCommand::Mcp
| SlashCommand::Side
| SlashCommand::Resume
@@ -159,7 +164,11 @@ impl SlashCommand {
pub fn available_in_side_conversation(self) -> bool {
matches!(
self,
SlashCommand::Copy | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status
SlashCommand::Copy
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::Ide
)
}
@@ -206,6 +215,7 @@ impl SlashCommand {
| SlashCommand::Statusline
| SlashCommand::AutoReview
| SlashCommand::Feedback
| SlashCommand::Ide
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Side => true,
@@ -257,6 +267,7 @@ mod tests {
#[test]
fn certain_commands_are_available_during_task() {
assert!(SlashCommand::Goal.available_during_task());
assert!(SlashCommand::Ide.available_during_task());
assert!(SlashCommand::Title.available_during_task());
assert!(SlashCommand::Statusline.available_during_task());
}
+3 -1
View File
@@ -60,7 +60,9 @@ disabled_tools = [
## Notify
Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings:
`notify` is deprecated and will be removed in a future release. Existing configurations still work for compatibility, but new automation should use lifecycle hooks instead.
Codex can run a legacy notification command when the agent finishes a turn. See the configuration reference for the latest notification settings:
- https://developers.openai.com/codex/config-reference