Merge branch 'main' into etraut/fix-agent-picker-deadlock

This commit is contained in:
Eric Traut
2026-06-09 13:32:34 -07:00
committed by GitHub
15 changed files with 473 additions and 198 deletions
-20
View File
@@ -70,13 +70,9 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
pub use codex_core::McpManager;
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub use codex_core::grant_read_root_non_elevated;
pub use codex_core::web_search_detail;
pub mod config {
pub use codex_core::config::*;
@@ -86,10 +82,6 @@ pub mod legacy_core {
}
}
pub mod connectors {
pub use codex_core::connectors::*;
}
pub mod otel_init {
pub use codex_core::otel_init::*;
}
@@ -98,22 +90,10 @@ pub mod legacy_core {
pub use codex_core::personality_migration::*;
}
pub mod review_format {
pub use codex_core::review_format::*;
}
pub mod review_prompts {
pub use codex_core::review_prompts::*;
}
pub mod test_support {
pub use codex_core::test_support::*;
}
pub mod util {
pub use codex_core::util::*;
}
pub mod windows_sandbox {
pub use codex_core::windows_sandbox::*;
}
+2 -2
View File
@@ -27,7 +27,7 @@ pub fn create_create_goal_tool() -> ToolSpec {
(
"objective".to_string(),
JsonSchema::string(Some(
"Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails."
"Required. The concrete objective to start pursuing. This starts a new active goal when no goal exists or replaces the current goal when it is complete."
.to_string(),
)),
),
@@ -44,7 +44,7 @@ pub fn create_create_goal_tool() -> ToolSpec {
name: CREATE_GOAL_TOOL_NAME.to_string(),
description: format!(
r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.
Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
),
strict: false,
defer_loading: None,
+1 -1
View File
@@ -191,7 +191,7 @@ impl GoalToolExecutor {
.map_err(|err| FunctionCallError::RespondToModel(format!("failed to create goal: {err}")))?
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
"cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"
.to_string(),
)
})?;
@@ -124,7 +124,7 @@ async fn goal_tools_hidden_for_review_subagents() -> anyhow::Result<()> {
}
#[tokio::test]
async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> {
async fn installed_goal_tools_only_replace_complete_goal() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
@@ -152,10 +152,31 @@ async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result
assert_eq!(
err,
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
"cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"
.to_string()
)
);
let update_tool = tool_by_name(&tools, "update_goal");
update_tool
.handle(tool_call(
"update_goal",
"call-complete-goal",
json!({ "status": "complete" }),
))
.await?;
let invocation = tool_call(
"create_goal",
"call-create-goal-3",
json!({ "objective": "replacement goal" }),
);
let output = create_tool.handle(invocation.clone()).await?;
let result = output.code_mode_result(&invocation.payload);
assert_eq!(json!("replacement goal"), result["goal"]["objective"]);
assert_eq!(json!("active"), result["goal"]["status"]);
assert_eq!(json!(0), result["goal"]["tokensUsed"]);
Ok(())
}
+5 -4
View File
@@ -107,9 +107,9 @@ When a request is blocked, the proxy responds with `403` and includes:
- `blocked-by-method-policy`
- `blocked-by-policy`
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
limited mode.
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests and
HTTPS SOCKS5 TCP targets on `:443` require MITM to enforce limited-mode method policy; otherwise
they are blocked. SOCKS5 UDP and non-HTTPS SOCKS5 TCP remain blocked in limited mode.
Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
through the same host allowlist/denylist checks.
@@ -215,7 +215,8 @@ what it can reasonably guarantee.
allowlisted (best-effort DNS lookup).
- Limited mode enforcement:
- only `GET`, `HEAD`, and `OPTIONS` are allowed
- HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS
- HTTPS `CONNECT` requests and HTTPS SOCKS5 TCP targets on `:443` require MITM so the proxy can
enforce limited-mode method policy; SOCKS5 UDP and non-HTTPS SOCKS5 TCP remain blocked
- Listener safety defaults:
- the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via
`dangerously_allow_non_loopback_proxy`
+1 -1
View File
@@ -276,7 +276,7 @@ impl NetworkProxySettings {
pub enum NetworkMode {
/// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
/// SOCKS5 remains blocked in limited mode.
/// SOCKS5 UDP and non-HTTPS SOCKS5 TCP remain blocked in limited mode.
Limited,
/// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly.
/// MITM hooks do not currently make full mode enter MITM.
+18 -8
View File
@@ -21,10 +21,12 @@ use rama_core::Layer;
use rama_core::Service;
use rama_core::bytes::Bytes;
use rama_core::error::BoxError;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_core::futures::stream::Stream;
use rama_core::futures::stream::Stream as FuturesStream;
use rama_core::rt::Executor;
use rama_core::service::service_fn;
use rama_core::stream::Stream;
use rama_http::Body;
use rama_http::BodyDataStream;
use rama_http::HeaderMap;
@@ -135,17 +137,25 @@ impl MitmState {
/// Terminate the upgraded CONNECT stream with a generated leaf cert and proxy inner HTTPS traffic.
pub(crate) async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {
let mitm = upgraded
mitm_stream(upgraded).await
}
/// Terminate a raw client stream with a generated leaf cert and proxy inner HTTPS traffic.
pub(crate) async fn mitm_stream<S>(stream: S) -> Result<()>
where
S: Stream + Unpin + ExtensionsMut,
{
let mitm = stream
.extensions()
.get::<Arc<MitmState>>()
.cloned()
.context("missing MITM state")?;
let app_state = upgraded
let app_state = stream
.extensions()
.get::<Arc<NetworkProxyState>>()
.cloned()
.context("missing app state")?;
let target = upgraded
let target = stream
.extensions()
.get::<ProxyTarget>()
.context("missing proxy target")?
@@ -154,7 +164,7 @@ pub(crate) async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {
let target_host = normalize_host(&target.host.to_string());
let target_port = target.port;
let acceptor_data = mitm.tls_acceptor_data_for_host(&target_host)?;
let mode = upgraded
let mode = stream
.extensions()
.get::<NetworkMode>()
.copied()
@@ -169,7 +179,7 @@ pub(crate) async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {
mitm,
});
let executor = upgraded
let executor = stream
.extensions()
.get::<Executor>()
.cloned()
@@ -194,7 +204,7 @@ pub(crate) async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {
.into_layer(http_service);
https_service
.serve(upgraded)
.serve(stream)
.await
.map_err(|err| anyhow!("MITM serve error: {err}"))?;
Ok(())
@@ -456,7 +466,7 @@ struct InspectStream<T> {
max_body_bytes: usize,
}
impl<T: BodyLoggable> Stream for InspectStream<T> {
impl<T: BodyLoggable> FuturesStream for InspectStream<T> {
type Item = Result<Bytes, BoxError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
+395 -115
View File
@@ -1,5 +1,6 @@
use crate::config::NetworkMode;
use crate::connect_policy::TargetCheckedTcpConnector;
use crate::mitm;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
@@ -24,10 +25,17 @@ use anyhow::Result;
use rama_core::Layer;
use rama_core::Service;
use rama_core::error::BoxError;
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_core::layer::AddInputExtensionLayer;
use rama_core::service::service_fn;
use rama_net::address::HostWithPort;
use rama_net::client::EstablishedClientConnection;
use rama_net::proxy::ProxyRequest;
use rama_net::proxy::ProxyTarget;
use rama_net::proxy::StreamForwardService;
use rama_net::stream::Socket;
use rama_net::stream::SocketInfo;
use rama_socks5::Socks5Acceptor;
use rama_socks5::server::DefaultConnector;
@@ -40,8 +48,14 @@ use rama_tcp::server::TcpListener;
use std::io;
use std::net::SocketAddr;
use std::net::TcpListener as StdTcpListener;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context as TaskContext;
use std::task::Poll;
use std::time::Instant;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::io::ReadBuf;
use tracing::error;
use tracing::info;
use tracing::warn;
@@ -88,7 +102,9 @@ async fn run_socks5_with_listener(
match state.network_mode().await {
Ok(NetworkMode::Limited) => {
info!("SOCKS5 is blocked in limited mode; set mode=\"full\" to allow SOCKS5");
info!(
"SOCKS5 UDP and non-HTTPS SOCKS5 TCP are blocked in limited mode; HTTPS SOCKS5 TCP requires MITM inspection"
);
}
Ok(NetworkMode::Full) => {}
Err(err) => {
@@ -106,7 +122,10 @@ async fn run_socks5_with_listener(
}
});
let socks_connector = DefaultConnector::default().with_connector(policy_tcp_connector);
let socks_proxy = service_fn(|request| async move { proxy_socks5_tcp(request).await });
let socks_connector = DefaultConnector::default()
.with_connector(policy_tcp_connector)
.with_service(socks_proxy);
let base = Socks5Acceptor::new().with_connector(socks_connector);
if enable_socks5_udp {
@@ -135,7 +154,7 @@ async fn handle_socks5_tcp(
req: TcpRequest,
tcp_connector: TargetCheckedTcpConnector,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
) -> Result<EstablishedClientConnection<TcpStream, TcpRequest>, BoxError> {
) -> Result<EstablishedClientConnection<Socks5TcpConnection, TcpRequest>, BoxError> {
let app_state = req
.extensions()
.get::<Arc<NetworkProxyState>>()
@@ -144,6 +163,7 @@ async fn handle_socks5_tcp(
let host = normalize_host(&req.authority.host.to_string());
let port = req.authority.port;
let target = req.authority.clone();
if host.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid host").into());
}
@@ -196,94 +216,52 @@ async fn handle_socks5_tcp(
}
}
match app_state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Limited),
protocol: "socks5".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"SOCKS blocked by method policy (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
);
return Err(policy_denied_error(REASON_METHOD_NOT_ALLOWED, &details).into());
}
Ok(NetworkMode::Full) => {}
let mode = match app_state.network_mode().await {
Ok(mode) => mode,
Err(err) => {
error!("failed to evaluate method policy: {err}");
return Err(io::Error::other("proxy error").into());
}
}
match app_state.host_has_mitm_hooks(&host).await {
Ok(true) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_MITM_REQUIRED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_MITM_REQUIRED.to_string(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Full),
protocol: "socks5".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"SOCKS blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode=full)"
);
return Err(policy_denied_error(REASON_MITM_REQUIRED, &details).into());
}
Ok(false) => {}
Err(err) => {
error!("failed to inspect MITM hooks for {host}: {err}");
return Err(io::Error::other("proxy error").into());
}
};
// SOCKS5 only exposes host and port, so only the default HTTPS port is identifiable as a
// TLS stream that the HTTPS MITM path can safely terminate.
let socks5_tcp_target_is_https = port == 443;
if mode == NetworkMode::Limited && !socks5_tcp_target_is_https {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Limited),
protocol: "socks5".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"SOCKS blocked; limited mode only supports HTTPS MITM (client={client}, host={host}, port={port})"
);
return Err(policy_denied_error(REASON_METHOD_NOT_ALLOWED, &details).into());
}
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
@@ -337,9 +315,85 @@ async fn handle_socks5_tcp(
}
}
let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await {
Ok(has_hooks) => has_hooks,
Err(err) => {
error!("failed to inspect MITM hooks for {host}: {err}");
return Err(io::Error::other("proxy error").into());
}
};
let mitm_state = match app_state.mitm_state().await {
Ok(state) => state,
Err(err) => {
error!("failed to load MITM state: {err}");
return Err(io::Error::other("proxy error").into());
}
};
let socks_needs_mitm =
socks5_tcp_target_is_https && (mode == NetworkMode::Limited || host_has_mitm_hooks);
if (host_has_mitm_hooks && !socks5_tcp_target_is_https)
|| (socks_needs_mitm && mitm_state.is_none())
{
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_MITM_REQUIRED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_MITM_REQUIRED.to_string(),
client: client.clone(),
method: None,
mode: Some(mode),
protocol: "socks5".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"SOCKS blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks}, https_target={socks5_tcp_target_is_https})"
);
return Err(policy_denied_error(REASON_MITM_REQUIRED, &details).into());
}
if socks_needs_mitm && let Some(mitm_state) = mitm_state {
let client = client.as_deref().unwrap_or_default();
info!("SOCKS MITM enabled (client={client}, host={host}, port={port}, mode={mode:?})");
return Ok(EstablishedClientConnection {
input: req,
conn: Socks5TcpConnection::Mitm {
target,
mode,
mitm: mitm_state,
extensions: Extensions::new(),
},
});
}
info!("SOCKS upstream dial started (host={host}, port={port})");
let connect_started_at = Instant::now();
let result = tcp_connector.serve(req).await;
let result = tcp_connector.serve(req).await.map(|connection| {
let EstablishedClientConnection { input, conn } = connection;
EstablishedClientConnection {
input,
conn: Socks5TcpConnection::Direct(conn),
}
});
match &result {
Ok(_) => info!(
"SOCKS upstream dial established (host={host}, port={port}, elapsed_ms={})",
@@ -353,6 +407,113 @@ async fn handle_socks5_tcp(
result
}
/// Internal connector output for SOCKS5 TCP. MITM requests do not dial upstream before the
/// inner HTTPS request is inspected, so they carry the target metadata instead of a socket.
#[derive(Debug)]
enum Socks5TcpConnection {
Direct(TcpStream),
Mitm {
target: HostWithPort,
mode: NetworkMode,
mitm: Arc<mitm::MitmState>,
extensions: Extensions,
},
}
impl AsyncRead for Socks5TcpConnection {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Direct(stream) => Pin::new(stream).poll_read(cx, buf),
Self::Mitm { .. } => Poll::Ready(Ok(())),
}
}
}
impl AsyncWrite for Socks5TcpConnection {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
match self.get_mut() {
Self::Direct(stream) => Pin::new(stream).poll_write(cx, buf),
Self::Mitm { .. } => Poll::Ready(Ok(buf.len())),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Direct(stream) => Pin::new(stream).poll_flush(cx),
Self::Mitm { .. } => Poll::Ready(Ok(())),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Direct(stream) => Pin::new(stream).poll_shutdown(cx),
Self::Mitm { .. } => Poll::Ready(Ok(())),
}
}
}
impl Socket for Socks5TcpConnection {
fn local_addr(&self) -> io::Result<SocketAddr> {
match self {
Self::Direct(stream) => stream.local_addr(),
Self::Mitm { .. } => Ok(SocketAddr::from(([0, 0, 0, 0], 0))),
}
}
fn peer_addr(&self) -> io::Result<SocketAddr> {
match self {
Self::Direct(stream) => stream.peer_addr(),
Self::Mitm { .. } => Ok(SocketAddr::from(([0, 0, 0, 0], 0))),
}
}
}
impl ExtensionsRef for Socks5TcpConnection {
fn extensions(&self) -> &Extensions {
match self {
Self::Direct(stream) => stream.extensions(),
Self::Mitm { extensions, .. } => extensions,
}
}
}
impl ExtensionsMut for Socks5TcpConnection {
fn extensions_mut(&mut self) -> &mut Extensions {
match self {
Self::Direct(stream) => stream.extensions_mut(),
Self::Mitm { extensions, .. } => extensions,
}
}
}
async fn proxy_socks5_tcp(
request: ProxyRequest<TcpStream, Socks5TcpConnection>,
) -> Result<(), BoxError> {
let ProxyRequest { mut source, target } = request;
match target {
Socks5TcpConnection::Direct(target) => StreamForwardService::default()
.serve(ProxyRequest { source, target })
.await
.map_err(Into::into),
Socks5TcpConnection::Mitm {
target, mode, mitm, ..
} => {
source.extensions_mut().insert(ProxyTarget(target));
source.extensions_mut().insert(mode);
source.extensions_mut().insert(mitm);
mitm::mitm_stream(source).await.map_err(Into::into)
}
}
}
async fn inspect_socks5_udp(
request: RelayRequest,
state: Arc<NetworkProxyState>,
@@ -554,7 +715,6 @@ mod tests {
use crate::network_policy::test_support::find_event_by_name;
use crate::runtime::ConfigReloader;
use crate::runtime::ConfigState;
use crate::runtime::network_proxy_state_for_policy;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use async_trait::async_trait;
@@ -567,6 +727,11 @@ mod tests {
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::sync::Arc;
use std::sync::Mutex;
// Managed MITM CA files live under the shared test CODEX_HOME, so MITM-enabled config state
// must be materialized one test at a time.
static MITM_CONFIG_STATE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Clone)]
struct StaticReloader {
@@ -590,6 +755,10 @@ mod tests {
fn state_for_settings(network: NetworkProxySettings) -> Arc<NetworkProxyState> {
let config = NetworkProxyConfig { network };
let _mitm_config_state_guard = config
.network
.mitm
.then(|| MITM_CONFIG_STATE_LOCK.lock().unwrap());
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
let reloader = Arc::new(StaticReloader {
state: state.clone(),
@@ -639,43 +808,56 @@ mod tests {
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_blocks_hooked_host_in_full_mode() {
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
async fn handle_socks5_tcp_uses_mitm_in_limited_mode() {
let mut settings = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
mode: NetworkMode::Limited,
mitm: true,
mitm_hooks: vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["GET".to_string()],
path_prefixes: vec!["/".to_string()],
..MitmHookMatchConfig::default()
},
..MitmHookConfig::default()
}],
..NetworkProxySettings::default()
}));
};
settings.set_allowed_domains(vec!["example.com".to_string()]);
let state = state_for_settings(settings);
let mut request =
TcpRequest::new(HostWithPort::try_from("api.github.com:443").expect("valid authority"));
TcpRequest::new(HostWithPort::try_from("example.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let result = handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state),
/*policy_decider*/ None,
)
.await
.expect("limited-mode HTTPS should use MITM");
assert!(matches!(result.conn, Socks5TcpConnection::Mitm { .. }));
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_blocks_non_https_in_limited_mode() {
let mut settings = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Limited,
..NetworkProxySettings::default()
};
settings.set_allowed_domains(vec!["example.com".to_string()]);
let state = state_for_settings(settings);
let mut request =
TcpRequest::new(HostWithPort::try_from("example.com:80").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let (result, events) = capture_events(|| async {
handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state.clone()),
TargetCheckedTcpConnector::new(state),
/*policy_decider*/ None,
)
.await
})
.await;
assert!(result.is_err(), "hooked host should require MITM");
let blocked = state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_REQUIRED);
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
assert_eq!(blocked[0].protocol, "socks5");
assert!(
result.is_err(),
"limited-mode non-HTTPS SOCKS should be denied"
);
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
@@ -684,18 +866,116 @@ mod tests {
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_MITM_REQUIRED)
Some(REASON_METHOD_NOT_ALLOWED)
);
assert_eq!(
event.field("network.transport.protocol"),
Some("socks5_tcp")
);
assert_eq!(event.field("server.address"), Some("api.github.com"));
assert_eq!(event.field("server.port"), Some("443"));
assert_eq!(event.field("server.address"), Some("example.com"));
assert_eq!(event.field("server.port"), Some("80"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_blocks_limited_mode_without_mitm_state() {
let mut settings = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Limited,
..NetworkProxySettings::default()
};
settings.set_allowed_domains(vec!["example.com".to_string()]);
let state = state_for_settings(settings);
let mut request =
TcpRequest::new(HostWithPort::try_from("example.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let err = handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state),
/*policy_decider*/ None,
)
.await
.expect_err("limited-mode HTTPS requires MITM");
assert!(
format!("{err:?}").contains("MITM required"),
"unexpected error: {err:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_uses_mitm_for_hooked_host_in_full_mode() {
let mut settings = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
mitm: true,
mitm_hooks: vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..MitmHookMatchConfig::default()
},
..MitmHookConfig::default()
}],
..NetworkProxySettings::default()
};
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
let state = state_for_settings(settings);
let mut request =
TcpRequest::new(HostWithPort::try_from("api.github.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let result = handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state),
/*policy_decider*/ None,
)
.await
.expect("hooked HTTPS should use MITM");
assert!(matches!(result.conn, Socks5TcpConnection::Mitm { .. }));
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_blocks_hooked_non_https_host_in_full_mode() {
let mut settings = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
mitm: true,
mitm_hooks: vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..MitmHookMatchConfig::default()
},
..MitmHookConfig::default()
}],
..NetworkProxySettings::default()
};
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
let state = state_for_settings(settings);
let mut request =
TcpRequest::new(HostWithPort::try_from("api.github.com:80").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let err = handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state),
/*policy_decider*/ None,
)
.await
.expect_err("hooked non-HTTPS SOCKS should require MITM");
assert!(
format!("{err:?}").contains("MITM required"),
"unexpected error: {err:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() {
let state = state_for_settings(NetworkProxySettings {
+10 -1
View File
@@ -141,7 +141,16 @@ INSERT INTO thread_goals (
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)
ON CONFLICT(thread_id) DO NOTHING
ON CONFLICT(thread_id) DO UPDATE SET
goal_id = excluded.goal_id,
objective = excluded.objective,
status = excluded.status,
token_budget = excluded.token_budget,
tokens_used = 0,
time_used_seconds = 0,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms
WHERE thread_goals.status = 'complete'
RETURNING
thread_id,
goal_id,
+1
View File
@@ -1,4 +1,5 @@
Generate a file named AGENTS.md that serves as a contributor guide for this repository.
Before writing, check whether AGENTS.md already exists in the current working directory. If it does, do not overwrite or modify it.
Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.
Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project.
+5 -1
View File
@@ -58,7 +58,6 @@ use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::diff_model::FileChange;
use crate::git_action_directives::parse_assistant_markdown;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::Constrained;
use crate::legacy_core::config::ConstraintResult;
@@ -257,6 +256,11 @@ fn queued_message_edit_hint_binding(
.or_else(|| bindings.first().copied())
}
fn normalize_thread_name(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::PermissionProfileSelection;
+1 -1
View File
@@ -302,7 +302,7 @@ impl ChatWidget {
/*initial_text*/ existing_name.unwrap_or_default().to_string(),
/*context_label*/ None,
Box::new(move |name: String| {
let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else {
let Some(name) = normalize_thread_name(&name) else {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
)));
@@ -213,14 +213,6 @@ impl ChatWidget {
.send(AppEvent::OpenDesktopThread { thread_id });
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
if init_target.exists() {
let message = format!(
"{DEFAULT_AGENTS_MD_FILENAME} already exists here. Skipping /init to avoid overwriting it."
);
self.add_info_message(message, /*hint*/ None);
return;
}
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
}
@@ -662,7 +654,7 @@ impl ChatWidget {
}
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some(name) = crate::legacy_core::util::normalize_thread_name(&args) else {
let Some(name) = normalize_thread_name(&args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
@@ -565,35 +565,14 @@ async fn ctrl_d_with_modal_open_does_not_quit() {
}
#[tokio::test]
async fn slash_init_skips_when_project_doc_exists() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let tempdir = tempdir().unwrap();
let existing_path = tempdir.path().join(DEFAULT_AGENTS_MD_FILENAME);
std::fs::write(&existing_path, "existing instructions").unwrap();
chat.config.cwd = tempdir.path().to_path_buf().abs();
async fn slash_init_does_not_depend_on_loaded_instruction_sources() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")];
submit_composer_text(&mut chat, "/init");
match op_rx.try_recv() {
Err(TryRecvError::Empty) => {}
other => panic!("expected no Codex op to be sent, got {other:?}"),
}
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(DEFAULT_AGENTS_MD_FILENAME),
"info message should mention the existing file: {rendered:?}"
);
assert!(
rendered.contains("Skipping /init"),
"info message should explain why /init was skipped: {rendered:?}"
);
assert_eq!(
std::fs::read_to_string(existing_path).unwrap(),
"existing instructions"
);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert!(drain_insert_history(&mut rx).is_empty());
assert_eq!(recall_latest_after_clearing(&mut chat), "/init");
}
+5 -7
View File
@@ -185,8 +185,6 @@ fn title_case(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
use crate::legacy_core::LOCAL_AGENTS_MD_FILENAME;
use crate::legacy_core::config::ConfigBuilder;
use codex_utils_absolute_path::test_support::PathBufExt;
use pretty_assertions::assert_eq;
@@ -227,7 +225,7 @@ mod tests {
async fn compose_agents_summary_includes_global_agents_path() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let global_agents_path = codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME);
let global_agents_path = codex_home.path().join("global.md");
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
@@ -240,7 +238,7 @@ mod tests {
async fn compose_agents_summary_names_global_agents_override() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME);
let override_path = codex_home.path().join("override.md");
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
@@ -253,8 +251,8 @@ mod tests {
async fn compose_agents_summary_orders_global_before_project_agents() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let global_agents_path = codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME);
let project_agents_path = cwd.path().join(DEFAULT_AGENTS_MD_FILENAME);
let global_agents_path = codex_home.path().join("global.md");
let project_agents_path = cwd.path().join("project.md");
let config = test_config(&codex_home, &cwd).await;
let summary = compose_agents_summary(
@@ -270,7 +268,7 @@ mod tests {
Some(format_directory_display(&global_agents_path, /*max_width*/ None).as_str())
);
let project_path = paths.next().expect("project agents path");
assert!(project_path.ends_with(DEFAULT_AGENTS_MD_FILENAME));
assert!(project_path.ends_with("project.md"));
assert_eq!(paths.next(), None);
}
}