[codex] Fix task migration and session env isolation (#2272)

* Fix task migration and session env isolation

* test: guard session HOME mutation

* test: stabilize tui CI checks

* fix: satisfy current tui clippy warnings

---------

Co-authored-by: Lee-take <210963840+Lee-take@users.noreply.github.com>
Co-authored-by: Hunter B <hmbown@gmail.com>
This commit is contained in:
Lee-take
2026-05-31 15:58:26 +08:00
committed by GitHub
parent 6247ac807c
commit ad7e127ef2
8 changed files with 160 additions and 55 deletions
+6
View File
@@ -312,6 +312,7 @@ mod tests {
use super::*;
use crate::config::Config;
use crate::localization::Locale;
use crate::test_support::{EnvVarGuard, lock_test_env};
use crate::tui::app::{App, TuiOptions};
fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App {
let mut config = Config::default();
@@ -505,6 +506,11 @@ Previous release.\n";
#[test]
fn change_in_non_english_without_api_key_uses_explicit_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let _lock = lock_test_env();
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
let _deepseek_key = EnvVarGuard::remove("DEEPSEEK_API_KEY");
let _deepseek_provider = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
let _codewhale_provider = EnvVarGuard::remove("CODEWHALE_PROVIDER");
let mut app = make_app(&tmp, Locale::ZhHans, false);
let result = change(&mut app, None);
assert!(!result.is_error);
+10 -15
View File
@@ -441,6 +441,7 @@ fn line_to_string(line: ratatui::text::Line<'static>) -> String {
mod tests {
use super::*;
use crate::config::{Config, DEFAULT_TEXT_MODEL};
use crate::test_support::EnvVarGuard;
use crate::tui::app::{App, ReasoningEffort, TuiOptions, TurnCacheRecord};
use std::time::Instant;
use tempfile::TempDir;
@@ -517,11 +518,8 @@ mod tests {
let _lock = crate::test_support::lock_test_env();
let home = tmpdir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let previous_home = std::env::var_os("HOME");
// SAFETY: guarded by the process-wide test env mutex above.
unsafe {
std::env::set_var("HOME", &home);
}
let home_guard = EnvVarGuard::set("HOME", &home);
let previous_home = home_guard.previous();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.current_session_id = Some("parent-session".to_string());
app.api_messages.push(crate::models::Message {
@@ -551,14 +549,8 @@ mod tests {
Some("parent-session")
);
assert_eq!(child.metadata.forked_from_message_count, Some(1));
// SAFETY: guarded by the process-wide test env mutex above.
unsafe {
if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
} else {
std::env::remove_var("HOME");
}
}
drop(home_guard);
assert_eq!(std::env::var_os("HOME"), previous_home);
}
#[test]
@@ -668,14 +660,15 @@ mod tests {
#[test]
fn test_save_with_default_path_uses_managed_sessions_dir() {
let tmpdir = TempDir::new().unwrap();
let _lock = crate::test_support::lock_test_env();
// Set CODEWHALE_HOME so the managed sessions directory lands inside the
// temp dir rather than the real user home. Pre-create the directory so
// resolve_state_dir picks it up instead of falling back to legacy.
let home = tmpdir.path().join("home");
let sessions_dir = home.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
// SAFETY: test-only, single-threaded via cargo test
unsafe { std::env::set_var("CODEWHALE_HOME", home.to_str().unwrap()) };
let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let previous_codewhale_home = codewhale_home.previous();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = save(&mut app, None);
assert!(result.message.is_some());
@@ -691,11 +684,13 @@ mod tests {
} else {
Vec::new()
};
drop(codewhale_home);
// Session should be saved to the managed dir, not the workspace root.
assert!(
!entries.is_empty(),
"expected session file in {sessions_dir:?}, got none; msg: {msg}"
);
assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home);
}
#[test]
+22 -5
View File
@@ -2883,7 +2883,14 @@ mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
async fn lock_mcp_loopback_tests() -> tokio::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
.lock()
.await
}
#[test]
fn test_mcp_config_defaults() {
@@ -3803,6 +3810,7 @@ mod tests {
socket.write_all(response.as_bytes()).await.unwrap();
}
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@@ -4081,6 +4089,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let post_seen = Arc::new(AtomicBool::new(false));
@@ -4172,6 +4181,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let post_seen = Arc::new(AtomicBool::new(false));
@@ -4262,6 +4272,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let get_header_seen = Arc::new(AtomicBool::new(false));
@@ -4363,6 +4374,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let cancel_token = tokio_util::sync::CancellationToken::new();
@@ -4448,6 +4460,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let get_count = Arc::new(AtomicUsize::new(0));
@@ -4586,8 +4599,8 @@ mod tests {
env: HashMap::new(),
url: Some(format!("http://{addr}/mcp")),
transport: None,
connect_timeout: Some(2),
execute_timeout: Some(2),
connect_timeout: Some(10),
execute_timeout: Some(10),
read_timeout: None,
disabled: false,
enabled: true,
@@ -4621,6 +4634,7 @@ mod tests {
use tokio::net::TcpListener;
use tokio::sync::mpsc;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
@@ -4717,6 +4731,7 @@ mod tests {
(headers, json)
}
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let active_sse = Arc::new(Mutex::new(None::<mpsc::UnboundedSender<Option<String>>>));
@@ -4838,8 +4853,8 @@ mod tests {
env: HashMap::new(),
url: Some(format!("http://{addr}/sse")),
transport: Some("sse".to_string()),
connect_timeout: Some(2),
execute_timeout: Some(2),
connect_timeout: Some(10),
execute_timeout: Some(10),
read_timeout: None,
disabled: false,
enabled: true,
@@ -4883,6 +4898,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@@ -4958,6 +4974,7 @@ mod tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _lock = lock_mcp_loopback_tests().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// The test signals success by writing to this flag — the GET handler
+1 -1
View File
@@ -315,7 +315,7 @@ impl ShellDispatcher {
if Self::find_exe("powershell.exe") {
return ShellKind::WindowsPowerShell;
}
return ShellKind::Cmd;
ShellKind::Cmd
}
#[cfg(not(windows))]
+74 -4
View File
@@ -1639,7 +1639,8 @@ fn default_auto_approve() -> bool {
true
}
/// Default task persistence location (`~/.deepseek/tasks`).
/// Default task manager data location (`~/.codewhale/tasks`, or legacy
/// `~/.deepseek/tasks` when only the legacy directory exists).
#[must_use]
pub fn default_tasks_dir() -> PathBuf {
if let Ok(path) = std::env::var("DEEPSEEK_TASKS_DIR")
@@ -1647,10 +1648,21 @@ pub fn default_tasks_dir() -> PathBuf {
{
return PathBuf::from(path);
}
if let Some(home) = dirs::home_dir() {
return home.join(".codewhale").join("tasks");
dirs::home_dir()
.map(|home| default_tasks_dir_for_home(&home))
.unwrap_or_else(|| PathBuf::from(".codewhale").join("tasks"))
}
fn default_tasks_dir_for_home(home: &Path) -> PathBuf {
let primary = home.join(".codewhale").join("tasks");
if primary.is_dir() {
return primary;
}
PathBuf::from(".codewhale").join("tasks")
let legacy = home.join(".deepseek").join("tasks");
if legacy.is_dir() {
return legacy;
}
primary
}
/// Wait for a task to reach a terminal status (tests and API helpers).
@@ -1910,4 +1922,62 @@ mod tests {
}
Ok(())
}
#[test]
fn default_tasks_dir_falls_back_to_legacy_deepseek_tasks() {
let temp_home = tempfile::tempdir().unwrap();
let home = temp_home.path();
let legacy_tasks = home.join(".deepseek").join("tasks");
std::fs::create_dir_all(&legacy_tasks).unwrap();
assert_eq!(default_tasks_dir_for_home(home), legacy_tasks);
}
#[test]
fn default_tasks_dir_prefers_existing_codewhale_tasks() {
let temp_home = tempfile::tempdir().unwrap();
let home = temp_home.path();
let primary_tasks = home.join(".codewhale").join("tasks");
let legacy_tasks = home.join(".deepseek").join("tasks");
std::fs::create_dir_all(&primary_tasks).unwrap();
std::fs::create_dir_all(&legacy_tasks).unwrap();
assert_eq!(default_tasks_dir_for_home(home), primary_tasks);
}
#[test]
fn default_tasks_dir_falls_back_to_legacy_when_primary_is_file() {
let temp_home = tempfile::tempdir().unwrap();
let home = temp_home.path();
let primary_tasks = home.join(".codewhale").join("tasks");
let legacy_tasks = home.join(".deepseek").join("tasks");
std::fs::create_dir_all(primary_tasks.parent().unwrap()).unwrap();
std::fs::write(&primary_tasks, "not a directory").unwrap();
std::fs::create_dir_all(&legacy_tasks).unwrap();
assert_eq!(default_tasks_dir_for_home(home), legacy_tasks);
}
#[test]
fn default_tasks_dir_ignores_legacy_file_for_new_installs() {
let temp_home = tempfile::tempdir().unwrap();
let home = temp_home.path();
let primary_tasks = home.join(".codewhale").join("tasks");
let legacy_tasks = home.join(".deepseek").join("tasks");
std::fs::create_dir_all(legacy_tasks.parent().unwrap()).unwrap();
std::fs::write(&legacy_tasks, "not a directory").unwrap();
assert_eq!(default_tasks_dir_for_home(home), primary_tasks);
}
#[test]
fn default_tasks_dir_uses_codewhale_tasks_for_new_installs() {
let temp_home = tempfile::tempdir().unwrap();
let home = temp_home.path();
assert_eq!(
default_tasks_dir_for_home(home),
home.join(".codewhale").join("tasks")
);
}
}
+44
View File
@@ -1,5 +1,6 @@
//! Shared test-only helpers.
use std::ffi::{OsStr, OsString};
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> &'static Mutex<()> {
@@ -18,6 +19,49 @@ pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> {
}
}
/// Restore one environment variable when dropped.
///
/// Callers that mutate process-global environment variables must hold
/// [`lock_test_env`] until after this guard is dropped.
pub(crate) struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
pub(crate) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
pub(crate) fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
pub(crate) fn previous(&self) -> Option<OsString> {
self.previous.clone()
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// SAFETY: callers hold the process-wide test env mutex until after this
// guard is dropped.
unsafe {
if let Some(value) = self.previous.take() {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
/// Find the byte position of the first divergence between two strings,
/// returning a windowed view (`±32 bytes` around the divergence) so failures
/// in cache-prefix-stability tests show *which* bytes drifted, not just that
+1 -30
View File
@@ -4884,11 +4884,10 @@ pub enum McpUiAction {
mod tests {
use super::*;
use crate::config::{ApiProvider, Config, ProviderConfig, ProvidersConfig};
use crate::test_support::lock_test_env;
use crate::test_support::{EnvVarGuard, lock_test_env};
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::clipboard::PastedImage;
use std::ffi::OsString;
fn test_options(yolo: bool) -> TuiOptions {
TuiOptions {
@@ -5019,34 +5018,6 @@ mod tests {
assert_eq!(app.cursor_position, "abc\n".len()); // unchanged
}
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.as_ref() {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn test_trust_mode_follows_yolo_on_startup() {
let app = App::new(test_options(true), &Config::default());
+2
View File
@@ -1360,6 +1360,8 @@ fn create_test_options() -> TuiOptions {
}
#[tokio::test]
// This test intentionally pins the process-global spillover root until the
// async receipt path finishes.
#[allow(clippy::await_holding_lock)]
async fn tool_result_api_content_receipts_large_live_output() {
let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD