mirror of
https://github.com/openai/codex.git
synced 2026-05-22 03:54:18 +00:00
Compare commits
23 Commits
starr/fix-
...
starr/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf36c3589c | ||
|
|
0e9b65531c | ||
|
|
4da2285b22 | ||
|
|
42294865a9 | ||
|
|
3548e1f7b1 | ||
|
|
1165e651fe | ||
|
|
bf623de44b | ||
|
|
e0cda247aa | ||
|
|
78cdba69c2 | ||
|
|
e0554f5167 | ||
|
|
d19daff01d | ||
|
|
aaefec794f | ||
|
|
c324297099 | ||
|
|
f719c0aa49 | ||
|
|
0134886aa8 | ||
|
|
20a5b436c7 | ||
|
|
bddaabf885 | ||
|
|
4473690d54 | ||
|
|
9cd7251919 | ||
|
|
c98047f9b8 | ||
|
|
35217f2dd9 | ||
|
|
a6bd9c46de | ||
|
|
3c4b381235 |
@@ -1726,16 +1726,49 @@ async fn non_git_repo_skills_search_does_not_walk_parents() {
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
write_skill_at(
|
||||
&outer_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
&outer_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"outer",
|
||||
"outer-skill",
|
||||
"from outer",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
let mut system_config = toml::map::Map::new();
|
||||
system_config.insert(
|
||||
"project_root_markers".to_string(),
|
||||
TomlValue::Array(vec![TomlValue::String(
|
||||
"__codex_test_project_root_marker_that_does_not_exist__".to_string(),
|
||||
)]),
|
||||
);
|
||||
let user_config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let system_config_path = codex_home.path().join("etc/codex/config.toml");
|
||||
fs::create_dir_all(
|
||||
system_config_path
|
||||
.parent()
|
||||
.expect("system config path should have a parent"),
|
||||
)
|
||||
.expect("create fake system config dir");
|
||||
let cfg = TestConfig {
|
||||
cwd: nested_dir.abs(),
|
||||
config_layer_stack: ConfigLayerStack::new(
|
||||
vec![
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: config_file(system_config_path),
|
||||
},
|
||||
TomlValue::Table(system_config),
|
||||
),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: config_file(user_config_path),
|
||||
},
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
),
|
||||
],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack"),
|
||||
};
|
||||
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -67,8 +68,10 @@ use codex_analytics::InvocationType;
|
||||
use codex_analytics::TurnResolvedConfigFact;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_features::Feature;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_git_utils::get_git_repo_root_with_fs;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
@@ -100,6 +103,7 @@ use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
use codex_utils_stream_parser::AssistantTextStreamParser;
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -367,8 +371,11 @@ pub(crate) async fn run_turn(
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
#[allow(deprecated)]
|
||||
let display_root = get_git_repo_root(turn_context.cwd.as_path())
|
||||
.unwrap_or_else(|| turn_context.cwd.clone().into_path_buf());
|
||||
let display_root = turn_diff_display_root(
|
||||
&turn_context.cwd,
|
||||
turn_context.environments.primary_filesystem(),
|
||||
)
|
||||
.await;
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root(
|
||||
display_root,
|
||||
)));
|
||||
@@ -2262,6 +2269,19 @@ async fn try_run_sampling_request(
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn turn_diff_display_root(
|
||||
cwd: &AbsolutePathBuf,
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> PathBuf {
|
||||
match fs {
|
||||
Some(fs) => get_git_repo_root_with_fs(fs.as_ref(), cwd)
|
||||
.await
|
||||
.map(AbsolutePathBuf::into_path_buf),
|
||||
None => get_git_repo_root(cwd.as_path()),
|
||||
}
|
||||
.unwrap_or_else(|| cwd.clone().into_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
|
||||
for item in responses.iter().rev() {
|
||||
if let Some(message) = last_assistant_message_from_item(item, /*plan_mode*/ false) {
|
||||
@@ -2270,3 +2290,29 @@ pub(crate) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_diff_display_root_uses_selected_filesystem_or_local_fallback() {
|
||||
let repo = tempfile::tempdir().expect("repo tempdir");
|
||||
std::fs::create_dir(repo.path().join(".git")).expect("create git dir");
|
||||
let nested = repo.path().join("nested");
|
||||
std::fs::create_dir(&nested).expect("create nested dir");
|
||||
let nested = AbsolutePathBuf::try_from(nested).expect("absolute nested path");
|
||||
let repo_root = repo.path().to_path_buf();
|
||||
|
||||
assert_eq!(
|
||||
turn_diff_display_root(&nested, Some(Arc::clone(&LOCAL_FS))).await,
|
||||
repo_root
|
||||
);
|
||||
assert_eq!(
|
||||
turn_diff_display_root(&nested, /*fs*/ None).await,
|
||||
repo_root
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,11 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
|
||||
let command =
|
||||
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
.map_err(|_| {
|
||||
ToolError::Codex(CodexErr::InvalidRequest(
|
||||
"missing command line for PTY".into(),
|
||||
))
|
||||
})?;
|
||||
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
|
||||
let mut exec_env = attempt
|
||||
.env_for(command, options, managed_network)
|
||||
@@ -319,7 +323,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
network_policy_decision: None,
|
||||
}))
|
||||
}
|
||||
other => ToolError::Rejected(other.to_string()),
|
||||
other => ToolError::Codex(CodexErr::Fatal(other.to_string())),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
@@ -331,7 +335,11 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
}
|
||||
let command =
|
||||
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
.map_err(|_| {
|
||||
ToolError::Codex(CodexErr::InvalidRequest(
|
||||
"missing command line for PTY".into(),
|
||||
))
|
||||
})?;
|
||||
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
|
||||
let mut exec_env = attempt
|
||||
.env_for(command, options, managed_network)
|
||||
@@ -353,7 +361,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
network_policy_decision: None,
|
||||
}))
|
||||
}
|
||||
other => ToolError::Rejected(other.to_string()),
|
||||
other => ToolError::Codex(CodexErr::Fatal(other.to_string())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub(crate) enum UnifiedExecError {
|
||||
StdinClosed,
|
||||
#[error("missing command line for unified exec request")]
|
||||
MissingCommandLine,
|
||||
#[error("{message}")]
|
||||
Rejected { message: String },
|
||||
#[error("Command denied by sandbox: {message}")]
|
||||
SandboxDenied {
|
||||
message: String,
|
||||
@@ -34,6 +36,10 @@ impl UnifiedExecError {
|
||||
Self::ProcessFailed { message }
|
||||
}
|
||||
|
||||
pub(crate) fn rejected(message: String) -> Self {
|
||||
Self::Rejected { message }
|
||||
}
|
||||
|
||||
pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self {
|
||||
Self::SandboxDenied { message, output }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::sandboxing::ExecServerEnvConfig;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::network_approval::DeferredNetworkApproval;
|
||||
use crate::tools::network_approval::finish_deferred_network_approval;
|
||||
@@ -372,27 +373,6 @@ impl UnifiedExecProcessManager {
|
||||
context: &UnifiedExecContext,
|
||||
) -> Result<ExecCommandToolOutput, UnifiedExecError> {
|
||||
let cwd = request.cwd.clone();
|
||||
let process = self
|
||||
.open_session_with_sandbox(&request, cwd.clone(), context)
|
||||
.await;
|
||||
|
||||
let (process, mut deferred_network_approval) = match process {
|
||||
Ok((process, deferred_network_approval)) => {
|
||||
(Arc::new(process), deferred_network_approval)
|
||||
}
|
||||
Err(err) => {
|
||||
self.release_process_id(request.process_id).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if let Some(deferred) = deferred_network_approval.as_ref() {
|
||||
terminate_process_on_network_denial(
|
||||
Arc::clone(&process),
|
||||
Arc::downgrade(&context.session),
|
||||
deferred.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
context.session.as_ref(),
|
||||
@@ -406,7 +386,42 @@ impl UnifiedExecProcessManager {
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
Some(request.process_id.to_string()),
|
||||
);
|
||||
|
||||
let process = self
|
||||
.open_session_with_sandbox(&request, cwd.clone(), context)
|
||||
.await;
|
||||
|
||||
let (process, mut deferred_network_approval) = match process {
|
||||
Ok((process, deferred_network_approval)) => {
|
||||
(Arc::new(process), deferred_network_approval)
|
||||
}
|
||||
Err(err) => {
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
let failure = match &err {
|
||||
UnifiedExecError::Rejected { message } => ToolEventFailure::Rejected {
|
||||
message: message.clone(),
|
||||
applied_patch_delta: None,
|
||||
},
|
||||
UnifiedExecError::SandboxDenied { output, .. } => {
|
||||
ToolEventFailure::Output(output.clone())
|
||||
}
|
||||
_ => ToolEventFailure::Message(format!("execution error: {err:?}")),
|
||||
};
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Failure(failure))
|
||||
.await;
|
||||
self.release_process_id(request.process_id).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
if let Some(deferred) = deferred_network_approval.as_ref() {
|
||||
terminate_process_on_network_denial(
|
||||
Arc::clone(&process),
|
||||
Arc::downgrade(&context.session),
|
||||
deferred.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
start_streaming_output(&process, context, Arc::clone(&transcript));
|
||||
let start = Instant::now();
|
||||
@@ -1085,6 +1100,7 @@ impl UnifiedExecProcessManager {
|
||||
};
|
||||
UnifiedExecError::sandbox_denied(message, output)
|
||||
}
|
||||
ToolError::Rejected(message) => UnifiedExecError::rejected(message),
|
||||
other => UnifiedExecError::create_process(format!("{other:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,6 +231,73 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_failure_emits_begin_then_failed_end() {
|
||||
let (session, turn, rx_event) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let context = UnifiedExecContext::new(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
"call-unified-startup-failure".to_string(),
|
||||
);
|
||||
let process_id = session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.allocate_process_id()
|
||||
.await;
|
||||
let invalid_remote = Arc::new(
|
||||
codex_exec_server::Environment::create_for_tests(Some("not-a-websocket-url".to_string()))
|
||||
.expect("remote test environment"),
|
||||
);
|
||||
#[allow(deprecated)]
|
||||
let request = ExecCommandRequest {
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
hook_command: String::new(),
|
||||
process_id,
|
||||
yield_time_ms: 1000,
|
||||
max_output_tokens: None,
|
||||
cwd: turn.cwd.clone(),
|
||||
sandbox_cwd: turn.cwd.clone(),
|
||||
environment: invalid_remote,
|
||||
network: None,
|
||||
tty: true,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
};
|
||||
|
||||
let err = session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.exec_command(request, &context)
|
||||
.await
|
||||
.expect_err("unreachable remote exec-server should fail startup");
|
||||
assert!(matches!(err, UnifiedExecError::CreateProcess { .. }));
|
||||
|
||||
let begin_event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("timed out waiting for begin event")
|
||||
.expect("event channel closed");
|
||||
let codex_protocol::protocol::EventMsg::ExecCommandBegin(begin_event) = begin_event.msg else {
|
||||
panic!("expected ExecCommandBegin event");
|
||||
};
|
||||
assert_eq!(begin_event.call_id, "call-unified-startup-failure");
|
||||
|
||||
let end_event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("timed out waiting for end event")
|
||||
.expect("event channel closed");
|
||||
let codex_protocol::protocol::EventMsg::ExecCommandEnd(end_event) = end_event.msg else {
|
||||
panic!("expected ExecCommandEnd event");
|
||||
};
|
||||
assert_eq!(end_event.call_id, "call-unified-startup-failure");
|
||||
assert_eq!(
|
||||
end_event.status,
|
||||
codex_protocol::protocol::ExecCommandStatus::Failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_prefers_exited_processes_outside_recently_used() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::get_remote_test_env;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
@@ -67,6 +68,15 @@ async fn apply_patch_harness_with(
|
||||
Box::pin(TestCodexHarness::with_remote_aware_builder(builder)).await
|
||||
}
|
||||
|
||||
async fn local_apply_patch_harness_with(
|
||||
configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder,
|
||||
) -> Result<TestCodexHarness> {
|
||||
let builder = configure(test_codex()).with_config(|config| {
|
||||
config.include_apply_patch_tool = true;
|
||||
});
|
||||
Box::pin(TestCodexHarness::with_builder(builder)).await
|
||||
}
|
||||
|
||||
async fn submit_without_wait(harness: &TestCodexHarness, prompt: &str) -> Result<()> {
|
||||
submit_without_wait_with_turn_permissions(
|
||||
harness,
|
||||
@@ -1358,40 +1368,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let harness = apply_patch_harness_with(|builder| {
|
||||
builder
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config.cwd = config.cwd.join("subdir");
|
||||
})
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
fs.create_directory(
|
||||
&cwd,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let repo_root = cwd.parent().expect("nested cwd should have parent");
|
||||
fs.write_file(
|
||||
&repo_root.join(".git"),
|
||||
b"gitdir: /tmp/fake-worktree\n".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(
|
||||
&repo_root.join("repo.txt"),
|
||||
b"before\n".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
async fn assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(
|
||||
harness: TestCodexHarness,
|
||||
) -> Result<()> {
|
||||
let test = harness.test();
|
||||
let codex = test.codex.clone();
|
||||
let repo_root = harness
|
||||
@@ -1437,6 +1416,54 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nested_repo_relative_diff_builder(builder: TestCodexBuilder) -> TestCodexBuilder {
|
||||
builder
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config.cwd = config.cwd.join("subdir");
|
||||
})
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
fs.create_directory(
|
||||
&cwd,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let repo_root = cwd.parent().expect("nested cwd should have parent");
|
||||
fs.write_file(
|
||||
&repo_root.join(".git"),
|
||||
b"gitdir: /tmp/fake-worktree\n".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(
|
||||
&repo_root.join("repo.txt"),
|
||||
b"before\n".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested() -> Result<()> {
|
||||
let harness = local_apply_patch_harness_with(nested_repo_relative_diff_builder).await?;
|
||||
assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(harness).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_turn_diff_paths_stay_repo_relative_for_remote_nested_session_cwd() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let harness = apply_patch_harness_with(nested_repo_relative_diff_builder).await?;
|
||||
assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(harness).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -38,6 +38,21 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
|
||||
find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root)
|
||||
}
|
||||
|
||||
/// Return the git repository root for `base_dir` using the provided executor
|
||||
/// filesystem. This is the remote-environment equivalent of [`get_git_repo_root`].
|
||||
pub async fn get_git_repo_root_with_fs(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let base = match fs.get_metadata(base_dir, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => base_dir.clone(),
|
||||
_ => base_dir.parent()?,
|
||||
};
|
||||
find_ancestor_git_entry_with_fs(fs, &base)
|
||||
.await
|
||||
.map(|(repo_root, _)| repo_root)
|
||||
}
|
||||
|
||||
/// Timeout for git commands to prevent freezing on large repositories
|
||||
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub use info::default_branch_name;
|
||||
pub use info::get_git_remote_urls;
|
||||
pub use info::get_git_remote_urls_assume_git_repo;
|
||||
pub use info::get_git_repo_root;
|
||||
pub use info::get_git_repo_root_with_fs;
|
||||
pub use info::get_has_changes;
|
||||
pub use info::get_head_commit_hash;
|
||||
pub use info::git_diff_to_remote;
|
||||
|
||||
@@ -188,12 +188,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn environment_id_fallback_has_cwd_prefix() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let env_id = environment_id_from_cwd(dir.path());
|
||||
let canonical = dir
|
||||
.path()
|
||||
let unique = tempfile::Builder::new()
|
||||
.prefix("codex-secrets-test-")
|
||||
.tempdir()
|
||||
.expect("tempdir");
|
||||
let cwd = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()).join(
|
||||
unique
|
||||
.path()
|
||||
.file_name()
|
||||
.expect("tempdir should have a file name"),
|
||||
);
|
||||
drop(unique);
|
||||
let env_id = environment_id_from_cwd(&cwd);
|
||||
let canonical = cwd
|
||||
.canonicalize()
|
||||
.expect("tempdir canonical path should exist")
|
||||
.unwrap_or_else(|_| cwd.clone())
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub(super) async fn test_config() -> Config {
|
||||
// Start from the built-in defaults so tests do not inherit host/system config.
|
||||
@@ -16,7 +18,7 @@ pub(super) async fn test_config() -> Config {
|
||||
config.codex_home = codex_home.abs();
|
||||
config.sqlite_home = codex_home.clone();
|
||||
config.log_dir = codex_home.join("log");
|
||||
config.cwd = PathBuf::from(test_path_display("/tmp/project")).abs();
|
||||
config.cwd = test_project_path().abs();
|
||||
config.config_layer_stack = ConfigLayerStack::default();
|
||||
config.startup_warnings.clear();
|
||||
config.user_instructions = None;
|
||||
@@ -24,7 +26,23 @@ pub(super) async fn test_config() -> Config {
|
||||
}
|
||||
|
||||
pub(super) fn test_project_path() -> PathBuf {
|
||||
PathBuf::from(test_path_display("/tmp/project"))
|
||||
let isolated_root = tempfile::Builder::new()
|
||||
.prefix("chatwidget-project-")
|
||||
.tempdir_in(std::env::temp_dir())
|
||||
.expect("tempdir")
|
||||
.keep();
|
||||
let project = isolated_root.join("project");
|
||||
std::fs::create_dir_all(project.join(".git")).expect("create test project git marker");
|
||||
isolated_test_project_paths()
|
||||
.lock()
|
||||
.expect("test project path registry")
|
||||
.push(project.clone());
|
||||
project
|
||||
}
|
||||
|
||||
fn isolated_test_project_paths() -> &'static Mutex<Vec<PathBuf>> {
|
||||
static TEST_PROJECT_PATHS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();
|
||||
TEST_PROJECT_PATHS.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
|
||||
@@ -37,6 +55,30 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
|
||||
pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
|
||||
let mut text = text.into();
|
||||
|
||||
for isolated_project in isolated_test_project_paths()
|
||||
.lock()
|
||||
.expect("test project path registry")
|
||||
.iter()
|
||||
{
|
||||
let isolated_project = isolated_project.to_string_lossy();
|
||||
text = text.replace(isolated_project.as_ref(), "/tmp/project");
|
||||
for isolated_prefix in truncated_path_variants(isolated_project.as_ref())
|
||||
.into_iter()
|
||||
.rev()
|
||||
{
|
||||
let normalized = if isolated_prefix.chars().count() >= "/tmp/project".chars().count() {
|
||||
"/tmp/project".to_string()
|
||||
} else {
|
||||
let unix_prefix: String = "/tmp/project"
|
||||
.chars()
|
||||
.take(isolated_prefix.chars().count())
|
||||
.collect();
|
||||
format!("{unix_prefix}…")
|
||||
};
|
||||
text = text.replace(&format!("{isolated_prefix}…"), &normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for unix_path in ["/tmp/project", "/tmp/hooks.json"] {
|
||||
let platform_path = test_path_display(unix_path);
|
||||
if platform_path != unix_path {
|
||||
@@ -64,13 +106,8 @@ pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
|
||||
}
|
||||
|
||||
pub(super) fn normalized_backend_snapshot<T: std::fmt::Display>(value: &T) -> String {
|
||||
let platform_test_cwd = test_path_display("/tmp/project");
|
||||
let rendered = format!("{value}");
|
||||
|
||||
if platform_test_cwd == "/tmp/project" {
|
||||
return rendered;
|
||||
}
|
||||
|
||||
rendered
|
||||
.lines()
|
||||
.map(|line| {
|
||||
|
||||
@@ -1917,10 +1917,10 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
|
||||
set_fast_mode_test_catalog(&mut chat);
|
||||
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
|
||||
chat.refresh_status_line();
|
||||
let test_cwd = test_path_display("/tmp/project");
|
||||
let test_cwd = normalize_snapshot_paths(test_path_display("/tmp/project"));
|
||||
|
||||
assert_eq!(
|
||||
status_line_text(&chat),
|
||||
status_line_text(&chat).map(normalize_snapshot_paths),
|
||||
Some(format!("gpt-5.4 xhigh fast · Context 0% used · {test_cwd}"))
|
||||
);
|
||||
|
||||
@@ -1928,7 +1928,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
|
||||
chat.refresh_status_line();
|
||||
|
||||
assert_eq!(
|
||||
status_line_text(&chat),
|
||||
status_line_text(&chat).map(normalize_snapshot_paths),
|
||||
Some(format!(
|
||||
"gpt-5.3-codex xhigh · Context 0% used · {test_cwd}"
|
||||
))
|
||||
|
||||
@@ -105,6 +105,7 @@ async fn status_line_setup_popup_live_only_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn status_surface_preview_lines_hardcoded_only_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
cache_project_root(&mut chat, "my-project");
|
||||
|
||||
let snapshot = combined_preview_snapshot(
|
||||
&mut chat,
|
||||
@@ -144,6 +145,7 @@ async fn thread_title_falls_back_to_thread_id_when_unnamed() {
|
||||
#[tokio::test]
|
||||
async fn status_line_setup_popup_hardcoded_only_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
cache_project_root(&mut chat, "my-project");
|
||||
chat.config.tui_status_line = Some(vec![
|
||||
"project-name".to_string(),
|
||||
"git-branch".to_string(),
|
||||
@@ -159,22 +161,30 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn status_surface_preview_lines_mixed_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
cache_project_root(&mut chat, "my-project");
|
||||
chat.status_line_branch = Some("feature/mixed-preview".to_string());
|
||||
chat.thread_name = Some("Mixed preview thread".to_string());
|
||||
|
||||
let snapshot = combined_preview_snapshot(
|
||||
let status_line = status_preview_line(
|
||||
&mut chat,
|
||||
&[
|
||||
StatusLineItem::ProjectRoot,
|
||||
StatusLineItem::GitBranch,
|
||||
StatusLineItem::ThreadTitle,
|
||||
],
|
||||
);
|
||||
chat.status_line_project_root_name_cache = None;
|
||||
let terminal_title = title_preview_line(
|
||||
&mut chat,
|
||||
&[
|
||||
TerminalTitleItem::Project,
|
||||
TerminalTitleItem::Thread,
|
||||
TerminalTitleItem::TaskProgress,
|
||||
],
|
||||
);
|
||||
let snapshot = normalize_snapshot_paths(format!(
|
||||
"status line: {status_line}\nterminal title: {terminal_title}"
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot);
|
||||
}
|
||||
@@ -182,6 +192,7 @@ async fn status_surface_preview_lines_mixed_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn status_line_setup_popup_mixed_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
cache_project_root(&mut chat, "my-project");
|
||||
chat.status_line_branch = Some("feature/mixed-preview".to_string());
|
||||
chat.thread_name = Some("Mixed preview thread".to_string());
|
||||
chat.config.tui_status_line = Some(vec![
|
||||
@@ -250,6 +261,10 @@ async fn terminal_title_setup_popup_mixed_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn missing_project_root_uses_different_status_and_title_preview_sources() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_project_root_name_cache = Some(CachedProjectRootName {
|
||||
cwd: chat.config.cwd.to_path_buf(),
|
||||
root_name: None,
|
||||
});
|
||||
|
||||
let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]);
|
||||
let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]);
|
||||
|
||||
Reference in New Issue
Block a user