feat(protocol): add MCP shim and sync tool stack

This commit is contained in:
James Peter
2025-10-03 14:36:00 +10:00
parent 8793632001
commit d0a0f011d5
113 changed files with 2230 additions and 741 deletions
+13 -5
View File
@@ -1,11 +1,19 @@
# npm releases
Run the following:
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
Use the staging helper in the repo root to generate npm tarballs for a release. For
example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0`:
```bash
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
./scripts/stage_npm_packages.py \
--release-version 0.6.0 \
--package codex \
--package codex-responses-api-proxy \
--package codex-sdk
```
Note this will create `./codex-cli/vendor/` as a side-effect.
This downloads the native artifacts once, hydrates `vendor/` for each package, and writes
tarballs to `dist/npm/`.
If you need to invoke `build_npm_package.py` directly, run
`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the
directory that contains the populated `vendor/` tree.
+34 -4
View File
@@ -197,8 +197,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-protocol",
"mcp-types",
"codex-app-server-protocol",
"serde",
"serde_json",
"tokio",
@@ -907,6 +906,14 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-app-server-protocol"
version = "0.0.0"
dependencies = [
"codex-protocol",
"mcp-types",
]
[[package]]
name = "codex-apply-patch"
version = "0.0.0"
@@ -987,6 +994,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-common",
"codex-core",
"codex-git-apply",
@@ -1006,6 +1014,7 @@ dependencies = [
"clap",
"clap_complete",
"codex-app-server",
"codex-app-server-protocol",
"codex-arg0",
"codex-chatgpt",
"codex-cloud-tasks",
@@ -1088,6 +1097,7 @@ name = "codex-common"
version = "0.0.0"
dependencies = [
"clap",
"codex-app-server-protocol",
"codex-core",
"codex-protocol",
"serde",
@@ -1103,9 +1113,11 @@ dependencies = [
"askama",
"assert_cmd",
"async-channel",
"async-trait",
"base64 0.22.1",
"bytes",
"chrono",
"codex-app-server-protocol",
"codex-apply-patch",
"codex-browser",
"codex-file-search",
@@ -1116,12 +1128,14 @@ dependencies = [
"codex-version",
"core_test_support",
"dirs",
"dunce",
"env-flags",
"eventsource-stream",
"fs2",
"futures",
"htmd",
"img_hash",
"indexmap 2.11.4",
"landlock",
"lazy_static",
"libc",
@@ -1173,6 +1187,7 @@ dependencies = [
"assert_cmd",
"chrono",
"clap",
"codex-app-server-protocol",
"codex-arg0",
"codex-common",
"codex-core",
@@ -1181,6 +1196,7 @@ dependencies = [
"core_test_support",
"libc",
"mcp-types",
"opentelemetry-appender-tracing",
"owo-colors",
"predicates",
"serde_json",
@@ -1266,8 +1282,8 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"codex-app-server-protocol",
"codex-core",
"codex-protocol",
"core_test_support",
"rand 0.9.2",
"reqwest",
@@ -1304,6 +1320,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"codex-app-server",
"codex-app-server-protocol",
"codex-arg0",
"codex-common",
"codex-core",
@@ -1346,6 +1363,7 @@ name = "codex-otel"
version = "0.0.0"
dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-protocol",
"eventsource-stream",
"opentelemetry",
@@ -1378,7 +1396,6 @@ dependencies = [
"icu_locale_core",
"mcp-types",
"mime_guess",
"pretty_assertions",
"serde",
"serde_json",
"serde_with",
@@ -1397,6 +1414,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-protocol",
"mcp-types",
"ts-rs",
@@ -4215,6 +4233,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16"
dependencies = [
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "opentelemetry-http"
version = "0.30.0"
+3
View File
@@ -3,6 +3,7 @@ members = [
"backend-client",
"ansi-escape",
"app-server",
"app-server-protocol",
"apply-patch",
"arg0",
"codex-backend-openapi-models",
@@ -47,6 +48,7 @@ edition = "2024"
app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-app-server = { path = "app-server" }
codex-app-server-protocol = { path = "app-server-protocol" }
codex-apply-patch = { path = "apply-patch" }
codex-arg0 = { path = "arg0" }
codex-chatgpt = { path = "chatgpt" }
@@ -97,6 +99,7 @@ derive_more = "2"
diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.5"
env-flags = "0.1.1"
env_logger = "0.11.5"
escargot = "0.5"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "codex-app-server-protocol"
version = { workspace = true }
edition = "2024"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
mcp-types = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
//! Compatibility shim for the legacy `codex_app_server_protocol` crate.
//! This crate re-exports the MCP protocol surface that downstream crates
//! still depend on while the fork finishes migrating to the new structure.
pub use codex_protocol::mcp_protocol::{
self,
AddConversationListenerParams,
AddConversationSubscriptionResponse,
ApplyPatchApprovalParams,
ApplyPatchApprovalResponse,
ArchiveConversationParams,
ArchiveConversationResponse,
AuthMode,
CancelLoginChatGptParams,
CancelLoginChatGptResponse,
ClientInfo,
ClientNotification,
ClientRequest,
ConversationId,
ExecCommandApprovalParams,
ExecCommandApprovalResponse,
GetAuthStatusParams,
GetAuthStatusResponse,
GetUserAgentResponse,
GetUserSavedConfigResponse,
GitDiffToRemoteParams,
GitDiffToRemoteResponse,
GitSha,
InitializeParams,
InputItem,
InterruptConversationParams,
InterruptConversationResponse,
ListConversationsParams,
ListConversationsResponse,
LoginApiKeyParams,
LoginChatGptCompleteNotification,
LoginChatGptResponse,
LogoutChatGptParams,
LogoutChatGptResponse,
NewConversationParams,
NewConversationResponse,
Profile,
RemoveConversationListenerParams,
RemoveConversationSubscriptionResponse,
ResumeConversationParams,
ResumeConversationResponse,
SandboxSettings,
SendUserMessageParams,
SendUserMessageResponse,
SendUserTurnParams,
SendUserTurnResponse,
ServerNotification,
ServerRequest,
SessionConfiguredNotification,
SetDefaultModelParams,
SetDefaultModelResponse,
Tools,
UserInfoResponse,
UserSavedConfig,
APPLY_PATCH_APPROVAL_METHOD,
EXEC_COMMAND_APPROVAL_METHOD,
};
pub use mcp_types::{
JSONRPCError,
JSONRPCErrorError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
};
+5 -1
View File
@@ -7,6 +7,7 @@ use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::InitializeResponse;
use codex_protocol::protocol::SessionSource;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -41,7 +42,10 @@ impl MessageProcessor {
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Mcp,
));
let config_for_processor = config.clone();
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
+1 -2
View File
@@ -9,8 +9,7 @@ path = "lib.rs"
[dependencies]
anyhow = { workspace = true }
assert_cmd = { workspace = true }
codex-protocol = { workspace = true }
mcp-types = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
+1 -1
View File
@@ -2,8 +2,8 @@ mod mcp_process;
mod mock_model_server;
mod responses;
use codex_app_server_protocol::JSONRPCResponse;
pub use mcp_process::McpProcess;
use mcp_types::JSONRPCResponse;
pub use mock_model_server::create_mock_chat_completions_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
+30 -33
View File
@@ -11,29 +11,30 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientNotification;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InitializeParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::LoginApiKeyParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::RemoveConversationListenerParams;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserTurnParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SetDefaultModelParams;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
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 std::process::Command as StdCommand;
use tokio::process::Command;
@@ -317,7 +318,6 @@ impl McpProcess {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let message = JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
@@ -331,12 +331,8 @@ impl McpProcess {
id: RequestId,
result: serde_json::Value,
) -> anyhow::Result<()> {
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result,
}))
.await
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { id, result }))
.await
}
pub async fn send_notification(
@@ -345,7 +341,6 @@ impl McpProcess {
) -> anyhow::Result<()> {
let value = serde_json::to_value(notification)?;
self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method: value
.get("method")
.and_then(|m| m.as_str())
@@ -373,7 +368,7 @@ impl McpProcess {
Ok(message)
}
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<JSONRPCRequest> {
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<ServerRequest> {
eprintln!("in read_stream_until_request_message()");
loop {
@@ -384,7 +379,9 @@ impl McpProcess {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
return Ok(jsonrpc_request);
return jsonrpc_request.try_into().with_context(
|| "failed to deserialize ServerRequest from JSONRPCRequest",
);
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
@@ -426,7 +423,7 @@ impl McpProcess {
pub async fn read_stream_until_error_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<mcp_types::JSONRPCError> {
) -> anyhow::Result<JSONRPCError> {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
@@ -2,13 +2,13 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::ArchiveConversationResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::ArchiveConversationResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
+3 -3
View File
@@ -1,9 +1,9 @@
use std::path::Path;
use codex_core::auth::login_with_api_key;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
@@ -5,20 +5,6 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use std::env;
use tempfile::TempDir;
@@ -109,7 +95,7 @@ async fn test_codex_jsonrpc_conversation_flow() {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "text".to_string(),
}],
})
@@ -259,7 +245,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run python".to_string(),
}],
})
@@ -284,11 +270,28 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
.await
.expect("waiting for exec approval request timeout")
.expect("exec approval request");
assert_eq!(request.method, EXEC_COMMAND_APPROVAL_METHOD);
let ServerRequest::ExecCommandApproval { request_id, params } = request else {
panic!("expected ExecCommandApproval request, got: {request:?}");
};
assert_eq!(
ExecCommandApprovalParams {
conversation_id,
call_id: "call1".to_string(),
command: vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
cwd: working_directory.clone(),
reason: None,
},
params
);
// Approve so the first turn can complete
mcp.send_response(
request.id,
request_id,
serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }),
)
.await
@@ -307,7 +310,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
let send_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run python again".to_string(),
}],
cwd: working_directory.clone(),
+7 -7
View File
@@ -3,18 +3,18 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::GetUserSavedConfigResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::Profile;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxSettings;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::Profile;
use codex_protocol::mcp_protocol::SandboxSettings;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -4,15 +4,15 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::InputItem;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -1,6 +1,6 @@
use app_test_support::McpProcess;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+8 -8
View File
@@ -3,13 +3,13 @@
use std::path::Path;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use core_test_support::non_sandbox_test;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
@@ -99,7 +99,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run first sleep command".to_string(),
}],
})
+10 -10
View File
@@ -3,16 +3,16 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::ResumeConversationResponse;
use codex_protocol::mcp_protocol::ServerNotification;
use codex_protocol::mcp_protocol::SessionConfiguredNotification;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::ListConversationsResponse;
use codex_app_server_protocol::NewConversationParams; // reused for overrides shape
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::ResumeConversationResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SessionConfiguredNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+6 -6
View File
@@ -2,12 +2,12 @@ use std::path::Path;
use std::time::Duration;
use codex_core::auth::login_with_api_key;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptResponse;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::CancelLoginChatGptResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutChatGptResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
+11 -11
View File
@@ -4,17 +4,17 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::InputItem;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_protocol::ConversationId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -2,11 +2,11 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SetDefaultModelParams;
use codex_app_server_protocol::SetDefaultModelResponse;
use codex_core::config::ConfigToml;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use codex_protocol::mcp_protocol::SetDefaultModelResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -1,8 +1,8 @@
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::GetUserAgentResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::GetUserAgentResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
+3 -3
View File
@@ -5,14 +5,14 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::UserInfoResponse;
use codex_core::auth::AuthDotJson;
use codex_core::auth::get_auth_file;
use codex_core::auth::write_auth_json;
use codex_core::token_data::IdTokenInfo;
use codex_core::token_data::TokenData;
use codex_protocol::mcp_protocol::UserInfoResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+1
View File
@@ -12,6 +12,7 @@ clap = { workspace = true, features = ["derive"] }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
+1 -1
View File
@@ -1,5 +1,5 @@
use codex_core::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use std::path::Path;
use std::sync::LazyLock;
use std::sync::RwLock;
+3 -2
View File
@@ -28,6 +28,7 @@ codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-tui = { workspace = true }
@@ -54,8 +55,8 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
[dev-dependencies]
assert_cmd = { workspace = true }
+1 -1
View File
@@ -9,7 +9,7 @@ use codex_core::ModelProviderInfo;
use codex_core::AuthManager;
use codex_core::Prompt;
use codex_core::TextFormat;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
+1 -1
View File
@@ -1,3 +1,4 @@
use codex_app_server_protocol::AuthMode;
use codex_common::CliConfigOverrides;
use codex_core::CodexAuth;
use codex_core::auth::CLIENT_ID;
@@ -9,7 +10,6 @@ use codex_core::config::ConfigOverrides;
use codex_login::ServerOptions;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
use codex_protocol::mcp_protocol::AuthMode;
use std::env;
use std::path::PathBuf;
+1 -14
View File
@@ -14,7 +14,6 @@ use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_login_with_device_code;
use codex_cli::login::run_logout;
use codex_cli::proto;
mod llm;
use llm::{LlmCli, run_llm};
use codex_common::CliConfigOverrides;
@@ -34,7 +33,6 @@ use tokio::runtime::{Builder as TokioRuntimeBuilder, Handle as TokioHandle};
mod mcp_cmd;
use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
const CLI_COMMAND_NAME: &str = "code";
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
@@ -106,10 +104,6 @@ enum Subcommand {
/// [experimental] Run the app server.
AppServer,
/// Run the Protocol stream via stdin/stdout
#[clap(visible_alias = "p")]
Proto(ProtoCli),
/// Generate shell completion scripts.
Completion(CompletionCommand),
@@ -380,13 +374,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
);
run_logout(logout_cli.config_overrides).await;
}
Some(Subcommand::Proto(mut proto_cli)) => {
prepend_config_flags(
&mut proto_cli.config_overrides,
root_config_overrides.clone(),
);
proto::run_main(proto_cli).await?;
}
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
@@ -591,7 +578,7 @@ fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result<O
.context("failed to look up session by id")?;
Ok(maybe)
} else if last {
let page = RolloutRecorder::list_conversations(&codex_home, 1, None)
let page = RolloutRecorder::list_conversations(&codex_home, 1, None, &[])
.await
.context("failed to list recorded sessions")?;
Ok(page.items.first().map(|it| it.path.clone()))
+9 -7
View File
@@ -10,6 +10,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Submission;
use codex_protocol::protocol::SessionSource;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -37,18 +38,19 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
// Use conversation_manager API to start a conversation
let conversation_manager = ConversationManager::new(
AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
),
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Cli);
let NewConversation {
conversation_id: _,
conversation,
session_configured,
} = conversation_manager.new_conversation(config).await?;
} = conversation_manager
.new_conversation(config.clone())
.await?;
// Simulate streaming the session_configured event.
let synthetic_event = Event {
+3
View File
@@ -851,6 +851,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
&& matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N'))
|| matches!(key.code, KeyCode::Char('\u{000E}'));
if is_ctrl_n {
if app.new_task.is_none() {
continue;
}
if app.best_of_modal.is_some() {
app.best_of_modal = None;
needs_redraw = true;
+21 -13
View File
@@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
if app.new_task.is_some() {
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
@@ -1004,32 +1004,40 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
const MAX_WIDTH: u16 = 40;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 12;
const MIN_HEIGHT: u16 = 6;
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
let modal_height = inner
.height
.min(MAX_HEIGHT)
.max(inner.height.min(MIN_HEIGHT));
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
frame.render_widget(Clear, modal_area);
frame.render_widget(block.clone(), modal_area);
let content = overlay_content(modal_area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let hint = Paragraph::new(Line::from(
"Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
.wrap(Wrap { trim: true });
frame.render_widget(hint, rows[0]);
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let options = [1usize, 2, 3, 4];
let mut items: Vec<ListItem> = Vec::new();
for &attempts in &options {
let mut spans: Vec<ratatui::text::Span> =
vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()];
let noun = if attempts == 1 { "attempt" } else { "attempts" };
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
spans.push(" ".into());
spans.push(format!("{attempts}x parallel").dim());
if attempts == app.best_of_n {
+1
View File
@@ -10,6 +10,7 @@ workspace = true
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }
+8 -8
View File
@@ -1,5 +1,5 @@
use codex_app_server_protocol::AuthMode;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_protocol::mcp_protocol::AuthMode;
/// A simple preset pairing a model slug with a reasoning effort.
#[derive(Debug, Clone, Copy)]
@@ -20,49 +20,49 @@ const PRESETS: &[ModelPreset] = &[
ModelPreset {
id: "gpt-5-codex-low",
label: "gpt-5-codex low",
description: "",
description: "Fastest responses with limited reasoning",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-codex-medium",
label: "gpt-5-codex medium",
description: "",
description: "Dynamically adjusts reasoning based on the task",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-codex-high",
label: "gpt-5-codex high",
description: "",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5-minimal",
label: "gpt-5 minimal",
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
description: "Fastest responses with little reasoning",
model: "gpt-5",
effort: Some(ReasoningEffort::Minimal),
},
ModelPreset {
id: "gpt-5-low",
label: "gpt-5 low",
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-medium",
label: "gpt-5 medium",
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-high",
label: "gpt-5 high",
description: "— maximizes reasoning depth for complex or ambiguous problems",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5",
effort: Some(ReasoningEffort::High),
},
+52 -48
View File
@@ -12,71 +12,75 @@ path = "src/lib.rs"
workspace = true
[dependencies]
anyhow = "1"
askama = "0.12"
async-channel = "2.3.1"
base64 = "0.22.1"
bytes = "1.10.1"
chrono = { version = "0.4", features = ["serde"] }
codex-apply-patch = { path = "../apply-patch" }
codex-file-search = { path = "../file-search" }
codex-mcp-client = { path = "../mcp-client" }
codex-protocol = { path = "../protocol" }
codex-rmcp-client = { path = "../rmcp-client" }
codex-otel = { path = "../otel", features = ["otel"] }
anyhow = { workspace = true }
askama = { workspace = true }
async-channel = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-apply-patch = { workspace = true }
codex-file-search = { workspace = true }
codex-mcp-client = { workspace = true }
codex-protocol = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-otel = { workspace = true, features = ["otel"] }
codex-browser = { path = "../browser" }
codex-version = { path = "../codex-version" }
agent-client-protocol = "0.4.3"
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
futures = "0.3"
libc = "0.2.175"
mcp-types = { path = "../mcp-types" }
os_info = "3.12.0"
portable-pty = "0.9.0"
rand = "0.9"
regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
indexmap = { workspace = true }
lazy_static = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
mime_guess = { workspace = true }
os_info = { workspace = true }
portable-pty = { workspace = true }
rand = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde = { workspace = true, features = ["derive"] }
serde_bytes = "0.11"
serde_json = { workspace = true }
serde_yaml = "0.9"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.16"
time = { version = "0.3", features = [
sha1 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
strum_macros = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true, features = [
"formatting",
"parsing",
"local-offset",
"macros",
] }
tokio = { version = "1", features = [
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.4"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
which = "6"
wildmatch = "2.5.0"
lazy_static = "1"
mime_guess = "2"
serde_bytes = "0.11"
fs2 = "0.4"
tokio-util = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4"] }
url = "2"
which = { workspace = true }
wildmatch = { workspace = true }
fs2 = "0.4"
htmd = "0.1"
img_hash = "3"
codex-browser = { path = "../browser" }
codex-version = { path = "../codex-version" }
[target.'cfg(target_os = "linux")'.dependencies]
+1 -2
View File
@@ -5,7 +5,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}).
## Editing constraints
@@ -90,7 +89,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; add a language hint whenever obvious.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
+6 -6
View File
@@ -1052,7 +1052,7 @@ pub fn create_run_agent_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1074,7 +1074,7 @@ pub fn create_check_agent_status_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1096,7 +1096,7 @@ pub fn create_get_agent_result_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1130,7 +1130,7 @@ pub fn create_cancel_agent_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1181,7 +1181,7 @@ pub fn create_wait_for_agent_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1219,7 +1219,7 @@ pub fn create_list_agents_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
+36 -8
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
@@ -197,6 +197,7 @@ impl CodexAuth {
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
@@ -204,6 +205,13 @@ fn read_openai_api_key_from_env() -> Option<String> {
.filter(|s| !s.is_empty())
}
pub fn read_codex_api_key_from_env() -> Option<String> {
env::var(CODEX_API_KEY_ENV_VAR)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
@@ -777,6 +785,7 @@ pub struct AuthManager {
codex_home: PathBuf,
originator: String,
inner: RwLock<CachedAuth>,
enable_codex_api_key_env: bool,
}
impl AuthManager {
@@ -785,16 +794,23 @@ impl AuthManager {
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten();
let mut effective_mode = preferred_auth_mode;
let auth = if let Some(api_key) = read_codex_api_key_from_env() {
effective_mode = AuthMode::ApiKey;
Some(CodexAuth::from_api_key(&api_key))
} else {
CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten()
};
Self {
codex_home,
originator,
inner: RwLock::new(CachedAuth {
preferred_auth_mode,
preferred_auth_mode: effective_mode,
auth,
}),
enable_codex_api_key_env: true,
}
}
@@ -809,6 +825,7 @@ impl AuthManager {
codex_home: PathBuf::new(),
originator: "codex_cli_rs".to_string(),
inner: RwLock::new(cached),
enable_codex_api_key_env: false,
})
}
@@ -829,12 +846,23 @@ impl AuthManager {
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let preferred = self.preferred_auth_method();
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten();
let env_auth = if self.enable_codex_api_key_env {
read_codex_api_key_from_env().map(|api_key| CodexAuth::from_api_key(&api_key))
} else {
None
};
let new_auth = env_auth.clone().or_else(|| {
CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten()
});
if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth;
guard.preferred_auth_mode = env_auth
.as_ref()
.map(|auth| auth.mode)
.unwrap_or(preferred);
changed
} else {
false
+1 -1
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::fs::OpenOptions;
+12 -4
View File
@@ -24,11 +24,13 @@ use crate::client_common::ResponseStream;
use crate::debug_logger::DebugLogger;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
use std::sync::{Arc, Mutex};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
@@ -403,7 +405,6 @@ pub(crate) async fn stream_chat_completions(
let status = res.status();
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
let body = (res.text().await).unwrap_or_default();
// Log error response
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
@@ -415,11 +416,18 @@ pub(crate) async fn stream_chat_completions(
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body));
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
if attempt > max_retries {
return Err(CodexErr::RetryLimit(status));
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
}
let retry_after_secs = res
+12 -3
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use crate::AuthManager;
use bytes::Bytes;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::prelude::*;
@@ -39,6 +39,8 @@ use crate::debug_logger::DebugLogger;
use crate::default_client::create_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::error::UsageLimitReachedError;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_family::ModelFamily;
@@ -509,7 +511,11 @@ impl ModelClient {
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body_text));
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body: body_text,
request_id: None,
}));
}
if status == StatusCode::TOO_MANY_REQUESTS {
@@ -589,7 +595,10 @@ impl ModelClient {
return Err(CodexErr::ServerError(msg));
}
return Err(CodexErr::RetryLimit(status));
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
}
let delay = retry_after_secs
+5 -3
View File
@@ -55,7 +55,7 @@ use crate::agent_tool::AgentStatusUpdatePayload;
use crate::protocol::ApprovedCommandMatchKind;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use crate::account_usage;
use crate::auth_accounts;
use codex_protocol::models::WebSearchAction;
@@ -614,6 +614,7 @@ use crate::shell;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_protocol::protocol::SessionSource;
use crate::rollout::recorder::SessionStateSnapshot;
use serde_json::Value;
use crate::exec_command::ExecSessionManager;
@@ -3040,8 +3041,9 @@ async fn submission_loop(
match RolloutRecorder::new(
&config,
crate::rollout::recorder::RolloutRecorderParams::new(
codex_protocol::mcp_protocol::ConversationId::from(session_id),
codex_protocol::ConversationId::from(session_id),
effective_user_instructions.clone(),
SessionSource::Cli,
),
)
.await
@@ -3067,7 +3069,7 @@ async fn submission_loop(
}
};
let conversation_id = codex_protocol::mcp_protocol::ConversationId::from(session_id);
let conversation_id = codex_protocol::ConversationId::from(session_id);
let auth_snapshot = auth_manager.as_ref().and_then(|mgr| mgr.auth());
let otel_event_manager = {
let manager = OtelEventManager::new(
@@ -1,25 +1,431 @@
// This is a WIP. This will eventually contain a real list of common safe Windows commands.
pub fn is_safe_command_windows(_command: &[String]) -> bool {
use shlex::split as shlex_split;
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
pub fn is_safe_command_windows(command: &[String]) -> bool {
if let Some(commands) = try_parse_powershell_command_sequence(command) {
return commands
.iter()
.all(|cmd| is_safe_powershell_command(cmd.as_slice()));
}
// Only PowerShell invocations are allowed on Windows for now; anything else is unsafe.
false
}
/// Returns each command sequence if the invocation starts with a PowerShell binary.
/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences.
fn try_parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
let (exe, rest) = command.split_first()?;
if !is_powershell_executable(exe) {
return None;
}
parse_powershell_invocation(rest)
}
/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns.
fn parse_powershell_invocation(args: &[String]) -> Option<Vec<Vec<String>>> {
if args.is_empty() {
// Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments.
return None;
}
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
let lower = arg.to_ascii_lowercase();
match lower.as_str() {
"-command" | "/command" | "-c" => {
let script = args.get(idx + 1)?;
if idx + 2 != args.len() {
// Reject if there is more than one token representing the actual command.
// Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra".
return None;
}
return parse_powershell_script(script);
}
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
if idx + 1 != args.len() {
// Reject if there are more tokens after the command itself.
// Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args.
return None;
}
let script = arg.split_once(':')?.1;
return parse_powershell_script(script);
}
// Benign, no-arg flags we tolerate.
"-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => {
idx += 1;
continue;
}
// Explicitly forbidden/opaque or unnecessary for read-only operations.
"-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy"
| "-workingdirectory" => {
// Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1".
return None;
}
// Unknown switch → bail conservatively.
_ if lower.starts_with('-') => {
// Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar".
return None;
}
// If we hit non-flag tokens, treat the remainder as a command sequence.
// This happens if powershell is invoked without -Command, e.g.
// ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"]
_ => {
return split_into_commands(args[idx..].to_vec());
}
}
}
// Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script.
None
}
/// Tokenizes an inline PowerShell script and delegates to the command splitter.
/// Examples of when this is called: pwsh.exe -Command '<script>' or pwsh.exe -Command:<script>
fn parse_powershell_script(script: &str) -> Option<Vec<Vec<String>>> {
let tokens = shlex_split(script)?;
split_into_commands(tokens)
}
/// Splits tokens into pipeline segments while ensuring no unsafe separators slip through.
/// e.g. Get-ChildItem | Measure-Object -> [['Get-ChildItem'], ['Measure-Object']]
fn split_into_commands(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
if tokens.is_empty() {
// Examples rejected here: "pwsh -Command ''" and "powershell -Command \"\"".
return None;
}
let mut commands = Vec::new();
let mut current = Vec::new();
for token in tokens.into_iter() {
match token.as_str() {
"|" | "||" | "&&" | ";" => {
if current.is_empty() {
// Examples rejected here: "pwsh -Command '| Get-ChildItem'" and "pwsh -Command '; dir'".
return None;
}
commands.push(current);
current = Vec::new();
}
// Reject if any token embeds separators, redirection, or call operator characters.
_ if token.contains(['|', ';', '>', '<', '&']) || token.contains("$(") => {
// Examples rejected here: "pwsh -Command 'dir|select'" and "pwsh -Command 'echo hi > out.txt'".
return None;
}
_ => current.push(token),
}
}
if current.is_empty() {
// Examples rejected here: "pwsh -Command 'dir |'" and "pwsh -Command 'Get-ChildItem ;'".
return None;
}
commands.push(current);
Some(commands)
}
/// Returns true when the executable name is one of the supported PowerShell binaries.
fn is_powershell_executable(exe: &str) -> bool {
matches!(
exe.to_ascii_lowercase().as_str(),
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
)
}
/// Validates that a parsed PowerShell command stays within our read-only safelist.
/// Everything before this is parsing, and rejecting things that make us feel uncomfortable.
fn is_safe_powershell_command(words: &[String]) -> bool {
if words.is_empty() {
// Examples rejected here: "pwsh -Command ''" and "pwsh -Command \"\"".
return false;
}
// Reject nested unsafe cmdlets inside parentheses or arguments
for w in words.iter() {
let inner = w
.trim_matches(|c| c == '(' || c == ')')
.trim_start_matches('-')
.to_ascii_lowercase();
if matches!(
inner.as_str(),
"set-content"
| "add-content"
| "out-file"
| "new-item"
| "remove-item"
| "move-item"
| "copy-item"
| "rename-item"
| "start-process"
| "stop-process"
) {
// Examples rejected here: "Write-Output (Set-Content foo6.txt 'abc')" and "Get-Content (New-Item bar.txt)".
return false;
}
}
// Block PowerShell call operator or any redirection explicitly.
if words.iter().any(|w| {
matches!(
w.as_str(),
"&" | ">" | ">>" | "1>" | "2>" | "2>&1" | "*>" | "<" | "<<"
)
}) {
// Examples rejected here: "pwsh -Command '& Remove-Item foo'" and "pwsh -Command 'Get-Content foo > bar'".
return false;
}
let command = words[0]
.trim_matches(|c| c == '(' || c == ')')
.trim_start_matches('-')
.to_ascii_lowercase();
match command.as_str() {
"echo" | "write-output" | "write-host" => true, // (no redirection allowed)
"dir" | "ls" | "get-childitem" | "gci" => true,
"cat" | "type" | "gc" | "get-content" => true,
"select-string" | "sls" | "findstr" => true,
"measure-object" | "measure" => true,
"get-location" | "gl" | "pwd" => true,
"test-path" | "tp" => true,
"resolve-path" | "rvpa" => true,
"select-object" | "select" => true,
"get-item" => true,
"git" => is_safe_git_command(words),
"rg" => is_safe_ripgrep(words),
// Extra safety: explicitly prohibit common side-effecting cmdlets regardless of args.
"set-content" | "add-content" | "out-file" | "new-item" | "remove-item" | "move-item"
| "copy-item" | "rename-item" | "start-process" | "stop-process" => {
// Examples rejected here: "pwsh -Command 'Set-Content notes.txt data'" and "pwsh -Command 'Remove-Item temp.log'".
false
}
_ => {
// Examples rejected here: "pwsh -Command 'Invoke-WebRequest https://example.com'" and "pwsh -Command 'Start-Service Spooler'".
false
}
}
}
/// Checks that an `rg` invocation avoids options that can spawn arbitrary executables.
fn is_safe_ripgrep(words: &[String]) -> bool {
const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &["--pre", "--hostname-bin"];
const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &["--search-zip", "-z"];
!words.iter().skip(1).any(|arg| {
let arg_lc = arg.to_ascii_lowercase();
// Examples rejected here: "pwsh -Command 'rg --pre cat pattern'" and "pwsh -Command 'rg --search-zip pattern'".
UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg_lc.as_str())
|| UNSAFE_RIPGREP_OPTIONS_WITH_ARGS
.iter()
.any(|opt| arg_lc == *opt || arg_lc.starts_with(&format!("{opt}=")))
})
}
/// Ensures a Git command sticks to whitelisted read-only subcommands and flags.
fn is_safe_git_command(words: &[String]) -> bool {
const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"];
let mut iter = words.iter().skip(1);
while let Some(arg) = iter.next() {
let arg_lc = arg.to_ascii_lowercase();
if arg.starts_with('-') {
if arg.eq_ignore_ascii_case("-c") || arg.eq_ignore_ascii_case("--config") {
if iter.next().is_none() {
// Examples rejected here: "pwsh -Command 'git -c'" and "pwsh -Command 'git --config'".
return false;
}
continue;
}
if arg_lc.starts_with("-c=")
|| arg_lc.starts_with("--config=")
|| arg_lc.starts_with("--git-dir=")
|| arg_lc.starts_with("--work-tree=")
{
continue;
}
if arg.eq_ignore_ascii_case("--git-dir") || arg.eq_ignore_ascii_case("--work-tree") {
if iter.next().is_none() {
// Examples rejected here: "pwsh -Command 'git --git-dir'" and "pwsh -Command 'git --work-tree'".
return false;
}
continue;
}
continue;
}
return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str());
}
// Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'".
false
}
#[cfg(test)]
mod tests {
use super::is_safe_command_windows;
use std::string::ToString;
/// Converts a slice of string literals into owned `String`s for the tests.
fn vec_str(args: &[&str]) -> Vec<String> {
args.iter().map(ToString::to_string).collect()
}
#[test]
fn everything_is_unsafe() {
for cmd in [
vec_str(&["powershell.exe", "-NoLogo", "-Command", "echo hello"]),
vec_str(&["copy", "foo", "bar"]),
vec_str(&["del", "file.txt"]),
vec_str(&["powershell.exe", "Get-ChildItem"]),
] {
assert!(!is_safe_command_windows(&cmd));
}
fn recognizes_safe_powershell_wrappers() {
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoLogo",
"-Command",
"Get-ChildItem -Path .",
])));
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoProfile",
"-Command",
"git status",
])));
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"Get-Content",
"Cargo.toml",
])));
// pwsh parity
assert!(is_safe_command_windows(&vec_str(&[
"pwsh.exe",
"-NoProfile",
"-Command",
"Get-ChildItem",
])));
}
#[test]
fn allows_read_only_pipelines_and_git_usage() {
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"rg --files-with-matches foo | Measure-Object | Select-Object -ExpandProperty Count",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"Get-Content foo.rs | Select-Object -Skip 200",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"git -c core.pager=cat show HEAD:foo.rs",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"-git cat-file -p HEAD:foo.rs",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"(Get-Content foo.rs -Raw)",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"Get-Item foo.rs | Select-Object Length",
])));
}
#[test]
fn rejects_powershell_commands_with_side_effects() {
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoLogo",
"-Command",
"Remove-Item foo.txt",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoProfile",
"-Command",
"rg --pre cat",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Set-Content foo.txt 'hello'",
])));
// Redirections are blocked
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"echo hi > out.txt",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-Content x | Out-File y",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Output foo 2> err.txt",
])));
// Call operator is blocked
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"& Remove-Item foo",
])));
// Chained safe + unsafe must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-ChildItem; Remove-Item foo",
])));
// Nested unsafe cmdlet inside safe command must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Output (Set-Content foo6.txt 'abc')",
])));
// Additional nested unsafe cmdlet examples must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Host (Remove-Item foo.txt)",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-Content (New-Item bar.txt)",
])));
}
}
+25 -2
View File
@@ -42,7 +42,7 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use crate::config_types::ReasoningSummary;
use crate::project_features::{load_project_commands, ProjectCommand, ProjectHooks};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::SandboxMode;
use std::time::Duration;
use dirs::home_dir;
@@ -220,6 +220,9 @@ pub struct Config {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
/// Ordered list of fallback filenames to consider when loading project docs.
pub project_doc_fallback_filenames: Vec<String>,
/// Directory containing all Codex state (defaults to `~/.code`; can be
/// overridden by the `CODE_HOME` or `CODEX_HOME` environment variables).
pub codex_home: PathBuf,
@@ -1617,6 +1620,9 @@ pub struct ConfigToml {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -2161,6 +2167,19 @@ impl Config {
agents,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
project_doc_fallback_filenames: cfg
.project_doc_fallback_filenames
.unwrap_or_default()
.into_iter()
.filter_map(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect(),
codex_home,
history,
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
@@ -2239,7 +2258,7 @@ impl Config {
/// Check if we're using ChatGPT authentication
fn is_using_chatgpt_auth(codex_home: &Path) -> bool {
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use crate::CodexAuth;
// Prefer ChatGPT when both ChatGPT tokens and an API key are present.
@@ -2896,6 +2915,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -2969,6 +2989,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -3057,6 +3078,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -3130,6 +3152,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
+18 -8
View File
@@ -11,7 +11,8 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
@@ -33,20 +34,25 @@ pub struct NewConversation {
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager,
session_source,
}
}
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub fn with_auth(auth: CodexAuth) -> Self {
Self::new(crate::AuthManager::from_auth_for_testing(auth))
Self::new(
crate::AuthManager::from_auth_for_testing(auth),
SessionSource::Exec,
)
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
@@ -67,7 +73,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
@@ -128,7 +134,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
@@ -163,11 +169,15 @@ impl ConversationManager {
}
// Otherwise, create a temporary rollout with the truncated items and resume from it.
let convo_id = codex_protocol::mcp_protocol::ConversationId::new();
let convo_id = codex_protocol::ConversationId::new();
let instructions = config.user_instructions.clone();
let recorder = RolloutRecorder::new(
&config,
crate::rollout::recorder::RolloutRecorderParams::new(convo_id, instructions),
crate::rollout::recorder::RolloutRecorderParams::new(
convo_id,
instructions,
self.session_source,
),
)
.await
.map_err(|e| CodexErr::Io(e))?;
@@ -190,7 +200,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(self.auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
}
+47 -4
View File
@@ -73,8 +73,8 @@ pub enum CodexErr {
Interrupted,
/// Unexpected HTTP status code.
#[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String),
#[error("{0}")]
UnexpectedStatus(UnexpectedResponseError),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
@@ -93,8 +93,8 @@ pub enum CodexErr {
ServerError(String),
/// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),
#[error("{0}")]
RetryLimit(RetryLimitReachedError),
/// Agent loop died unexpectedly
#[error("internal error; agent loop died unexpectedly")]
@@ -137,6 +137,49 @@ pub enum CodexErr {
EnvVar(EnvVarError),
}
#[derive(Debug)]
pub struct UnexpectedResponseError {
pub status: StatusCode,
pub body: String,
pub request_id: Option<String>,
}
impl std::fmt::Display for UnexpectedResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
impl std::error::Error for UnexpectedResponseError {}
#[derive(Debug)]
pub struct RetryLimitReachedError {
pub status: StatusCode,
pub request_id: Option<String>,
}
impl std::fmt::Display for RetryLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"exceeded retry limit, last status: {}{}",
self.status,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub plan_type: Option<String>,
@@ -49,7 +49,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}
@@ -92,7 +92,7 @@ Can write control characters (\u0003 for Ctrl-C), or an empty string to just pol
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string(), "chars".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::GitSha;
use codex_app_server_protocol::GitSha;
use codex_protocol::protocol::GitInfo;
use futures::future::join_all;
use serde::Deserialize;
+1
View File
@@ -86,6 +86,7 @@ mod tool_apply_patch;
mod workflow_validation;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
+1 -1
View File
@@ -7,7 +7,7 @@
//! key. These override or extend the defaults at runtime.
use crate::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
+165 -22
View File
@@ -164,6 +164,26 @@ impl ToolsConfig {
}
}
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
impl From<bool> for AdditionalProperties {
fn from(b: bool) -> Self {
Self::Boolean(b)
}
}
impl From<JsonSchema> for AdditionalProperties {
fn from(s: JsonSchema) -> Self {
Self::Schema(Box::new(s))
}
}
/// Generic JSON‑Schema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -196,7 +216,7 @@ pub(crate) enum JsonSchema {
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
additional_properties: Option<bool>,
additional_properties: Option<AdditionalProperties>,
},
}
@@ -229,7 +249,7 @@ fn create_shell_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -326,7 +346,7 @@ Long-running commands may be backgrounded after an initial window. Use `wait` to
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -654,7 +674,7 @@ pub fn create_wait_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -675,7 +695,7 @@ pub fn create_kill_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["call_id".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -917,7 +937,130 @@ mod tests {
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: None,
},
description: "Do something cool".to_string(),
strict: false,
})
);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
include_view_image_tool: true,
experimental_unified_exec_tool: true,
});
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
},
"required": [
"string_property",
"number_property",
],
"additionalProperties": {
"type": "object",
"properties": {
"addtl_prop": { "type": "string" },
},
"required": [
"addtl_prop",
],
"additionalProperties": false,
},
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
)])),
);
assert_eq_tool_names(
&tools,
&[
"unified_exec",
"web_search",
"view_image",
"test_server/do_something_cool",
],
);
assert_eq!(
tools[3],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
),
]),
required: Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(
JsonSchema::Object {
properties: BTreeMap::from([(
"addtl_prop".to_string(),
JsonSchema::String { description: None }
),]),
required: Some(vec!["addtl_prop".to_string(),]),
additional_properties: Some(false.into()),
}
.into()
),
},
),
]),
@@ -1334,7 +1477,7 @@ fn create_browser_open_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1349,7 +1492,7 @@ fn create_browser_close_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1364,7 +1507,7 @@ fn create_browser_status_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1397,7 +1540,7 @@ fn create_browser_click_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1446,7 +1589,7 @@ fn create_browser_move_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1467,7 +1610,7 @@ fn create_browser_type_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["text".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1490,7 +1633,7 @@ fn create_browser_key_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["key".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1511,7 +1654,7 @@ fn create_browser_javascript_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["code".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1538,7 +1681,7 @@ fn create_browser_scroll_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1559,7 +1702,7 @@ fn create_browser_history_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["direction".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1592,7 +1735,7 @@ fn create_browser_inspect_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1613,7 +1756,7 @@ fn create_browser_console_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1626,7 +1769,7 @@ fn create_browser_cleanup_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1644,7 +1787,7 @@ fn create_browser_cdp_tool() -> OpenAiTool {
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(true),
additional_properties: Some(true.into()),
},
);
properties.insert(
@@ -1661,7 +1804,7 @@ fn create_browser_cdp_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["method".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1696,7 +1839,7 @@ fn create_web_fetch_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
+2 -2
View File
@@ -32,7 +32,7 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
items: Box::new(JsonSchema::Object {
properties: plan_item_props,
required: Some(vec!["step".to_string(), "status".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
}),
};
@@ -56,7 +56,7 @@ At most one step can be in_progress at a time.
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
});
+102 -54
View File
@@ -1,4 +1,3 @@
#![allow(dead_code)]
use std::cmp::Reverse;
use std::io::{self};
use std::path::Path;
@@ -15,9 +14,12 @@ use time::macros::format_description;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
use crate::config::resolve_codex_path_for_read;
use crate::protocol::event_msg_from_protocol;
use crate::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use crate::config::resolve_codex_path_for_read;
use codex_protocol::protocol::SessionSource;
/// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)]
@@ -37,13 +39,31 @@ pub struct ConversationsPage {
pub struct ConversationItem {
/// Absolute path to the rollout file.
pub path: PathBuf,
/// First up to 5 JSONL records parsed as JSON (includes meta line).
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
pub head: Vec<serde_json::Value>,
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
pub tail: Vec<serde_json::Value>,
/// RFC3339 timestamp string for when the session was created, if available.
pub created_at: Option<String>,
/// RFC3339 timestamp string for the most recent response in the tail, if available.
pub updated_at: Option<String>,
}
#[derive(Default)]
struct HeadTailSummary {
head: Vec<serde_json::Value>,
tail: Vec<serde_json::Value>,
saw_session_meta: bool,
saw_user_event: bool,
source: Option<SessionSource>,
created_at: Option<String>,
updated_at: Option<String>,
}
/// Hard cap to bound worst‑case work per request.
const MAX_SCAN_FILES: usize = 100;
const MAX_SCAN_FILES: usize = 10000;
const HEAD_RECORD_LIMIT: usize = 10;
const TAIL_RECORD_LIMIT: usize = 10;
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -90,6 +110,7 @@ pub(crate) async fn get_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let root = resolve_codex_path_for_read(codex_home, Path::new(SESSIONS_SUBDIR));
@@ -104,7 +125,8 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned();
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?;
let result =
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
Ok(result)
}
@@ -117,13 +139,13 @@ pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
/// Load conversation file paths from disk using directory traversal.
///
/// Directory layout: `~/.code/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// (Code still reads legacy `~/.codex/sessions/...`).
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// Returned newest (latest) first.
async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -175,15 +197,32 @@ async fn traverse_directories_for_paths(
if items.len() == page_size {
break 'outer;
}
// Read head and simultaneously detect message events within the same
// first N JSONL records to avoid a second file read.
let (head, saw_session_meta, saw_user_event) =
read_head_and_flags(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or((Vec::new(), false, false));
// Apply filters: must have session meta and at least one user message event
if saw_session_meta && saw_user_event {
items.push(ConversationItem { path, head });
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
tail,
created_at,
mut updated_at,
..
} = summary;
updated_at = updated_at.or_else(|| created_at.clone());
items.push(ConversationItem {
path,
head,
tail,
created_at,
updated_at,
});
}
}
}
@@ -199,9 +238,13 @@ async fn traverse_directories_for_paths(
})
}
/// Pagination cursor token format: "<file_ts>|<uuid>" where `file_ts` matches the
/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames.
/// The cursor orders files by timestamp desc, then UUID desc.
fn build_next_cursor(items: &[ConversationItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
fn parse_cursor(token: &str) -> Option<Cursor> {
let (file_ts, uuid_str) = token.split_once('|')?;
@@ -216,15 +259,6 @@ fn parse_cursor(token: &str) -> Option<Cursor> {
Some(Cursor::new(ts, uuid))
}
fn build_next_cursor(items: &[ConversationItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,
/// and returns them sorted descending by the parsed key.
async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>
where
T: Ord + Copy,
@@ -248,7 +282,6 @@ where
Ok(vec)
}
/// Collects files in a directory and parses them with `parse`.
async fn collect_files<T, F>(parent: &Path, parse: F) -> io::Result<Vec<T>>
where
F: Fn(&str, &Path) -> Option<T>,
@@ -271,10 +304,8 @@ where
}
fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> {
// Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
// Scan from the right for a '-' such that the suffix parses as a UUID.
let (sep_idx, uuid) = core
.match_indices('-')
.rev()
@@ -287,20 +318,19 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
Some((ts, uuid))
}
async fn read_head_and_flags(
async fn read_head_and_tail(
path: &Path,
max_records: usize,
) -> io::Result<(Vec<serde_json::Value>, bool, bool)> {
head_limit: usize,
tail_limit: usize,
) -> io::Result<HeadTailSummary> {
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut head: Vec<serde_json::Value> = Vec::new();
let mut saw_session_meta = false;
let mut saw_user_event = false;
let mut summary = HeadTailSummary::default();
while head.len() < max_records {
while summary.head.len() < head_limit {
let line_opt = lines.next_line().await?;
let Some(line) = line_opt else { break };
let trimmed = line.trim();
@@ -313,31 +343,49 @@ async fn read_head_and_flags(
match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
if let Ok(val) = serde_json::to_value(session_meta_line) {
head.push(val);
saw_session_meta = true;
}
}
RolloutItem::ResponseItem(item) => {
if let Ok(val) = serde_json::to_value(item) {
head.push(val);
}
summary.source = Some(session_meta_line.meta.source);
summary.created_at = summary
.created_at
.clone()
.or_else(|| Some(rollout_line.timestamp.clone()));
summary.updated_at = Some(rollout_line.timestamp.clone());
summary.saw_session_meta = true;
}
RolloutItem::Event(event) => {
if let Some(msg) = crate::protocol::event_msg_from_protocol(&event.msg) {
if matches!(msg, crate::protocol::EventMsg::AgentMessage(_)) {
saw_user_event = true;
if let Some(msg) = event_msg_from_protocol(&event.msg) {
if matches!(msg, EventMsg::UserMessage(_) | EventMsg::AgentMessage(_)) {
summary.saw_user_event = true;
summary.updated_at = Some(rollout_line.timestamp.clone());
}
}
}
// Skip variants not displayed in list summaries.
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
}
summary.head.push(serde_json::from_str(trimmed).unwrap_or_default());
}
Ok((head, saw_session_meta, saw_user_event))
}
// Collect tail by reading entire file (bounded by TAIL_RECORD_LIMIT).
let tail_lines = tokio::fs::read_to_string(path).await?;
let mut tail_events = Vec::new();
for line in tail_lines.lines().rev() {
if tail_events.len() >= tail_limit {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: Result<RolloutLine, _> = serde_json::from_str(trimmed);
if parsed.is_ok() {
tail_events.push(serde_json::from_str(trimmed).unwrap_or_default());
}
}
tail_events.reverse();
summary.tail = tail_events;
Ok(summary)
}
/// Locate a recorded conversation rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
+4
View File
@@ -1,8 +1,12 @@
//! Rollout module: persistence and discovery of session rollout files.
use codex_protocol::protocol::SessionSource;
pub const SESSIONS_SUBDIR: &str = "sessions";
#[allow(dead_code)]
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
&[SessionSource::Cli, SessionSource::VSCode];
pub mod list;
pub(crate) mod policy;
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::protocol::event_msg_from_protocol;
use crate::protocol::EventMsg;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
@@ -7,7 +8,8 @@ use codex_protocol::protocol::RolloutItem;
pub(crate) fn should_persist_rollout_item(item: &RolloutItem) -> bool {
match item {
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
RolloutItem::Event(_) => true,
RolloutItem::Event(ev) => event_msg_from_protocol(&ev.msg)
.is_some_and(|msg| should_persist_event_msg(&msg)),
// Always persist session meta
RolloutItem::SessionMeta(_) => true,
// Persist compacted summaries and turn context for accurate history reconstruction.
+29 -12
View File
@@ -6,9 +6,10 @@ use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde::{Serialize, Deserialize};
use time::OffsetDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;
@@ -20,13 +21,15 @@ use tracing::info;
use tracing::warn;
use super::SESSIONS_SUBDIR;
use super::list::get_conversations;
use super::list::ConversationsPage;
use super::list::Cursor;
use super::list::get_conversations;
use super::policy::{should_persist_response_item, should_persist_rollout_item};
use crate::config::Config;
use crate::default_client::DEFAULT_ORIGINATOR;
use crate::git_info::collect_git_info;
use crate::history::HistorySnapshot;
use crate::protocol::event_msg_from_protocol;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -44,7 +47,7 @@ pub struct SavedSession {
#[serde(default)]
pub items: Vec<RolloutItem>,
#[serde(default)]
pub history_snapshot: Option<crate::history::HistorySnapshot>,
pub history_snapshot: Option<HistorySnapshot>,
#[serde(default)]
pub state: SessionStateSnapshot,
pub session_id: uuid::Uuid,
@@ -70,6 +73,7 @@ pub enum RolloutRecorderParams {
Create {
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
},
Resume {
path: PathBuf,
@@ -83,10 +87,15 @@ enum RolloutCmd {
}
impl RolloutRecorderParams {
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
pub fn new(
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
) -> Self {
Self::Create {
conversation_id,
instructions,
source,
}
}
@@ -95,13 +104,13 @@ impl RolloutRecorderParams {
impl RolloutRecorder {
/// List conversations (rollout files) under the provided Codex home directory.
#[allow(dead_code)]
pub async fn list_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor).await
get_conversations(codex_home, page_size, cursor, allowed_sources).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -112,6 +121,7 @@ impl RolloutRecorder {
RolloutRecorderParams::Create {
conversation_id,
instructions,
source,
} => {
let LogFileInfo {
file,
@@ -138,6 +148,7 @@ impl RolloutRecorder {
originator: DEFAULT_ORIGINATOR.to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
}),
)
}
@@ -210,11 +221,17 @@ impl RolloutRecorder {
if events.is_empty() {
return Ok(());
}
let filtered = events
.iter()
.cloned()
.map(RolloutItem::Event)
.collect::<Vec<_>>();
let mut filtered = Vec::new();
for event in events {
if event_msg_from_protocol(&event.msg)
.is_some_and(|msg| crate::rollout::policy::should_persist_event_msg(&msg))
{
filtered.push(RolloutItem::Event(event.clone()));
}
}
if filtered.is_empty() {
return Ok(());
}
self.tx
.send(RolloutCmd::AddItems(filtered))
.await
+211 -36
View File
@@ -2,6 +2,7 @@
use std::fs::File;
use std::fs::{self};
use std::io::BufWriter;
use std::io::Write;
use std::path::Path;
@@ -12,17 +13,30 @@ use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::list::ConversationItem;
use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor;
use crate::rollout::list::get_conversation;
use crate::rollout::list::get_conversations;
use codex_protocol::ConversationId;
use codex_protocol::protocol::RecordedEvent;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::UserMessageEvent;
use crate::protocol::EventMsg;
const NO_SOURCE_FILTER: &[SessionSource] = &[];
fn write_session_file(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -38,20 +52,46 @@ fn write_session_file(
let filename = format!("rollout-{ts_str}-{uuid}.jsonl");
let file_path = dir.join(filename);
let mut file = File::create(file_path)?;
let file = File::create(file_path)?;
let mut writer = BufWriter::new(file);
let meta = serde_json::json!({
"timestamp": ts_str,
"id": uuid.to_string()
});
writeln!(file, "{meta}")?;
let conversation_id = ConversationId::from(uuid);
let session_meta = SessionMeta {
id: conversation_id,
timestamp: ts_str.to_string(),
cwd: Path::new(".").to_path_buf(),
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
instructions: None,
source: source.unwrap_or_default(),
};
let session_meta_line = RolloutLine {
timestamp: ts_str.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
serde_json::to_writer(&mut writer, &session_meta_line)?;
writer.write_all(b"\n")?;
for i in 0..num_records {
let rec = serde_json::json!({
"record_type": "response",
"index": i
});
writeln!(file, "{rec}")?;
let event = RecordedEvent {
id: format!("event-{i}"),
event_seq: i as u64,
order: None,
msg: EventMsg::UserMessage(UserMessageEvent {
message: format!("Message {i}"),
kind: None,
images: None,
}),
};
let line = RolloutLine {
timestamp: ts_str.to_string(),
item: RolloutItem::Event(event),
};
serde_json::to_writer(&mut writer, &line)?;
writer.write_all(b"\n")?;
}
Ok((dt, uuid))
}
@@ -67,11 +107,34 @@ async fn test_list_conversations_latest_first() {
let u3 = Uuid::from_u128(3);
// Create three sessions across three days
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap();
write_session_file(
home,
"2025-01-01T12-00-00",
u1,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-02T12-00-00",
u2,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-03T12-00-00",
u3,
3,
Some(SessionSource::VSCode),
)
.unwrap();
let page = get_conversations(home, 10, None).await.unwrap();
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
// Build expected objects
let p1 = home
@@ -151,13 +214,50 @@ async fn test_pagination_cursor() {
let u5 = Uuid::from_u128(55);
// Oldest to newest
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap();
write_session_file(
home,
"2025-03-01T09-00-00",
u1,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-02T09-00-00",
u2,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-03T09-00-00",
u3,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-04T09-00-00",
u4,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-05T09-00-00",
u5,
1,
Some(SessionSource::VSCode),
)
.unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p5 = home
.join("sessions")
.join("2025")
@@ -197,9 +297,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p3 = home
.join("sessions")
.join("2025")
@@ -239,9 +344,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page2, expected_page2);
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref())
.await
.unwrap();
let page3 = get_conversations(
home,
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -273,9 +383,11 @@ async fn test_get_conversation_contents() {
let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2).unwrap();
write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap();
let page = get_conversations(home, 1, None).await.unwrap();
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap();
@@ -322,11 +434,13 @@ async fn test_stable_ordering_same_second_pagination() {
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0).unwrap();
write_session_file(home, ts, u2, 0).unwrap();
write_session_file(home, ts, u3, 0).unwrap();
write_session_file(home, ts, u1, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p3 = home
.join("sessions")
@@ -361,9 +475,14 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -382,3 +501,59 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page2, expected_page2);
}
#[tokio::test]
async fn test_source_filter_excludes_non_matching_sessions() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let interactive_id = Uuid::from_u128(42);
let non_interactive_id = Uuid::from_u128(77);
write_session_file(
home,
"2025-08-02T10-00-00",
interactive_id,
2,
Some(SessionSource::Cli),
)
.unwrap();
write_session_file(
home,
"2025-08-01T10-00-00",
non_interactive_id,
2,
Some(SessionSource::Exec),
)
.unwrap();
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let paths: Vec<_> = interactive_only
.items
.iter()
.map(|item| item.path.as_path())
.collect();
assert_eq!(paths.len(), 1);
assert!(paths.iter().all(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
.items
.into_iter()
.map(|item| item.path)
.collect();
assert_eq!(all_paths.len(), 2);
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl")
}));
}
+1 -1
View File
@@ -118,7 +118,7 @@ It is important to remember:
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use codex_app_server_protocol::AuthMode;
use codex_core::ContentItem;
use codex_core::LocalShellAction;
use codex_core::LocalShellExecAction;
+2 -1
View File
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use codex_app_server_protocol::AuthMode;
use codex_core::ContentItem;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
@@ -19,7 +20,7 @@ use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use codex_protocol::mcp_protocol::{AuthMode, ConversationId};
use codex_protocol::ConversationId;
fn network_disabled() -> bool {
std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok()
@@ -1,4 +1,5 @@
#![allow(clippy::expect_used)]
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use std::path::Path;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -14,7 +15,7 @@ impl TestCodexExecBuilder {
.expect("should find binary for codex-exec");
cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path())
.env("OPENAI_API_KEY", "dummy");
.env(CODEX_API_KEY_ENV_VAR, "dummy");
cmd
}
pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command {
+1 -1
View File
@@ -76,7 +76,7 @@ async fn chat_mode_stream_cli() {
server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None)
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[])
.await
.expect("list conversations");
assert!(
+2 -1
View File
@@ -1,3 +1,4 @@
use codex_app_server_protocol::AuthMode;
use codex_core::CodexAuth;
use codex_core::ContentItem;
use codex_core::debug_logger::DebugLogger;
@@ -542,7 +543,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec);
let NewConversation {
conversation: codex,
..
+1 -1
View File
@@ -44,7 +44,7 @@ use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use chrono::Utc;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
// --- Test helpers -----------------------------------------------------------
pub(super) const FIRST_REPLY: &str = "FIRST_REPLY";
+7 -3
View File
@@ -1,7 +1,8 @@
use codex_core::protocol::{event_msg_to_protocol, AgentMessageEvent, EventMsg, RecordedEvent};
use codex_core::rollout::RolloutRecorder;
use codex_core::rollout::recorder::RolloutRecorderParams;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::models::{ContentItem, ResponseItem};
use tempfile::TempDir;
use uuid::Uuid;
@@ -14,8 +15,11 @@ async fn resume_restores_recorded_events() {
let mut config = load_default_config_for_test(&codex_home);
config.cwd = codex_home.path().to_path_buf();
let conversation_id = ConversationId(Uuid::new_v4());
let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::new(conversation_id, None))
let conversation_id = ConversationId::from(Uuid::new_v4());
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(conversation_id, None, SessionSource::Cli),
)
.await
.expect("create recorder");
+3 -3
View File
@@ -10,7 +10,7 @@ This document describes Codex’s experimental MCP interface: a JSON‑RPC API t
## Overview
Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `protocol/src/mcp_protocol.rs` and re‑used by the MCP server implementation in `mcp-server/`.
Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `app-server-protocol/src/protocol.rs` and re-used by the MCP server implementation in `mcp-server/`.
At a glance:
@@ -31,7 +31,7 @@ At a glance:
- `loginChatGptComplete`, `authStatusChange`
- `codex/event` stream with agent events
See code for full type definitions and exact shapes: `protocol/src/mcp_protocol.rs`.
See code for full type definitions and exact shapes: `app-server-protocol/src/protocol.rs`.
## Starting the server
@@ -123,4 +123,4 @@ While processing, the server emits `codex/event` notifications containing agent
## Compatibility and stability
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`.
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `app-server-protocol/src/protocol.rs` and the corresponding server wiring in `mcp-server/`.
+4 -2
View File
@@ -16,7 +16,6 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = [
@@ -27,6 +26,9 @@ codex-common = { workspace = true, features = [
codex-core = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
chrono = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }
shlex = { workspace = true }
@@ -45,9 +47,9 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
assert_cmd = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
wiremock = { workspace = true }
mcp-types = { workspace = true }
+47 -17
View File
@@ -2,34 +2,45 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Top-level events emitted on the Codex Exec thread stream.
/// Top-level JSONL events emitted by codex exec
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "type")]
pub enum ThreadEvent {
/// Emitted when a new thread is started as the first event.
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
/// Emitted when a turn is started by sending a new prompt to the model.
/// A turn encompasses all events that happen while agent is processing the prompt.
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
/// Emitted when a turn is completed. Typically right after the assistant's response.
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
/// Indicates that a turn failed with an error.
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
/// Emitted when a new item is added to the thread. Typically the item will be in an "in progress" state.
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
/// Emitted when an item is updated.
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent),
/// Signals that an item has reached a terminal state—either success or failure.
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
/// Represents an unrecoverable error emitted directly by the event stream.
#[serde(rename = "error")]
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ThreadStartedEvent {
/// The identified of the new thread. Can be used to resume the thread later.
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct TurnStartedEvent {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
@@ -42,11 +53,14 @@ pub struct TurnFailedEvent {
pub error: ThreadErrorEvent,
}
/// Minimal usage summary for a turn.
/// Describes the usage of tokens during a turn.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct Usage {
/// The number of input tokens used during the turn.
pub input_tokens: u64,
/// The number of cached input tokens used during the turn.
pub cached_input_tokens: u64,
/// The number of output tokens used during the turn.
pub output_tokens: u64,
}
@@ -81,36 +95,46 @@ pub struct ThreadItem {
/// Typed payloads for each supported thread item type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "item_type", rename_all = "snake_case")]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThreadItemDetails {
AssistantMessage(AssistantMessageItem),
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
AgentMessage(AgentMessageItem),
/// Agent's reasoning summary.
Reasoning(ReasoningItem),
/// Tracks a command executed by the agent. The item starts when the command is
/// spawned, and completes when the process exits with an exit code.
CommandExecution(CommandExecutionItem),
/// Represents a set of file changes by the agent. The item is emitted only as a
/// completed event once the patch succeeds or fails.
FileChange(FileChangeItem),
/// Represents a call to an MCP tool. The item starts when the invocation is
/// dispatched and completes when the MCP server reports success or failure.
McpToolCall(McpToolCallItem),
/// Captures a web search request. It starts when the search is kicked off
/// and completes when results are returned to the agent.
WebSearch(WebSearchItem),
/// Tracks the agent's running to-do list. It starts when the plan is first
/// issued, updates as steps change state, and completes when the turn ends.
TodoList(TodoListItem),
/// Describes a non-fatal error surfaced as an item.
Error(ErrorItem),
}
/// Session metadata.
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionItem {
pub session_id: String,
}
/// Assistant message payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct AssistantMessageItem {
pub struct AgentMessageItem {
pub text: String,
}
/// Model reasoning summary payload.
/// Agent's reasoning summary.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ReasoningItem {
pub text: String,
}
/// The status of a command execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum CommandExecutionStatus {
@@ -120,7 +144,7 @@ pub enum CommandExecutionStatus {
Failed,
}
/// Local shell command execution payload.
/// A command executed by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct CommandExecutionItem {
pub command: String,
@@ -130,13 +154,14 @@ pub struct CommandExecutionItem {
pub status: CommandExecutionStatus,
}
/// Single file change summary for a patch.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileUpdateChange {
pub path: String,
pub kind: PatchChangeKind,
}
/// The status of a file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
@@ -144,14 +169,14 @@ pub enum PatchApplyStatus {
Failed,
}
/// Patch application payload.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileChangeItem {
pub changes: Vec<FileUpdateChange>,
pub status: PatchApplyStatus,
}
/// Known change kinds for a patch.
/// Indicates the type of the file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchChangeKind {
@@ -160,6 +185,7 @@ pub enum PatchChangeKind {
Update,
}
/// The status of an MCP tool call.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum McpToolCallStatus {
@@ -169,6 +195,7 @@ pub enum McpToolCallStatus {
Failed,
}
/// A call to an MCP tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct McpToolCallItem {
pub server: String,
@@ -176,16 +203,19 @@ pub struct McpToolCallItem {
pub status: McpToolCallStatus,
}
/// A web search request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct WebSearchItem {
pub query: String,
}
/// An error notification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ErrorItem {
pub message: String,
}
/// An item in agent's to-do list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct TodoItem {
pub text: String,
+13 -14
View File
@@ -18,6 +18,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::TaskCompleteEvent;
use codex_protocol::protocol::SessionSource;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
@@ -219,11 +220,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let conversation_manager = ConversationManager::new(AuthManager::shared_with_mode_and_originator(
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
config.responses_originator_header.clone(),
));
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
let NewConversation {
@@ -235,22 +237,19 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if let Some(path) = resume_path {
conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path,
AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
config.responses_originator_header.clone(),
),
)
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
.await?
} else {
conversation_manager.new_conversation(config).await?
conversation_manager
.new_conversation(config.clone())
.await?
}
} else {
conversation_manager.new_conversation(config).await?
conversation_manager
.new_conversation(config.clone())
.await?
};
event_processor.print_config_summary(&config, &prompt);
info!("Codex initialized with event: {session_configured:?}");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
@@ -353,7 +352,7 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[]).await {
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
error!("Error listing conversations: {e}");
+27 -45
View File
@@ -1,10 +1,9 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use core_test_support::test_codex_exec::test_codex_exec;
use serde_json::Value;
use std::process::Command;
use std::path::Path;
use std::string::ToString;
use tempfile::TempDir;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -72,18 +71,15 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
#[test]
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session with a unique marker in the content.
let marker = format!("resume-last-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -94,7 +90,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.success();
// Find the created session file containing the marker.
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
@@ -102,11 +98,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-last-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -114,8 +106,9 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg("--last");
cmd.assert().success();
.arg("--last")
.assert()
.success();
// Ensure the same file was updated and contains both markers.
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
@@ -132,18 +125,15 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session
let marker = format!("resume-by-id-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -153,7 +143,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let session_id = extract_conversation_id(&path);
@@ -166,11 +156,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -178,8 +164,9 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg(&session_id);
cmd.assert().success();
.arg(&session_id)
.assert()
.success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
@@ -195,17 +182,14 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-config-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -219,17 +203,15 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let marker2 = format!("resume-config-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let output = Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
let output = test
.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
rand = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
+7 -7
View File
@@ -148,20 +148,20 @@ fn print_colored_warning_device_code() {
/// Full device code login flow.
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
let client = reqwest::Client::new();
let auth_base_url = opts.issuer.trim_end_matches('/').to_owned();
let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
print_colored_warning_device_code();
println!("⏳ Generating a new 9-digit device code for authentication...\n");
let uc = request_user_code(&client, &auth_base_url, &opts.client_id).await?;
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
println!(
"To authenticate, visit: {}/deviceauth/authorize and enter code: {}",
opts.issuer.trim_end_matches('/'),
uc.user_code
api_base_url, uc.user_code
);
let code_resp = poll_for_token(
&client,
&auth_base_url,
&api_base_url,
&uc.device_auth_id,
&uc.user_code,
uc.interval,
@@ -173,10 +173,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
code_challenge: code_resp.code_challenge,
};
println!("authorization code received");
let redirect_uri = format!("{}/deviceauth/callback", opts.issuer.trim_end_matches('/'));
let redirect_uri = format!("{base_url}/deviceauth/callback");
let tokens = crate::server::exchange_code_for_tokens(
&opts.issuer,
base_url,
&opts.client_id,
&redirect_uri,
&pkce,
+2 -1
View File
@@ -9,10 +9,12 @@ pub use server::ShutdownHandle;
pub use server::run_login_server;
// Re-export commonly used auth types and helpers from codex-core for compatibility
pub use codex_app_server_protocol::AuthMode;
pub use codex_core::AuthManager;
pub use codex_core::CodexAuth;
pub use codex_core::auth::AuthDotJson;
pub use codex_core::auth::CLIENT_ID;
pub use codex_core::auth::CODEX_API_KEY_ENV_VAR;
pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
pub use codex_core::auth::get_auth_file;
pub use codex_core::auth::login_with_api_key;
@@ -20,4 +22,3 @@ pub use codex_core::auth::logout;
pub use codex_core::auth::try_read_auth_json;
pub use codex_core::auth::write_auth_json;
pub use codex_core::token_data::TokenData;
pub use codex_protocol::mcp_protocol::AuthMode;
+9
View File
@@ -295,6 +295,15 @@ async fn process_request(
}
}
/// tiny_http filters `Connection` headers out of `Response` objects, so using
/// `req.respond` never informs the client (or the library) that a keep-alive
/// socket should be closed. That leaves the per-connection worker parked in a
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_http’s response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// server-side connection persistence, but it does not.
fn build_authorize_url(
issuer: &str,
client_id: &str,
@@ -33,7 +33,7 @@ fn make_jwt(payload: serde_json::Value) -> String {
async fn mock_usercode_success(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/deviceauth/usercode"))
.and(path("/api/accounts/deviceauth/usercode"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"device_auth_id": "device-auth-123",
"user_code": "CODE-12345",
@@ -46,7 +46,7 @@ async fn mock_usercode_success(server: &MockServer) {
async fn mock_usercode_failure(server: &MockServer, status: u16) {
Mock::given(method("POST"))
.and(path("/deviceauth/usercode"))
.and(path("/api/accounts/deviceauth/usercode"))
.respond_with(ResponseTemplate::new(status))
.mount(server)
.await;
@@ -59,7 +59,7 @@ async fn mock_poll_token_two_step(
) {
let c = counter.clone();
Mock::given(method("POST"))
.and(path("/deviceauth/token"))
.and(path("/api/accounts/deviceauth/token"))
.respond_with(move |_: &Request| {
let attempt = c.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
@@ -223,7 +223,7 @@ async fn device_code_login_integration_handles_error_payload() {
// // /deviceauth/token → returns error payload with status 401
mock_poll_token_single(
&mock_server,
"/deviceauth/token",
"/api/accounts/deviceauth/token",
ResponseTemplate::new(401).set_body_json(json!({
"error": "authorization_declined",
"error_description": "Denied"
+1
View File
@@ -21,6 +21,7 @@ codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-app-server = { workspace = true }
mcp-types = { workspace = true }
+8 -4
View File
@@ -19,8 +19,9 @@ use crate::session_store::SessionMap;
use agent_client_protocol as acp;
use anyhow::anyhow;
use anyhow::Context as _;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_app_server_protocol::ClientRequest;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_common::model_presets::{builtin_model_presets, ModelPreset};
use codex_core::AuthManager;
@@ -32,7 +33,7 @@ use codex_core::default_client::get_codex_user_agent_default;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_core::protocol::Submission;
use codex_core::protocol::Op;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::ClientRequest as McpClientRequest;
@@ -78,7 +79,10 @@ impl MessageProcessor {
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Mcp,
));
let config_for_processor = config.clone();
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
+1 -1
View File
@@ -75,7 +75,7 @@ impl OutgoingMessageSenderExt for OutgoingMessageSender {
mod tests {
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::sync::mpsc;
@@ -12,14 +12,14 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_mcp_server::CodexToolCallParam;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::RemoveConversationListenerParams;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserTurnParams;
use mcp_types::CallToolRequestParams;
use mcp_types::ClientCapabilities;
+22 -12
View File
@@ -4,9 +4,9 @@ name = "codex-otel"
version = { workspace = true }
[lib]
doctest = false
name = "codex_otel"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
@@ -24,17 +24,27 @@ otel = [
otel-shim = []
[dependencies]
codex-protocol = { path = "../protocol" }
chrono = { workspace = true }
tracing = { workspace = true }
opentelemetry = { workspace = true, features = ["logs"], optional = true }
opentelemetry_sdk = { workspace = true, features = ["logs", "rt-tokio"], optional = true }
opentelemetry-otlp = { workspace = true, features = ["grpc-tonic", "http-proto", "http-json", "reqwest", "reqwest-rustls"], optional = true }
opentelemetry-semantic-conventions = { workspace = true }
tonic = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
strum_macros = { workspace = true }
reqwest = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
eventsource-stream = { workspace = true }
tokio = { workspace = true }
opentelemetry = { workspace = true, features = ["logs"], optional = true }
opentelemetry-otlp = { workspace = true, features = [
"grpc-tonic",
"http-proto",
"http-json",
"reqwest",
"reqwest-rustls",
], optional = true }
opentelemetry-semantic-conventions = { workspace = true }
opentelemetry_sdk = { workspace = true, features = [
"logs",
"rt-tokio",
], optional = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum_macros = { workspace = true }
tokio = { workspace = true }
tonic = { workspace = true, optional = true }
tracing = { workspace = true }
+2 -2
View File
@@ -1,9 +1,9 @@
use chrono::SecondsFormat;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_protocol::ConversationId;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InputItem;
+1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
ts-rs = { workspace = true }
mcp-types = { workspace = true }
+32 -32
View File
@@ -17,38 +17,38 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
// Generate TS bindings
mcp_types::InitializeResult::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ConversationId::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?;
codex_protocol::ConversationId::export_all_to(out_dir)?;
codex_app_server_protocol::InputItem::export_all_to(out_dir)?;
codex_app_server_protocol::ClientRequest::export_all_to(out_dir)?;
codex_app_server_protocol::ServerRequest::export_all_to(out_dir)?;
codex_app_server_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_app_server_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_app_server_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_app_server_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_app_server_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
codex_app_server_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserMessageParams::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserTurnParams::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_app_server_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_app_server_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_app_server_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_app_server_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_app_server_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_app_server_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_app_server_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_app_server_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_app_server_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_app_server_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ServerNotification::export_all_to(out_dir)?;
codex_app_server_protocol::ClientNotification::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;
+1 -2
View File
@@ -28,11 +28,10 @@ ts-rs = { workspace = true, features = [
"serde-json-impl",
"no-serde-warnings",
] }
uuid = { workspace = true, features = ["serde", "v7"] }
uuid = { workspace = true, features = ["serde", "v4", "v7"] }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
[package.metadata.cargo-shear]
+1
View File
@@ -7,3 +7,4 @@ pub mod num_format;
pub mod parse_command;
pub mod plan_tool;
pub mod protocol;
pub use mcp_protocol::ConversationId;
+42 -2
View File
@@ -10,10 +10,10 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use crate::ConversationId;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::custom_prompts::CustomPrompt;
use crate::mcp_protocol::ConversationId;
use crate::message_history::HistoryEntry;
use crate::models::ContentItem;
use crate::models::ResponseItem;
@@ -533,6 +533,9 @@ pub enum EventMsg {
ExecCommandEnd(ExecCommandEndEvent),
/// Notification that the agent attached a local image via the view_image tool.
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -980,7 +983,20 @@ impl InitialHistory {
}
}
#[derive(Serialize, Deserialize, Clone, Default, Debug, TS)]
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum SessionSource {
Cli,
#[default]
VSCode,
Exec,
Mcp,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
pub struct SessionMeta {
pub id: ConversationId,
pub timestamp: String,
@@ -988,6 +1004,22 @@ pub struct SessionMeta {
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
#[serde(default)]
pub source: SessionSource,
}
impl Default for SessionMeta {
fn default() -> Self {
SessionMeta {
id: ConversationId::default(),
timestamp: String::new(),
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
instructions: None,
source: SessionSource::default(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
@@ -1157,6 +1189,14 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ViewImageToolCallEvent {
/// Identifier for the originating tool call.
pub call_id: String,
/// Local filesystem path provided to the tool.
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {
+11 -8
View File
@@ -49,6 +49,7 @@ pub struct Args {
#[derive(Serialize)]
struct ServerInfo {
port: u16,
pid: u32,
}
/// Entry point for the library main, for parity with other crates.
@@ -100,15 +101,17 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
let parent_display = parent.display();
fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?;
fs::create_dir_all(parent)?;
}
let info = ServerInfo { port };
let data = serde_json::to_vec(&info).context("serialize startup info")?;
let p = path.display();
let mut f = File::create(path).with_context(|| format!("create {p}"))?;
f.write_all(&data).with_context(|| format!("write {p}"))?;
f.write_all(b"\n").with_context(|| format!("newline {p}"))?;
let info = ServerInfo {
port,
pid: std::process::id(),
};
let mut data = serde_json::to_string(&info)?;
data.push('\n');
let mut f = File::create(path)?;
f.write_all(data.as_bytes())?;
Ok(())
}
+20 -3
View File
@@ -22,7 +22,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
help="Print the version that would be used and exit before making changes.",
)
group = parser.add_mutually_exclusive_group(required=True)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--publish-alpha",
action="store_true",
@@ -33,13 +33,30 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Publish the next stable release by bumping the minor version.",
)
return parser.parse_args(argv[1:])
parser.add_argument(
"--emergency-version-override",
help="Publish a specific version because tag was created for the previous release but it never succeeded. Value should be semver, e.g., `0.43.0-alpha.9`.",
)
args = parser.parse_args(argv[1:])
if not (
args.publish_alpha
or args.publish_release
or args.emergency_version_override
):
parser.error(
"Must specify --publish-alpha, --publish-release, or --emergency-version-override."
)
return args
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
version = determine_version(args)
if args.emergency_version_override:
version = args.emergency_version_override
else:
version = determine_version(args)
print(f"Publishing version {version}")
if args.dry_run:
return 0
+7 -2
View File
@@ -25,6 +25,7 @@ use codex_core::ConversationManager;
use codex_login::{AuthManager, AuthMode, ServerOptions};
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::CloudTaskError;
use codex_protocol::protocol::SessionSource;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -208,11 +209,15 @@ impl App<'_> {
startup_footer_notice: Option<String>,
latest_upgrade_version: Option<String>,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::new(AuthManager::shared_with_mode_and_originator(
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
)));
);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
));
// Split queues so interactive input never waits behind bulk updates.
let (high_tx, app_event_rx_high) = channel();
+5 -1
View File
@@ -44,6 +44,7 @@ use codex_core::auth_accounts::{self, StoredAccount};
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::mcp_protocol::AuthMode as McpAuthMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::num_format::format_with_separators;
@@ -1579,7 +1580,10 @@ impl ChatWidget<'_> {
tokio::spawn(async move {
let mut codex_op_rx = codex_op_rx;
let conversation_manager = ConversationManager::new(auth_manager.clone());
let conversation_manager = ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
);
let resume_path = config.experimental_resume.clone();
let new_conversation = match resume_path {
Some(path) => conversation_manager
@@ -936,11 +936,11 @@ fn build_prompt_for_model(
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
if let CodexErr::UnexpectedStatus(err) = codex_err {
if !err.status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
let body_lower = err.body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
@@ -964,8 +964,10 @@ fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
reason: "model request timed out".to_string(),
};
}
CodexErr::UnexpectedStatus(status, body) => {
if *status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 {
CodexErr::UnexpectedStatus(err) => {
let status = err.status;
let body = &err.body;
if status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 {
return RetryDecision::RetryAfterBackoff {
reason: format!("provider returned {status}"),
};
@@ -975,7 +977,7 @@ fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
reason: "client closed request (499)".to_string(),
};
}
if *status == StatusCode::TOO_MANY_REQUESTS {
if status == StatusCode::TOO_MANY_REQUESTS {
if let Some(wait_until) = parse_rate_limit_hint(body) {
return RetryDecision::RateLimited {
wait_until,
@@ -1169,7 +1171,7 @@ pub(crate) use classify_model_error as test_classify_model_error;
#[cfg(test)]
mod tests {
use super::*;
use codex_core::error::UsageLimitReachedError;
use codex_core::error::{UnexpectedResponseError, UsageLimitReachedError};
#[test]
fn rate_limit_hint_uses_reset_seconds() {
@@ -1226,7 +1228,11 @@ mod tests {
#[test]
fn classify_fatal_on_bad_request() {
let err = anyhow!(CodexErr::UnexpectedStatus(StatusCode::BAD_REQUEST, "bad".to_string()));
let err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::BAD_REQUEST,
body: "bad".to_string(),
request_id: None,
}));
match classify_model_error(&err) {
RetryDecision::Fatal(_) => {}
other => panic!("expected fatal, got {:?}", other),
+3 -3
View File
@@ -274,11 +274,11 @@ fn build_observer_prompt(trigger: &ObserverTrigger, model_slug: &str) -> Prompt
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
if let CodexErr::UnexpectedStatus(err) = codex_err {
if !err.status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
let body_lower = err.body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
+14 -7
View File
@@ -1,7 +1,7 @@
#![cfg(feature = "dev-faults")]
use anyhow::anyhow;
use codex_core::error::{CodexErr, UsageLimitReachedError};
use codex_core::error::{CodexErr, UnexpectedResponseError, UsageLimitReachedError};
use once_cell::sync::OnceCell;
use rand::Rng;
use std::collections::HashMap;
@@ -129,19 +129,26 @@ pub fn fault_to_error(fault: InjectedFault) -> anyhow::Error {
resets_in_seconds: Some(secs),
})),
Some(FaultReset::Timestamp(instant)) => {
let reset_at = chrono::Utc::now() + ChronoDuration::from_std(instant.saturating_duration_since(Instant::now())).unwrap_or_else(|_| ChronoDuration::seconds(0));
let reset_at = chrono::Utc::now()
+ ChronoDuration::from_std(instant.saturating_duration_since(Instant::now()))
.unwrap_or_else(|_| ChronoDuration::seconds(0));
let body = json!({
"error": {
"reset_at": reset_at.to_rfc3339(),
}
})
.to_string();
anyhow!(CodexErr::UnexpectedStatus( StatusCode::TOO_MANY_REQUESTS, body ))
anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}))
}
None => anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
json!({ "error": { "message": "fault injector 429" } }).to_string()
)),
None => anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body: json!({ "error": { "message": "fault injector 429" } }).to_string(),
request_id: None,
})),
},
}
}
+16 -14
View File
@@ -2,7 +2,7 @@ use super::auto_coordinator::test_classify_model_error;
use super::retry::{retry_with_backoff, RetryDecision, RetryError, RetryOptions, RetryStatus};
use anyhow::{anyhow, Error};
use chrono::{Duration as ChronoDuration, Utc};
use codex_core::error::{CodexErr, UsageLimitReachedError};
use codex_core::error::{CodexErr, UnexpectedResponseError, UsageLimitReachedError};
use rand::{rngs::StdRng, Rng, SeedableRng};
use reqwest::StatusCode;
use serde_json::json;
@@ -128,10 +128,11 @@ async fn retry_respects_reset_seconds_rate_limit() {
}
})
.to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -194,10 +195,11 @@ async fn retry_respects_reset_at_rate_limit() {
}
})
.to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -251,10 +253,11 @@ async fn retry_rate_limit_without_reset_falls_back_to_backoff() {
};
let body = json!({ "error": { "message": "slow down" } }).to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -346,4 +349,3 @@ async fn retry_cancellation_interrupts_sleep() {
let result = task.await.unwrap();
assert!(matches!(result, Err(RetryError::Aborted)));
}
+8 -1
View File
@@ -3,6 +3,7 @@ use codex_protocol::models::{ContentItem, ResponseItem};
use codex_protocol::protocol::{RolloutItem, RolloutLine};
use codex_core::Cursor;
use codex_core::RolloutRecorder;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use serde::Deserialize;
use std::fs;
use std::io::{BufRead, BufReader};
@@ -142,7 +143,13 @@ fn fallback_scan_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCa
let mut collected: Vec<ResumeCandidate> = Vec::new();
let mut cursor: Option<Cursor> = None;
while collected.len() < MAX_RESULTS {
let page = match RolloutRecorder::list_conversations(codex_home.as_path(), 256, cursor.as_ref()).await {
let page = match RolloutRecorder::list_conversations(
codex_home.as_path(),
256,
cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await {
Ok(page) => page,
Err(err) => {
tracing::warn!("failed to list conversations for resume fallback: {err}");
+9 -1
View File
@@ -9,6 +9,7 @@ use codex_core::ConversationItem;
use codex_core::ConversationsPage;
use codex_core::Cursor;
use codex_core::RolloutRecorder;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -75,6 +76,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
&request.codex_home,
PAGE_SIZE,
request.cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {
@@ -321,7 +323,13 @@ impl PickerState {
}
async fn load_initial_page(&mut self) -> Result<()> {
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, None).await?;
let page = RolloutRecorder::list_conversations(
&self.codex_home,
PAGE_SIZE,
None,
INTERACTIVE_SESSION_SOURCES,
)
.await?;
self.reset_pagination();
self.all_rows.clear();
self.filtered_rows.clear();
+10
View File
@@ -820,6 +820,16 @@ This is analogous to `model_context_window`, but for the maximum number of outpu
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
## project_doc_fallback_filenames
Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time.
```toml
project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"]
```
We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance.
## tui
Options that are specific to the TUI.
+15
View File
@@ -1084,6 +1084,12 @@ packages:
jest:
optional: true
[email protected]:
resolution: {integrity: sha512-razzgbr3EcB5+bm8/gqTqzTJ7Bpiu8PIChiAMRfZCNigr9GZBtnVSI+wPw+RGbWYCCIzWAsK/A7ihoAeSz5j7A==}
engines: {node: ^14.18.0 || ^16.0.0 || >= 18.0.0}
peerDependencies:
eslint: '>=7'
[email protected]:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1964,6 +1970,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
[email protected]:
resolution: {integrity: sha512-KGrp9Nh/SdyrQs5hZvtkp0CFPOgAh3DL57NZgFRbtlvMyEo7XuXLbeyylmxFZGGu30pL338h9KxwSxrNDndygw==}
peerDependencies:
ts-jest: '>=20.0.0'
[email protected]:
resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
@@ -3216,6 +3227,10 @@ snapshots:
- supports-color
- typescript
[email protected]([email protected]):
dependencies:
eslint: 9.36.0
[email protected]:
dependencies:
esrecurse: 4.3.0
+13 -7
View File
@@ -1,8 +1,14 @@
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import nodeImport from "eslint-plugin-node-import";
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
);
export default defineConfig(eslint.configs.recommended, tseslint.configs.recommended, {
plugins: {
"node-import": nodeImport,
},
rules: {
"node-import/prefer-node-protocol": 2,
},
});
+21 -6
View File
@@ -3,14 +3,29 @@ module.exports = {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
globals: {
"ts-jest": {
useESM: true,
tsconfig: "tsconfig.json",
},
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
testMatch: ["**/tests/**/*.test.ts"],
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
tsconfig: "tsconfig.json",
diagnostics: {
ignoreCodes: [1343],
},
astTransformers: {
before: [
{
path: "ts-jest-mock-import-meta",
// Workaround for meta.url not working in jest
options: { metaObjectReplacement: { url: "file://" + __dirname + "/dist/index.js" } },
},
],
},
},
],
},
};
+9 -2
View File
@@ -2,6 +2,11 @@
"name": "@openai/codex-sdk",
"version": "0.0.0-dev",
"description": "TypeScript SDK for Codex APIs.",
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",
"directory": "sdk/typescript"
},
"keywords": [
"openai",
"codex",
@@ -42,15 +47,17 @@
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^20.19.18",
"typescript-eslint": "^8.45.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-node-import": "^1.0.5",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.3.4",
"ts-jest-mock-import-meta": "^1.3.1",
"ts-node": "^10.9.2",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"eslint-plugin-jest": "^29.0.1"
"typescript-eslint": "^8.45.0"
}
}

Some files were not shown because too many files have changed in this diff Show More