Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions src/apps/cli/src/dispatch/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,15 @@ fn process_group_alive(process_group: i32) -> bool {
)
}

#[cfg(any(test, target_os = "macos"))]
fn macos_process_state_allows_escalation(stat: &str) -> bool {
let Some(stat) = stat.split_whitespace().next() else {
return false;
};
matches!(stat.chars().next(), Some('I' | 'R' | 'S' | 'T' | 'U'))
&& !stat.chars().skip(1).any(|modifier| modifier == 'E')
}

#[cfg(unix)]
pub(crate) fn process_alive(pid: u32) -> bool {
let Ok(pid) = i32::try_from(pid) else {
Expand Down Expand Up @@ -246,9 +255,10 @@ pub(crate) fn process_alive(pid: u32) -> bool {

#[cfg(target_os = "macos")]
{
// macOS also reports zombies as present to kill(0). Query the process
// state before using a leader PID to authenticate SIGKILL escalation;
// a failed/empty query means the process disappeared during the check.
// macOS reports zombies as present to kill(0), and ps marks a process
// that is trying to exit with the E modifier. Require a known live
// state with no exit modifier before authenticating SIGKILL escalation;
// a failed or unrecognized query must fail closed.
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "stat="])
.output();
Expand All @@ -258,11 +268,7 @@ pub(crate) fn process_alive(pid: u32) -> bool {
if !output.status.success() {
return false;
}
return String::from_utf8_lossy(&output.stdout)
.trim_start()
.chars()
.next()
.is_some_and(|state| state != 'Z');
return macos_process_state_allows_escalation(&String::from_utf8_lossy(&output.stdout));
}

#[cfg(not(target_os = "macos"))]
Expand Down Expand Up @@ -390,6 +396,36 @@ mod tests {
));
}

#[test]
fn macos_live_process_states_can_authenticate_sigkill_escalation() {
for stat in ["I", "R+", "Ss", "T", "U"] {
assert!(
macos_process_state_allows_escalation(stat),
"live state {stat} should authenticate escalation"
);
}
}

#[test]
fn macos_dead_or_unknown_process_states_cannot_authenticate_sigkill_escalation() {
for stat in ["", "Z", "Z+", "?"] {
assert!(
!macos_process_state_allows_escalation(stat),
"dead state {stat:?} must not authenticate escalation"
);
}
}

#[test]
fn macos_exiting_process_states_cannot_authenticate_sigkill_escalation() {
for stat in ["SE", "UEs"] {
assert!(
!macos_process_state_allows_escalation(stat),
"exiting state {stat} must not authenticate escalation"
);
}
}

#[cfg(unix)]
#[test]
fn cancellation_does_not_signal_an_unverified_group_after_leader_exit() {
Expand Down
30 changes: 30 additions & 0 deletions src/crates/assembly/core/src/service/config/normalization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult {
diagnostics,
};
}

normalize_incompatible_telemetry_value(&mut value, &mut diagnostics);

if previous_schema < u64::from(CURRENT_CONFIG_SCHEMA_VERSION) {
if let Some(root) = value.as_object_mut() {
root.insert(
Expand All @@ -73,6 +76,33 @@ pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult {
}
}

fn normalize_incompatible_telemetry_value(
config: &mut Value,
diagnostics: &mut Vec<ConfigDiagnostic>,
) {
let Some(telemetry) = config
.get_mut("app")
.and_then(Value::as_object_mut)
.and_then(|app| app.get_mut("telemetry"))
else {
return;
};

if telemetry.is_boolean() {
return;
}

*telemetry = Value::Bool(false);
diagnostics.push(ConfigDiagnostic {
path: "app.telemetry".to_string(),
message: "Disabled an unsupported telemetry configuration during compatibility recovery"
.to_string(),
code: "CONFIG_TELEMETRY_DOWNGRADED".to_string(),
severity: ConfigDiagnosticSeverity::Warning,
recoverability: ConfigDiagnosticRecoverability::AutoFix,
});
}

pub fn reject_unsupported_schema(diagnostics: &[ConfigDiagnostic]) -> BitFunResult<()> {
if let Some(diagnostic) = diagnostics
.iter()
Expand Down
80 changes: 80 additions & 0 deletions src/crates/assembly/core/src/service/config/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,86 @@ mod tests {
assert!(current["mcpServers"].get("stale").is_none());
}

#[tokio::test]
async fn startup_downgrades_structured_telemetry_without_losing_models() {
let dir = tempfile::tempdir().expect("tempdir");
let user_root = dir.path().join("structured-telemetry-compatibility");
let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root));
path_manager
.initialize_user_directories()
.await
.expect("user directories");

let mut config = GlobalConfig::default();
config
.ai
.models
.push(model("configured-model", true, ModelCategory::GeneralChat));
let mut config_value = serde_json::to_value(config).expect("serialize config");
config_value["app"]["telemetry"] = serde_json::json!({
"version": 2,
"level": "basic",
"sensitive_content_consent": false,
});
let original = serde_json::to_string_pretty(&config_value).expect("format config");
tokio::fs::write(path_manager.app_config_file(), &original)
.await
.expect("seed config");

let service = ConfigService::with_settings(ConfigManagerSettings {
path_manager: Some(path_manager.clone()),
auto_save: true,
backup_count: 5,
})
.await
.expect("config service should recover the telemetry field");

let loaded: GlobalConfig = service.get_config(None).await.expect("loaded config");
assert!(loaded
.ai
.models
.iter()
.any(|configured| configured.id == "configured-model"));

let diagnostics = service.load_diagnostics().await;
assert!(!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY"));
let telemetry_diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "CONFIG_TELEMETRY_DOWNGRADED")
.expect("telemetry compatibility diagnostic");
assert_eq!(telemetry_diagnostic.path, "app.telemetry");
assert_eq!(
telemetry_diagnostic.recoverability,
ConfigDiagnosticRecoverability::AutoFix
);

let persisted: serde_json::Value = serde_json::from_str(
&tokio::fs::read_to_string(path_manager.app_config_file())
.await
.expect("persisted config"),
)
.expect("valid persisted config");
assert_eq!(persisted["app"]["telemetry"], serde_json::json!(false));

let backups = std::fs::read_dir(path_manager.user_config_dir().join("backups"))
.expect("backup directory")
.collect::<Result<Vec<_>, _>>()
.expect("backup entries");
assert_eq!(backups.len(), 1);
assert!(backups[0]
.file_name()
.to_string_lossy()
.contains("startup-normalization"));
assert_eq!(
tokio::fs::read_to_string(backups[0].path())
.await
.expect("backup content"),
original
);
}

#[tokio::test]
async fn startup_repairs_speech_sentinels_and_creates_a_backup() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down