From 45b9ae249125b6f3ce696ed65c49df67182db83f Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:09:38 -0300 Subject: [PATCH 01/14] refactor(sanitize): 08-R1 encapsulate startup migration summary (#3227) --- src-tauri/src/startup.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/src/startup.rs b/src-tauri/src/startup.rs index 8171d2bad..afc03b09d 100644 --- a/src-tauri/src/startup.rs +++ b/src-tauri/src/startup.rs @@ -101,8 +101,7 @@ pub(crate) const INSTANCE_FAMILIES_SCOPE: &str = "instance-families"; /// The summary `run()` logs once `init_logger` has run: the migration runs /// before the logger, so `log::*` from inside it goes nowhere. -pub(crate) static NAMING_MIGRATION_SUMMARY: std::sync::Mutex> = - std::sync::Mutex::new(None); +static NAMING_MIGRATION_SUMMARY: std::sync::Mutex> = std::sync::Mutex::new(None); pub(crate) fn record_naming_migration_summary(summary: Option) { if let Ok(mut slot) = NAMING_MIGRATION_SUMMARY.lock() { From b96b25399e34f0f5970e0ef29bd6fd399caa87e8 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:09:28 -0300 Subject: [PATCH 02/14] refactor(sanitize): 04-R3 move attempts reader to storage (#3227) --- src-tauri/src/cli/role_experiment/resume.rs | 24 +------------------ src-tauri/src/cli/role_experiment/storage.rs | 23 ++++++++++++++++++ src-tauri/src/cli/role_experiment/tests.rs | 25 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/cli/role_experiment/resume.rs b/src-tauri/src/cli/role_experiment/resume.rs index 16e4bc313..f2d994c34 100644 --- a/src-tauri/src/cli/role_experiment/resume.rs +++ b/src-tauri/src/cli/role_experiment/resume.rs @@ -1,11 +1,11 @@ use super::report::build_attempts; use super::report::default_run_artifact_paths; use super::report::validate_report_artifacts; +use super::storage::read_attempts_jsonl; use super::storage::read_json; use super::storage::validate_workgroup_under_ac_root; use super::storage::write_attempts_jsonl_replace; use super::support::err; -use super::support::err_at_line; use super::support::err_at_path; use super::validation::reject_link_or_reparse; use super::AttemptArtifact; @@ -369,28 +369,6 @@ pub(super) fn replica_root_for_attempt( } } -fn read_attempts_jsonl(path: &Path) -> Result, Vec> { - let content = fs::read_to_string(path).map_err(|e| { - vec![err_at_path( - "run_artifact_unparseable", - format!("Failed to read {}: {}", path.display(), e), - path, - )] - })?; - let mut attempts = Vec::new(); - for (idx, line) in content.lines().enumerate() { - attempts.push(serde_json::from_str(line).map_err(|e| { - vec![err_at_line( - "run_artifact_unparseable", - format!("Attempt line {} is invalid JSON: {}", idx + 1, e), - idx + 1, - None, - )] - })?); - } - Ok(attempts) -} - pub(super) fn reconcile_mutable_artifacts( run_dir: &Path, workgroup: &RunWorkgroupArtifact, diff --git a/src-tauri/src/cli/role_experiment/storage.rs b/src-tauri/src/cli/role_experiment/storage.rs index 83a46fdcf..4f5a9b2c7 100644 --- a/src-tauri/src/cli/role_experiment/storage.rs +++ b/src-tauri/src/cli/role_experiment/storage.rs @@ -1,5 +1,6 @@ use super::report::experiments_dir; use super::support::err; +use super::support::err_at_line; use super::support::err_at_path; use super::support::variant_matrix_path; use super::validation::reject_link_or_reparse; @@ -119,6 +120,28 @@ pub(super) fn write_json_new(path: &Path, value: &T) -> Result<(), write_new_file(path, json.as_bytes()) } +pub(super) fn read_attempts_jsonl(path: &Path) -> Result, Vec> { + let content = fs::read_to_string(path).map_err(|e| { + vec![err_at_path( + "run_artifact_unparseable", + format!("Failed to read {}: {}", path.display(), e), + path, + )] + })?; + let mut attempts = Vec::new(); + for (idx, line) in content.lines().enumerate() { + attempts.push(serde_json::from_str(line).map_err(|e| { + vec![err_at_line( + "run_artifact_unparseable", + format!("Attempt line {} is invalid JSON: {}", idx + 1, e), + idx + 1, + None, + )] + })?); + } + Ok(attempts) +} + pub(super) fn write_attempts_jsonl_new( path: &Path, attempts: &[AttemptArtifact], diff --git a/src-tauri/src/cli/role_experiment/tests.rs b/src-tauri/src/cli/role_experiment/tests.rs index 9e8cdc891..e68bc1ab2 100644 --- a/src-tauri/src/cli/role_experiment/tests.rs +++ b/src-tauri/src/cli/role_experiment/tests.rs @@ -446,3 +446,28 @@ fn role_exp_room_dir_name_is_padded() { "room-100-role-exp-team" ); } + +#[test] +fn attempts_reader_preserves_empty_and_invalid_line_behavior() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("reader-fixture.jsonl"); + std::fs::write(&path, "").expect("empty fixture"); + assert!(storage::read_attempts_jsonl(&path) + .expect("empty attempts") + .is_empty()); + + let (_tmp, _loaded, _suite, _variants, _args, run_dir, _run_id, _report) = resume_fixture(); + let fixture = std::fs::read_to_string(run_dir.join("attempts.jsonl")).expect("fixture"); + let first = fixture.lines().next().expect("first attempt"); + std::fs::write(&path, format!("{}\ninvalid\n", first)).expect("invalid fixture"); + let errors = storage::read_attempts_jsonl(&path).expect_err("invalid line"); + assert_eq!(errors[0].code, "run_artifact_unparseable"); + assert_eq!(errors[0].line, Some(2)); + assert!(errors[0] + .message + .starts_with("Attempt line 2 is invalid JSON: ")); + + std::fs::write(&path, format!("{}\n\n", first)).expect("blank line fixture"); + let errors = storage::read_attempts_jsonl(&path).expect_err("blank line"); + assert_eq!(errors[0].line, Some(2)); +} From d149b08d711524ac61d4d60064dcfd4f30317eef Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:09:42 -0300 Subject: [PATCH 03/14] refactor(sanitize): 07-R4 introduce capture catalog disk reader (#3227) --- src-tauri/src/filesystem.rs | 11 ++++++ src-tauri/src/filesystem/tests.rs | 56 +++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 68 insertions(+) create mode 100644 src-tauri/src/filesystem.rs create mode 100644 src-tauri/src/filesystem/tests.rs diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs new file mode 100644 index 000000000..ecaf63bc1 --- /dev/null +++ b/src-tauri/src/filesystem.rs @@ -0,0 +1,11 @@ +//! Disk reads for user-owned documents. + +use std::path::Path; + +/// Read the capture catalog without changing the platform's file semantics. +pub(crate) fn read_capture_catalog(path: &Path) -> std::io::Result { + std::fs::read_to_string(path) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/filesystem/tests.rs b/src-tauri/src/filesystem/tests.rs new file mode 100644 index 000000000..1bcf5d2f4 --- /dev/null +++ b/src-tauri/src/filesystem/tests.rs @@ -0,0 +1,56 @@ +use super::*; + +#[test] +fn reads_the_complete_utf8_document() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("catalog.json"); + let document = "{\"categories\":{}}\nñ"; + std::fs::write(&path, document).unwrap(); + + assert_eq!(read_capture_catalog(&path).unwrap(), document); +} + +#[test] +fn preserves_a_missing_file_error() { + let directory = tempfile::tempdir().unwrap(); + let error = read_capture_catalog(&directory.path().join("missing.json")).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); +} + +#[test] +fn preserves_an_invalid_utf8_error() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("catalog.json"); + std::fs::write(&path, [0xff]).unwrap(); + let expected = std::fs::read_to_string(&path).unwrap_err(); + let actual = read_capture_catalog(&path).unwrap_err(); + + assert_eq!(actual.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(actual.to_string(), expected.to_string()); + assert_eq!(actual.raw_os_error(), expected.raw_os_error()); +} + +#[test] +fn preserves_a_directory_read_error() { + let directory = tempfile::tempdir().unwrap(); + let expected = std::fs::read_to_string(directory.path()).unwrap_err(); + let actual = read_capture_catalog(directory.path()).unwrap_err(); + + assert_ne!(actual.kind(), std::io::ErrorKind::NotFound); + assert_eq!(actual.kind(), expected.kind()); + assert_eq!(actual.raw_os_error(), expected.raw_os_error()); + assert_eq!(actual.to_string(), expected.to_string()); +} + +#[cfg(unix)] +#[test] +fn follows_a_symlink_to_the_document() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("catalog.json"); + let link = directory.path().join("catalog-link.json"); + std::fs::write(&target, "catalog contents").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert_eq!(read_capture_catalog(&link).unwrap(), "catalog contents"); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7974564e..1b4de0a52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod cli; pub mod commands; pub mod config; pub(crate) mod errors; +pub(crate) mod filesystem; pub mod logging; pub(crate) mod loops; pub mod network; From b0cdb11d54466fde006883992f0b239bbc899818 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:09:54 -0300 Subject: [PATCH 04/14] refactor(sanitize): 09-R4 centralize window placement environment input (#3227) --- src-tauri/module-arcs.txt | 1 + src-tauri/src/config.rs | 1 + src-tauri/src/config/environment.rs | 3 +++ src-tauri/src/testability/window_placement.rs | 2 +- 4 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/config/environment.rs diff --git a/src-tauri/module-arcs.txt b/src-tauri/module-arcs.txt index df60af889..e77d3e39e 100644 --- a/src-tauri/module-arcs.txt +++ b/src-tauri/module-arcs.txt @@ -2885,6 +2885,7 @@ agentscommander_lib::testability::ui_automation::files -> agentscommander_lib::t agentscommander_lib::testability::window_info -> agentscommander_lib agentscommander_lib::testability::window_info::windows_impl -> agentscommander_lib::config::profile agentscommander_lib::testability::window_info::windows_impl -> agentscommander_lib::testability::window_info +agentscommander_lib::testability::window_placement -> agentscommander_lib::config::environment agentscommander_lib::update_check -> agentscommander_lib::config agentscommander_lib::update_check -> agentscommander_lib::config::settings agentscommander_lib::update_check -> agentscommander_lib::network diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7d8e2483a..e1819ba62 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -15,6 +15,7 @@ pub(crate) mod config_seed; pub(crate) mod coordinator_clocks; pub(crate) mod daemon_pid; pub(crate) mod entity_prefix; +pub(crate) mod environment; pub(crate) mod injected_messages; pub(crate) mod instance_artifacts; pub(crate) mod instance_gitignore; diff --git a/src-tauri/src/config/environment.rs b/src-tauri/src/config/environment.rs new file mode 100644 index 000000000..9d393aab0 --- /dev/null +++ b/src-tauri/src/config/environment.rs @@ -0,0 +1,3 @@ +pub(crate) fn test_window_placement() -> Option { + std::env::var("AC_TEST_WINDOW_PLACEMENT").ok() +} diff --git a/src-tauri/src/testability/window_placement.rs b/src-tauri/src/testability/window_placement.rs index 7b222bac6..bc18b1a6f 100644 --- a/src-tauri/src/testability/window_placement.rs +++ b/src-tauri/src/testability/window_placement.rs @@ -24,7 +24,7 @@ pub fn resolve_from_cli_or_env( ) -> Result, String> { let exe_path = std::env::current_exe().map_err(|e| format!("failed_to_resolve_current_exe: {e}"))?; - let env_value = std::env::var("AC_TEST_WINDOW_PLACEMENT").ok(); + let env_value = crate::config::environment::test_window_placement(); resolve_from_cli_or_env_for_exe( &exe_path, env_value.as_deref(), From 1d5c821ffa3d468753761d33b405862ba16298e9 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:16:40 -0300 Subject: [PATCH 05/14] refactor(sanitize): 08-R1 encapsulate install command registry (#3227) --- src-tauri/src/agent_update.rs | 4 ++-- src-tauri/src/agent_update/install.rs | 10 +++++++++- src-tauri/src/agent_update/tests/agent_install.rs | 5 +---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/agent_update.rs b/src-tauri/src/agent_update.rs index 0e3acce60..82d5556c1 100644 --- a/src-tauri/src/agent_update.rs +++ b/src-tauri/src/agent_update.rs @@ -68,8 +68,8 @@ pub use gate::{AgentUpdateGate, AnswerClaim, ClaimedPrompt, PromptState}; pub(crate) use install::start_agent_install; #[cfg(test)] use install::{ - agent_install_log_lines, install_finished_payload, run_agent_install, - start_agent_install_with_timeout, AgentInstallRun, InstallInFlight, INSTALLS_IN_FLIGHT, + agent_install_log_lines, contains_install_command, install_finished_payload, run_agent_install, + start_agent_install_with_timeout, AgentInstallRun, InstallInFlight, }; pub use pass::pass_nodes; use pass::PassSupervisor; diff --git a/src-tauri/src/agent_update/install.rs b/src-tauri/src/agent_update/install.rs index b72da1932..7503ad98f 100644 --- a/src-tauri/src/agent_update/install.rs +++ b/src-tauri/src/agent_update/install.rs @@ -15,9 +15,17 @@ use tauri::AppHandle; /// #2736 - resolved install commands with a running install task. Keyed by the /// command string, so two catalog keys sharing one command never run it twice. -pub(crate) static INSTALLS_IN_FLIGHT: std::sync::LazyLock>> = +static INSTALLS_IN_FLIGHT: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); +#[cfg(test)] +pub(crate) fn contains_install_command(command: &str) -> bool { + INSTALLS_IN_FLIGHT + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(command) +} + /// #2736 - RAII entry in `INSTALLS_IN_FLIGHT`; dropping it releases the command. pub(crate) struct InstallInFlight(pub(crate) String); diff --git a/src-tauri/src/agent_update/tests/agent_install.rs b/src-tauri/src/agent_update/tests/agent_install.rs index d0e2946da..ea2e032d5 100644 --- a/src-tauri/src/agent_update/tests/agent_install.rs +++ b/src-tauri/src/agent_update/tests/agent_install.rs @@ -31,10 +31,7 @@ async fn next_install_frame( } fn install_in_flight(command: &str) -> bool { - INSTALLS_IN_FLIGHT - .lock() - .unwrap_or_else(|e| e.into_inner()) - .contains(command) + contains_install_command(command) } /// A shell command valid under the host shell: `windows` for cmd.exe /C, From 2ee43ae6c8e6db20b5c4aba97e3cce5f74c15382 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:20:39 -0300 Subject: [PATCH 06/14] refactor(sanitize): 04-R2 centralize attempts paths and typed API (#3227) --- src-tauri/src/cli/role_experiment/commands.rs | 21 ++++-- src-tauri/src/cli/role_experiment/fake_run.rs | 13 ++-- src-tauri/src/cli/role_experiment/report.rs | 15 +++-- src-tauri/src/cli/role_experiment/resume.rs | 25 +++++--- src-tauri/src/cli/role_experiment/storage.rs | 30 +++++++-- src-tauri/src/cli/role_experiment/tests.rs | 64 +++++++++++++++++-- 6 files changed, 126 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/cli/role_experiment/commands.rs b/src-tauri/src/cli/role_experiment/commands.rs index c979ecbf0..9bc715a85 100644 --- a/src-tauri/src/cli/role_experiment/commands.rs +++ b/src-tauri/src/cli/role_experiment/commands.rs @@ -14,15 +14,16 @@ use super::report::run_dir; use super::report::validate_replicates; use super::report::validate_report_artifacts; use super::report::validate_run_id; +use super::storage::create_attempts; use super::storage::load_experiment; use super::storage::load_variant_metadata; use super::storage::read_json; use super::storage::resolve_ac_root; use super::storage::resolve_project; -use super::storage::write_attempts_jsonl_new; use super::storage::write_json; use super::storage::write_json_new; use super::storage::write_new_file; +use super::storage::{attempts_name, attempts_path, attempts_path_display}; use super::support::err; use super::support::err_at_path; use super::support::line_diff; @@ -712,7 +713,7 @@ pub(super) fn run(args: RunArgs) -> Result> { &validation.variant_metas, args.replicates, ); - let artifacts = default_run_artifact_paths(); + let artifacts = default_run_artifact_paths(attempts_name()); let run_artifact = RunArtifact { schema_version: 1, run_id: run_id.clone(), @@ -756,7 +757,7 @@ pub(super) fn run(args: RunArgs) -> Result> { let report_artifact = build_report_artifact( &run_id, &loaded.experiment.name, - &run_dir, + report_artifact_paths(&run_dir, attempts_path_display(&run_dir)), ×tamp, &suite.prompts, &validation.variant_metas, @@ -768,7 +769,7 @@ pub(super) fn run(args: RunArgs) -> Result> { let report_markdown = render_report_markdown(&report_artifact); write_json_new(&run_dir.join("run.json"), &run_artifact)?; - write_attempts_jsonl_new(&run_dir.join("attempts.jsonl"), &attempts)?; + create_attempts(&run_dir, &attempts)?; write_json_new(&run_dir.join("report.json"), &report_artifact)?; write_new_file(&run_dir.join("report.md"), report_markdown.as_bytes())?; @@ -834,7 +835,7 @@ pub(super) fn report(args: ReportArgs) -> Result> { )]); } let run_path = dir.join("run.json"); - let attempts_path = dir.join("attempts.jsonl"); + let attempts_path = attempts_path(&dir); let report_path = dir.join("report.json"); let markdown_path = dir.join("report.md"); for path in [&run_path, &attempts_path, &report_path, &markdown_path] { @@ -851,8 +852,14 @@ pub(super) fn report(args: ReportArgs) -> Result> { .map_err(|e| vec![err_at_path("run_artifact_unparseable", e, &run_path)])?; let report_artifact: ReportArtifact = read_json(&report_path) .map_err(|e| vec![err_at_path("run_artifact_unparseable", e, &report_path)])?; - validate_report_artifacts(&loaded, &args.run_id, &run_artifact, &report_artifact)?; - let expected_artifact_paths = report_artifact_paths(&dir); + validate_report_artifacts( + &loaded, + &args.run_id, + &run_artifact, + &report_artifact, + attempts_path_display(&dir), + )?; + let expected_artifact_paths = report_artifact_paths(&dir, attempts_path_display(&dir)); let artifact_paths = serde_json::to_value(&expected_artifact_paths).unwrap_or(serde_json::Value::Null); if args.format == "json" { diff --git a/src-tauri/src/cli/role_experiment/fake_run.rs b/src-tauri/src/cli/role_experiment/fake_run.rs index f7149218d..94db398d4 100644 --- a/src-tauri/src/cli/role_experiment/fake_run.rs +++ b/src-tauri/src/cli/role_experiment/fake_run.rs @@ -19,8 +19,9 @@ use super::storage::ensure_run_artifact_dirs; use super::storage::load_experiment; use super::storage::load_variant_metadata; use super::storage::next_attempt_output_path; +use super::storage::replace_attempts; use super::storage::write_attempt_prompt_file; -use super::storage::write_attempts_jsonl_replace; +use super::storage::{attempts_name, attempts_path_display}; use super::support::err; use super::support::err_at_path; use super::support::source_matrix_path; @@ -100,7 +101,7 @@ pub(super) fn run_fake_execution(args: RunArgs) -> Result Result Result u64 { u64::from_le_bytes(bytes[0..8].try_into().expect("uuid slice is 8 bytes")) } -pub(super) fn default_run_artifact_paths() -> RunArtifactPaths { +pub(super) fn default_run_artifact_paths(attempts: String) -> RunArtifactPaths { RunArtifactPaths { - attempts: "attempts.jsonl".to_string(), + attempts, report_json: "report.json".to_string(), report_markdown: "report.md".to_string(), prompts: "prompts".to_string(), @@ -159,10 +159,10 @@ pub(super) fn default_run_artifact_paths() -> RunArtifactPaths { } } -pub(super) fn report_artifact_paths(run_dir: &Path) -> ReportArtifactPaths { +pub(super) fn report_artifact_paths(run_dir: &Path, attempts: String) -> ReportArtifactPaths { ReportArtifactPaths { run: run_dir.join("run.json").to_string_lossy().to_string(), - attempts: run_dir.join("attempts.jsonl").to_string_lossy().to_string(), + attempts, report_json: run_dir.join("report.json").to_string_lossy().to_string(), report_markdown: run_dir.join("report.md").to_string_lossy().to_string(), } @@ -218,7 +218,7 @@ pub(super) fn build_attempts( pub(super) fn build_report_artifact( run_id: &str, experiment: &str, - run_dir: &Path, + artifact_paths: ReportArtifactPaths, timestamp: &str, prompts: &[PromptCase], variants: &[VariantMetadata], @@ -285,7 +285,7 @@ pub(super) fn build_report_artifact( } }) .collect(), - artifact_paths: report_artifact_paths(run_dir), + artifact_paths, notes: report_notes(dry_run, workgroup), } } @@ -449,6 +449,7 @@ pub(super) fn validate_report_artifacts( run_id: &str, run: &RunArtifact, report: &ReportArtifact, + attempts_path: String, ) -> Result<(), Vec> { let mut errors = Vec::new(); if run.schema_version != 1 || report.schema_version != 1 { @@ -510,7 +511,7 @@ pub(super) fn validate_report_artifacts( None, )); } - let expected_artifact_paths = report_artifact_paths(&run_dir(loaded, run_id)); + let expected_artifact_paths = report_artifact_paths(&run_dir(loaded, run_id), attempts_path); if report.artifact_paths != expected_artifact_paths { errors.push(err( "run_artifact_mismatch", diff --git a/src-tauri/src/cli/role_experiment/resume.rs b/src-tauri/src/cli/role_experiment/resume.rs index f2d994c34..027d29663 100644 --- a/src-tauri/src/cli/role_experiment/resume.rs +++ b/src-tauri/src/cli/role_experiment/resume.rs @@ -1,10 +1,11 @@ use super::report::build_attempts; use super::report::default_run_artifact_paths; use super::report::validate_report_artifacts; -use super::storage::read_attempts_jsonl; +use super::storage::load_attempts; use super::storage::read_json; +use super::storage::replace_attempts; use super::storage::validate_workgroup_under_ac_root; -use super::storage::write_attempts_jsonl_replace; +use super::storage::{attempts_name, attempts_path, attempts_path_display}; use super::support::err; use super::support::err_at_path; use super::validation::reject_link_or_reparse; @@ -43,7 +44,7 @@ pub(super) fn load_and_validate_resume_artifacts( Vec, > { let run_path = run_dir.join("run.json"); - let attempts_path = run_dir.join("attempts.jsonl"); + let attempts_path = attempts_path(run_dir); let report_path = run_dir.join("report.json"); let markdown_path = run_dir.join("report.md"); let state_dir = run_dir.join("attempt-state"); @@ -68,7 +69,7 @@ pub(super) fn load_and_validate_resume_artifacts( let run_artifact: RunArtifact = read_json(&run_path) .map_err(|e| vec![err_at_path("run_artifact_unparseable", e, &run_path)])?; - let attempts = read_attempts_jsonl(&attempts_path)?; + let attempts = load_attempts(run_dir)?; let report_artifact: ReportArtifact = read_json(&report_path) .map_err(|e| vec![err_at_path("run_artifact_unparseable", e, &report_path)])?; @@ -89,9 +90,13 @@ pub(super) fn load_and_validate_resume_artifacts( }; let mut errors = Vec::new(); - if let Err(mut report_errors) = - validate_report_artifacts(loaded, run_id, &run_artifact, &report_artifact) - { + if let Err(mut report_errors) = validate_report_artifacts( + loaded, + run_id, + &run_artifact, + &report_artifact, + attempts_path_display(&super::report::run_dir(loaded, run_id)), + ) { errors.append(&mut report_errors); } if run_artifact.schema_version != 1 || run_artifact.project != args.project { @@ -116,7 +121,7 @@ pub(super) fn load_and_validate_resume_artifacts( || run_artifact.replicates != args.replicates || run_artifact.variants != expected_variants || run_artifact.attempt_count != expected_attempts.len() - || run_artifact.artifacts != default_run_artifact_paths() + || run_artifact.artifacts != default_run_artifact_paths(attempts_name()) { errors.push(err( "run_artifact_mismatch", @@ -374,7 +379,7 @@ pub(super) fn reconcile_mutable_artifacts( workgroup: &RunWorkgroupArtifact, retry_failed: bool, ) -> Result, Vec> { - let mut attempts = read_attempts_jsonl(&run_dir.join("attempts.jsonl"))?; + let mut attempts = load_attempts(run_dir)?; let state_dir = run_dir.join("attempt-state"); reject_link_or_reparse(&state_dir, "run_artifact_link_or_reparse", None)?; for attempt in &mut attempts { @@ -417,7 +422,7 @@ pub(super) fn reconcile_mutable_artifacts( } } } - write_attempts_jsonl_replace(&run_dir.join("attempts.jsonl"), &attempts)?; + replace_attempts(run_dir, &attempts)?; Ok(attempts) } diff --git a/src-tauri/src/cli/role_experiment/storage.rs b/src-tauri/src/cli/role_experiment/storage.rs index 4f5a9b2c7..5cbf39031 100644 --- a/src-tauri/src/cli/role_experiment/storage.rs +++ b/src-tauri/src/cli/role_experiment/storage.rs @@ -120,7 +120,25 @@ pub(super) fn write_json_new(path: &Path, value: &T) -> Result<(), write_new_file(path, json.as_bytes()) } -pub(super) fn read_attempts_jsonl(path: &Path) -> Result, Vec> { +const ATTEMPTS_FILE_NAME: &str = "attempts.jsonl"; + +pub(super) fn attempts_name() -> String { + ATTEMPTS_FILE_NAME.to_string() +} + +pub(super) fn attempts_path(run_dir: &Path) -> PathBuf { + run_dir.join(ATTEMPTS_FILE_NAME) +} + +pub(super) fn attempts_path_display(run_dir: &Path) -> String { + attempts_path(run_dir).to_string_lossy().to_string() +} + +pub(super) fn load_attempts(run_dir: &Path) -> Result, Vec> { + read_attempts_jsonl(&attempts_path(run_dir)) +} + +fn read_attempts_jsonl(path: &Path) -> Result, Vec> { let content = fs::read_to_string(path).map_err(|e| { vec![err_at_path( "run_artifact_unparseable", @@ -142,10 +160,11 @@ pub(super) fn read_attempts_jsonl(path: &Path) -> Result, V Ok(attempts) } -pub(super) fn write_attempts_jsonl_new( - path: &Path, +pub(super) fn create_attempts( + run_dir: &Path, attempts: &[AttemptArtifact], ) -> Result<(), Vec> { + let path = &attempts_path(run_dir); let mut content = String::new(); for attempt in attempts { let line = serde_json::to_string(attempt).map_err(|e| { @@ -165,10 +184,11 @@ pub(super) fn write_attempts_jsonl_new( write_new_file(path, content.as_bytes()) } -pub(super) fn write_attempts_jsonl_replace( - path: &Path, +pub(super) fn replace_attempts( + run_dir: &Path, attempts: &[AttemptArtifact], ) -> Result<(), Vec> { + let path = &attempts_path(run_dir); let mut content = String::new(); for attempt in attempts { let line = serde_json::to_string(attempt).map_err(|e| { diff --git a/src-tauri/src/cli/role_experiment/tests.rs b/src-tauri/src/cli/role_experiment/tests.rs index e68bc1ab2..7cc0f3b96 100644 --- a/src-tauri/src/cli/role_experiment/tests.rs +++ b/src-tauri/src/cli/role_experiment/tests.rs @@ -1,3 +1,4 @@ +use super::storage::{attempts_name, attempts_path, attempts_path_display}; use super::*; fn loaded_fixture(source_agent: &str) -> (tempfile::TempDir, LoadedExperiment) { @@ -141,7 +142,7 @@ fn resume_fixture() -> ( let report = build_report_artifact( &run_id, &loaded.experiment.name, - &run_dir, + report::report_artifact_paths(&run_dir, attempts_path_display(&run_dir)), "2026-06-01T18:15:00Z", &suite.prompts, &variants, @@ -177,7 +178,7 @@ fn resume_fixture() -> ( }) .collect(), attempt_count: attempts.len(), - artifacts: default_run_artifact_paths(), + artifacts: default_run_artifact_paths(attempts_name()), execution: Some(RunExecutionArtifact { provider: "auto".to_string(), attempt_timeout_secs: 900, @@ -450,17 +451,17 @@ fn role_exp_room_dir_name_is_padded() { #[test] fn attempts_reader_preserves_empty_and_invalid_line_behavior() { let tmp = tempfile::tempdir().expect("tempdir"); - let path = tmp.path().join("reader-fixture.jsonl"); + let path = attempts_path(tmp.path()); std::fs::write(&path, "").expect("empty fixture"); - assert!(storage::read_attempts_jsonl(&path) + assert!(storage::load_attempts(tmp.path()) .expect("empty attempts") .is_empty()); let (_tmp, _loaded, _suite, _variants, _args, run_dir, _run_id, _report) = resume_fixture(); - let fixture = std::fs::read_to_string(run_dir.join("attempts.jsonl")).expect("fixture"); + let fixture = std::fs::read_to_string(attempts_path(&run_dir)).expect("fixture"); let first = fixture.lines().next().expect("first attempt"); std::fs::write(&path, format!("{}\ninvalid\n", first)).expect("invalid fixture"); - let errors = storage::read_attempts_jsonl(&path).expect_err("invalid line"); + let errors = storage::load_attempts(tmp.path()).expect_err("invalid line"); assert_eq!(errors[0].code, "run_artifact_unparseable"); assert_eq!(errors[0].line, Some(2)); assert!(errors[0] @@ -468,6 +469,55 @@ fn attempts_reader_preserves_empty_and_invalid_line_behavior() { .starts_with("Attempt line 2 is invalid JSON: ")); std::fs::write(&path, format!("{}\n\n", first)).expect("blank line fixture"); - let errors = storage::read_attempts_jsonl(&path).expect_err("blank line"); + let errors = storage::load_attempts(tmp.path()).expect_err("blank line"); assert_eq!(errors[0].line, Some(2)); } + +#[test] +fn attempts_storage_preserves_bytes_order_and_create_new() { + let (_tmp, _loaded, _suite, _variants, _args, run_dir, _run_id, _report) = resume_fixture(); + let fixture = std::fs::read_to_string(attempts_path(&run_dir)).expect("fixture"); + let attempts: Vec = fixture + .lines() + .map(|line| serde_json::from_str(line).expect("fixture attempt")) + .collect(); + let target = tempfile::tempdir().expect("target"); + storage::create_attempts(target.path(), &attempts).expect("create"); + let expected = format!("{}\n", fixture); + assert_eq!( + std::fs::read_to_string(attempts_path(target.path())).unwrap(), + expected + ); + let errors = storage::create_attempts(target.path(), &[]).expect_err("already exists"); + assert_eq!(errors[0].code, "run_artifact_write_failed"); + assert_eq!( + std::fs::read_to_string(attempts_path(target.path())).unwrap(), + expected + ); + let loaded = storage::load_attempts(target.path()).expect("read"); + assert_eq!( + serde_json::to_value(loaded).unwrap(), + serde_json::to_value(&attempts).unwrap() + ); + storage::replace_attempts(target.path(), &attempts[..1]).expect("replace"); + assert_eq!( + std::fs::read_to_string(attempts_path(target.path())).unwrap(), + format!("{}\n", fixture.lines().next().unwrap()) + ); +} + +#[test] +fn resume_checks_run_presence_before_invalid_attempts() { + let (_tmp, loaded, suite, variants, args, run_dir, run_id, _report) = resume_fixture(); + std::fs::remove_file(run_dir.join("run.json")).expect("remove run"); + std::fs::write(attempts_path(&run_dir), "invalid").expect("invalid attempts"); + let errors = resume::load_and_validate_resume_artifacts( + &args, &loaded, &suite, &run_id, &run_dir, &variants, + ) + .expect_err("missing run precedes parsing attempts"); + assert_eq!(errors[0].code, "run_artifact_missing"); + assert_eq!( + errors[0].path, + Some(run_dir.join("run.json").to_string_lossy().to_string()) + ); +} From e1b7ed73ea516a6e121be79afff4247d938ae060 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:23:47 -0300 Subject: [PATCH 07/14] refactor(sanitize): 08-R1 encapsulate settings save sequence (#3227) --- src-tauri/src/config/settings.rs | 10 +++---- .../src/config/settings/agent/instance.rs | 6 ++-- .../config/settings/project_path/migration.rs | 9 +++--- src-tauri/src/config/settings/save.rs | 8 ++++-- .../tests/save_backups_and_diagnostics.rs | 28 +++++++++++++++++++ 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index 7ff6d5945..b825f5938 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -219,11 +219,11 @@ use save::{ SETTINGS_SAVE_DIAGNOSTIC_CAPTURE, }; use save::{ - io_error_kind_as_str, json_error_category_as_str, project_lists_from_projection, - read_disk_object_for_write_typed, report_settings_save_error, save_settings_value_with_startup, - serialize_settings_component, write_value_atomic, write_value_atomic_labeled, SettingsFileLock, - SettingsSaveCause, SettingsSaveError, SettingsSaveLegacyOutward, SettingsSaveReason, - SettingsSaveReportSurface, SettingsSaveStage, SAVE_OP_ID, + io_error_kind_as_str, json_error_category_as_str, next_settings_save_op_id, + project_lists_from_projection, read_disk_object_for_write_typed, report_settings_save_error, + save_settings_value_with_startup, serialize_settings_component, write_value_atomic, + write_value_atomic_labeled, SettingsFileLock, SettingsSaveCause, SettingsSaveError, + SettingsSaveLegacyOutward, SettingsSaveReason, SettingsSaveReportSurface, SettingsSaveStage, }; #[cfg(test)] pub(crate) use save::{ diff --git a/src-tauri/src/config/settings/agent/instance.rs b/src-tauri/src/config/settings/agent/instance.rs index 39386b860..0d2126899 100644 --- a/src-tauri/src/config/settings/agent/instance.rs +++ b/src-tauri/src/config/settings/agent/instance.rs @@ -5,11 +5,10 @@ mod overlay; use super::super::project_atomic_checkpoint; use super::super::replace_settings_file_atomic; use super::super::{ - read_disk_object_for_write_typed, refresh_physical_pair_id_hashes, + next_settings_save_op_id, read_disk_object_for_write_typed, refresh_physical_pair_id_hashes, serialize_settings_component, validate_physical_identity_delta, AgentsInstanceRead, AppSettings, CodingAgentProfilesConfig, ProjectWriteMode, SettingsFileLock, SettingsSaveError, SettingsSaveLegacyOutward, SettingsSaveReason, SettingsSaveStage, OVERLAY_KEY_AGENTS, - SAVE_OP_ID, }; use super::{ duplicate_profile_document, registered_profile_raw_gate, registered_profiles_for_read, @@ -38,7 +37,6 @@ pub(crate) use overlay::{ }; use serde_json::{Map, Value}; use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; /// #2716 (B3) - the two top-level groups that live in `agents.30.instance.no-git.json` /// instead of the settings file. Serde names, so the file reuses the settings shape. @@ -198,7 +196,7 @@ pub(crate) fn write_agents_instance_file( SettingsSaveLegacyOutward::SettingsSaveFailed, ) })?; - let op_id = SAVE_OP_ID.fetch_add(1, Ordering::Relaxed); + let op_id = next_settings_save_op_id(); let tmp_path = path.with_file_name(format!( "{AGENTS_INSTANCE_FILE_NAME}.{}.{op_id}.tmp", std::process::id() diff --git a/src-tauri/src/config/settings/project_path/migration.rs b/src-tauri/src/config/settings/project_path/migration.rs index cd1e8ce12..278300f41 100644 --- a/src-tauri/src/config/settings/project_path/migration.rs +++ b/src-tauri/src/config/settings/project_path/migration.rs @@ -1,7 +1,7 @@ use super::super::{ - rotate_settings_backups_labeled, write_value_atomic_labeled, AppSettings, SettingsFileLock, - SettingsSaveError, SettingsSaveLegacyOutward, SettingsSaveReason, SettingsSaveStage, - SAVE_OP_ID, + next_settings_save_op_id, rotate_settings_backups_labeled, write_value_atomic_labeled, + AppSettings, SettingsFileLock, SettingsSaveError, SettingsSaveLegacyOutward, + SettingsSaveReason, SettingsSaveStage, }; use super::{ FIELD_ARCHIVED, FIELD_ARCHIVED_REL, FIELD_PROJECT_PATH, FIELD_PROJECT_PATHS, @@ -9,7 +9,6 @@ use super::{ }; use serde_json::{Map, Value}; use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; // Sole project registration authority; callers hold the shared settings lock. pub(crate) fn project_paths_path(settings_path: &Path) -> PathBuf { @@ -327,7 +326,7 @@ pub(crate) fn publish_project_migration_backup_labeled( name.push(format!( ".{}.{}.tmp", std::process::id(), - SAVE_OP_ID.fetch_add(1, Ordering::Relaxed) + next_settings_save_op_id() )); let temp = parent.join(name); checkpoint("backup_create", &temp)?; diff --git a/src-tauri/src/config/settings/save.rs b/src-tauri/src/config/settings/save.rs index ba6c1ef38..6742bdbe2 100644 --- a/src-tauri/src/config/settings/save.rs +++ b/src-tauri/src/config/settings/save.rs @@ -65,7 +65,11 @@ pub(crate) use value::{ /// from any leftover temp written by a prior crashed run. Mirrors /// `sessions_persistence::SAVE_OP_ID` (sessions_persistence.rs:47); a separate /// counter so the two can be reasoned about independently in diagnostics. -pub(crate) static SAVE_OP_ID: AtomicU64 = AtomicU64::new(0); +static SAVE_OP_ID: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn next_settings_save_op_id() -> u64 { + SAVE_OP_ID.fetch_add(1, Ordering::Relaxed) +} /// Save unrelated settings under the shared settings lock. Migration completes /// first; afterward the sidecar bytes are preserved exactly and all six project @@ -201,7 +205,7 @@ pub(crate) fn write_value_atomic_labeled( )); } - let op_id = SAVE_OP_ID.fetch_add(1, Ordering::Relaxed); + let op_id = next_settings_save_op_id(); let pid = std::process::id(); // Settings keep their existing instance-qualified temp prefix, including // callers supplying a fixture path. The fixed sidecar uses its own name. diff --git a/src-tauri/src/config/settings/tests/save_backups_and_diagnostics.rs b/src-tauri/src/config/settings/tests/save_backups_and_diagnostics.rs index 93f4865ca..03c47ee7e 100644 --- a/src-tauri/src/config/settings/tests/save_backups_and_diagnostics.rs +++ b/src-tauri/src/config/settings/tests/save_backups_and_diagnostics.rs @@ -862,3 +862,31 @@ fn settings_save_diagnostic_redacts_values_and_encodes_untrusted_paths() { assert!(super::super::render_diagnostic_path(&path).contains(r"\u{fffd}")); } } + +#[test] +fn settings_save_operation_ids_are_unique_across_concurrent_callers() { + const CALLERS: usize = 8; + const OPERATIONS: usize = 128; + let barrier = std::sync::Barrier::new(CALLERS); + let ids = std::thread::scope(|scope| { + let workers: Vec<_> = (0..CALLERS) + .map(|_| { + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + let ids: Vec<_> = (0..OPERATIONS) + .map(|_| super::super::next_settings_save_op_id()) + .collect(); + assert!(ids.windows(2).all(|pair| pair[0] < pair[1])); + ids + }) + }) + .collect(); + workers + .into_iter() + .flat_map(|worker| worker.join().unwrap()) + .collect::>() + }); + let unique: std::collections::HashSet<_> = ids.into_iter().collect(); + assert_eq!(unique.len(), CALLERS * OPERATIONS); +} From e64db50a8ff8580eb8bb8a3dfc1241b6b1cbc905 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:21:37 -0300 Subject: [PATCH 08/14] refactor(sanitize): 07-R4 delegate catalog reads to filesystem (#3227) --- src-tauri/module-arcs.txt | 1 + src-tauri/src/capture/catalog.rs | 4 +-- src-tauri/src/capture/catalog/tests.rs | 47 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src-tauri/module-arcs.txt b/src-tauri/module-arcs.txt index e77d3e39e..993fe55d8 100644 --- a/src-tauri/module-arcs.txt +++ b/src-tauri/module-arcs.txt @@ -286,6 +286,7 @@ agentscommander_lib::app_run::setup -> agentscommander_lib::session::selection agentscommander_lib::app_run::setup -> agentscommander_lib::testability::ui_automation agentscommander_lib::app_run::setup -> agentscommander_lib::testability::window_placement agentscommander_lib::app_run::setup -> agentscommander_lib::update_check +agentscommander_lib::capture::catalog -> agentscommander_lib::filesystem agentscommander_lib::capture::jev -> agentscommander_lib::capture::catalog agentscommander_lib::capture::jev -> agentscommander_lib::network agentscommander_lib::capture::key -> agentscommander_lib::capture::record diff --git a/src-tauri/src/capture/catalog.rs b/src-tauri/src/capture/catalog.rs index aa3b82e63..100b6718f 100644 --- a/src-tauri/src/capture/catalog.rs +++ b/src-tauri/src/capture/catalog.rs @@ -12,7 +12,7 @@ //! could express the user's approval is rejected **at load time** (plan section //! 8), so a bad catalog fails visibly once instead of silently on every turn. //! -//! Leaf module: `serde`, `std` and nothing from the rest of this crate. It +//! Catalog validation uses `serde` and delegates disk reads to `filesystem`. It //! never names the phone, session or commands subtrees, either config module //! named in plan section 4, or the network module, which is what keeps it out //! of the 88-module SCC. @@ -114,7 +114,7 @@ impl Catalog { /// Read and validate the catalog file. A missing file is [`Self::missing`]; /// any other read failure or parse failure is an unusable catalog. pub fn load(path: &Path) -> Self { - match std::fs::read_to_string(path) { + match crate::filesystem::read_capture_catalog(path) { Ok(raw) => Self::from_json_str(&raw), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::missing(), Err(error) => Self { diff --git a/src-tauri/src/capture/catalog/tests.rs b/src-tauri/src/capture/catalog/tests.rs index 1f5afdd3f..d70daa0d0 100644 --- a/src-tauri/src/capture/catalog/tests.rs +++ b/src-tauri/src/capture/catalog/tests.rs @@ -132,3 +132,50 @@ fn a_sibling_with_a_missing_question_does_not_poison_the_valid_one() { .collect(); assert_eq!(ids, vec!["a"]); } + +#[test] +fn loads_a_valid_document_from_disk() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("catalog.json"); + std::fs::write(&path, include_str!("tests/fixtures/valid-reply.json")).unwrap(); + + let catalog = Catalog::load(&path); + assert!(!catalog.is_missing()); + assert_eq!(catalog.unparseable_reason(), None); + assert!(matches!( + catalog.resolve("note"), + Resolution::Valid { + destination: Destination::DefaultReply, + .. + } + )); +} + +#[test] +fn invalid_utf8_preserves_the_unreadable_catalog_reason() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("catalog.json"); + std::fs::write(&path, [0xff]).unwrap(); + let error = std::fs::read_to_string(&path).unwrap_err(); + let expected = format!("catalog is unreadable: {error}"); + + let catalog = Catalog::load(&path); + assert!(!catalog.is_missing()); + assert_eq!(catalog.unparseable_reason(), Some(expected.as_str())); + assert!(catalog.questions_in_order().is_empty()); + assert_eq!(catalog.resolve("note"), Resolution::Unknown); +} + +#[test] +fn a_directory_preserves_the_non_missing_read_error() { + let directory = tempfile::tempdir().unwrap(); + let error = std::fs::read_to_string(directory.path()).unwrap_err(); + assert_ne!(error.kind(), std::io::ErrorKind::NotFound); + let expected = format!("catalog is unreadable: {error}"); + + let catalog = Catalog::load(directory.path()); + assert!(!catalog.is_missing()); + assert_eq!(catalog.unparseable_reason(), Some(expected.as_str())); + assert!(catalog.questions_in_order().is_empty()); + assert_eq!(catalog.resolve("note"), Resolution::Unknown); +} From ffdddb7462919a60ef097da3b5480dd9d9c75453 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:32:37 -0300 Subject: [PATCH 09/14] refactor(sanitize): 04 add scoped containers ownership gate (#3227) --- package.json | 3 +- scripts/containers.json | 16 +++++ scripts/sanitizing_containers.mjs | 99 +++++++++++++++++++++++++++++++ scripts/sanitizing_tests.mjs | 63 ++++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/containers.json create mode 100644 scripts/sanitizing_containers.mjs diff --git a/package.json b/package.json index 37b67cba2..73922b103 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "sanitizing:tauri_edge": "node scripts/sanitizing_tauri_edge.mjs", "sanitizing:global_state": "node scripts/sanitizing_global_state.mjs", "sanitizing:process_env": "node scripts/sanitizing_process_env.mjs", - "sanitizing:self": "node --test scripts/sanitizing_tests.mjs" + "sanitizing:self": "node --test scripts/sanitizing_tests.mjs", + "sanitizing:containers": "node scripts/sanitizing_containers.mjs" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/containers.json b/scripts/containers.json new file mode 100644 index 000000000..0fa1acae6 --- /dev/null +++ b/scripts/containers.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "containers": [ + { + "id": "RoleExperiment/Attempts", + "name": "attempts.jsonl", + "scope": "src-tauri/src/cli/role_experiment/", + "owner": "src-tauri/src/cli/role_experiment/storage.rs", + "externalLiteralBaseline": 0, + "writerInventory": { + "files": 2, + "sites": 4 + } + } + ] +} diff --git a/scripts/sanitizing_containers.mjs b/scripts/sanitizing_containers.mjs new file mode 100644 index 000000000..688eb2cc9 --- /dev/null +++ b/scripts/sanitizing_containers.mjs @@ -0,0 +1,99 @@ +// Initial finite inventory: literals identify only registered, scoped containers. +// Writer calls are syntactic observations, never proof of a container's identity. +import { gateCli, isMain, GateError, codeOrder } from './sanitizing_baseline.mjs'; +import { scanRust } from './sanitizing_rust_scan.mjs'; +import { qualified } from './sanitizing_gate_support.mjs'; + +const registryPath = 'scripts/containers.json'; +const writerPath = /^(?:(?:std|tokio)::fs::(?:write|File::create|OpenOptions::new)|(?:fs::)?write|(?:File::create|OpenOptions::new))$/; +const rustSource = p => /^src-tauri\/src\/.*\.rs$/.test(p); +const validPath = p => typeof p === 'string' && /^src-tauri\/src\//.test(p) && + !p.includes('..') && !p.includes(String.fromCharCode(92)); +export function parseRegistry(text) { + let doc; + try { doc = JSON.parse(text); } catch { throw new GateError('containers.json: invalid JSON'); } + if (doc?.version !== 1 || !Array.isArray(doc.containers) || !doc.containers.length) + throw new GateError('containers.json: version 1 and nonempty containers required'); + const ids = new Set(), names = new Set(); + for (const c of doc.containers) { + if (!c || typeof c.id !== 'string' || !c.id.trim() || ids.has(c.id) || + typeof c.name !== 'string' || !/^[\w.-]+\.jsonl?$/.test(c.name) || + !validPath(c.scope) || !c.scope.endsWith('/') || + !validPath(c.owner) || !c.owner.startsWith(c.scope) || !c.owner.endsWith('.rs') || + c.externalLiteralBaseline !== 0 || + !Number.isInteger(c.writerInventory?.files) || c.writerInventory.files < 0 || + !Number.isInteger(c.writerInventory?.sites) || c.writerInventory.sites < c.writerInventory.files || + names.has(c.scope + c.name)) + throw new GateError('containers.json: invalid/duplicate identity, owner, zero baseline or inventory'); + ids.add(c.id); names.add(c.scope + c.name); + } + return doc.containers; +} + +export function measureSource(path, text) { + const scan = scanRust(path, text), ts = scan.productionTokens, writers = []; + for (let i = 0; i < ts.length; i++) { + const t = ts[i]; + if (t.kind !== 'ident' || ts[i - 1]?.text === '.' || + ts[i - 1]?.text === '::' && ts[i - 2]?.kind === 'ident') continue; + const r = qualified(scan, ts, i, writerPath); + if (ts[r.end]?.text === '(' && writerPath.test(r.path ?? '') && + ((r.path ?? '').includes('::') || r.binding?.kind === 'import')) + writers.push({ line: t.line, symbol: r.path, ambiguous: !!r.ambiguous }); + i = r.end - 1; + } + return { literals: ts.filter(t => t.kind === 'string').map(t => ({ value: t.value, line: t.line })), + writers }; +} + +export function runGate(snapshot) { + const diagnostics = [], metrics = [], inventory = { files: 0, sites: 0 }; + try { + const entry = snapshot.headTree.get(registryPath); + if (!entry) throw new GateError('containers.json: registry missing'); + const containers = parseRegistry(snapshot.readBlob(entry)); + for (const c of containers) if (!snapshot.headTree.has(c.owner)) + throw new GateError('containers.json: owner missing: ' + c.owner); + const measurements = new Map(); + for (const p of [...snapshot.headTree.keys()].filter(rustSource).sort(codeOrder)) { + const m = measureSource(p, snapshot.readBlob(snapshot.headTree.get(p))); + measurements.set(p, m); + if (m.writers.length) { inventory.files++; inventory.sites += m.writers.length; } + } + const registeredScopes = containers.map(c => c.scope); + const unknown = [...measurements].filter(([p, m]) => m.writers.length && + !registeredScopes.some(scope => p.startsWith(scope))); + diagnostics.push({ gate: '04', status: 'WARN', rule: 'unclassified_writers', + path: '', line: 1, + reason: 'Syntactic writers outside registered scopes need identity review; no global prohibition.', + files: unknown.length, sites: unknown.reduce((n, [,m]) => n + m.writers.length, 0), + paths: unknown.map(([p]) => p) }); + for (const c of containers) { + const external = [], scopedWriters = { files: 0, sites: 0 }; + for (const [p, m] of measurements) { + if (!p.startsWith(c.scope)) continue; + if (m.writers.length) { scopedWriters.files++; scopedWriters.sites += m.writers.length; } + if (p !== c.owner) for (const t of m.literals) if (t.value === c.name) + external.push({ path: p, line: t.line }); + } + metrics.push({ id: c.id, externalFiles: new Set(external.map(t => t.path)).size, + externalLiterals: external.length, writerInventory: scopedWriters, + recordedWriterInventory: c.writerInventory }); + for (const t of external) diagnostics.push({ gate: '04', status: 'FAIL', + rule: 'external_container_literal', ...t, identity: c.id, + reason: 'Registered container name belongs to ' + c.owner, + remedy: 'Use the typed owner API or its name/path projection.' }); + if (scopedWriters.files !== c.writerInventory.files || scopedWriters.sites !== c.writerInventory.sites) + diagnostics.push({ gate: '04', status: 'WARN', rule: 'writer_inventory_changed', + identity: c.id, path: c.scope, line: 1, reason: 'Review syntactic scope writers; other containers are not classified yet.' }); + } + snapshot.assertStableHead(); + const fail = diagnostics.some(d => d.status === 'FAIL'); + return { gate: '04', status: fail ? 'FAIL' : 'PASS', exit: fail ? 1 : 0, + baseOid: snapshot.baseOid, headOid: snapshot.headOid, inventory, metrics, diagnostics }; + } catch (e) { + return { gate: '04', status: 'ERROR', exit: e.exit ?? 3, + diagnostics: [{ gate: '04', reason: e.message, remedy: 'Repair registry/Git input and rerun.' }] }; + } +} +if (isMain(import.meta.url)) process.exitCode = await gateCli(runGate); diff --git a/scripts/sanitizing_tests.mjs b/scripts/sanitizing_tests.mjs index 6bc29cf67..9adf58a14 100644 --- a/scripts/sanitizing_tests.mjs +++ b/scripts/sanitizing_tests.mjs @@ -796,3 +796,66 @@ test('ambiguous moved aliases never receive transfer credit', () => { const result=processGate(trees({[p]:code},{[q]:code})); assert.equal(result.exit,1); assert.ok(result.diagnostics.some(d=>d.status==='WARN')); }); + +import { runGate as containersGate, parseRegistry, measureSource } from './sanitizing_containers.mjs'; +const attemptsScope = rustRoot + 'cli/role_experiment/'; +const attemptsRegistry = () => ({ + version: 1, + containers: [{ + id: 'RoleExperiment/Attempts', name: 'attempts.jsonl', scope: attemptsScope, + owner: attemptsScope + 'storage.rs', externalLiteralBaseline: 0, + writerInventory: { files: 0, sites: 0 }, + }], +}); +function containerTree(files = {}, registry = attemptsRegistry()) { + return trees({ + 'scripts/containers.json': JSON.stringify(registry), + [attemptsScope + 'storage.rs']: 'const NAME: &str = "attempts.jsonl";', + ...files, + }); +} +test('04 registered literal: external fails, owner and test fixtures pass', () => { + const consumer = attemptsScope + 'resume.rs'; + assert.equal(containersGate(containerTree()).exit, 0); + const bad = containersGate(containerTree({ [consumer]: 'fn f() { let p = "attempts.jsonl"; }' })); + assert.equal(bad.exit, 1); + assert.equal(bad.metrics[0].externalLiterals, 1); + assert.equal(containersGate(containerTree({ + [attemptsScope + 'tests.rs']: 'fn fixture() { let p = "attempts.jsonl"; }', + [consumer]: '#[cfg(test)] fn fixture() { let p = "attempts.jsonl"; }', + })).exit, 0); + assert.equal(containersGate(containerTree({ + [consumer]: '#[cfg(any(test, windows))] fn f() { let p = r#"attempts.jsonl"#; }', + })).exit, 1); + assert.equal(containersGate(containerTree({ + [consumer]: '// "attempts.jsonl"\nfn f() { let p = "other.jsonl"; }', + })).exit, 0); +}); +test('04 writer inventory recognizes aliases, excludes cfg and warns unknown identities', () => { + const source = 'use std::fs::{write as save, File as F, OpenOptions as O}; ' + + 'fn f() { save("unknown.json", ""); F::create("x"); O::new(); } ' + + '#[cfg(test)] fn fixture() { std::fs::write("fixture", ""); }'; + assert.equal(measureSource(rustRoot + 'example.rs', source).writers.length, 3); + assert.equal(measureSource(rustRoot + 'example/tests.rs', source).writers.length, 0); + const result = containersGate(containerTree({ [rustRoot + 'example.rs']: source })); + assert.equal(result.exit, 0); + assert.equal(result.inventory.sites, 3); + assert.equal(result.diagnostics.find(d => d.rule === 'unclassified_writers').files, 1); +}); +test('04 rejects invalid registries and missing owners rather than passing silently', () => { + for (const mutate of [ + r => { r.version = 2; }, + r => { r.containers[0].externalLiteralBaseline = 1; }, + r => { r.containers[0].owner = '../outside.rs'; }, + r => { r.containers[0].writerInventory.sites = -1; }, + r => { r.containers.push({ ...r.containers[0] }); }, + ]) { + const registry = attemptsRegistry(); mutate(registry); + assert.throws(() => parseRegistry(JSON.stringify(registry))); + assert.equal(containersGate(containerTree({}, registry)).status, 'ERROR'); + } + assert.throws(() => parseRegistry('{')); + const snapshot = containerTree(); + snapshot.headTree.delete(attemptsScope + 'storage.rs'); + assert.equal(containersGate(snapshot).status, 'ERROR'); +}); From e57ff2fcd1229255d89ad7294b0ebddf07bc7f21 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:33:24 -0300 Subject: [PATCH 10/14] refactor(sanitize): 08-R1 encapsulate settings lock diagnostics (#3227) --- src-tauri/src/config/settings.rs | 12 +++---- src-tauri/src/config/settings/save.rs | 8 ++--- src-tauri/src/config/settings/save/lock.rs | 29 ++++++++++++++--- .../tests/lock_debug_and_id_hash_schema.rs | 32 ++++++------------- 4 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index b825f5938..bac6043a0 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -210,12 +210,12 @@ use save::{ read_terminal_snapshot_security_settings_strict_from_path, render_diagnostic_path, render_settings_save_diagnostic, save_settings_to_path, save_settings_to_path_preserving_project_paths_typed, save_settings_value, - save_settings_value_locked, settings_lock_debug_path, settings_lock_debug_transport, - SettingsLockDebugAttempt, SettingsLockDebugContext, SettingsLockDebugTestHooks, - SettingsSaveDiagnosticCapture, PROJECT_MIGRATION_ATOMIC_FAULT, SETTINGS_LOCK_DEBUG_ATTEMPT, - SETTINGS_LOCK_DEBUG_CAPACITY, SETTINGS_LOCK_DEBUG_LOST, SETTINGS_LOCK_DEBUG_PATH_BYTES, - SETTINGS_LOCK_DEBUG_TEST_HOOKS, SETTINGS_LOCK_DEBUG_TEST_UNLOCK_ERROR, - SETTINGS_LOCK_DEBUG_TRANSPORT, SETTINGS_LOCK_UNAVAILABLE_CODE, + save_settings_value_locked, settings_lock_debug_lost_count, settings_lock_debug_path, + settings_lock_debug_sender, settings_lock_debug_set_attempt, settings_lock_debug_transport, + settings_lock_debug_transport_initialized, SettingsLockDebugAttempt, SettingsLockDebugContext, + SettingsLockDebugTestHooks, SettingsSaveDiagnosticCapture, PROJECT_MIGRATION_ATOMIC_FAULT, + SETTINGS_LOCK_DEBUG_CAPACITY, SETTINGS_LOCK_DEBUG_PATH_BYTES, SETTINGS_LOCK_DEBUG_TEST_HOOKS, + SETTINGS_LOCK_DEBUG_TEST_UNLOCK_ERROR, SETTINGS_LOCK_UNAVAILABLE_CODE, SETTINGS_SAVE_DIAGNOSTIC_CAPTURE, }; use save::{ diff --git a/src-tauri/src/config/settings/save.rs b/src-tauri/src/config/settings/save.rs index 6742bdbe2..8a7ed924a 100644 --- a/src-tauri/src/config/settings/save.rs +++ b/src-tauri/src/config/settings/save.rs @@ -44,11 +44,11 @@ pub(crate) use error::{ pub(crate) use lock::SettingsFileLock; #[cfg(test)] pub(crate) use lock::{ - settings_lock_debug_path, settings_lock_debug_transport, SettingsLockDebugAttempt, - SettingsLockDebugContext, SettingsLockDebugTestHooks, SETTINGS_LOCK_DEBUG_ATTEMPT, - SETTINGS_LOCK_DEBUG_CAPACITY, SETTINGS_LOCK_DEBUG_LOST, SETTINGS_LOCK_DEBUG_PATH_BYTES, + settings_lock_debug_lost_count, settings_lock_debug_path, settings_lock_debug_sender, + settings_lock_debug_set_attempt, settings_lock_debug_transport, + settings_lock_debug_transport_initialized, SettingsLockDebugAttempt, SettingsLockDebugContext, + SettingsLockDebugTestHooks, SETTINGS_LOCK_DEBUG_CAPACITY, SETTINGS_LOCK_DEBUG_PATH_BYTES, SETTINGS_LOCK_DEBUG_TEST_HOOKS, SETTINGS_LOCK_DEBUG_TEST_UNLOCK_ERROR, - SETTINGS_LOCK_DEBUG_TRANSPORT, }; pub(crate) use locked::save_settings_value_locked_with_startup; #[cfg(test)] diff --git a/src-tauri/src/config/settings/save/lock.rs b/src-tauri/src/config/settings/save/lock.rs index 725c29924..6dc5248f4 100644 --- a/src-tauri/src/config/settings/save/lock.rs +++ b/src-tauri/src/config/settings/save/lock.rs @@ -11,12 +11,33 @@ use std::sync::{Arc, OnceLock}; // only place that formats or writes logs; an owner never waits for its sink. pub(crate) const SETTINGS_LOCK_DEBUG_CAPACITY: usize = 256; pub(crate) const SETTINGS_LOCK_DEBUG_PATH_BYTES: usize = 4096; -pub(crate) static SETTINGS_LOCK_DEBUG_TRANSPORT: OnceLock< +static SETTINGS_LOCK_DEBUG_TRANSPORT: OnceLock< Option>, > = OnceLock::new(); -pub(crate) static SETTINGS_LOCK_DEBUG_INSTANCE: OnceLock = OnceLock::new(); -pub(crate) static SETTINGS_LOCK_DEBUG_ATTEMPT: AtomicU64 = AtomicU64::new(0); -pub(crate) static SETTINGS_LOCK_DEBUG_LOST: AtomicU64 = AtomicU64::new(0); +static SETTINGS_LOCK_DEBUG_INSTANCE: OnceLock = OnceLock::new(); +static SETTINGS_LOCK_DEBUG_ATTEMPT: AtomicU64 = AtomicU64::new(0); +static SETTINGS_LOCK_DEBUG_LOST: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +pub(crate) fn settings_lock_debug_transport_initialized() -> bool { + SETTINGS_LOCK_DEBUG_TRANSPORT.get().is_some() +} + +#[cfg(test)] +pub(crate) fn settings_lock_debug_sender( +) -> Option<&'static std::sync::mpsc::SyncSender> { + SETTINGS_LOCK_DEBUG_TRANSPORT.get().and_then(Option::as_ref) +} + +#[cfg(test)] +pub(crate) fn settings_lock_debug_lost_count() -> u64 { + SETTINGS_LOCK_DEBUG_LOST.load(Ordering::Relaxed) +} + +#[cfg(test)] +pub(crate) fn settings_lock_debug_set_attempt(value: u64) { + SETTINGS_LOCK_DEBUG_ATTEMPT.store(value, Ordering::Relaxed); +} pub(crate) struct SettingsLockDebugContext { pub(crate) instance: uuid::Uuid, diff --git a/src-tauri/src/config/settings/tests/lock_debug_and_id_hash_schema.rs b/src-tauri/src/config/settings/tests/lock_debug_and_id_hash_schema.rs index db0e15139..39358bbd9 100644 --- a/src-tauri/src/config/settings/tests/lock_debug_and_id_hash_schema.rs +++ b/src-tauri/src/config/settings/tests/lock_debug_and_id_hash_schema.rs @@ -184,7 +184,7 @@ fn settings_lock_debug_child() { let guard = super::super::SettingsFileLock::acquire(&path, std::time::Duration::ZERO) .unwrap_or_else(|_| panic!("probe lock unavailable")); drop(guard); - assert!(super::super::SETTINGS_LOCK_DEBUG_TRANSPORT.get().is_none()); + assert!(!super::super::settings_lock_debug_transport_initialized()); assert!(processed.try_recv().is_err()); assert!(!log().contains("settings-lock phase=")); if mode == "probe" { @@ -240,7 +240,7 @@ fn settings_lock_debug_owner_scenario( let guard = super::super::SettingsFileLock::acquire(path, std::time::Duration::ZERO) .unwrap_or_else(|_| panic!("initial INFO acquisition")); drop(guard); - assert!(super::super::SETTINGS_LOCK_DEBUG_TRANSPORT.get().is_none()); + assert!(!super::super::settings_lock_debug_transport_initialized()); assert!(processed.try_recv().is_err()); assert!(!log().contains("settings-lock phase=")); assert!(crate::logging::set_runtime_log_level("debug")); @@ -268,11 +268,7 @@ fn settings_lock_debug_owner_scenario( assert!(log().contains("settings-lock phase=release")); settings_lock_debug_receipt(&release); if mode == "runtime" { - let sender = super::super::SETTINGS_LOCK_DEBUG_TRANSPORT - .get() - .unwrap() - .as_ref() - .unwrap(); + let sender = super::super::settings_lock_debug_sender().unwrap(); assert!(crate::logging::set_runtime_log_level("info")); let guard = super::super::SettingsFileLock::acquire(path, std::time::Duration::ZERO) .unwrap_or_else(|_| panic!("INFO re-acquisition")); @@ -284,11 +280,7 @@ fn settings_lock_debug_owner_scenario( // The same OnceLock sender/worker serves the next acquisition. assert!(std::ptr::eq( sender, - super::super::SETTINGS_LOCK_DEBUG_TRANSPORT - .get() - .unwrap() - .as_ref() - .unwrap() + super::super::settings_lock_debug_sender().unwrap() )); settings_lock_debug_processed(processed, "acquire-start"); settings_lock_debug_processed(processed, "acquired"); @@ -338,15 +330,9 @@ fn settings_lock_debug_saturated_scenario( for _ in 0..super::super::SETTINGS_LOCK_DEBUG_CAPACITY + 10 { debug.event("acquire-start", None, None); } - assert_eq!( - super::super::SETTINGS_LOCK_DEBUG_LOST.load(super::super::Ordering::Relaxed), - 10 - ); + assert_eq!(super::super::settings_lock_debug_lost_count(), 10); drop(guard); - assert_eq!( - super::super::SETTINGS_LOCK_DEBUG_LOST.load(super::super::Ordering::Relaxed), - 11 - ); + assert_eq!(super::super::settings_lock_debug_lost_count(), 11); settings_lock_debug_receipt(&serde_json::json!({"phase":"closed-sink-blocked"})); settings_lock_debug_wait_command("resume"); resume.send(()).unwrap(); @@ -488,7 +474,7 @@ fn settings_lock_debug_failure_scenarios( drop(transaction); settings_lock_debug_processed(processed, "release"); } - super::super::SETTINGS_LOCK_DEBUG_ATTEMPT.store(u64::MAX, super::super::Ordering::Relaxed); + super::super::settings_lock_debug_set_attempt(u64::MAX); let guard = super::super::SettingsFileLock::acquire(path, std::time::Duration::ZERO) .unwrap_or_else(|_| panic!("counter overflow changed lock result")); assert!(guard.debug.is_none()); @@ -630,9 +616,9 @@ fn settings_lock_debug_disabled_spawn_failure_and_disconnected_transport() { retry_started: None, acquired_at: None, }; - let before = super::super::SETTINGS_LOCK_DEBUG_LOST.load(super::super::Ordering::Relaxed); + let before = super::super::settings_lock_debug_lost_count(); debug.event("acquire-start", None, None); - assert!(super::super::SETTINGS_LOCK_DEBUG_LOST.load(super::super::Ordering::Relaxed) > before); + assert!(super::super::settings_lock_debug_lost_count() > before); let fixture = tempfile::tempdir().unwrap(); let path = fixture.path().join("settings.json"); let mut debug = Some(debug); From 5a53357b9b1d48ed9f1fc669adac310ab3c8f709 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:39:51 -0300 Subject: [PATCH 11/14] refactor(sanitize): 07-R4 gate production disk access (#3227) --- .github/workflows/pr-regression-gates.yml | 1 + package.json | 1 + scripts/sanitizing_baseline.mjs | 2 +- scripts/sanitizing_disk_access.mjs | 55 +++++++++++++++++++ scripts/sanitizing_tests.mjs | 65 +++++++++++++++++++++++ 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 scripts/sanitizing_disk_access.mjs diff --git a/.github/workflows/pr-regression-gates.yml b/.github/workflows/pr-regression-gates.yml index 1ca56a373..1bad64124 100644 --- a/.github/workflows/pr-regression-gates.yml +++ b/.github/workflows/pr-regression-gates.yml @@ -126,6 +126,7 @@ jobs: run: | npm run sanitizing:file_layout -- --base-ref "${GATE_BASE_REF:-origin/main}" npm run sanitizing:tauri_edge -- --base-ref "${GATE_BASE_REF:-origin/main}" + npm run sanitizing:disk_access -- --base-ref "${GATE_BASE_REF:-origin/main}" npm run sanitizing:global_state -- --base-ref "${GATE_BASE_REF:-origin/main}" npm run sanitizing:process_env -- --base-ref "${GATE_BASE_REF:-origin/main}" diff --git a/package.json b/package.json index 73922b103..97a8e9472 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "sanitizing:file_layout": "node scripts/sanitizing_file_layout.mjs", "sanitizing:tauri_edge": "node scripts/sanitizing_tauri_edge.mjs", "sanitizing:global_state": "node scripts/sanitizing_global_state.mjs", + "sanitizing:disk_access": "node scripts/sanitizing_disk_access.mjs", "sanitizing:process_env": "node scripts/sanitizing_process_env.mjs", "sanitizing:self": "node --test scripts/sanitizing_tests.mjs", "sanitizing:containers": "node scripts/sanitizing_containers.mjs" diff --git a/scripts/sanitizing_baseline.mjs b/scripts/sanitizing_baseline.mjs index 210a44d4a..5cd121fca 100644 --- a/scripts/sanitizing_baseline.mjs +++ b/scripts/sanitizing_baseline.mjs @@ -124,7 +124,7 @@ export function createSnapshot({ baseRef = 'origin/main', cwd, runner = gitRunne // an entry that matches nothing is only a WARN: these gates compare against the PR base, // so an entry goes stale once its debt is merged. export const ALLOWLIST_FILE = 'sanitizing.allowlist.json'; -const ALLOWLIST_GATES = ['01', '02', '05', '06', '08', '09', 'cog']; +const ALLOWLIST_GATES = ['01', '02', '05', '06', '07', '08', '09', 'cog']; export function parseAllowlist(text) { let document; try { document = JSON.parse(text); } catch { throw new GateError(`${ALLOWLIST_FILE}: invalid JSON`); } diff --git a/scripts/sanitizing_disk_access.mjs b/scripts/sanitizing_disk_access.mjs new file mode 100644 index 000000000..84064f743 --- /dev/null +++ b/scripts/sanitizing_disk_access.mjs @@ -0,0 +1,55 @@ +// Finite lexical calls; receiver methods are an explicitly separate approximation. +import { gateCli, isMain } from './sanitizing_baseline.mjs'; +import { runMeasuredGate, metric, hit, warning, qualified } from './sanitizing_gate_support.mjs'; + +const diskPath = /^(?:(?:std|tokio)::fs::|fs::|(?:File::(?:open|create)|OpenOptions::new)$)/; +const bareOperation = /^(?:read|read_to_string|read_dir|write|metadata|symlink_metadata|canonicalize|copy|rename|create_dir|create_dir_all|remove_file|remove_dir|remove_dir_all|hard_link|soft_link|read_link|set_permissions|try_exists)$/; +const receiverMethod = /^(?:exists|try_exists|metadata|symlink_metadata|canonicalize|is_file|is_dir|read_dir)$/; +const owner = p => /^src-tauri\/src\/(?:filesystem|path_identity|path_utils)(?:\.rs$|\/)/.test(p); +const inUniverse = p => /^src-tauri\/src\/.*\.rs$/.test(p); + +function measure(path, scan) { + const m = metric(), ts = scan.productionTokens; + if (owner(path)) return m; + for (let i = 0; i < ts.length; i++) { + const t = ts[i]; + if (t.kind !== 'ident') continue; + if (ts[i - 1]?.text === '.') { + if (receiverMethod.test(t.name) && ts[i + 1]?.text === '(') + hit(m, 'receiver_method_approximation', t.line); + continue; + } + if (ts[i - 1]?.text === '::' && ts[i - 2]?.kind === 'ident') continue; + const r = qualified(scan, ts, i, diskPath); + const value = r.path ?? ''; + const ambiguousOperation = r.ambiguous && bareOperation.test(value); + if (ts[r.end]?.text !== '(' || !(diskPath.test(value) || ambiguousOperation)) continue; + // Imports/qualified paths identify std/tokio; bare File is the established + // finite signal unless the resolver proves a local homonymous binding. + warning(m, path, t.line, r); + if (!m.counts.disk_files) hit(m, 'disk_files', t.line); + i = r.end - 1; + } + return m; +} + +export function runGate(snapshot) { + const result = runMeasuredGate(snapshot, { + gate: '07', rules: ['disk_files'], newFileRules: ['disk_files'], + inUniverse, measure, extraChecks() {}, + }); + if (result.measurements) { + result.approximation = Object.fromEntries(['base', 'head'].map(side => { + const files = result.measurements[side].filter(m => m.counts.receiver_method_approximation); + return [side, { files: files.length, sites: files.reduce((n, m) => + n + m.counts.receiver_method_approximation, 0), paths: files.map(m => m.path) }]; + })); + result.diagnostics.push({ gate: '07', status: 'WARN', rule: 'receiver_method_approximation', + path: '', line: 1, + reason: 'Receiver methods are lexical approximations, may be homonyms, and do not enter disk_files totals.', + ...result.approximation.head }); + } + return result; +} + +if (isMain(import.meta.url)) process.exitCode = await gateCli(runGate); diff --git a/scripts/sanitizing_tests.mjs b/scripts/sanitizing_tests.mjs index 9adf58a14..d5a365ea3 100644 --- a/scripts/sanitizing_tests.mjs +++ b/scripts/sanitizing_tests.mjs @@ -859,3 +859,68 @@ test('04 rejects invalid registries and missing owners rather than passing silen snapshot.headTree.delete(attemptsScope + 'storage.rs'); assert.equal(containersGate(snapshot).status, 'ERROR'); }); + +import { runGate as diskGate } from './sanitizing_disk_access.mjs'; +test('07 calls, aliases, multiline, ambiguity and shadowing', () => { + for (const bad of [ + 'fn f() { std::fs::read("x"); }', + 'use std::fs as disk; fn f() { disk::read("x"); }', + 'use std::fs::read as load; fn f() { load("x"); }', + 'use std::fs::{File as F, OpenOptions as O}; fn f() { F::open("x"); O::new(); }', + 'fn f() { File::create("x"); OpenOptions::new(); }', + 'fn f() { std\n::fs\n::read\n("x"); }', + ]) control(diskGate, 'disk_files', 'disk.rs', 'fn f() {}', bad); + const p = rustRoot + 'disk.rs'; + const ambiguous = diskGate(trees({}, {[p]: + 'use std::fs as disk; use other as disk; fn f() { disk::read("x"); }'})); + assert.equal(ambiguous.exit, 1); + assert.ok(ambiguous.diagnostics.some(d => d.status === 'WARN' && d.reason === 'duplicate binding')); + const glob = diskGate(trees({}, {[p]:'use std::fs::*; fn f() { read("x"); }'})); + assert.equal(glob.exit, 1); + assert.ok(glob.diagnostics.some(d => d.reason === 'glob import may bind path')); + for (const clean of [ + 'fn f() { read("x"); }', + 'fn f() { /* std::fs::read("x"); */ let s = "File::open"; }', + '#[cfg(test)] mod tests { fn f() { std::fs::read("x"); } }', + 'mod File { fn open() {} } fn f() { File::open(); }', + 'use custom::fs; fn f() { fs::read("x"); }', + 'use std::fs::read as load; fn f() { let load = || (); load(); }', + ]) assert.equal(diskGate(trees({}, {[p]:clean})).exit, 0, clean); +}); +test('07 exact owners, tests and separate untyped receiver approximation', () => { + const bad = 'fn f() { std::fs::read("x"); }'; + for (const name of ['filesystem.rs', 'filesystem/read.rs', 'path_identity.rs', + 'path_identity/read.rs', 'path_utils.rs', 'path_utils/read.rs', 'tests.rs', + 'foo/tests/read.rs', 'foo_tests.rs']) + assert.equal(diskGate(trees({}, {[rustRoot + name]:bad})).exit, 0, name); + for (const name of ['filesystem_extra.rs', 'store.rs', 'containers/read.rs']) + assert.equal(diskGate(trees({}, {[rustRoot + name]:bad})).exit, 1, name); + const result = diskGate(trees({}, {[rustRoot + 'model.rs']: + 'fn f(record: Record) { record.metadata(); record.exists(); record.canonicalize(); }'})); + assert.equal(result.exit, 0); + assert.equal(result.totals[1].disk_files, 0); + assert.deepEqual(result.approximation.head, {files:1, sites:3, paths:[rustRoot + 'model.rs']}); +}); +test('07 file counts equal/down/up, no compensated new file or move credit', () => { + const p = rustRoot + 'old.rs', q = rustRoot + 'new.rs'; + const bad = 'fn f() { std::fs::read("x"); }'; + const base = {[p]:bad}; + assert.equal(diskGate(trees(base, {[p]:bad + bad})).exit, 0); + assert.equal(diskGate(trees(base, {[p]:''})).exit, 0); + assert.equal(diskGate(trees(base, {[p]:bad, [q]:bad})).exit, 1); + assert.equal(diskGate(trees(base, {[p]:'', [q]:bad})).exit, 1); + assert.equal(diskGate(trees(base, {[q]:bad})).exit, 1); +}); +test('07 immutable real Git base, working tree ignored, drift and blob errors', t => { + const f = fixture(t), p = rustRoot + 'disk.rs'; + f.write(p, 'fn f() {}'); const base = f.commit(); + f.write(p, 'fn f() { std::fs::read("x"); }'); f.commit(); + const snapshot = f.snapshot(base); + f.write(p, 'fn f() {}'); + assert.equal(diskGate(snapshot).exit, 1); + f.write('next', 'next'); f.commit(); + assert.equal(diskGate(snapshot).exit, 3); + const broken = trees({}, {[p]:'x'}); + broken.readBlob = () => { throw Error('missing blob'); }; + assert.equal(diskGate(broken).status, 'ERROR'); +}); From 30dab51ab7e00d6110fdb6968e6bd21748b308cc Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:25:10 -0300 Subject: [PATCH 12/14] refactor(sanitize): 09-R3 move cache owner process query to process (#3227) --- src-tauri/module-arcs.txt | 1 + src-tauri/src/config/agency_cache.rs | 49 +---------- src-tauri/src/lib.rs | 1 + src-tauri/src/process.rs | 49 +++++++++++ src-tauri/src/process/tests.rs | 124 +++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 src-tauri/src/process.rs create mode 100644 src-tauri/src/process/tests.rs diff --git a/src-tauri/module-arcs.txt b/src-tauri/module-arcs.txt index 993fe55d8..0a2e5018f 100644 --- a/src-tauri/module-arcs.txt +++ b/src-tauri/module-arcs.txt @@ -1167,6 +1167,7 @@ agentscommander_lib::config::activity_log -> agentscommander_lib::session::profi agentscommander_lib::config::activity_log -> agentscommander_lib::session::session agentscommander_lib::config::agency_cache -> agentscommander_lib::config agentscommander_lib::config::agency_cache -> agentscommander_lib::config::role_templates +agentscommander_lib::config::agency_cache -> agentscommander_lib::process agentscommander_lib::config::agency_cache::git -> agentscommander_lib::config::agent_path agentscommander_lib::config::agency_cache::git -> agentscommander_lib::config::role_templates agentscommander_lib::config::agency_cache::git -> agentscommander_lib::pty::credentials diff --git a/src-tauri/src/config/agency_cache.rs b/src-tauri/src/config/agency_cache.rs index 94985affe..bbf0ef27a 100644 --- a/src-tauri/src/config/agency_cache.rs +++ b/src-tauri/src/config/agency_cache.rs @@ -1,7 +1,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::{self, Command}; +use std::process; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Args; @@ -14,6 +14,8 @@ use crate::config::role_templates::{ AgencyTemplatesManifest, AGENCY_MANIFEST_FILE, AGENCY_TEMPLATES_DIR, }; +use crate::process::is_running as process_is_running; + mod git; #[cfg(test)] mod tests; @@ -203,51 +205,6 @@ fn live_missing_with_one_valid_previous_cache(config_dir: &Path) -> bool { valid_prev == 1 } -#[cfg(windows)] -fn process_is_running(pid: u32) -> bool { - if pid == process::id() { - return true; - } - let filter = format!("PID eq {}", pid); - // #992 - reachable from the GUI process (Tauri get_agency_templates_status / - // update_agency_templates -> CacheLock::acquire -> recover_stale_lock_if_possible), - // which owns no console: without the flag Windows pops a Windows Terminal tab for - // tasklist.exe. Both pipes are captured, so nothing would ever be shown in it. - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let mut cmd = Command::new("tasklist"); - cmd.args(["/FI", &filter, "/NH", "/FO", "CSV"]) - .creation_flags(CREATE_NO_WINDOW); - let Ok(output) = cmd.output() else { - return true; - }; - if !output.status.success() { - return true; - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.lines().any(|line| { - line.split(',') - .any(|field| field.trim_matches('"').trim() == pid.to_string()) - }) -} - -#[cfg(unix)] -fn process_is_running(pid: u32) -> bool { - if pid == process::id() { - return true; - } - Command::new("kill") - .args(["-0", &pid.to_string()]) - .status() - .map(|status| status.success()) - .unwrap_or(true) -} - -#[cfg(not(any(unix, windows)))] -fn process_is_running(pid: u32) -> bool { - pid == process::id() -} - pub(crate) fn config_dir_or_err() -> Result { crate::config::config_dir() .ok_or_else(|| "Could not resolve AgentsCommander config dir".to_string()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b4de0a52..4bfa018ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub mod network; pub(crate) mod path_identity; pub(crate) mod path_utils; pub mod phone; +pub(crate) mod process; pub mod pty; pub mod resource_monitor; pub mod screenshot; diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs new file mode 100644 index 000000000..ddb090d10 --- /dev/null +++ b/src-tauri/src/process.rs @@ -0,0 +1,49 @@ +use std::process::{self, Command}; + +#[cfg(windows)] +pub(crate) fn is_running(pid: u32) -> bool { + if pid == process::id() { + return true; + } + let filter = format!("PID eq {}", pid); + // #992 - reachable from the GUI process (Tauri get_agency_templates_status / + // update_agency_templates -> CacheLock::acquire -> recover_stale_lock_if_possible), + // which owns no console: without the flag Windows pops a Windows Terminal tab for + // tasklist.exe. Both pipes are captured, so nothing would ever be shown in it. + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = Command::new("tasklist"); + cmd.args(["/FI", &filter, "/NH", "/FO", "CSV"]) + .creation_flags(CREATE_NO_WINDOW); + let Ok(output) = cmd.output() else { + return true; + }; + if !output.status.success() { + return true; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| { + line.split(',') + .any(|field| field.trim_matches('"').trim() == pid.to_string()) + }) +} + +#[cfg(unix)] +pub(crate) fn is_running(pid: u32) -> bool { + if pid == process::id() { + return true; + } + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|status| status.success()) + .unwrap_or(true) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn is_running(pid: u32) -> bool { + pid == process::id() +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/process/tests.rs b/src-tauri/src/process/tests.rs new file mode 100644 index 000000000..b6c7ff625 --- /dev/null +++ b/src-tauri/src/process/tests.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn current_process_is_running() { + assert!(is_running(process::id())); +} + +#[cfg(any(windows, unix))] +struct OwnedChild(process::Child); + +#[cfg(any(windows, unix))] +impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(any(windows, unix))] +fn fixture_command(mode: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "process::tests::owned_child_fixture", + "--nocapture", + ]) + .env("AC_PROCESS_TEST_CHILD", mode) + .stdin(process::Stdio::null()) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()); + command +} + +#[cfg(any(windows, unix))] +#[test] +fn owned_child_is_running_until_reaped() { + let mut child = OwnedChild(fixture_command("sleep").spawn().expect("spawn owned child")); + let pid = child.0.id(); + assert!(is_running(pid)); + child.0.kill().expect("kill owned child"); + child.0.wait().expect("reap owned child"); + assert!(!is_running(pid)); +} + +#[cfg(any(windows, unix))] +#[test] +fn query_failure_preserves_conservative_result() { + let mut exited = OwnedChild( + fixture_command("exit") + .spawn() + .expect("spawn short-lived child"), + ); + let pid = exited.0.id(); + assert!(exited.0.wait().expect("reap short-lived child").success()); + assert!(!is_running(pid)); + let mut command = fixture_command("query-failure"); + command.env("AC_PROCESS_TEST_PROBE_PID", pid.to_string()); + #[cfg(unix)] + command.env("PATH", ""); + command.stderr(process::Stdio::inherit()); + let mut child = OwnedChild(command.spawn().expect("spawn query fixture")); + let status = child.0.wait().expect("reap query fixture"); + assert!(status.success(), "query fixture failed: {status}"); +} + +#[cfg(any(windows, unix))] +#[test] +fn owned_child_fixture() { + match std::env::var("AC_PROCESS_TEST_CHILD").as_deref() { + Ok("sleep") => std::thread::sleep(std::time::Duration::from_secs(60)), + Ok("query-failure") => { + let pid = std::env::var("AC_PROCESS_TEST_PROBE_PID") + .expect("probe pid") + .parse() + .expect("numeric parent pid"); + assert_ne!(pid, process::id()); + #[cfg(windows)] + let _job = prevent_child_creation(); + #[cfg(windows)] + assert!(Command::new("tasklist").output().is_err()); + assert!(is_running(pid)); + } + _ => {} + } +} + +#[cfg(windows)] +fn prevent_child_creation() -> std::os::windows::io::OwnedHandle { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + }; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + // SAFETY: the handle is checked and owned immediately; the initialized + // buffer has the documented size. Only this isolated fixture joins the job. + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + assert!(!handle.is_null(), "create fixture job"); + let job = OwnedHandle::from_raw_handle(handle); + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + info.BasicLimitInformation.ActiveProcessLimit = 1; + assert_ne!( + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + ), + 0, + "configure fixture job" + ); + assert_ne!( + AssignProcessToJobObject(handle, GetCurrentProcess()), + 0, + "assign fixture job" + ); + job + } +} From 4532f79e3b5a3a2ea38c9489372116f24f6713b9 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 15:27:23 -0300 Subject: [PATCH 13/14] test: retry publication contention before native rename failure (#3228) --- .../sessions_persistence/tests/replacement.rs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/config/sessions_persistence/tests/replacement.rs b/src-tauri/src/config/sessions_persistence/tests/replacement.rs index 3ab3ef060..a22866283 100644 --- a/src-tauri/src/config/sessions_persistence/tests/replacement.rs +++ b/src-tauri/src/config/sessions_persistence/tests/replacement.rs @@ -354,7 +354,56 @@ async fn native_rename_failure_keeps_epoch_checkpoint_and_own_pending_payload() let write = super::super::PreparedSessionsWrite::prepare(temp.path(), &[]).unwrap(); let attempt = manager .with_current_carrier_snapshot(&permit, |_| { - super::super::rename_and_publish(temp.path(), &slot, before.epoch, &mut next, &write) + // Process-wide contention prevents rename even for a private fixture. + { + let leaf = super::super::write::SAVE_SESSIONS_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let contended = super::super::rename_and_publish( + temp.path(), + &slot, + before.epoch, + &mut next, + &write, + ); + assert!(matches!( + contended, + super::super::RenamePublicationAttempt::Retry + )); + assert!(matches!( + super::super::settle_rename_attempt( + contended, + &slot, + &write.path, + nonce, + epoch, + 0, + None, + ), + super::super::SettledRenameAttempt::Recapture + )); + drop(leaf); + } + // Retry only contention; every other outcome reaches the strict + // native-failure assertion below with the original pending payload. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let attempt = super::super::rename_and_publish( + temp.path(), + &slot, + before.epoch, + &mut next, + &write, + ); + if !matches!(attempt, super::super::RenamePublicationAttempt::Retry) { + break attempt; + } + assert!( + std::time::Instant::now() < deadline, + "publication locks stayed busy" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } }) .await; let super::super::SettledRenameAttempt::Failed(error) = From 01765505b0c942d720be990384565e4ab7b023a9 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 24 Oct 2026 16:45:23 -0300 Subject: [PATCH 14/14] test(sanitize): refresh census for batch 11 files (#3227) --- src-tauri/tests/c4_state_key_census.rs | 2 +- src-tauri/tests/spawn_search_path_inventory.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src-tauri/tests/c4_state_key_census.rs b/src-tauri/tests/c4_state_key_census.rs index b0aeb5cb0..e5b68ec81 100644 --- a/src-tauri/tests/c4_state_key_census.rs +++ b/src-tauri/tests/c4_state_key_census.rs @@ -214,7 +214,7 @@ fn assert_frozen(id: &str, hits: &[Hit], pin: &Pin) { #[test] fn the_inventory_is_the_certified_file_set() { let sources = rust_sources(); - assert_eq!(sources.len(), 1276, "inventory count"); + assert_eq!(sources.len(), 1281, "inventory count"); for (path, text) in &sources { assert!(path.starts_with("src-tauri/src/"), "path form: {path}"); assert!(!path.contains('\\'), "path separator: {path}"); diff --git a/src-tauri/tests/spawn_search_path_inventory.rs b/src-tauri/tests/spawn_search_path_inventory.rs index d7ac6ee62..bb5b4669c 100644 --- a/src-tauri/tests/spawn_search_path_inventory.rs +++ b/src-tauri/tests/spawn_search_path_inventory.rs @@ -264,12 +264,6 @@ const INVENTORY: &[Row] = &[ 0, &[(DesktopOpener, 2)], ), - ( - "src-tauri/src/config/agency_cache.rs", - 2, - 0, - &[(WindowsOnly, 1), (PosixBaseUtility, 1)], - ), ("src-tauri/src/config/agency_cache/git.rs", 1, 1, &[]), ( "src-tauri/src/config/agent_config/load_probe_harness.rs", @@ -423,6 +417,13 @@ const INVENTORY: &[Row] = &[ 0, &[(TestOnly, 1)], ), + ( + "src-tauri/src/process.rs", + 2, + 0, + &[(WindowsOnly, 1), (PosixBaseUtility, 1)], + ), + ("src-tauri/src/process/tests.rs", 2, 0, &[(TestOnly, 2)]), ( "src-tauri/src/pty/credentials/tests.rs", 4,