initial commit

This commit is contained in:
kevin zhao
2025-10-15 10:34:18 -07:00
parent 26f7c46856
commit 6b05624040
8 changed files with 397 additions and 3 deletions

109
codex-rs/Cargo.lock generated
View File

@@ -1056,6 +1056,7 @@ dependencies = [
"libc",
"maplit",
"mcp-types",
"notify",
"openssl-sys",
"os_info",
"portable-pty",
@@ -1645,7 +1646,7 @@ dependencies = [
"bitflags 2.9.1",
"crossterm_winapi",
"futures-core",
"mio",
"mio 1.0.4",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
@@ -2299,6 +2300,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.60.2",
]
[[package]]
name = "fixed_decimal"
version = "0.7.0"
@@ -2371,6 +2384,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -3057,6 +3079,26 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -3257,6 +3299,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "lalrpop"
version = "0.19.12"
@@ -3334,6 +3396,7 @@ checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall",
]
[[package]]
@@ -3534,6 +3597,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.4"
@@ -3656,6 +3731,25 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.9.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
@@ -5380,7 +5474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio",
"mio 1.0.4",
"signal-hook",
]
@@ -5948,7 +6042,7 @@ dependencies = [
"bytes",
"io-uring",
"libc",
"mio",
"mio 1.0.4",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -6846,6 +6940,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"

View File

@@ -76,6 +76,7 @@ codex-utils-string = { path = "utils/string" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
notify = "6"
# External
allocative = "0.3.3"

View File

@@ -35,6 +35,7 @@ futures = { workspace = true }
indexmap = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
notify = { workspace = true }
os_info = { workspace = true }
portable-pty = { workspace = true }
rand = { workspace = true }

View File

@@ -57,6 +57,7 @@ use crate::exec_command::WriteStdinParams;
use crate::executor::Executor;
use crate::executor::ExecutorConfig;
use crate::executor::normalize_exec_result;
use crate::file_change_notifier::FileChangeWatcher;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_family::find_family_for_model;
@@ -462,6 +463,14 @@ impl Session {
is_review_mode: false,
final_output_json_schema: None,
};
let (file_change_watcher, file_change_collector) =
match FileChangeWatcher::start(turn_context.cwd.clone()) {
Ok((watcher, collector)) => (Some(watcher), Some(collector)),
Err(err) => {
warn!("file change notifications disabled: {err:#}");
(None, None)
}
};
let services = SessionServices {
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
@@ -475,6 +484,8 @@ impl Session {
turn_context.cwd.clone(),
config.codex_linux_sandbox_exe.clone(),
)),
file_change_collector,
_file_change_watcher: file_change_watcher,
};
let sess = Arc::new(Session {
@@ -729,6 +740,7 @@ impl Session {
Some(turn_context.approval_policy),
Some(turn_context.sandbox_policy.clone()),
Some(self.user_shell().clone()),
None,
)));
items
}
@@ -986,6 +998,13 @@ impl Session {
self.send_event(event).await;
}
pub(crate) async fn take_changed_files(&self) -> Vec<PathBuf> {
match &self.services.file_change_collector {
Some(collector) => collector.drain().await,
None => Vec::new(),
}
}
async fn notify_stream_error(&self, sub_id: &str, message: impl Into<String>) {
let event = Event {
id: sub_id.to_string(),
@@ -1227,6 +1246,7 @@ async fn submission_loop(
sandbox_policy,
// Shell is not configurable from turn to turn
None,
None,
))])
.await;
}
@@ -1659,6 +1679,22 @@ pub(crate) async fn run_task(
} else {
sess.record_input_and_rollout_usermsg(&initial_input_for_turn)
.await;
let changed_files = sess.take_changed_files().await;
if !changed_files.is_empty() {
let response_item: ResponseItem =
EnvironmentContext::new(None, None, None, None, Some(changed_files)).into();
sess.record_conversation_items(std::slice::from_ref(&response_item))
.await;
for msg in
map_response_item_to_event_messages(&response_item, sess.show_raw_agent_reasoning())
{
let event = Event {
id: sub_id.clone(),
msg,
};
sess.send_event(event).await;
}
}
}
let mut last_agent_message: Option<String> = None;
@@ -2450,6 +2486,7 @@ mod tests {
use super::*;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::file_change_notifier::collector_for_tests;
use crate::protocol::CompactedItem;
use crate::protocol::InitialHistory;
@@ -2780,6 +2817,8 @@ mod tests {
turn_context.cwd.clone(),
None,
)),
file_change_collector: None,
_file_change_watcher: None,
};
let session = Session {
conversation_id,
@@ -2853,6 +2892,8 @@ mod tests {
config.cwd.clone(),
None,
)),
file_change_collector: None,
_file_change_watcher: None,
};
let session = Arc::new(Session {
conversation_id,
@@ -3004,6 +3045,46 @@ mod tests {
}
}
#[tokio::test]
async fn run_task_emits_environment_context_with_changed_files() {
let (mut session, turn_context) = make_session_and_context();
let collector = collector_for_tests(turn_context.cwd.clone());
collector
.record_for_tests(PathBuf::from("tracked.rs"))
.await;
session.services.file_change_collector = Some(collector);
session.services._file_change_watcher = None;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let input = vec![InputItem::Text {
text: "hello".to_string(),
}];
let _ = run_task(
Arc::clone(&session),
Arc::clone(&turn_context),
"sub".to_string(),
input,
)
.await;
let history = session.history_snapshot().await;
let mut found = false;
for item in history {
if let ResponseItem::Message { content, .. } = item {
for part in content {
if let ContentItem::InputText { text } = part
&& text.contains("<changed_files>")
&& text.contains("tracked.rs")
{
found = true;
}
}
}
}
assert!(found, "missing changed_files environment context");
}
fn sample_rollout(
session: &Session,
turn_context: &TurnContext,

View File

@@ -29,6 +29,7 @@ pub(crate) struct EnvironmentContext {
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub shell: Option<Shell>,
pub changed_files: Option<Vec<PathBuf>>,
}
impl EnvironmentContext {
@@ -37,6 +38,7 @@ impl EnvironmentContext {
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
shell: Option<Shell>,
changed_files: Option<Vec<PathBuf>>,
) -> Self {
Self {
cwd,
@@ -70,6 +72,8 @@ impl EnvironmentContext {
_ => None,
},
shell,
changed_files: changed_files
.and_then(|paths| if paths.is_empty() { None } else { Some(paths) }),
}
}
@@ -85,6 +89,7 @@ impl EnvironmentContext {
writable_roots,
// should compare all fields except shell
shell: _,
changed_files,
} = other;
self.cwd == *cwd
@@ -92,6 +97,7 @@ impl EnvironmentContext {
&& self.sandbox_mode == *sandbox_mode
&& self.network_access == *network_access
&& self.writable_roots == *writable_roots
&& self.changed_files == *changed_files
}
}
@@ -103,6 +109,7 @@ impl From<&TurnContext> for EnvironmentContext {
Some(turn_context.sandbox_policy.clone()),
// Shell is not configurable from turn to turn
None,
None,
)
}
}
@@ -155,6 +162,14 @@ impl EnvironmentContext {
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
if let Some(changed_files) = &self.changed_files
&& !changed_files.is_empty() {
lines.push(" <changed_files>".to_string());
for path in changed_files {
lines.push(format!(" <path>{}</path>", path.to_string_lossy()));
}
lines.push(" </changed_files>".to_string());
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
@@ -196,6 +211,7 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
None,
None,
);
let expected = r#"<environment_context>
@@ -219,6 +235,7 @@ mod tests {
Some(AskForApproval::Never),
Some(SandboxPolicy::ReadOnly),
None,
None,
);
let expected = r#"<environment_context>
@@ -237,6 +254,7 @@ mod tests {
Some(AskForApproval::OnFailure),
Some(SandboxPolicy::DangerFullAccess),
None,
None,
);
let expected = r#"<environment_context>
@@ -256,12 +274,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::Never),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
}
@@ -273,12 +293,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_read_only_policy()),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_workspace_write_policy()),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -291,12 +313,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -312,6 +336,7 @@ mod tests {
shell_path: "/bin/bash".into(),
bashrc_path: "/home/user/.bashrc".into(),
})),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -321,6 +346,7 @@ mod tests {
shell_path: "/bin/zsh".into(),
zshrc_path: "/home/user/.zshrc".into(),
})),
None,
);
assert!(context1.equals_except_shell(&context2));

