mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Merge branch 'main' into owen/separate_v2_namespace_take_two
This commit is contained in:
Generated
+2
@@ -994,6 +994,7 @@ dependencies = [
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1471,6 +1472,7 @@ dependencies = [
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"shlex",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
|
||||
@@ -31,8 +31,8 @@ use std::process::Command;
|
||||
use ts_rs::TS;
|
||||
|
||||
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
|
||||
type JsonSchemaEmitter = fn(&Path) -> Result<RootSchema>;
|
||||
|
||||
type JsonSchemaEmitter = fn(&Path) -> Result<RootSchema>;
|
||||
pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
generate_ts(out_dir, prettier)?;
|
||||
generate_json(out_dir)?;
|
||||
|
||||
@@ -548,6 +548,11 @@ server_notification_definitions! {
|
||||
#[strum(serialize = "item/mcpToolCall/progress")]
|
||||
McpToolCallProgress(v2::McpToolCallProgressNotification),
|
||||
|
||||
#[serde(rename = "account/updated")]
|
||||
#[ts(rename = "account/updated")]
|
||||
#[strum(serialize = "account/updated")]
|
||||
AccountUpdated(v2::AccountUpdatedNotification),
|
||||
|
||||
#[serde(rename = "account/rateLimits/updated")]
|
||||
#[ts(rename = "account/rateLimits/updated")]
|
||||
#[strum(serialize = "account/rateLimits/updated")]
|
||||
|
||||
@@ -400,6 +400,7 @@ pub struct SessionConfiguredNotification {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
/// Deprecated notification. Use AccountUpdatedNotification instead.
|
||||
pub struct AuthStatusChangeNotification {
|
||||
pub auth_method: Option<AuthMode>,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::protocol::common::AuthMode;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
@@ -239,9 +240,7 @@ pub struct FeedbackUploadResponse {
|
||||
}
|
||||
|
||||
// === Threads, Turns, and Items ===
|
||||
|
||||
// Thread APIs
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -657,4 +656,8 @@ impl From<codex_protocol::protocol::RateLimitWindow> for RateLimitWindow {
|
||||
resets_at: value.resets_at,
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountUpdatedNotification {
|
||||
pub auth_method: Option<AuthMode>,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::models::supported_models;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::OutgoingNotification;
|
||||
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AddConversationListenerParams;
|
||||
use codex_app_server_protocol::AddConversationSubscriptionResponse;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalParams;
|
||||
@@ -248,8 +249,7 @@ impl CodexMessageProcessor {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.send_unimplemented_error(request_id, "account/logout")
|
||||
.await;
|
||||
self.logout_v2(request_id).await;
|
||||
}
|
||||
ClientRequest::GetAccount {
|
||||
request_id,
|
||||
@@ -298,7 +298,7 @@ impl CodexMessageProcessor {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.logout_chatgpt(request_id).await;
|
||||
self.logout_v1(request_id).await;
|
||||
}
|
||||
ClientRequest::GetAuthStatus { request_id, params } => {
|
||||
self.get_auth_status(request_id, params).await;
|
||||
@@ -542,9 +542,9 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout_chatgpt(&mut self, request_id: RequestId) {
|
||||
async fn logout_common(&mut self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
|
||||
// Cancel any active login attempt.
|
||||
{
|
||||
// Cancel any active login attempt.
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
active.drop();
|
||||
@@ -552,31 +552,61 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
if let Err(err) = self.auth_manager.logout() {
|
||||
let error = JSONRPCErrorError {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("logout failed: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
codex_app_server_protocol::LogoutChatGptResponse {},
|
||||
)
|
||||
.await;
|
||||
// Reflect the current auth method after logout (likely None).
|
||||
Ok(self.auth_manager.auth().map(|auth| auth.mode))
|
||||
}
|
||||
|
||||
// Send auth status change notification reflecting the current auth mode
|
||||
// after logout.
|
||||
let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode);
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
async fn logout_v1(&mut self, request_id: RequestId) {
|
||||
match self.logout_common().await {
|
||||
Ok(current_auth_method) => {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
codex_app_server_protocol::LogoutChatGptResponse {},
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout_v2(&mut self, request_id: RequestId) {
|
||||
match self.logout_common().await {
|
||||
Ok(current_auth_method) => {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
codex_app_server_protocol::LogoutAccountResponse {},
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_auth_status(
|
||||
|
||||
@@ -141,6 +141,8 @@ pub(crate) struct OutgoingError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
@@ -210,4 +212,24 @@ mod tests {
|
||||
"ensure the notification serializes correctly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_account_updated_notification_serialization() {
|
||||
let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification {
|
||||
auth_method: Some(AuthMode::ApiKey),
|
||||
});
|
||||
|
||||
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "account/updated",
|
||||
"params": {
|
||||
"authMethod": "apikey"
|
||||
},
|
||||
}),
|
||||
serde_json::to_value(jsonrpc_notification)
|
||||
.expect("ensure the notification serializes correctly"),
|
||||
"ensure the notification serializes correctly"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +381,11 @@ impl McpProcess {
|
||||
self.send_request("logoutChatGpt", None).await
|
||||
}
|
||||
|
||||
/// Send an `account/logout` JSON-RPC request.
|
||||
pub async fn send_logout_account_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("account/logout", None).await
|
||||
}
|
||||
|
||||
/// Send a `fuzzyFileSearch` JSON-RPC request.
|
||||
pub async fn send_fuzzy_file_search_request(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_login::login_with_api_key;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
// Helper to create a minimal config.toml for the app server
|
||||
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "http://127.0.0.1:0/v1"
|
||||
wire_api = "chat"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-test-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
assert!(codex_home.path().join("auth.json").exists());
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let id = mcp.send_logout_account_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(id)),
|
||||
)
|
||||
.await??;
|
||||
let _ok: LogoutAccountResponse = to_response(resp)?;
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountUpdated(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert!(
|
||||
payload.auth_method.is_none(),
|
||||
"auth_method should be None after logout"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should be deleted"
|
||||
);
|
||||
|
||||
let status_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
let status_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
|
||||
)
|
||||
.await??;
|
||||
let status: GetAuthStatusResponse = to_response(status_resp)?;
|
||||
assert_eq!(status.auth_method, None);
|
||||
assert_eq!(status.auth_token, None);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod account;
|
||||
mod model_list;
|
||||
mod rate_limits;
|
||||
mod thread_archive;
|
||||
|
||||
@@ -39,6 +39,7 @@ ctor = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
@@ -124,6 +124,7 @@ async fn run_command_under_sandbox(
|
||||
let cwd_clone = cwd.clone();
|
||||
let env_map = env.clone();
|
||||
let command_vec = command.clone();
|
||||
let base_dir = config.codex_home.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
run_windows_sandbox_capture(
|
||||
policy_str,
|
||||
@@ -132,6 +133,7 @@ async fn run_command_under_sandbox(
|
||||
&cwd_clone,
|
||||
env_map,
|
||||
None,
|
||||
Some(base_dir.as_path()),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -510,15 +510,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
|
||||
FeaturesSubcommand::List => {
|
||||
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
|
||||
let cli_kv_overrides = root_config_overrides
|
||||
let mut cli_kv_overrides = root_config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
// Honor `--search` via the new feature toggle.
|
||||
if interactive.web_search {
|
||||
cli_kv_overrides.push((
|
||||
"features.web_search_request".to_string(),
|
||||
toml::Value::Boolean(true),
|
||||
));
|
||||
}
|
||||
|
||||
// Thread through relevant top-level flags (at minimum, `--profile`).
|
||||
// Also honor `--search` since it maps to a feature toggle.
|
||||
let overrides = ConfigOverrides {
|
||||
config_profile: interactive.config_profile.clone(),
|
||||
tools_web_search_request: interactive.web_search.then_some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -353,7 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
.context("failed to load configuration")?;
|
||||
|
||||
if !config.features.enabled(Feature::RmcpClient) {
|
||||
bail!("OAuth login is only supported when [feature].rmcp_client is true in config.toml.");
|
||||
bail!("OAuth login is only supported when [features].rmcp_client is true in config.toml.");
|
||||
}
|
||||
|
||||
let LoginArgs { name, scopes } = login_args;
|
||||
|
||||
@@ -1773,19 +1773,14 @@ pub(crate) async fn run_task(
|
||||
sess.clone_history().await.get_history_for_prompt()
|
||||
};
|
||||
|
||||
let turn_input_messages: Vec<String> = turn_input
|
||||
let turn_input_messages = turn_input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ResponseItem::Message { content, .. } => Some(content),
|
||||
.filter_map(|item| match parse_turn_item(item) {
|
||||
Some(TurnItem::UserMessage(user_message)) => Some(user_message),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|content| {
|
||||
content.iter().filter_map(|item| match item {
|
||||
ContentItem::OutputText { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.map(|user_message| user_message.message())
|
||||
.collect::<Vec<String>>();
|
||||
match run_turn(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
|
||||
@@ -516,8 +516,8 @@ fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Anything that is not a system message or "reasoning" message is considered
|
||||
/// an API message.
|
||||
/// API messages include every non-system item (user/assistant messages, reasoning,
|
||||
/// tool calls, tool outputs, shell calls, and web-search calls).
|
||||
fn is_api_message(message: &ResponseItem) -> bool {
|
||||
match message {
|
||||
ResponseItem::Message { role, .. } => role.as_str() != "system",
|
||||
@@ -542,6 +542,8 @@ mod tests {
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::LocalShellExecAction;
|
||||
use codex_protocol::models::LocalShellStatus;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn assistant_msg(text: &str) -> ResponseItem {
|
||||
@@ -570,10 +572,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Reasoning {
|
||||
id: String::new(),
|
||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
content: Some(vec![ReasoningItemContent::ReasoningText {
|
||||
text: text.to_string(),
|
||||
}]),
|
||||
encrypted_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_non_api_messages() {
|
||||
let mut h = ConversationHistory::default();
|
||||
// System message is not an API message; Other is ignored.
|
||||
// System message is not API messages; Other is ignored.
|
||||
let system = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "system".to_string(),
|
||||
@@ -581,7 +596,8 @@ mod tests {
|
||||
text: "ignored".to_string(),
|
||||
}],
|
||||
};
|
||||
h.record_items([&system, &ResponseItem::Other]);
|
||||
let reasoning = reasoning_msg("thinking...");
|
||||
h.record_items([&system, &reasoning, &ResponseItem::Other]);
|
||||
|
||||
// User and assistant should be retained.
|
||||
let u = user_msg("hi");
|
||||
@@ -592,6 +608,16 @@ mod tests {
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
ResponseItem::Reasoning {
|
||||
id: String::new(),
|
||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
content: Some(vec![ReasoningItemContent::ReasoningText {
|
||||
text: "thinking...".to_string(),
|
||||
}]),
|
||||
encrypted_content: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
|
||||
@@ -171,6 +171,7 @@ async fn exec_windows_sandbox(
|
||||
params: ExecParams,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
use crate::config::find_codex_home;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture;
|
||||
|
||||
let ExecParams {
|
||||
@@ -188,8 +189,17 @@ async fn exec_windows_sandbox(
|
||||
};
|
||||
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let logs_base_dir = find_codex_home().ok();
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
run_windows_sandbox_capture(policy_str, &sandbox_cwd, command, &cwd, env, timeout_ms)
|
||||
run_windows_sandbox_capture(
|
||||
policy_str,
|
||||
&sandbox_cwd,
|
||||
command,
|
||||
&cwd,
|
||||
env,
|
||||
timeout_ms,
|
||||
logs_base_dir.as_deref(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ pub mod util;
|
||||
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
pub use command_safety::is_safe_command;
|
||||
pub use safety::get_platform_sandbox;
|
||||
pub use safety::set_windows_sandbox_enabled;
|
||||
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
|
||||
// `codex_core::protocol::...` references continue to work across the workspace.
|
||||
pub use codex_protocol::protocol;
|
||||
|
||||
@@ -10,6 +10,8 @@ use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExitedReviewModeEvent;
|
||||
use codex_protocol::protocol::ItemCompletedEvent;
|
||||
use codex_protocol::protocol::ReasoningContentDeltaEvent;
|
||||
use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
|
||||
use codex_protocol::protocol::ReviewOutputEvent;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -122,7 +124,9 @@ async fn process_review_events(
|
||||
..
|
||||
})
|
||||
| EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { .. })
|
||||
| EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) => {}
|
||||
| EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. })
|
||||
| EventMsg::ReasoningContentDelta(ReasoningContentDeltaEvent { .. })
|
||||
| EventMsg::ReasoningRawContentDelta(ReasoningRawContentDeltaEvent { .. }) => {}
|
||||
EventMsg::TaskComplete(task_complete) => {
|
||||
// Parse review output from the last agent message (if present).
|
||||
let out = task_complete
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::either::Either;
|
||||
@@ -53,13 +54,16 @@ impl ToolCallRuntime {
|
||||
let turn = Arc::clone(&self.turn_context);
|
||||
let tracker = Arc::clone(&self.tracker);
|
||||
let lock = Arc::clone(&self.parallel_execution);
|
||||
let aborted_response = Self::aborted_response(&call);
|
||||
let started = Instant::now();
|
||||
let readiness = self.turn_context.tool_call_gate.clone();
|
||||
|
||||
let handle: AbortOnDropHandle<Result<ResponseInputItem, FunctionCallError>> =
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => Ok(aborted_response),
|
||||
_ = cancellation_token.cancelled() => {
|
||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||
Ok(Self::aborted_response(&call, secs))
|
||||
},
|
||||
res = async {
|
||||
tracing::info!("waiting for tool gate");
|
||||
readiness.wait_ready().await;
|
||||
@@ -71,7 +75,7 @@ impl ToolCallRuntime {
|
||||
};
|
||||
|
||||
router
|
||||
.dispatch_tool_call(session, turn, tracker, call)
|
||||
.dispatch_tool_call(session, turn, tracker, call.clone())
|
||||
.await
|
||||
} => res,
|
||||
}
|
||||
@@ -91,23 +95,32 @@ impl ToolCallRuntime {
|
||||
}
|
||||
|
||||
impl ToolCallRuntime {
|
||||
fn aborted_response(call: &ToolCall) -> ResponseInputItem {
|
||||
fn aborted_response(call: &ToolCall, secs: f32) -> ResponseInputItem {
|
||||
match &call.payload {
|
||||
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
output: "aborted".to_string(),
|
||||
output: Self::abort_message(call, secs),
|
||||
},
|
||||
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
result: Err("aborted".to_string()),
|
||||
result: Err(Self::abort_message(call, secs)),
|
||||
},
|
||||
_ => ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "aborted".to_string(),
|
||||
content: Self::abort_message(call, secs),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn abort_message(call: &ToolCall, secs: f32) -> String {
|
||||
match call.tool_name.as_str() {
|
||||
"shell" | "container.exec" | "local_shell" | "unified_exec" => {
|
||||
format!("Wall time: {secs:.1} seconds\naborted by user")
|
||||
}
|
||||
_ => format!("aborted by user after {secs:.1}s"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ pub mod fs_wait {
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(anyhow!("timed out waiting for {:?}", path))
|
||||
Err(anyhow!("timed out waiting for {path:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ pub mod fs_wait {
|
||||
if let Some(found) = scan_for_match(&root, predicate) {
|
||||
Ok(found)
|
||||
} else {
|
||||
Err(anyhow!("timed out waiting for matching file in {:?}", root))
|
||||
Err(anyhow!("timed out waiting for matching file in {root:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use assert_matches::assert_matches;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -13,6 +14,7 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::json;
|
||||
|
||||
/// Integration test: spawn a long‑running shell tool via a mocked Responses SSE
|
||||
@@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() {
|
||||
)
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs_f32(0.1)).await;
|
||||
codex.submit(Op::Interrupt).await.unwrap();
|
||||
|
||||
wait_for_event_with_timeout(
|
||||
@@ -159,9 +162,26 @@ async fn interrupt_tool_records_history_entries() {
|
||||
response_mock.saw_function_call(call_id),
|
||||
"function call not recorded in responses payload"
|
||||
);
|
||||
assert_eq!(
|
||||
response_mock.function_call_output_text(call_id).as_deref(),
|
||||
Some("aborted"),
|
||||
"aborted function call output not recorded in responses payload"
|
||||
let output = response_mock
|
||||
.function_call_output_text(call_id)
|
||||
.expect("missing function_call_output text");
|
||||
let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9])?) seconds\naborted by user$")
|
||||
.expect("compile regex");
|
||||
let captures = re.captures(&output);
|
||||
assert_matches!(
|
||||
captures.as_ref(),
|
||||
Some(caps) if caps.get(1).is_some(),
|
||||
"aborted message with elapsed seconds"
|
||||
);
|
||||
let secs: f32 = captures
|
||||
.expect("aborted message with elapsed seconds")
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(
|
||||
secs >= 0.1,
|
||||
"expected at least one tenth of a second of elapsed time, got {secs}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
@@ -61,6 +64,12 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
|
||||
|
||||
// We fork the notify script, so we need to wait for it to write to the file.
|
||||
fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?;
|
||||
let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?;
|
||||
let payload: Value = serde_json::from_str(¬ify_payload_raw)?;
|
||||
|
||||
assert_eq!(payload["type"], json!("agent-turn-complete"));
|
||||
assert_eq!(payload["input-messages"], json!(["hello world"]));
|
||||
assert_eq!(payload["last-assistant-message"], json!("Done"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ pub fn run(
|
||||
.threads(num_walk_builder_threads)
|
||||
// Allow hidden entries.
|
||||
.hidden(false)
|
||||
// Follow symlinks to search their contents.
|
||||
.follow_links(true)
|
||||
// Don't require git to be present to apply to apply git-related ignore rules.
|
||||
.require_git(false);
|
||||
if !respect_gitignore {
|
||||
|
||||
@@ -812,12 +812,8 @@ impl TokenUsage {
|
||||
(self.non_cached_input() + self.output_tokens.max(0)).max(0)
|
||||
}
|
||||
|
||||
/// For estimating what % of the model's context window is used, we need to account
|
||||
/// for reasoning output tokens from prior turns being dropped from the context window.
|
||||
/// We approximate this here by subtracting reasoning output tokens from the total.
|
||||
/// This will be off for the current turn and pending function calls.
|
||||
pub fn tokens_in_context_window(&self) -> i64 {
|
||||
(self.total_tokens - self.reasoning_output_tokens).max(0)
|
||||
self.total_tokens
|
||||
}
|
||||
|
||||
/// Estimate the remaining user-controllable percentage of the model's context window.
|
||||
|
||||
@@ -40,12 +40,23 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
## CLI
|
||||
|
||||
```
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>]
|
||||
```
|
||||
|
||||
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
|
||||
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT>, "pid": <PID> }` once listening.
|
||||
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
|
||||
- `--upstream-url <URL>`: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`.
|
||||
- Authentication is fixed to `Authorization: Bearer <key>` to match the Codex CLI expectations.
|
||||
|
||||
For Azure, for example (ensure your deployment accepts `Authorization: Bearer <key>`):
|
||||
|
||||
```shell
|
||||
printenv AZURE_OPENAI_API_KEY | env -u AZURE_OPENAI_API_KEY codex-responses-api-proxy \
|
||||
--http-shutdown \
|
||||
--server-info /tmp/server-info.json \
|
||||
--upstream-url "https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT/responses?api-version=2025-04-01-preview"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -57,7 +68,7 @@ codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdow
|
||||
Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory:
|
||||
|
||||
- We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques.
|
||||
- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes.
|
||||
- At startup, we allocate a `1024` byte buffer on the stack and copy `"Bearer "` into the start of the buffer.
|
||||
- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`.
|
||||
- After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap).
|
||||
- We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler.
|
||||
|
||||
@@ -12,6 +12,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use clap::Parser;
|
||||
use reqwest::Url;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::HOST;
|
||||
@@ -44,6 +45,10 @@ pub struct Args {
|
||||
/// Enable HTTP shutdown endpoint at GET /shutdown
|
||||
#[arg(long)]
|
||||
pub http_shutdown: bool,
|
||||
|
||||
/// Absolute URL the proxy should forward requests to (defaults to OpenAI).
|
||||
#[arg(long, default_value = "https://api.openai.com/v1/responses")]
|
||||
pub upstream_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -52,10 +57,29 @@ struct ServerInfo {
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
struct ForwardConfig {
|
||||
upstream_url: Url,
|
||||
host_header: HeaderValue,
|
||||
}
|
||||
|
||||
/// Entry point for the library main, for parity with other crates.
|
||||
pub fn run_main(args: Args) -> Result<()> {
|
||||
let auth_header = read_auth_header_from_stdin()?;
|
||||
|
||||
let upstream_url = Url::parse(&args.upstream_url).context("parsing --upstream-url")?;
|
||||
let host = match (upstream_url.host_str(), upstream_url.port()) {
|
||||
(Some(host), Some(port)) => format!("{host}:{port}"),
|
||||
(Some(host), None) => host.to_string(),
|
||||
_ => return Err(anyhow!("upstream URL must include a host")),
|
||||
};
|
||||
let host_header =
|
||||
HeaderValue::from_str(&host).context("constructing Host header from upstream URL")?;
|
||||
|
||||
let forward_config = Arc::new(ForwardConfig {
|
||||
upstream_url,
|
||||
host_header,
|
||||
});
|
||||
|
||||
let (listener, bound_addr) = bind_listener(args.port)?;
|
||||
if let Some(path) = args.server_info.as_ref() {
|
||||
write_server_info(path, bound_addr.port())?;
|
||||
@@ -75,13 +99,14 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
let http_shutdown = args.http_shutdown;
|
||||
for request in server.incoming_requests() {
|
||||
let client = client.clone();
|
||||
let forward_config = forward_config.clone();
|
||||
std::thread::spawn(move || {
|
||||
if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" {
|
||||
let _ = request.respond(Response::new_empty(StatusCode(200)));
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
if let Err(e) = forward_request(&client, auth_header, request) {
|
||||
if let Err(e) = forward_request(&client, auth_header, &forward_config, request) {
|
||||
eprintln!("forwarding error: {e}");
|
||||
}
|
||||
});
|
||||
@@ -115,7 +140,12 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> {
|
||||
fn forward_request(
|
||||
client: &Client,
|
||||
auth_header: &'static str,
|
||||
config: &ForwardConfig,
|
||||
mut req: Request,
|
||||
) -> Result<()> {
|
||||
// Only allow POST /v1/responses exactly, no query string.
|
||||
let method = req.method().clone();
|
||||
let url_path = req.url().to_string();
|
||||
@@ -157,11 +187,10 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request)
|
||||
auth_header_value.set_sensitive(true);
|
||||
headers.insert(AUTHORIZATION, auth_header_value);
|
||||
|
||||
headers.insert(HOST, HeaderValue::from_static("api.openai.com"));
|
||||
headers.insert(HOST, config.host_header.clone());
|
||||
|
||||
let upstream = "https://api.openai.com/v1/responses";
|
||||
let upstream_resp = client
|
||||
.post(upstream)
|
||||
.post(config.upstream_url.clone())
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
|
||||
@@ -121,7 +121,7 @@ where
|
||||
if total_read == capacity && !saw_newline && !saw_eof {
|
||||
buf.zeroize();
|
||||
return Err(anyhow!(
|
||||
"OPENAI_API_KEY is too large to fit in the 512-byte buffer"
|
||||
"API key is too large to fit in the {BUFFER_SIZE}-byte buffer"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ where
|
||||
if total == AUTH_HEADER_PREFIX.len() {
|
||||
buf.zeroize();
|
||||
return Err(anyhow!(
|
||||
"OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
|
||||
"API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'"
|
||||
"API key may only contain ASCII letters, numbers, '-' or '_'"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -290,7 +290,9 @@ mod tests {
|
||||
})
|
||||
.unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer"));
|
||||
let expected_error =
|
||||
format!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer");
|
||||
assert!(message.contains(&expected_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,9 +319,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
|
||||
);
|
||||
assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,8 +337,6 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
|
||||
);
|
||||
assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,3 +106,4 @@ insta = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
vt100 = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
|
||||
@@ -14,6 +14,12 @@ use textwrap::Options;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
|
||||
|
||||
fn is_word_separator(ch: char) -> bool {
|
||||
WORD_SEPARATORS.contains(ch)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TextElement {
|
||||
range: Range<usize>,
|
||||
@@ -841,21 +847,23 @@ impl TextArea {
|
||||
|
||||
pub(crate) fn beginning_of_previous_word(&self) -> usize {
|
||||
let prefix = &self.text[..self.cursor_pos];
|
||||
let Some((first_non_ws_idx, _)) = prefix
|
||||
let Some((first_non_ws_idx, ch)) = prefix
|
||||
.char_indices()
|
||||
.rev()
|
||||
.find(|&(_, ch)| !ch.is_whitespace())
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
let before = &prefix[..first_non_ws_idx];
|
||||
let candidate = before
|
||||
.char_indices()
|
||||
.rev()
|
||||
.find(|&(_, ch)| ch.is_whitespace())
|
||||
.map(|(idx, ch)| idx + ch.len_utf8())
|
||||
.unwrap_or(0);
|
||||
self.adjust_pos_out_of_elements(candidate, true)
|
||||
let is_separator = is_word_separator(ch);
|
||||
let mut start = first_non_ws_idx;
|
||||
for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() {
|
||||
if ch.is_whitespace() || is_word_separator(ch) != is_separator {
|
||||
start = idx + ch.len_utf8();
|
||||
break;
|
||||
}
|
||||
start = idx;
|
||||
}
|
||||
self.adjust_pos_out_of_elements(start, true)
|
||||
}
|
||||
|
||||
pub(crate) fn end_of_next_word(&self) -> usize {
|
||||
@@ -864,11 +872,19 @@ impl TextArea {
|
||||
return self.text.len();
|
||||
};
|
||||
let word_start = self.cursor_pos + first_non_ws;
|
||||
let candidate = match self.text[word_start..].find(|c: char| c.is_whitespace()) {
|
||||
Some(rel_idx) => word_start + rel_idx,
|
||||
None => self.text.len(),
|
||||
let mut iter = self.text[word_start..].char_indices();
|
||||
let Some((_, first_ch)) = iter.next() else {
|
||||
return word_start;
|
||||
};
|
||||
self.adjust_pos_out_of_elements(candidate, false)
|
||||
let is_separator = is_word_separator(first_ch);
|
||||
let mut end = self.text.len();
|
||||
for (idx, ch) in iter {
|
||||
if ch.is_whitespace() || is_word_separator(ch) != is_separator {
|
||||
end = word_start + idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.adjust_pos_out_of_elements(end, false)
|
||||
}
|
||||
|
||||
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
|
||||
@@ -1239,6 +1255,56 @@ mod tests {
|
||||
assert_eq!(t.cursor(), elem_range.start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_respects_word_separators() {
|
||||
let mut t = ta_with("path/to/file");
|
||||
t.set_cursor(t.text().len());
|
||||
t.delete_backward_word();
|
||||
assert_eq!(t.text(), "path/to/");
|
||||
assert_eq!(t.cursor(), t.text().len());
|
||||
|
||||
t.delete_backward_word();
|
||||
assert_eq!(t.text(), "path/to");
|
||||
assert_eq!(t.cursor(), t.text().len());
|
||||
|
||||
let mut t = ta_with("foo/ ");
|
||||
t.set_cursor(t.text().len());
|
||||
t.delete_backward_word();
|
||||
assert_eq!(t.text(), "foo");
|
||||
assert_eq!(t.cursor(), 3);
|
||||
|
||||
let mut t = ta_with("foo /");
|
||||
t.set_cursor(t.text().len());
|
||||
t.delete_backward_word();
|
||||
assert_eq!(t.text(), "foo ");
|
||||
assert_eq!(t.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_word_respects_word_separators() {
|
||||
let mut t = ta_with("path/to/file");
|
||||
t.set_cursor(0);
|
||||
t.delete_forward_word();
|
||||
assert_eq!(t.text(), "/to/file");
|
||||
assert_eq!(t.cursor(), 0);
|
||||
|
||||
t.delete_forward_word();
|
||||
assert_eq!(t.text(), "to/file");
|
||||
assert_eq!(t.cursor(), 0);
|
||||
|
||||
let mut t = ta_with("/ foo");
|
||||
t.set_cursor(0);
|
||||
t.delete_forward_word();
|
||||
assert_eq!(t.text(), " foo");
|
||||
assert_eq!(t.cursor(), 0);
|
||||
|
||||
let mut t = ta_with(" /foo");
|
||||
t.set_cursor(0);
|
||||
t.delete_forward_word();
|
||||
assert_eq!(t.text(), "foo");
|
||||
assert_eq!(t.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_restores_last_kill() {
|
||||
let mut t = ta_with("hello");
|
||||
|
||||
+44
-5
@@ -16,6 +16,7 @@ use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -93,7 +94,7 @@ use std::io::Write as _;
|
||||
// (tests access modules directly within the crate)
|
||||
|
||||
pub async fn run_main(
|
||||
cli: Cli,
|
||||
mut cli: Cli,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
let (sandbox_mode, approval_policy) = if cli.full_auto {
|
||||
@@ -113,6 +114,13 @@ pub async fn run_main(
|
||||
)
|
||||
};
|
||||
|
||||
// Map the legacy --search flag to the new feature toggle.
|
||||
if cli.web_search {
|
||||
cli.config_overrides
|
||||
.raw_overrides
|
||||
.push("features.web_search_request=true".to_string());
|
||||
}
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model (defaulting to
|
||||
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
|
||||
// `oss` model provider.
|
||||
@@ -148,7 +156,7 @@ pub async fn run_main(
|
||||
compact_prompt: None,
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||||
tools_web_search_request: cli.web_search.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
experimental_sandbox_command_assessment: None,
|
||||
additional_writable_roots: additional_dirs,
|
||||
};
|
||||
@@ -512,8 +520,8 @@ async fn load_config_or_exit(
|
||||
/// or if the current cwd project is already trusted. If not, we need to
|
||||
/// show the trust screen.
|
||||
fn should_show_trust_screen(config: &Config) -> bool {
|
||||
if cfg!(target_os = "windows") {
|
||||
// Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely.
|
||||
if cfg!(target_os = "windows") && get_platform_sandbox().is_none() {
|
||||
// If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely.
|
||||
return false;
|
||||
}
|
||||
if config.did_user_set_custom_approval_policy_or_sandbox_mode {
|
||||
@@ -557,10 +565,13 @@ mod tests {
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::ProjectConfig;
|
||||
use codex_core::set_windows_sandbox_enabled;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn windows_skips_trust_prompt() -> std::io::Result<()> {
|
||||
#[serial]
|
||||
fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
@@ -569,6 +580,7 @@ mod tests {
|
||||
)?;
|
||||
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
|
||||
config.active_project = ProjectConfig { trust_level: None };
|
||||
set_windows_sandbox_enabled(false);
|
||||
|
||||
let should_show = should_show_trust_screen(&config);
|
||||
if cfg!(target_os = "windows") {
|
||||
@@ -584,4 +596,31 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
|
||||
config.active_project = ProjectConfig { trust_level: None };
|
||||
set_windows_sandbox_enabled(true);
|
||||
|
||||
let should_show = should_show_trust_screen(&config);
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
should_show,
|
||||
"Windows trust prompt should be shown on native Windows with sandbox enabled"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
should_show,
|
||||
"Non-Windows should still show trust prompt when project is untrusted"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ expression: sanitized
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.1K used / 272K) │
|
||||
│ Context window: 100% left (2.25K used / 272K) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ expression: sanitized
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.1K used / 272K) │
|
||||
│ Context window: 100% left (2.25K used / 272K) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │
|
||||
│ Warning: limits may be stale - start new turn to refresh. │
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ expression: sanitized
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + │
|
||||
│ Context window: 100% left (2.1K used / │
|
||||
│ Context window: 100% left (2.25K used / │
|
||||
│ 5h limit: [███████████████░░░░░] │
|
||||
│ (resets 03:14) │
|
||||
╰────────────────────────────────────────────╯
|
||||
|
||||
@@ -182,6 +182,7 @@ mod windows_impl {
|
||||
cwd: &Path,
|
||||
mut env_map: HashMap<String, String>,
|
||||
timeout_ms: Option<u64>,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<CaptureResult> {
|
||||
let policy = SandboxPolicy::parse(policy_json_or_preset)?;
|
||||
normalize_null_device_env(&mut env_map);
|
||||
@@ -191,7 +192,7 @@ mod windows_impl {
|
||||
let current_dir = cwd.to_path_buf();
|
||||
// for now, don't fail if we detect world-writable directories
|
||||
// audit::audit_everyone_writable(¤t_dir, &env_map)?;
|
||||
log_start(&command);
|
||||
log_start(&command, logs_base_dir);
|
||||
let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe {
|
||||
match &policy.0 {
|
||||
SandboxMode::ReadOnly => {
|
||||
@@ -295,7 +296,7 @@ mod windows_impl {
|
||||
env_block.len(),
|
||||
si.dwFlags,
|
||||
);
|
||||
debug_log(&dbg);
|
||||
debug_log(&dbg, logs_base_dir);
|
||||
unsafe {
|
||||
CloseHandle(in_r);
|
||||
CloseHandle(in_w);
|
||||
@@ -395,9 +396,9 @@ mod windows_impl {
|
||||
};
|
||||
|
||||
if exit_code == 0 {
|
||||
log_success(&command);
|
||||
log_success(&command, logs_base_dir);
|
||||
} else {
|
||||
log_failure(&command, &format!("exit code {}", exit_code));
|
||||
log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir);
|
||||
}
|
||||
|
||||
if !persist_aces {
|
||||
@@ -446,6 +447,7 @@ mod stub {
|
||||
_cwd: &Path,
|
||||
_env_map: HashMap<String, String>,
|
||||
_timeout_ms: Option<u64>,
|
||||
_logs_base_dir: Option<&Path>,
|
||||
) -> Result<CaptureResult> {
|
||||
bail!("Windows sandbox is only available on Windows")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const LOG_COMMAND_PREVIEW_LIMIT: usize = 200;
|
||||
pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log";
|
||||
@@ -13,35 +15,43 @@ fn preview(command: &[String]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn append_line(line: &str) {
|
||||
if let Ok(mut f) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(LOG_FILE_NAME)
|
||||
{
|
||||
let _ = writeln!(f, "{}", line);
|
||||
fn log_file_path(base_dir: &Path) -> Option<PathBuf> {
|
||||
if base_dir.is_dir() {
|
||||
Some(base_dir.join(LOG_FILE_NAME))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_start(command: &[String]) {
|
||||
let p = preview(command);
|
||||
append_line(&format!("START: {}", p));
|
||||
fn append_line(line: &str, base_dir: Option<&Path>) {
|
||||
if let Some(dir) = base_dir {
|
||||
if let Some(path) = log_file_path(dir) {
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_success(command: &[String]) {
|
||||
pub fn log_start(command: &[String], base_dir: Option<&Path>) {
|
||||
let p = preview(command);
|
||||
append_line(&format!("SUCCESS: {}", p));
|
||||
append_line(&format!("START: {p}"), base_dir);
|
||||
}
|
||||
|
||||
pub fn log_failure(command: &[String], detail: &str) {
|
||||
pub fn log_success(command: &[String], base_dir: Option<&Path>) {
|
||||
let p = preview(command);
|
||||
append_line(&format!("FAILURE: {} ({})", p, detail));
|
||||
append_line(&format!("SUCCESS: {p}"), base_dir);
|
||||
}
|
||||
|
||||
pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) {
|
||||
let p = preview(command);
|
||||
append_line(&format!("FAILURE: {p} ({detail})"), base_dir);
|
||||
}
|
||||
|
||||
// Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs.
|
||||
pub fn debug_log(msg: &str) {
|
||||
pub fn debug_log(msg: &str, base_dir: Option<&Path>) {
|
||||
if std::env::var("SBX_DEBUG").ok().as_deref() == Some("1") {
|
||||
append_line(&format!("DEBUG: {}", msg));
|
||||
eprintln!("{}", msg);
|
||||
append_line(&format!("DEBUG: {msg}"), base_dir);
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub unsafe fn create_process_as_user(
|
||||
argv: &[String],
|
||||
cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> {
|
||||
let cmdline_str = argv
|
||||
.iter()
|
||||
@@ -142,7 +143,7 @@ pub unsafe fn create_process_as_user(
|
||||
env_block.len(),
|
||||
si.dwFlags,
|
||||
);
|
||||
logging::debug_log(&msg);
|
||||
logging::debug_log(&msg, logs_base_dir);
|
||||
return Err(anyhow!("CreateProcessAsUserW failed: {}", err));
|
||||
}
|
||||
Ok((pi, si))
|
||||
|
||||
Reference in New Issue
Block a user