test(ci): hermeticize flaky config and service fixtures
Seal intentional config fixtures, isolate unsealed config/state/skills paths from the developer home, serialize persistent-service cases under nextest and libtest, and give the PDF success path a realistic timeout while retaining its short timeout regression. Also treat removed or empty home variables as unsealed so tests cannot fall back into the user profile. Verified with 477 config tests, 7 test-support tests, 27 config-persistence tests, 12 provider-key tests, 5 PDF tests, 3 nextest and 3 libtest persistent-service tests, workflow wiring checks, formatting, and diff checks. Signed-off-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
@@ -34,6 +34,15 @@
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
# Hermetic HOME so libtest's shared process cannot see a populated
|
||||
# ~/.codewhale/config.toml (#5355 config-fixture family). Tests stay in
|
||||
# the suite; isolation is scheduling, not deletion.
|
||||
hermetic_home="${TMPDIR:-/tmp}/cw-cnb-hermetic-home-$$"
|
||||
mkdir -p "${hermetic_home}/.codewhale"
|
||||
export HOME="${hermetic_home}"
|
||||
export USERPROFILE="${hermetic_home}"
|
||||
export CODEWHALE_HOME="${hermetic_home}/.codewhale"
|
||||
unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME || true
|
||||
RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked
|
||||
# Parity gates as first-class steps so drift surfaces as a named failure,
|
||||
# not a buried workspace-test entry. Mirrors release.yml's parity job.
|
||||
|
||||
@@ -32,6 +32,10 @@ spawns-binaries = { max-threads = 3 }
|
||||
# in-process mutex cannot serialize nextest's one-process-per-test workers.
|
||||
# Keep one telemetry process alongside the other three integration workers.
|
||||
telemetry-contract = { max-threads = 1 }
|
||||
# Persistent-service exec tests wait on a pid file from a real child. Under
|
||||
# parallel load the file never appears (#5355). Serialize them; do not drop
|
||||
# the tests.
|
||||
exec-persistent-service = { max-threads = 1 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
# The PTY test binary (`crates/tui/tests/pty`) drives real pseudo-terminals
|
||||
@@ -41,10 +45,18 @@ filter = 'binary(pty)'
|
||||
test-group = 'pty'
|
||||
slow-timeout = { period = "120s" }
|
||||
|
||||
# First matching test-group override wins. Keep these more-specific
|
||||
# integration filters before binary(integration) so they are not stolen
|
||||
# by the three-thread group.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'binary(integration) & test(/^telemetry_contract::/)'
|
||||
test-group = 'telemetry-contract'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'binary(integration) & test(/^exec_persistent_service::/)'
|
||||
test-group = 'exec-persistent-service'
|
||||
slow-timeout = { period = "120s" }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'binary(integration)'
|
||||
test-group = 'spawns-binaries'
|
||||
|
||||
@@ -420,6 +420,30 @@ assert.match(
|
||||
/timeout: 45m[\s\S]*export CARGO_BUILD_JOBS=1[\s\S]*export CARGO_PROFILE_TEST_DEBUG=0[\s\S]*cargo check --workspace --all-targets --locked[\s\S]*cargo clippy --workspace --all-targets --all-features --locked -- -D warnings[\s\S]*RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked/,
|
||||
"CNB must serialize the memory-heavy Rust gate and preserve the workspace test stack contract",
|
||||
);
|
||||
assert.match(
|
||||
cnbRustGates[1],
|
||||
/export HOME="\$\{hermetic_home\}"[\s\S]*export CODEWHALE_HOME="\$\{hermetic_home\}\/\.codewhale"[\s\S]*unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME/,
|
||||
"CNB workspace tests must not read a populated runner ~/.codewhale (#5355)",
|
||||
);
|
||||
|
||||
const nextest = read(".config/nextest.toml");
|
||||
const integrationGroup = nextest.search(/^filter = 'binary\(integration\)'$/m);
|
||||
const telemetryGroup = nextest.indexOf(
|
||||
"filter = 'binary(integration) & test(/^telemetry_contract::/)'",
|
||||
);
|
||||
const execGroup = nextest.indexOf(
|
||||
"filter = 'binary(integration) & test(/^exec_persistent_service::/)'",
|
||||
);
|
||||
assert.ok(integrationGroup >= 0, "nextest must bound the integration binary");
|
||||
assert.ok(
|
||||
telemetryGroup >= 0 && telemetryGroup < integrationGroup,
|
||||
"telemetry-contract override must precede binary(integration); first matching group wins",
|
||||
);
|
||||
assert.ok(
|
||||
execGroup >= 0 && execGroup < integrationGroup,
|
||||
"exec_persistent_service override must precede binary(integration); first matching group wins",
|
||||
);
|
||||
assert.match(nextest, /exec-persistent-service = \{ max-threads = 1 \}/);
|
||||
assert.equal(
|
||||
(cnb.match(/^\s+- \*rust_workspace_gates_stage$/gm) || []).length,
|
||||
2,
|
||||
|
||||
@@ -254,9 +254,12 @@ jobs:
|
||||
- name: Hermetic safety and authorization tests
|
||||
env:
|
||||
HOME: ${{ runner.temp }}/cw-hermetic-home
|
||||
USERPROFILE: ${{ runner.temp }}/cw-hermetic-home
|
||||
CODEWHALE_HOME: ${{ runner.temp }}/cw-hermetic-home/.codewhale
|
||||
RUST_MIN_STACK: "8388608"
|
||||
run: |
|
||||
mkdir -p "${HOME}"
|
||||
mkdir -p "${HOME}" "${CODEWHALE_HOME}"
|
||||
unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME || true
|
||||
cargo test -p codewhale-tui --lib --locked -- command_safety auto_review authority sandbox
|
||||
cargo test -p codewhale-execpolicy --locked
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ abort under load.
|
||||
gets a verdict instead of a cancelled pending run. A hermetic Safety
|
||||
gate job runs authorization tests in under 15 minutes (test bankruptcy
|
||||
restructuring — no tests deleted).
|
||||
- Config-fixture tests no longer honor `lock_test_env` as a license to
|
||||
read a populated `~/.codewhale/config.toml`; they need an `EnvVarGuard`
|
||||
like settings already did. Safety-gate and CNB workspace tests pin a
|
||||
hermetic `CODEWHALE_HOME`. `exec_persistent_service` is serialized in
|
||||
nextest and inside the cargo-test binary instead of dropped (#5355).
|
||||
- Short CLI no longer waits up to three seconds for a telemetry POST on
|
||||
exit; `session_end` is recorded and the buffer ships on the next
|
||||
interactive session.
|
||||
|
||||
@@ -48,6 +48,9 @@ abort under load.
|
||||
proceeds; unpersistable evidence blocks the tool; resume reconstructs
|
||||
closed and interrupted approvals (#5360).
|
||||
- `test(tui): break the LazyLock/env-barrier deadlock in the test harness`.
|
||||
- `test(tui): do not treat lock_test_env as a license to read a populated
|
||||
~/.codewhale/config.toml` — config paths now require the same EnvVarGuard
|
||||
seal settings already used (#5355, #5359).
|
||||
- `ci: give test threads the 8 MiB stack they need` — the lib suite aborted
|
||||
with SIGABRT under load on the default 2 MiB stack.
|
||||
- YOLO entry points honor a locked approval policy: `--yolo`, `/mode yolo`,
|
||||
|
||||
+10
-16
@@ -7471,6 +7471,16 @@ use paths::{
|
||||
pub(crate) use paths::{effective_home_dir, expand_path};
|
||||
|
||||
pub(crate) fn workspace_trust_config_candidate_paths() -> Vec<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if !crate::test_support::guarded_environment_provides_state_paths() {
|
||||
return vec![
|
||||
crate::test_support::unsealed_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
match env_config_path() {
|
||||
Ok(Some(path)) => return vec![path],
|
||||
Ok(None) => {}
|
||||
@@ -7572,22 +7582,6 @@ pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Result<Option<P
|
||||
return Ok(Some(expand_pathbuf(path)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
let honor_guarded_environment = crate::test_support::current_thread_holds_test_env_lock();
|
||||
crate::test_support::with_test_env_lock(|| {
|
||||
if honor_guarded_environment {
|
||||
try_default_config_path().map(Some)
|
||||
} else {
|
||||
Ok(Some(
|
||||
crate::test_support::isolated_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
try_default_config_path().map(Some)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,10 @@ pub(crate) fn default_config_path() -> anyhow::Result<PathBuf> {
|
||||
pub(crate) fn try_default_config_path() -> anyhow::Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let honor_guarded_environment = crate::test_support::current_thread_holds_test_env_lock();
|
||||
crate::test_support::with_test_env_lock(|| {
|
||||
if honor_guarded_environment {
|
||||
try_default_config_path_from_environment()
|
||||
} else {
|
||||
Ok(crate::test_support::isolated_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME))
|
||||
}
|
||||
})
|
||||
return with_test_state_path(try_default_config_path_from_environment, || {
|
||||
Ok(crate::test_support::unsealed_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME))
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
@@ -44,6 +39,26 @@ fn try_default_config_path_from_environment() -> anyhow::Result<PathBuf> {
|
||||
codewhale_config::resolve_config_path(None)
|
||||
}
|
||||
|
||||
/// Holding [`lock_test_env`] is not enough to read the process environment:
|
||||
/// many tests take that lock only to serialize unrelated variables, and
|
||||
/// trusting it routed them at a populated `~/.codewhale/config.toml` (#5355,
|
||||
/// #5359). Settings already requires a sealed `EnvVarGuard`; config paths
|
||||
/// must use the same gate.
|
||||
#[cfg(test)]
|
||||
fn with_test_state_path<T>(
|
||||
from_environment: impl FnOnce() -> T,
|
||||
isolated: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
let honor_guarded_environment = crate::test_support::guarded_environment_provides_state_paths();
|
||||
crate::test_support::with_test_env_lock(|| {
|
||||
if honor_guarded_environment {
|
||||
from_environment()
|
||||
} else {
|
||||
isolated()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn codewhale_home_dir() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
|
||||
codewhale_paths::codewhale_home_override()
|
||||
}
|
||||
@@ -56,6 +71,21 @@ pub(crate) fn codewhale_home_dir() -> Result<Option<PathBuf>, codewhale_paths::P
|
||||
/// to a workspace-scoped document (#5045, #5193); non-credential settings keep
|
||||
/// the ambient scoping.
|
||||
pub(crate) fn home_config_path() -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
return with_test_state_path(home_config_path_from_environment, || {
|
||||
Some(
|
||||
crate::test_support::unsealed_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
home_config_path_from_environment()
|
||||
}
|
||||
|
||||
fn home_config_path_from_environment() -> Option<PathBuf> {
|
||||
match codewhale_home_dir() {
|
||||
Ok(Some(home)) => return Some(home.join(codewhale_config::CONFIG_FILE_NAME)),
|
||||
Ok(None) => {}
|
||||
@@ -94,7 +124,7 @@ pub(crate) fn canonicalize_or_keep(path: &Path) -> PathBuf {
|
||||
pub(crate) fn env_config_path() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
crate::test_support::with_test_env_lock(env_config_path_unlocked)
|
||||
return with_test_state_path(env_config_path_unlocked, || Ok(None));
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
@@ -180,6 +210,19 @@ pub(crate) fn default_memory_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn default_user_state_path(name: &str) -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
return with_test_state_path(
|
||||
|| default_user_state_path_from_environment(name),
|
||||
|| Some(crate::test_support::unsealed_test_state_root().join(name)),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
default_user_state_path_from_environment(name)
|
||||
}
|
||||
|
||||
fn default_user_state_path_from_environment(name: &str) -> Option<PathBuf> {
|
||||
match codewhale_home_dir() {
|
||||
Ok(Some(home)) => return Some(home.join(name)),
|
||||
Ok(None) => {}
|
||||
|
||||
@@ -1463,6 +1463,13 @@ fn search_provider_resolution_ignores_invalid_env_override() {
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
// Seal path overrides through EnvVarGuard so default_config_path honors
|
||||
// this fixture instead of the isolated test root (#5355, #5359).
|
||||
_sealed_home: EnvVarGuard,
|
||||
_sealed_userprofile: EnvVarGuard,
|
||||
_sealed_codewhale_home: EnvVarGuard,
|
||||
_sealed_codewhale_config_path: EnvVarGuard,
|
||||
_sealed_deepseek_config_path: EnvVarGuard,
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
codewhale_home: Option<OsString>,
|
||||
@@ -1666,13 +1673,13 @@ impl EnvGuard {
|
||||
let hf_base_url_prev = env::var_os("HF_BASE_URL");
|
||||
let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL");
|
||||
let hf_model_prev = env::var_os("HF_MODEL");
|
||||
let sealed_home = EnvVarGuard::set("HOME", &home_str);
|
||||
let sealed_userprofile = EnvVarGuard::set("USERPROFILE", &home_str);
|
||||
let sealed_codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
|
||||
let sealed_codewhale_config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
|
||||
let sealed_deepseek_config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::remove_var("CODEWHALE_HOME");
|
||||
env::remove_var("CODEWHALE_CONFIG_PATH");
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
env::remove_var("CODEWHALE_SECRET_BACKEND");
|
||||
env::remove_var("DEEPSEEK_SECRET_BACKEND");
|
||||
env::remove_var("DEEPSEEK_PROVIDER");
|
||||
@@ -1768,6 +1775,11 @@ impl EnvGuard {
|
||||
env::remove_var("HF_MODEL");
|
||||
}
|
||||
Self {
|
||||
_sealed_home: sealed_home,
|
||||
_sealed_userprofile: sealed_userprofile,
|
||||
_sealed_codewhale_home: sealed_codewhale_home,
|
||||
_sealed_codewhale_config_path: sealed_codewhale_config_path,
|
||||
_sealed_deepseek_config_path: sealed_deepseek_config_path,
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
codewhale_home: codewhale_home_prev,
|
||||
@@ -5353,8 +5365,6 @@ fn non_unicode_codewhale_home_is_preserved_by_config_owned_user_paths() -> Resul
|
||||
#[test]
|
||||
fn codewhale_config_path_env_wins_over_legacy_env() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let prev_codewhale = env::var_os("CODEWHALE_CONFIG_PATH");
|
||||
let prev_deepseek = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -5366,19 +5376,11 @@ fn codewhale_config_path_env_wins_over_legacy_env() -> Result<()> {
|
||||
));
|
||||
let preferred = temp_root.join("preferred.toml");
|
||||
let legacy = temp_root.join("legacy.toml");
|
||||
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_CONFIG_PATH", &preferred);
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
|
||||
}
|
||||
let _codewhale_config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &preferred);
|
||||
let _legacy_config = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &legacy);
|
||||
|
||||
assert_eq!(env_config_path().unwrap().unwrap(), preferred);
|
||||
|
||||
unsafe {
|
||||
EnvGuard::restore_var("CODEWHALE_CONFIG_PATH", prev_codewhale);
|
||||
EnvGuard::restore_var("DEEPSEEK_CONFIG_PATH", prev_deepseek);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -482,110 +482,34 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
codewhale_home: Option<OsString>,
|
||||
codewhale_config_path: Option<OsString>,
|
||||
deepseek_config_path: Option<OsString>,
|
||||
_home: crate::test_support::EnvVarGuard,
|
||||
_userprofile: crate::test_support::EnvVarGuard,
|
||||
_codewhale_home: crate::test_support::EnvVarGuard,
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard,
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard,
|
||||
_lock: crate::test_support::TestEnvLock,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(home: &Path) -> Self {
|
||||
let lock = crate::test_support::lock_test_env();
|
||||
let home_str = OsString::from(home.as_os_str());
|
||||
let config_path = home.join(".deepseek").join("config.toml");
|
||||
let config_str = OsString::from(config_path.as_os_str());
|
||||
let home_prev = env::var_os("HOME");
|
||||
let userprofile_prev = env::var_os("USERPROFILE");
|
||||
let codewhale_home_prev = env::var_os("CODEWHALE_HOME");
|
||||
let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
|
||||
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
// Safety: test-only environment mutation guarded by process-wide mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::remove_var("CODEWHALE_HOME");
|
||||
env::remove_var("CODEWHALE_CONFIG_PATH");
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
}
|
||||
|
||||
Self {
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
codewhale_home: codewhale_home_prev,
|
||||
codewhale_config_path: codewhale_config_prev,
|
||||
deepseek_config_path: deepseek_config_prev,
|
||||
_home: crate::test_support::EnvVarGuard::set("HOME", home),
|
||||
_userprofile: crate::test_support::EnvVarGuard::set("USERPROFILE", home),
|
||||
_codewhale_home: crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME"),
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard::remove(
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
),
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
&config_path,
|
||||
),
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.home.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("HOME");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.userprofile.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("USERPROFILE", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("USERPROFILE");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.codewhale_home.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_HOME", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("CODEWHALE_HOME");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.codewhale_config_path.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_CONFIG_PATH", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("CODEWHALE_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.deepseek_config_path.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_root(prefix: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -138,6 +138,14 @@ fn record_skill_md_read_attempt() {
|
||||
|
||||
#[must_use]
|
||||
pub fn default_skills_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if !crate::test_support::guarded_environment_provides_state_paths() {
|
||||
return crate::test_support::unsealed_test_state_root()
|
||||
.join(".codewhale")
|
||||
.join("skills");
|
||||
}
|
||||
}
|
||||
crate::config::effective_home_dir().map_or_else(
|
||||
|| PathBuf::from("/tmp/codewhale/skills"),
|
||||
|p| p.join(".codewhale").join("skills"),
|
||||
@@ -147,6 +155,16 @@ pub fn default_skills_dir() -> PathBuf {
|
||||
/// Global agentskills.io-compatible skills directory (`~/.agents/skills`).
|
||||
#[must_use]
|
||||
pub fn agents_global_skills_dir() -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if !crate::test_support::guarded_environment_provides_state_paths() {
|
||||
return Some(
|
||||
crate::test_support::unsealed_test_state_root()
|
||||
.join(".agents")
|
||||
.join("skills"),
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::config::effective_home_dir().map(|p| p.join(".agents").join("skills"))
|
||||
}
|
||||
|
||||
|
||||
@@ -173,19 +173,20 @@ pub(crate) fn guarded_environment_provides_state_paths() -> bool {
|
||||
if !current_thread_holds_test_env_lock() {
|
||||
return false;
|
||||
}
|
||||
let guarded_override_present = [
|
||||
let guarded_path_is_present = |var: &str| {
|
||||
env_var_currently_guarded(var)
|
||||
&& std::env::var_os(var)
|
||||
.is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
|
||||
};
|
||||
[
|
||||
"CODEWHALE_HOME",
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
]
|
||||
.iter()
|
||||
.any(|var| {
|
||||
env_var_currently_guarded(var)
|
||||
&& std::env::var(var).is_ok_and(|value| !value.trim().is_empty())
|
||||
});
|
||||
guarded_override_present
|
||||
|| env_var_currently_guarded("HOME")
|
||||
|| env_var_currently_guarded("USERPROFILE")
|
||||
.any(|var| guarded_path_is_present(var))
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
@@ -383,6 +384,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_home_variables_does_not_seal_a_missing_path() {
|
||||
let _lock = lock_test_env();
|
||||
let _home = EnvVarGuard::remove("HOME");
|
||||
let _userprofile = EnvVarGuard::remove("USERPROFILE");
|
||||
|
||||
assert!(
|
||||
!guarded_environment_provides_state_paths(),
|
||||
"removing HOME variables must keep state in the isolated test root"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::config_persistence::config_toml_path(None)
|
||||
.expect("resolve isolated config path"),
|
||||
unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_without_sealed_paths_does_not_use_developer_config() {
|
||||
let _lock = lock_test_env();
|
||||
let path = crate::config_persistence::config_toml_path(None)
|
||||
.expect("resolve isolated config path");
|
||||
let root = isolated_test_state_root();
|
||||
assert!(
|
||||
path.starts_with(root),
|
||||
"holding lock_test_env without an EnvVarGuard must not read ~/.codewhale ({})",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
path,
|
||||
unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unguarded_state_writes_use_isolated_test_root() {
|
||||
const PROBE_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_PROBE";
|
||||
|
||||
@@ -51,7 +51,10 @@ async fn shared_adapter_forwards_page_window_and_returns_stdout() {
|
||||
let (temporary, binary) = executable_script(
|
||||
"#!/bin/sh\nprintf 'args:%s\\n' \"$*\"\nprintf 'page one\\fpage two\\n'\n",
|
||||
);
|
||||
let request = PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(1), None);
|
||||
// Success-path budget, not a tightness proof. Under a loaded cargo-test
|
||||
// process a 1s spawn of `#!/bin/sh` timed out as TimedOut (#5355). The
|
||||
// neighboring test still uses 50ms to prove the timeout path.
|
||||
let request = PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None);
|
||||
let input = temporary.path().join("input with spaces.pdf");
|
||||
std::fs::write(&input, b"fixture bytes").expect("fixture");
|
||||
let text = extract_path(&input, Some((2, 4)), request)
|
||||
|
||||
+11
-22
@@ -3185,12 +3185,12 @@ mod provider_key_validation_tests {
|
||||
use super::*;
|
||||
use crate::core::engine::mock_engine_handle;
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
use std::ffi::OsString;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct ConfigPathEnvGuard {
|
||||
_tmp: TempDir,
|
||||
previous: Option<OsString>,
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard,
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard,
|
||||
_lock: crate::test_support::TestEnvLock,
|
||||
}
|
||||
|
||||
@@ -3201,38 +3201,27 @@ mod provider_key_validation_tests {
|
||||
let config_path = tmp.path().join(".codewhale").join("config.toml");
|
||||
std::fs::create_dir_all(config_path.parent().expect("config parent"))
|
||||
.expect("config dir");
|
||||
let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
|
||||
}
|
||||
Self {
|
||||
_tmp: tmp,
|
||||
previous,
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard::set(
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
&config_path,
|
||||
),
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
&config_path,
|
||||
),
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path(&self) -> PathBuf {
|
||||
std::env::var_os("DEEPSEEK_CONFIG_PATH")
|
||||
std::env::var_os("CODEWHALE_CONFIG_PATH")
|
||||
.map(PathBuf::from)
|
||||
.expect("config path set")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigPathEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
if let Some(previous) = self.previous.take() {
|
||||
std::env::set_var("DEEPSEEK_CONFIG_PATH", previous);
|
||||
} else {
|
||||
std::env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
start_in_agent_mode: true,
|
||||
|
||||
@@ -26,7 +26,6 @@ use crate::working_set::Workspace;
|
||||
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -1187,15 +1186,16 @@ impl ConfigPathEnvGuard {
|
||||
|
||||
struct SettingsHomeGuard {
|
||||
_tmp: TempDir,
|
||||
previous_home: Option<OsString>,
|
||||
previous_userprofile: Option<OsString>,
|
||||
previous_codewhale_home: Option<OsString>,
|
||||
previous_deepseek_config_path: Option<OsString>,
|
||||
previous_codewhale_provider: Option<OsString>,
|
||||
previous_deepseek_provider: Option<OsString>,
|
||||
previous_xdg_config_home: Option<OsString>,
|
||||
previous_appdata: Option<OsString>,
|
||||
previous_localappdata: Option<OsString>,
|
||||
_home: crate::test_support::EnvVarGuard,
|
||||
_userprofile: crate::test_support::EnvVarGuard,
|
||||
_codewhale_home: crate::test_support::EnvVarGuard,
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard,
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard,
|
||||
_codewhale_provider: crate::test_support::EnvVarGuard,
|
||||
_deepseek_provider: crate::test_support::EnvVarGuard,
|
||||
_xdg_config_home: crate::test_support::EnvVarGuard,
|
||||
_appdata: crate::test_support::EnvVarGuard,
|
||||
_localappdata: crate::test_support::EnvVarGuard,
|
||||
_lock: crate::test_support::TestEnvLock,
|
||||
}
|
||||
|
||||
@@ -1203,74 +1203,38 @@ impl SettingsHomeGuard {
|
||||
fn new() -> Self {
|
||||
let lock = crate::test_support::lock_test_env();
|
||||
let tmp = TempDir::new().expect("settings tempdir");
|
||||
let previous_home = std::env::var_os("HOME");
|
||||
let previous_userprofile = std::env::var_os("USERPROFILE");
|
||||
let previous_codewhale_home = std::env::var_os("CODEWHALE_HOME");
|
||||
let previous_deepseek_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
let previous_codewhale_provider = std::env::var_os("CODEWHALE_PROVIDER");
|
||||
let previous_deepseek_provider = std::env::var_os("DEEPSEEK_PROVIDER");
|
||||
let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
|
||||
let previous_appdata = std::env::var_os("APPDATA");
|
||||
let previous_localappdata = std::env::var_os("LOCALAPPDATA");
|
||||
let codewhale_home = tmp.path().join(".codewhale");
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.path());
|
||||
std::env::set_var("USERPROFILE", tmp.path());
|
||||
std::env::set_var("CODEWHALE_HOME", &codewhale_home);
|
||||
std::env::set_var("DEEPSEEK_CONFIG_PATH", codewhale_home.join("config.toml"));
|
||||
std::env::remove_var("CODEWHALE_PROVIDER");
|
||||
std::env::remove_var("DEEPSEEK_PROVIDER");
|
||||
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("xdg-config"));
|
||||
std::env::set_var("APPDATA", tmp.path().join("appdata"));
|
||||
std::env::set_var("LOCALAPPDATA", tmp.path().join("localappdata"));
|
||||
}
|
||||
Self {
|
||||
_home: crate::test_support::EnvVarGuard::set("HOME", tmp.path()),
|
||||
_userprofile: crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path()),
|
||||
_codewhale_home: crate::test_support::EnvVarGuard::set(
|
||||
"CODEWHALE_HOME",
|
||||
&codewhale_home,
|
||||
),
|
||||
_codewhale_config_path: crate::test_support::EnvVarGuard::remove(
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
),
|
||||
_deepseek_config_path: crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
codewhale_home.join("config.toml"),
|
||||
),
|
||||
_codewhale_provider: crate::test_support::EnvVarGuard::remove("CODEWHALE_PROVIDER"),
|
||||
_deepseek_provider: crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER"),
|
||||
_xdg_config_home: crate::test_support::EnvVarGuard::set(
|
||||
"XDG_CONFIG_HOME",
|
||||
tmp.path().join("xdg-config"),
|
||||
),
|
||||
_appdata: crate::test_support::EnvVarGuard::set("APPDATA", tmp.path().join("appdata")),
|
||||
_localappdata: crate::test_support::EnvVarGuard::set(
|
||||
"LOCALAPPDATA",
|
||||
tmp.path().join("localappdata"),
|
||||
),
|
||||
_tmp: tmp,
|
||||
previous_home,
|
||||
previous_userprofile,
|
||||
previous_codewhale_home,
|
||||
previous_deepseek_config_path,
|
||||
previous_codewhale_provider,
|
||||
previous_deepseek_provider,
|
||||
previous_xdg_config_home,
|
||||
previous_appdata,
|
||||
previous_localappdata,
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SettingsHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
fn restore(key: &str, previous: Option<OsString>) {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
match previous {
|
||||
Some(previous) => std::env::set_var(key, previous),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore("HOME", self.previous_home.take());
|
||||
restore("USERPROFILE", self.previous_userprofile.take());
|
||||
restore("CODEWHALE_HOME", self.previous_codewhale_home.take());
|
||||
restore(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
self.previous_deepseek_config_path.take(),
|
||||
);
|
||||
restore(
|
||||
"CODEWHALE_PROVIDER",
|
||||
self.previous_codewhale_provider.take(),
|
||||
);
|
||||
restore("DEEPSEEK_PROVIDER", self.previous_deepseek_provider.take());
|
||||
restore("XDG_CONFIG_HOME", self.previous_xdg_config_home.take());
|
||||
restore("APPDATA", self.previous_appdata.take());
|
||||
restore("LOCALAPPDATA", self.previous_localappdata.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_hint_uses_canonical_resume_command() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
async fn serialize_persistent_service_tests() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.await
|
||||
}
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use wait_timeout::ChildExt;
|
||||
@@ -325,6 +332,7 @@ const SERVICE_COMMAND: &str = "echo $$ > service.pid; exec sleep 600";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn successful_exec_releases_persisted_service() {
|
||||
let _serial = serialize_persistent_service_tests().await;
|
||||
let server = start_mock_llm(SERVICE_COMMAND, final_answer_sse(), None).await;
|
||||
let workspace = TempDir::new().expect("workspace tempdir");
|
||||
let home = TempDir::new().expect("home tempdir");
|
||||
@@ -377,6 +385,7 @@ async fn successful_exec_releases_persisted_service() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn failed_exec_kills_pending_service_and_exits_nonzero() {
|
||||
let _serial = serialize_persistent_service_tests().await;
|
||||
let server = start_mock_llm(SERVICE_COMMAND, incomplete_answer_sse(), None).await;
|
||||
let workspace = TempDir::new().expect("workspace tempdir");
|
||||
let home = TempDir::new().expect("home tempdir");
|
||||
@@ -415,6 +424,7 @@ async fn failed_exec_kills_pending_service_and_exits_nonzero() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn terminating_signal_kills_pending_service_and_exits_nonzero() {
|
||||
let _serial = serialize_persistent_service_tests().await;
|
||||
// Hold the second model turn open long past the signal.
|
||||
let server = start_mock_llm(
|
||||
SERVICE_COMMAND,
|
||||
|
||||
@@ -113,6 +113,16 @@ class CiWiringTests(unittest.TestCase):
|
||||
self.assertIn("name: Safety gate", ci)
|
||||
self.assertIn("Hermetic safety and authorization tests", ci)
|
||||
|
||||
def test_safety_gate_is_hermetic_for_config_home(self) -> None:
|
||||
ci = load_ci()
|
||||
start = ci.index("Hermetic safety and authorization tests")
|
||||
next_step = ci.index("- name:", start + 1)
|
||||
block = ci[start:next_step]
|
||||
self.assertIn("CODEWHALE_HOME:", block)
|
||||
self.assertIn("cw-hermetic-home", block)
|
||||
self.assertIn("unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME", block)
|
||||
self.assertIn("command_safety auto_review authority sandbox", block)
|
||||
|
||||
def test_valid_wiring_passes_all_assertions(self) -> None:
|
||||
# The live workflow must satisfy every structural invariant above.
|
||||
ci = load_ci()
|
||||
|
||||
Reference in New Issue
Block a user