View File

@@ -0,0 +1,177 @@
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use dunce::canonicalize;
use notify::Config;
use notify::Event;
use notify::EventKind;
use notify::RecommendedWatcher;
use notify::RecursiveMode;
use notify::Watcher;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::warn;
#[derive(Clone)]
pub(crate) struct FileChangeCollector {
root: Arc<PathBuf>,
changes: Arc<Mutex<HashSet<PathBuf>>>,
}
impl FileChangeCollector {
fn new(root: PathBuf) -> Self {
Self {
root: Arc::new(root),
changes: Arc::new(Mutex::new(HashSet::new())),
}
}
fn root_path(&self) -> &Path {
self.root.as_ref()
}
pub(crate) async fn record_event(&self, event: Event) {
let Event { kind, paths, .. } = event;
if should_skip(kind) {
return;
}
let mut normalized_paths = Vec::new();
for path in paths {
if let Some(rel) = self.normalize_path(&path) {
normalized_paths.push(rel);
}
}
if normalized_paths.is_empty() {
return;
}
let mut guard = self.changes.lock().await;
guard.extend(normalized_paths);
}
fn normalize_path(&self, path: &Path) -> Option<PathBuf> {
let rel = path.strip_prefix(self.root_path()).ok()?;
if should_ignore(rel) {
return None;
}
Some(rel.to_path_buf())
}
pub(crate) async fn drain(&self) -> Vec<PathBuf> {
let mut guard = self.changes.lock().await;
let mut entries: Vec<PathBuf> = guard.drain().collect();
entries.sort();
entries
}
}
fn should_skip(kind: EventKind) -> bool {
matches!(
kind,
EventKind::Access(_) | EventKind::Other | EventKind::Any
)
}
fn should_ignore(path: &Path) -> bool {
path.components().any(|component| {
if let std::path::Component::Normal(part) = component {
matches!(
part.to_str(),
Some(".git") | Some(".codex") | Some("target") | Some(".DS_Store") | Some(".idea")
)
} else {
false
}
})
}
pub(crate) struct FileChangeWatcher {
_watcher: RecommendedWatcher,
task: JoinHandle<()>,
}
impl FileChangeWatcher {
pub(crate) fn start(root: PathBuf) -> Result<(Self, FileChangeCollector)> {
let canonical_root = canonicalize(&root)
.with_context(|| format!("failed to canonicalize {}", root.display()))?;
let collector = FileChangeCollector::new(canonical_root.clone());
let (tx, mut rx) = mpsc::unbounded_channel();
let mut watcher = RecommendedWatcher::new(
move |res| {
let _ = tx.send(res);
},
Config::default(),
)
.context("failed to initialize file watcher")?;
watcher
.watch(&canonical_root, RecursiveMode::Recursive)
.with_context(|| format!("failed to watch path {}", canonical_root.display()))?;
let collector_clone = collector.clone();
let task = tokio::spawn(async move {
while let Some(event_result) = rx.recv().await {
match event_result {
Ok(event) => collector_clone.record_event(event).await,
Err(err) => warn!("file watcher error: {err}"),
}
}
});
Ok((
Self {
_watcher: watcher,
task,
},
collector,
))
}
}
impl Drop for FileChangeWatcher {
fn drop(&mut self) {
if self.task.is_finished() {
return;
}
self.task.abort();
}
}
#[cfg(test)]
pub(crate) fn collector_for_tests(root: PathBuf) -> FileChangeCollector {
FileChangeCollector::new(root)
}
#[cfg(test)]
impl FileChangeCollector {
pub(crate) async fn record_for_tests(&self, path: PathBuf) {
let mut guard = self.changes.lock().await;
guard.insert(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn drain_returns_empty_when_no_changes() {
let collector = collector_for_tests(PathBuf::from("."));
let drained = collector.drain().await;
assert!(drained.is_empty());
}
#[tokio::test]
async fn record_and_drain_changes() {
let collector = collector_for_tests(PathBuf::from("/tmp/root"));
collector.record_for_tests(PathBuf::from("file.txt")).await;
let drained = collector.drain().await;
assert_eq!(drained, vec![PathBuf::from("file.txt")]);
}
}

View File

@@ -29,6 +29,7 @@ pub mod exec;
mod exec_command;
pub mod exec_env;
pub mod executor;
mod file_change_notifier;
mod flags;
pub mod git_info;
pub mod landlock;

View File

@@ -1,6 +1,8 @@
use crate::RolloutRecorder;
use crate::exec_command::ExecSessionManager;
use crate::executor::Executor;
use crate::file_change_notifier::FileChangeCollector;
use crate::file_change_notifier::FileChangeWatcher;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_notification::UserNotifier;
@@ -15,4 +17,6 @@ pub(crate) struct SessionServices {
pub(crate) user_shell: crate::shell::Shell,
pub(crate) show_raw_agent_reasoning: bool,
pub(crate) executor: Executor,
pub(crate) file_change_collector: Option<FileChangeCollector>,
pub(crate) _file_change_watcher: Option<FileChangeWatcher>,
}