diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 5cc28847b..4b43a7522 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -121,6 +121,263 @@ pub(crate) struct SettingsDraftUpdateEvents { pub env_agent_ids: Vec, } +// #3083: closed diagnostic vocabulary; no client-selected target or text. +const RAIL_DIAGNOSTIC_ERROR: &str = "invalid rail diagnostic"; +const RAIL_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailSurface { + Embedded, + Sidebar, + Store, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailKind { + Custom, + System, + Nonstop, + Ungrouped, + Project, + Favorites, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailPhase { + Arming, + Dragging, + Saving, + Idle, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailOutcome { + Applied, + Unchanged, + Stale, + Error, + Disconnected, + Unloaded, + Queued, + Discarded, + Suppressed, + Overflow, + NoPress, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailTag { + Button, + Span, + Svg, + Path, + Aside, + Body, + Input, + Textarea, + Other, + None, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum RailEvent { + Pointerdown, + Pointerup, + Pointercancel, + Mousedown, + Mouseup, + Click, + Gotpointercapture, + Lostpointercapture, + Focusin, + Focusout, + WindowFocus, + WindowBlur, + HandlerEnter, + HandlerEnd, + SelectionChange, + RailMount, + RailDispose, + SectionMount, + SectionDispose, + NodeMount, + NodeDispose, + ProjectList, + ProjectButtons, + FavoritesEntries, + ReloadStart, + ReloadEnd, + DiscoverStart, + DiscoverEnd, + SnapshotStart, + SnapshotEnd, + SnapshotsStart, + SnapshotsEnd, + TaskStart, + TaskEnd, + LoopStart, + LoopEnd, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RailDiagnosticRecord { + version: u8, + window_id: Uuid, + seq: u64, + at_ms: f64, + surface: RailSurface, + event: RailEvent, + #[serde(skip_serializing_if = "Option::is_none")] + rail_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + group_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + entity_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + original_node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + object_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + selection_project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + selection_group_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + changed_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + selection_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + outcome: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pointer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + button: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + active_tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + primary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + trusted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + connected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + original_connected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + captured: Option, + #[serde(skip_serializing_if = "Option::is_none")] + document_focused: Option, +} + +impl RailDiagnosticRecord { + fn validate(&self) -> bool { + self.version == 1 + && (1..=RAIL_SAFE_INTEGER).contains(&self.seq) + && self.at_ms.is_finite() + && self.at_ms >= 0.0 + && self.duration_ms.is_none_or(|n| n.is_finite() && n >= 0.0) + && self.button.is_none_or(|n| (-1..=5).contains(&n)) + && self + .rail_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .operation_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .project_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .group_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .entity_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .node_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .original_node_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .object_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .selection_project_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self + .selection_group_id + .is_none_or(|n| (1..=RAIL_SAFE_INTEGER).contains(&n)) + && self.count.is_none_or(|n| n <= RAIL_SAFE_INTEGER) + && self.changed_count.is_none_or(|n| n <= RAIL_SAFE_INTEGER) + } +} + +pub(crate) fn rail_diagnostic_line(record: Option) -> Result { + let invalid = || RAIL_DIAGNOSTIC_ERROR.to_string(); + let value = record.ok_or_else(invalid)?; + // Serde unit enums also accept externally tagged maps; IPC permits scalar fields only. + let object = value.as_object().ok_or_else(invalid)?; + if object.values().any(|v| v.is_object() || v.is_array()) { + return Err(invalid()); + } + let record: RailDiagnosticRecord = serde_json::from_value(value).map_err(|_| invalid())?; + if !record.validate() { + return Err(invalid()); + } + let json = serde_json::to_string(&record).map_err(|_| invalid())?; + if json.len() > 2048 { + return Err(invalid()); + } + Ok(format!("[rail-diag]{json}")) +} + +pub(crate) fn append_rail_diagnostic_to( + record: Option, + logger: &dyn log::Log, +) -> Result<(), String> { + let line = rail_diagnostic_line(record)?; + let metadata = log::Metadata::builder() + .level(log::Level::Debug) + .target("agentscommander_lib::commands::config") + .build(); + if logger.enabled(&metadata) { + logger.log( + &log::Record::builder() + .metadata(metadata) + .args(format_args!("{line}")) + .build(), + ); + } + Ok(()) +} + +/// ACK covers validation and dispatch, not file write/fsync. RUST_LOG must allow Debug. +#[tauri::command] +pub fn append_rail_diagnostic(record: Option) -> Result<(), String> { + append_rail_diagnostic_to(record, log::logger()) +} + #[tauri::command] pub async fn save_debug_logs(content: String) -> Result<(), String> { let path = crate::config::config_dir() @@ -13809,3 +14066,231 @@ mod p02_wire_schema_tests { assert!(cell.get("command").is_none()); } } + +#[cfg(test)] +mod issue_3083_tests { + use super::*; + use serde_json::{json, Value}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn record() -> Value { + json!({"version":1,"windowId":"fedcba98-7654-4321-8abc-012345678901", + "seq":1,"atMs":0.0,"surface":"embedded","event":"pointerdown"}) + } + + #[derive(Default)] + struct Counter { + enabled: AtomicUsize, + emitted: AtomicUsize, + } + impl log::Log for Counter { + fn enabled(&self, metadata: &log::Metadata) -> bool { + assert_eq!(metadata.level(), log::Level::Debug); + assert_eq!(metadata.target(), "agentscommander_lib::commands::config"); + self.enabled.fetch_add(1, Ordering::Relaxed); + true + } + fn log(&self, record: &log::Record) { + assert_eq!(record.level(), log::Level::Debug); + assert_eq!(record.target(), "agentscommander_lib::commands::config"); + let line = record.args().to_string(); + let json = line.strip_prefix("[rail-diag]").unwrap(); + let parsed: Value = serde_json::from_str(json).unwrap(); + assert_eq!(parsed["seq"], 1); + assert_eq!(serde_json::to_string(&parsed).unwrap().len(), json.len()); + assert!(!line.contains('\n')); + self.emitted.fetch_add(1, Ordering::Relaxed); + } + fn flush(&self) {} + } + + fn reject(value: Option) { + let logger = Counter::default(); + assert_eq!( + append_rail_diagnostic_to(value.clone(), &logger), + Err(RAIL_DIAGNOSTIC_ERROR.into()) + ); + assert_eq!( + append_rail_diagnostic(value), + Err(RAIL_DIAGNOSTIC_ERROR.into()) + ); + assert_eq!(logger.enabled.load(Ordering::Relaxed), 0); + assert_eq!(logger.emitted.load(Ordering::Relaxed), 0); + } + + #[test] + fn issue_3083_private_boundary_and_single_debug_emission() { + let logger = Counter::default(); + assert_eq!(append_rail_diagnostic_to(Some(record()), &logger), Ok(())); + assert_eq!(logger.enabled.load(Ordering::Relaxed), 1); + assert_eq!(logger.emitted.load(Ordering::Relaxed), 1); + reject(None); + for value in [ + Value::Null, + json!(true), + json!("private/path"), + json!(3), + json!([]), + json!({}), + ] { + reject(Some(value)); + } + let mut extra = record(); + extra["unknown"] = json!({"nested":"secret"}); + reject(Some(extra)); + for (field, value) in [ + ("surface", json!({"embedded":null})), + ("event", json!({"pointerdown":null})), + ("kind", json!({"custom":null})), + ("windowId", json!([0, 1, 2, 3])), + ] { + let mut nested = record(); + nested[field] = value; + reject(Some(nested)); + } + let mut huge = record(); + huge["windowId"] = json!("é".repeat(2049)); + assert!(serde_json::to_vec(&huge).unwrap().len() > 2048); + reject(Some(huge)); + } + + #[test] + fn issue_3083_numeric_type_and_range_validation() { + for field in [ + "seq", + "railId", + "operationId", + "projectId", + "groupId", + "entityId", + "nodeId", + "originalNodeId", + "objectId", + "selectionProjectId", + "selectionGroupId", + ] { + for bad in [ + json!(0), + json!(-1), + json!(1.5), + json!(9_007_199_254_740_992u64), + json!("1"), + json!({"unknown":1}), + ] { + let mut value = record(); + value[field] = bad; + reject(Some(value)); + } + let mut value = record(); + value[field] = json!(RAIL_SAFE_INTEGER); + assert!(rail_diagnostic_line(Some(value)).is_ok()); + } + for field in ["count", "changedCount"] { + for bad in [json!(-1), json!(0.5), json!(9_007_199_254_740_992u64)] { + let mut value = record(); + value[field] = bad; + reject(Some(value)); + } + let mut value = record(); + value[field] = json!(0); + assert!(rail_diagnostic_line(Some(value)).is_ok()); + } + for (field, bad) in [ + ("version", json!(2)), + ("version", json!("1")), + ("windowId", json!("not-a-uuid")), + ("atMs", json!(-0.1)), + ("atMs", json!("0")), + ("durationMs", json!(-1)), + ("pointerId", json!(2147483648u64)), + ("pointerId", json!(-2147483649i64)), + ("button", json!(-2)), + ("button", json!(6)), + ] { + let mut value = record(); + value[field] = bad; + reject(Some(value)); + } + for field in [ + "primary", + "trusted", + "connected", + "originalConnected", + "captured", + "documentFocused", + ] { + let mut value = record(); + value[field] = json!(1); + reject(Some(value)); + } + let mut dto: RailDiagnosticRecord = serde_json::from_value(record()).unwrap(); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { + dto.at_ms = bad; + assert!(!dto.validate()); + dto.at_ms = 0.0; + dto.duration_ms = Some(bad); + assert!(!dto.validate()); + dto.duration_ms = None; + } + } + + #[test] + fn issue_3083_closed_enums_and_full_record() { + for (field, values) in [ + ("surface", "embedded sidebar store"), + ("kind", "custom system nonstop ungrouped project favorites"), + ("selectionKind", "custom system nonstop ungrouped project favorites"), + ("phase", "arming dragging saving idle"), + ("outcome", "applied unchanged stale error disconnected unloaded queued discarded suppressed overflow no-press"), + ("targetTag", "button span svg path aside body input textarea other none"), + ("activeTag", "button span svg path aside body input textarea other none"), + ("event", "pointerdown pointerup pointercancel mousedown mouseup click gotpointercapture lostpointercapture focusin focusout window-focus window-blur handler-enter handler-end selection-change rail-mount rail-dispose section-mount section-dispose node-mount node-dispose project-list project-buttons favorites-entries reload-start reload-end discover-start discover-end snapshot-start snapshot-end snapshots-start snapshots-end task-start task-end loop-start loop-end"), + ] { + for good in values.split_whitespace() { + let mut value = record(); value[field] = json!(good); + assert!(rail_diagnostic_line(Some(value)).is_ok(), "{field}: {good}"); + } + for bad in [json!("private text"), json!(1), json!({"unknown":"nested"})] { + let mut value = record(); value[field] = bad; reject(Some(value)); + } + } + let mut full = record(); + for field in [ + "railId", + "operationId", + "projectId", + "groupId", + "entityId", + "nodeId", + "originalNodeId", + "objectId", + "selectionProjectId", + "selectionGroupId", + "count", + "changedCount", + ] { + full[field] = json!(RAIL_SAFE_INTEGER); + } + for field in [ + "primary", + "trusted", + "connected", + "originalConnected", + "captured", + "documentFocused", + ] { + full[field] = json!(true); + } + full["kind"] = json!("custom"); + full["selectionKind"] = json!("ungrouped"); + full["phase"] = json!("dragging"); + full["outcome"] = json!("no-press"); + full["pointerId"] = json!(i32::MIN); + full["button"] = json!(-1); + full["targetTag"] = json!("textarea"); + full["activeTag"] = json!("none"); + full["durationMs"] = json!(f64::MAX); + let line = rail_diagnostic_line(Some(full)).unwrap(); + assert!(line.len() <= 2048 + "[rail-diag]".len()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dd229fb8..bac66e092 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5608,6 +5608,7 @@ pub fn run( commands::voice::voice_mark_recording, commands::voice::voice_had_typing, commands::config::save_debug_logs, + commands::config::append_rail_diagnostic, commands::config::drain_error_logs, commands::config::get_error_popup_mutes, commands::config::set_error_popup_muted, diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index f5ca45a5b..d751be893 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -1150,6 +1150,99 @@ mod tests { } } + fn issue_3083_record(seq: u64) -> serde_json::Value { + serde_json::json!({"version":1,"windowId":"fedcba98-7654-4321-8abc-012345678901", + "seq":seq,"atMs":0,"surface":"embedded","event":"pointerdown"}) + } + + #[test] + fn issue_3083_real_file_gate_format_append_and_private_rejection() { + for level in [log::Level::Info, log::Level::Debug, log::Level::Trace] { + let tmp = tempfile::tempdir().expect("temp log directory"); + { + let mut builder = env_logger::Builder::new(); + builder.parse_filters(GATE_INNER_FILTER); + install_format(&mut builder, Arc::new(Some(build_app_log(tmp.path(), 0)))); + let gate = LevelGateLogger { + inner: builder.build(), + level: Arc::new(AtomicU8::new(level as u8)), + }; + for seq in [1, 2] { + assert_eq!( + crate::commands::config::append_rail_diagnostic_to( + Some(issue_3083_record(seq)), + &gate + ), + Ok(()) + ); + } + assert_eq!( + crate::commands::config::append_rail_diagnostic_to( + Some(serde_json::json!({"secret":"PRIVATE_3083"})), + &gate + ), + Err("invalid rail diagnostic".into()) + ); + } + let bytes = std::fs::read_to_string(tmp.path().join("app.log")).expect("real bytes"); + assert!(!bytes.contains("PRIVATE_3083")); + if level == log::Level::Info { + assert!(bytes.is_empty(), "Info admitted diagnostic: {bytes}"); + continue; + } + assert_eq!(bytes.matches("[rail-diag]").count(), 2, "{bytes}"); + assert_eq!(bytes.lines().count(), 2, "{bytes}"); + for (line, seq) in bytes.lines().zip([1, 2]) { + assert!(line.contains("[DEBUG] agentscommander_lib::commands::config")); + let (_, json) = line.split_once("[rail-diag]").expect("diagnostic prefix"); + let value: serde_json::Value = serde_json::from_str(json).expect("compact JSON"); + assert_eq!(value["windowId"], "fedcba98-7654-4321-8abc-012345678901"); + assert_eq!(value["seq"], seq); + assert_eq!(value["event"], "pointerdown"); + assert_eq!(serde_json::to_string(&value).unwrap().len(), json.len()); + } + } + } + + #[test] + fn issue_3083_real_file_rotation_and_continuity() { + let tmp = tempfile::tempdir().expect("temp log directory"); + { + let mut builder = env_logger::Builder::new(); + builder.parse_filters(GATE_INNER_FILTER); + install_format( + &mut builder, + Arc::new(Some(build_app_log(tmp.path(), APP_LOG_MAX_BYTES - 1))), + ); + let gate = LevelGateLogger { + inner: builder.build(), + level: Arc::new(AtomicU8::new(log::Level::Debug as u8)), + }; + for seq in [3083, 3084] { + crate::commands::config::append_rail_diagnostic_to( + Some(issue_3083_record(seq)), + &gate, + ) + .expect("dispatch"); + } + } + let active = std::fs::read_to_string(tmp.path().join("app.log")).expect("active file"); + let rotated = std::fs::read_to_string(tmp.path().join("app.log.1")).expect("rotated file"); + assert!(rotated.contains("\"seq\":3083"), "{rotated}"); + assert!(active.contains("\"seq\":3084"), "{active}"); + let mut all = active; + for n in 1..=5 { + all.push_str( + &std::fs::read_to_string(tmp.path().join(format!("app.log.{n}"))) + .unwrap_or_default(), + ); + } + for seq in [3083, 3084] { + assert_eq!(all.matches(&format!("\"seq\":{seq}")).count(), 1, "{all}"); + } + assert_eq!(all.matches("[rail-diag]").count(), 2); + } + #[test] fn muted_type_is_skipped_by_real_logger_but_still_written_and_redacted() { let _drain_guard = ERROR_SINK_TEST_DRAIN_LOCK.blocking_lock(); diff --git a/src-tauri/src/web/commands.rs b/src-tauri/src/web/commands.rs index 7adcc7eec..facfd5234 100644 --- a/src-tauri/src/web/commands.rs +++ b/src-tauri/src/web/commands.rs @@ -1045,6 +1045,10 @@ async fn dispatch_inner(state: &WsState, cmd: &str, args: &Value) -> Result { + crate::commands::config::append_rail_diagnostic(args.get("record").cloned())?; + Ok(Value::Null) + } "save_debug_logs" => { let content = require_str(args, "content")?; let path = crate::config::config_dir() @@ -1270,6 +1274,35 @@ fn str_vec_or(args: &Value, key: &str, default: &[String]) -> Vec { #[cfg(test)] mod tests { + #[tokio::test] + async fn issue_3083_web_record_boundary_envelope_and_restore_guard() { + let (state, _rx, _gate) = ws_state_with_agent_update(AppSettings::default(), false, true); + let valid = json!({"record":{"version":1, + "windowId":"fedcba98-7654-4321-8abc-012345678901", "seq":1, + "atMs":0,"surface":"sidebar","event":"click"}}); + assert_eq!( + dispatch(&state, 3083, "append_rail_diagnostic", &valid).await, + json!({"id":3083,"result":null}) + ); + for args in [ + json!({}), + json!({"record":null}), + json!({"record":"private/path"}), + json!({"record":[]}), + json!({"record":{"unknown":{"secret":true}}}), + ] { + assert_eq!( + dispatch(&state, 3083, "append_rail_diagnostic", &args).await, + json!({"id":3083,"error":"invalid rail diagnostic"}) + ); + } + let (blocked, _rx, _gate) = ws_state_with_agent_update(AppSettings::default(), true, false); + assert_eq!( + dispatch(&blocked, 3083, "append_rail_diagnostic", &valid).await, + json!({"id":3083,"error":"selectionCoordinatorBusy"}) + ); + } + use super::*; use crate::commands::project_settings::{ get_project_groups_inner, PROJECT_GROUPS_UPDATED_EVENT,