mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Merge 9475d37b83 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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"
|
||||
Generated
+40
-7
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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>,
|
||||
+1
-1
@@ -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>,
|
||||
+1
-1
@@ -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,
|
||||
+1
-1
@@ -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,
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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(¬ification)?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Plan mode (shift+tab to cycle) · IDE context "
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user