1use std::collections::BTreeMap;
26use std::io::Write;
27use std::path::{Path, PathBuf};
28use std::sync::OnceLock;
29use std::time::{Duration, Instant};
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34const LOG_FILE_NAME: &str = "log.jsonl";
36
37#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48 #[default]
50 Invocation,
51 Http,
53 Gh,
57 Worktree,
61 DriveMutation,
68 #[serde(other)]
70 Unknown,
71}
72
73impl RecordKind {
74 #[must_use]
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Invocation => "invocation",
80 Self::Http => "http",
81 Self::Gh => "gh",
82 Self::Worktree => "worktree",
83 Self::DriveMutation => "drivemutation",
84 Self::Unknown => "unknown",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum Source {
94 #[default]
96 Cli,
97 Mcp,
99 Daemon,
101 #[serde(other)]
103 Unknown,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct LogRecord {
111 #[serde(default)]
114 pub id: String,
115 #[serde(default)]
117 pub invocation_id: String,
118 #[serde(default)]
120 pub kind: RecordKind,
121 #[serde(default)]
123 pub timestamp: String,
124 #[serde(default)]
126 pub hostname: String,
127 #[serde(default)]
129 pub pid: u32,
130 #[serde(default)]
132 pub omni_dev_version: String,
133 #[serde(default)]
135 pub cwd: String,
136 #[serde(default)]
138 pub system_user: String,
139
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub command: Vec<String>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub command_line: Vec<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub exit_code: Option<i32>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub duration_ms: Option<u64>,
153 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
155 pub env: BTreeMap<String, String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub source: Option<Source>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub mcp_tool: Option<String>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub service: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub method: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub url: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub status_code: Option<u16>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub elapsed_ms: Option<u64>,
179 #[serde(default, skip_serializing_if = "is_false")]
181 pub via_daemon: bool,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub daemon_session_id: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub auth_principal: Option<String>,
189 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191 pub request_headers: BTreeMap<String, String>,
192 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
194 pub response_headers: BTreeMap<String, String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub request_body: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub response_body: Option<String>,
201 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203 pub context: BTreeMap<String, String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub error: Option<String>,
209}
210
211#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
214 !*b
215}
216
217impl LogRecord {
218 fn new(kind: RecordKind, invocation_id: String) -> Self {
220 Self {
221 id: new_id(),
222 invocation_id,
223 kind,
224 timestamp: now_rfc3339_millis(),
225 hostname: hostname(),
226 pid: std::process::id(),
227 omni_dev_version: crate::VERSION.to_string(),
228 cwd: cwd(),
229 system_user: system_user(),
230 ..Self::default()
231 }
232 }
233}
234
235#[derive(Debug, Clone)]
241pub struct RequestLogContext {
242 pub invocation_id: String,
244 pub source: Source,
246 pub mcp_tool: Option<String>,
248}
249
250impl Default for RequestLogContext {
251 fn default() -> Self {
252 Self {
253 invocation_id: new_id(),
254 source: Source::Cli,
255 mcp_tool: None,
256 }
257 }
258}
259
260impl RequestLogContext {
261 pub fn cli() -> Self {
263 Self {
264 invocation_id: new_id(),
265 source: Source::Cli,
266 mcp_tool: None,
267 }
268 }
269
270 pub fn mcp(tool: impl Into<String>) -> Self {
272 Self {
273 invocation_id: new_id(),
274 source: Source::Mcp,
275 mcp_tool: Some(tool.into()),
276 }
277 }
278}
279
280static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
281
282tokio::task_local! {
283 pub static CTX: RequestLogContext;
285}
286
287pub fn set_global(ctx: RequestLogContext) {
290 let _ = GLOBAL.set(ctx);
291}
292
293pub fn current_context() -> RequestLogContext {
296 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
297 return ctx;
298 }
299 if let Some(ctx) = GLOBAL.get() {
300 return ctx.clone();
301 }
302 RequestLogContext::default()
303}
304
305pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
315where
316 F: std::future::Future<Output = T>,
317{
318 let mut ctx = current_context();
319 ctx.invocation_id = origin_id;
320 CTX.scope(ctx, fut).await
321}
322
323pub fn disabled() -> bool {
325 env_flag("OMNI_DEV_LOG_DISABLE")
326}
327
328pub fn bodies_enabled() -> bool {
330 env_flag("OMNI_DEV_LOG_BODIES")
331}
332
333pub fn headers_enabled() -> bool {
335 env_flag("OMNI_DEV_LOG_HEADERS")
336}
337
338fn env_flag(name: &str) -> bool {
340 std::env::var(name).is_ok_and(|v| {
341 let v = v.trim().to_ascii_lowercase();
342 v == "1" || v == "true" || v == "yes"
343 })
344}
345
346pub fn log_file_path() -> Option<PathBuf> {
349 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
350 if !path.is_empty() {
351 return Some(PathBuf::from(path));
352 }
353 }
354 let base = dirs::state_dir().or_else(dirs::data_dir)?;
355 Some(base.join("omni-dev").join(LOG_FILE_NAME))
356}
357
358pub fn record(entry: &LogRecord) {
361 if disabled() {
362 return;
363 }
364 if let Err(e) = try_record(entry) {
365 tracing::debug!("request_log: failed to append record: {e}");
366 }
367}
368
369fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
371 use anyhow::Context;
372
373 let path = log_file_path().context("could not resolve the log file path")?;
374 if let Some(parent) = path.parent() {
378 if !parent.as_os_str().is_empty() && !parent.exists() {
379 crate::daemon::paths::ensure_dir_0700(parent)?;
380 }
381 }
382 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
383 line.push('\n');
384 append_line(&path, &line)?;
385 Ok(())
386}
387
388#[cfg(unix)]
396fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
397 use std::os::unix::fs::OpenOptionsExt;
398
399 if let Some(cfg) = rotation_config() {
402 return append_with_rotation(path, line, &cfg);
403 }
404
405 let file = std::fs::OpenOptions::new()
406 .append(true)
407 .create(true)
408 .mode(0o600)
409 .open(path)?;
410 crate::daemon::paths::ensure_handle_0600(&file)?;
411
412 if bodies_enabled() {
413 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
414 Ok(mut guard) => {
415 guard.write_all(line.as_bytes())?;
416 }
417 Err((mut file, _)) => {
418 file.write_all(line.as_bytes())?;
419 }
420 }
421 } else {
422 let mut file = file;
423 file.write_all(line.as_bytes())?;
424 }
425 Ok(())
426}
427
428#[cfg(not(unix))]
432fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
433 let mut file = std::fs::OpenOptions::new()
434 .append(true)
435 .create(true)
436 .open(path)?;
437 file.write_all(line.as_bytes())?;
438 Ok(())
439}
440
441fn sibling(path: &Path, suffix: &str) -> PathBuf {
455 let mut name = path.as_os_str().to_owned();
456 name.push(suffix);
457 PathBuf::from(name)
458}
459
460pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
464 use anyhow::Context as _;
465
466 let lower = s.trim().to_ascii_lowercase();
467 if lower.is_empty() {
468 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
469 }
470 let split = lower
471 .find(|c: char| !c.is_ascii_digit() && c != '.')
472 .unwrap_or(lower.len());
473 let (num, unit) = lower.split_at(split);
474 let value: f64 = num
475 .parse()
476 .with_context(|| format!("invalid size number: {s}"))?;
477 if !value.is_finite() || value < 0.0 {
478 anyhow::bail!("invalid size: {s}");
479 }
480 let mult: u64 = match unit.trim() {
481 "" | "b" => 1,
482 "k" | "kb" | "kib" => 1024,
483 "m" | "mb" | "mib" => 1024 * 1024,
484 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
485 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
486 };
487 Ok((value * mult as f64) as u64)
488}
489
490#[cfg(unix)]
494struct RotationConfig {
495 max_size: u64,
497 keep_files: u32,
499}
500
501#[cfg(unix)]
505fn rotation_config() -> Option<RotationConfig> {
506 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
507 if raw.trim().is_empty() {
508 return None;
509 }
510 let max_size = match parse_size(&raw) {
511 Ok(0) => return None,
512 Ok(n) => n,
513 Err(e) => {
514 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
515 return None;
516 }
517 };
518 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
519 .ok()
520 .and_then(|v| v.trim().parse::<u32>().ok())
521 .unwrap_or(DEFAULT_KEEP_FILES);
522 Some(RotationConfig {
523 max_size,
524 keep_files,
525 })
526}
527
528#[cfg(unix)]
532fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
533 if keep_files == 0 {
534 let _ = std::fs::remove_file(path);
537 return Ok(());
538 }
539 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
541 for i in (1..keep_files).rev() {
542 let from = sibling(path, &format!(".{i}"));
543 if from.exists() {
544 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
545 }
546 }
547 std::fs::rename(path, sibling(path, ".1"))?;
548 Ok(())
549}
550
551#[cfg(unix)]
557fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
558 use std::os::unix::fs::OpenOptionsExt;
559
560 let lock_path = sibling(path, ".lock");
561 let lock_file = std::fs::OpenOptions::new()
562 .create(true)
563 .write(true)
564 .truncate(false)
565 .mode(0o600)
566 .open(&lock_path)?;
567 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
568 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
571
572 let current = std::fs::metadata(path).map_or(0, |m| m.len());
573 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
574 if let Err(e) = rotate(path, cfg.keep_files) {
575 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
576 }
577 }
578
579 let mut file = std::fs::OpenOptions::new()
580 .append(true)
581 .create(true)
582 .mode(0o600)
583 .open(path)?;
584 crate::daemon::paths::ensure_handle_0600(&file)?;
585 file.write_all(line.as_bytes())?;
586 Ok(())
587}
588
589pub struct PruneOptions {
591 pub older_than: Option<DateTime<Utc>>,
595 pub max_size: Option<u64>,
598 pub dry_run: bool,
600}
601
602pub struct PruneOutcome {
604 pub removed: usize,
606 pub kept: usize,
608 pub bytes_before: u64,
610 pub bytes_after: u64,
612}
613
614pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
623 use anyhow::Context as _;
624
625 let data = match std::fs::read(path) {
626 Ok(data) => data,
627 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
628 return Ok(PruneOutcome {
629 removed: 0,
630 kept: 0,
631 bytes_before: 0,
632 bytes_after: 0,
633 });
634 }
635 Err(e) => return Err(e).context("failed to read the log file"),
636 };
637 let bytes_before = data.len() as u64;
638 let text = String::from_utf8_lossy(&data);
639
640 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
643 let aged: Vec<&str> = all
644 .iter()
645 .copied()
646 .filter(|line| keep_by_age(line, opts.older_than))
647 .collect();
648
649 let kept: &[&str] = match opts.max_size {
650 None => &aged,
651 Some(max) => keep_by_size(&aged, max),
652 };
653
654 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
655 let outcome = PruneOutcome {
656 removed: all.len() - kept.len(),
657 kept: kept.len(),
658 bytes_before,
659 bytes_after,
660 };
661
662 if !opts.dry_run && outcome.removed > 0 {
663 rewrite_atomically(path, kept)?;
664 }
665 Ok(outcome)
666}
667
668fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
671 let Some(cutoff) = older_than else {
672 return true;
673 };
674 match serde_json::from_str::<LogRecord>(line) {
675 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
676 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
677 Err(_) => true,
678 },
679 Err(_) => true,
680 }
681}
682
683fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
686 let mut acc = 0u64;
687 let mut start = lines.len();
688 for (i, line) in lines.iter().enumerate().rev() {
689 acc += line.len() as u64 + 1;
690 if acc > max {
691 break;
692 }
693 start = i;
694 }
695 if start == lines.len() && !lines.is_empty() {
696 start = lines.len() - 1; }
698 &lines[start..]
699}
700
701fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
704 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
705 let result = (|| -> anyhow::Result<()> {
706 let mut options = std::fs::OpenOptions::new();
707 options.create(true).write(true).truncate(true);
708 #[cfg(unix)]
709 {
710 use std::os::unix::fs::OpenOptionsExt;
711 options.mode(0o600);
712 }
713 let mut file = options.open(&tmp)?;
714 #[cfg(unix)]
715 crate::daemon::paths::ensure_handle_0600(&file)?;
716 for line in lines {
717 file.write_all(line.as_bytes())?;
718 file.write_all(b"\n")?;
719 }
720 file.flush()?;
721 std::fs::rename(&tmp, path)?;
722 Ok(())
723 })();
724 if result.is_err() {
725 let _ = std::fs::remove_file(&tmp);
726 }
727 result
728}
729
730#[derive(Debug, Clone)]
732pub struct InvocationOutcome {
733 pub command: Vec<String>,
735 pub command_line: Vec<String>,
737 pub exit_code: i32,
739 pub error: Option<String>,
741 pub duration: Duration,
743}
744
745pub fn record_invocation(outcome: InvocationOutcome) {
747 let ctx = current_context();
748 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
749 rec.source = Some(ctx.source);
750 rec.mcp_tool = ctx.mcp_tool;
751 rec.command = outcome.command;
752 rec.command_line = scrub_argv(&outcome.command_line);
753 rec.exit_code = Some(outcome.exit_code);
754 rec.error = outcome.error;
755 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
756 rec.env = whitelisted_env();
757 record(&rec);
758}
759
760#[derive(Debug, Clone)]
763pub struct GhOutcome {
764 pub label: String,
767 pub argv: Vec<String>,
769 pub exit_code: Option<i32>,
771 pub duration: Duration,
773 pub error: Option<String>,
775}
776
777pub fn record_gh(outcome: GhOutcome) {
782 record(&build_gh_record(outcome, current_context()));
783}
784
785fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
789 let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
790 rec.source = Some(ctx.source);
791 rec.mcp_tool = ctx.mcp_tool;
792 rec.command = outcome
793 .label
794 .split(' ')
795 .filter(|s| !s.is_empty())
796 .map(str::to_string)
797 .collect();
798 rec.command_line = scrub_argv(&outcome.argv);
802 rec.exit_code = outcome.exit_code;
803 rec.error = outcome.error;
804 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
805 rec
806}
807
808#[derive(Debug, Clone)]
811pub struct WorktreeOutcome {
812 pub verb: String,
816 pub argv: Vec<String>,
819 pub exit_code: Option<i32>,
821 pub duration: Duration,
823 pub error: Option<String>,
825 pub context: BTreeMap<String, String>,
828}
829
830pub fn record_worktree(outcome: WorktreeOutcome) {
836 record(&build_worktree_record(outcome, current_context()));
837}
838
839fn build_worktree_record(outcome: WorktreeOutcome, ctx: RequestLogContext) -> LogRecord {
844 let mut rec = LogRecord::new(RecordKind::Worktree, ctx.invocation_id);
845 rec.source = Some(ctx.source);
846 rec.mcp_tool = ctx.mcp_tool;
847 rec.service = Some("worktree".to_string());
849 rec.command = vec!["git".to_string(), "worktree".to_string(), outcome.verb];
850 rec.command_line = scrub_argv(&outcome.argv);
851 rec.exit_code = outcome.exit_code;
852 rec.error = outcome.error;
853 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
854 rec.context = outcome.context;
855 rec
856}
857
858#[derive(Debug, Clone)]
860pub struct DriveMutationOutcome {
861 pub operation: &'static str,
863 pub file_id: String,
865 pub file_name: String,
867 pub status: String,
871 pub added_principals: Vec<String>,
875 pub removed_principals: Vec<String>,
878 pub crosses_drive_boundary: bool,
880 pub resolved_folder_id: Option<String>,
886 pub decided_by_folder_id: Option<String>,
890 pub decided_by_depth: Option<usize>,
892 pub error: Option<String>,
894 pub duration: Duration,
896}
897
898pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
917 record(&build_drive_mutation_record(outcome, current_context()));
918}
919
920fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
924 let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
925 rec.source = Some(ctx.source);
926 rec.mcp_tool = ctx.mcp_tool;
927 rec.service = Some("drive".to_string());
928 rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
929 rec.error = outcome.error;
930 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
931
932 let mut context = BTreeMap::new();
933 context.insert("file_id".to_string(), outcome.file_id);
934 context.insert("file_name".to_string(), outcome.file_name);
935 context.insert("status".to_string(), outcome.status);
936 if !outcome.added_principals.is_empty() {
937 context.insert(
938 "added_principals".to_string(),
939 outcome.added_principals.join(","),
940 );
941 }
942 if !outcome.removed_principals.is_empty() {
943 context.insert(
944 "removed_principals".to_string(),
945 outcome.removed_principals.join(","),
946 );
947 }
948 if outcome.crosses_drive_boundary {
949 context.insert("crosses_drive_boundary".to_string(), "true".to_string());
950 }
951 if let Some(resolved_folder_id) = outcome.resolved_folder_id {
952 context.insert("resolved_folder_id".to_string(), resolved_folder_id);
953 }
954 if let Some(decided_by_folder_id) = outcome.decided_by_folder_id {
955 context.insert("decided_by_folder_id".to_string(), decided_by_folder_id);
956 }
957 if let Some(decided_by_depth) = outcome.decided_by_depth {
958 context.insert("decided_by_depth".to_string(), decided_by_depth.to_string());
959 }
960 rec.context = context;
961 rec
962}
963
964#[derive(Debug, Clone, Default)]
967pub struct HttpExtra {
968 pub via_daemon: bool,
970 pub daemon_session_id: Option<String>,
972 pub auth_principal: Option<String>,
974 pub request_headers: BTreeMap<String, String>,
976 pub response_headers: BTreeMap<String, String>,
978 pub request_body: Option<String>,
980 pub response_body: Option<String>,
982 pub context: BTreeMap<String, String>,
984}
985
986pub fn record_http(
988 service: &str,
989 method: &str,
990 url: &str,
991 started: Instant,
992 status: Option<u16>,
993 error: Option<&str>,
994) {
995 record_http_with(
996 service,
997 method,
998 url,
999 started,
1000 status,
1001 error,
1002 HttpExtra::default(),
1003 );
1004}
1005
1006pub fn record_http_result(
1012 service: &str,
1013 method: &str,
1014 url: &str,
1015 started: Instant,
1016 result: &reqwest::Result<reqwest::Response>,
1017) {
1018 match result {
1019 Ok(response) => {
1020 record_http(
1021 service,
1022 method,
1023 url,
1024 started,
1025 Some(response.status().as_u16()),
1026 None,
1027 );
1028 }
1029 Err(error) => {
1030 record_http(
1031 service,
1032 method,
1033 url,
1034 started,
1035 None,
1036 Some(&error.to_string()),
1037 );
1038 }
1039 }
1040}
1041
1042#[allow(clippy::too_many_arguments)]
1049pub fn record_http_with(
1050 service: &str,
1051 method: &str,
1052 url: &str,
1053 started: Instant,
1054 status: Option<u16>,
1055 error: Option<&str>,
1056 extra: HttpExtra,
1057) {
1058 if disabled() {
1059 return;
1060 }
1061 let ctx = current_context();
1062 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
1063 rec.source = Some(ctx.source);
1064 rec.mcp_tool = ctx.mcp_tool;
1065 rec.service = Some(service.to_string());
1066 rec.method = Some(method.to_string());
1067 rec.url = Some(redact_url(url));
1068 rec.status_code = status;
1069 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
1070 rec.error = error.map(str::to_string);
1071 rec.via_daemon = extra.via_daemon;
1072 rec.daemon_session_id = extra.daemon_session_id;
1073 rec.auth_principal = extra.auth_principal;
1074 rec.context = extra.context;
1075 if headers_enabled() {
1076 rec.request_headers = redact_headers(&extra.request_headers);
1077 rec.response_headers = redact_headers(&extra.response_headers);
1078 }
1079 if bodies_enabled() {
1080 rec.request_body = extra.request_body;
1081 rec.response_body = extra.response_body;
1082 }
1083 record(&rec);
1084}
1085
1086const SENSITIVE_HEADERS: &[&str] = &[
1088 "authorization",
1089 "proxy-authorization",
1090 "cookie",
1091 "set-cookie",
1092 "x-api-key",
1093 "api-key",
1094 "dd-api-key",
1095 "dd-application-key",
1096 "x-datadog-api-key",
1097 "x-datadog-application-key",
1098 "x-omni-bridge",
1099 "x-omni-bridge-target",
1100];
1101
1102const SENSITIVE_HEADER_MARKERS: &[&str] = &[
1106 "auth",
1107 "token",
1108 "secret",
1109 "key",
1110 "cookie",
1111 "password",
1112 "session",
1113 "signature",
1114 "credential",
1115];
1116
1117pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1122 headers
1123 .iter()
1124 .map(|(name, value)| {
1125 let lower = name.to_ascii_lowercase();
1126 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
1127 || SENSITIVE_HEADER_MARKERS
1128 .iter()
1129 .any(|marker| lower.contains(marker));
1130 (
1131 name.clone(),
1132 if redacted {
1133 "REDACTED".to_string()
1134 } else {
1135 value.clone()
1136 },
1137 )
1138 })
1139 .collect()
1140}
1141
1142const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
1146
1147fn is_secretish_flag(name: &str) -> bool {
1151 let segments: Vec<String> = name
1152 .split(['-', '_'])
1153 .map(str::to_ascii_lowercase)
1154 .collect();
1155 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
1156 !takes_path
1157 && segments
1158 .iter()
1159 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
1160}
1161
1162fn scrub_header_arg(value: &str) -> Option<String> {
1166 let Some((name, _)) = value.split_once(':') else {
1167 return Some("REDACTED".to_string());
1168 };
1169 SENSITIVE_HEADERS
1170 .contains(&name.trim().to_ascii_lowercase().as_str())
1171 .then(|| format!("{}: REDACTED", name.trim()))
1172}
1173
1174fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1178 match name {
1179 "header" => scrub_header_arg(value),
1180 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1181 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1182 _ => None,
1183 }
1184}
1185
1186fn scrub_argv(argv: &[String]) -> Vec<String> {
1198 scrub_flag_secrets(argv)
1199 .iter()
1200 .map(|arg| redact_url(arg))
1201 .collect()
1202}
1203
1204fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1209 let mut out = Vec::with_capacity(argv.len());
1210 let mut i = 0;
1211 while i < argv.len() {
1212 let arg = &argv[i];
1213 i += 1;
1214 let Some(flag_body) = arg.strip_prefix("--") else {
1215 out.push(arg.clone());
1216 continue;
1217 };
1218 if let Some((name, value)) = flag_body.split_once('=') {
1219 match scrub_flag_value(name, value) {
1220 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1221 None => out.push(arg.clone()),
1222 }
1223 } else {
1224 out.push(arg.clone());
1225 let takes_secret_value =
1226 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1227 if takes_secret_value {
1228 if let Some(value) = argv.get(i) {
1229 i += 1;
1230 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1231 }
1232 }
1233 }
1234 }
1235 out
1236}
1237
1238const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1240
1241const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1244 "token",
1245 "secret",
1246 "password",
1247 "passwd",
1248 "signature",
1249 "apikey",
1250 "api_key",
1251 "api-key",
1252];
1253
1254const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1256
1257fn sensitive_query_key(key: &str) -> bool {
1259 let key = key.to_ascii_lowercase();
1260 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1261 || SENSITIVE_QUERY_KEY_SUFFIXES
1262 .iter()
1263 .any(|suffix| key.ends_with(suffix))
1264 || SENSITIVE_QUERY_KEY_PREFIXES
1265 .iter()
1266 .any(|prefix| key.starts_with(prefix))
1267}
1268
1269fn redact_pairs(pairs: &str) -> String {
1273 pairs
1274 .split('&')
1275 .map(|segment| match segment.split_once('=') {
1276 Some((raw_key, _)) => {
1277 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1280 .next()
1281 .is_some_and(|(key, _)| sensitive_query_key(&key));
1282 if sensitive {
1283 format!("{raw_key}=REDACTED")
1284 } else {
1285 segment.to_string()
1286 }
1287 }
1288 None => segment.to_string(),
1290 })
1291 .collect::<Vec<_>>()
1292 .join("&")
1293}
1294
1295fn redact_url(url: &str) -> String {
1301 let (rest, fragment) = url
1302 .split_once('#')
1303 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1304 let (prefix, query) = rest
1305 .split_once('?')
1306 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1307 let mut out = prefix.to_string();
1308 if let Some(query) = query {
1309 out.push('?');
1310 out.push_str(&redact_pairs(query));
1311 }
1312 if let Some(fragment) = fragment {
1313 out.push('#');
1314 out.push_str(&redact_pairs(fragment));
1315 }
1316 out
1317}
1318
1319pub fn new_id() -> String {
1325 let millis = chrono::Utc::now().timestamp_millis().max(0);
1326 let suffix = rand::random::<u64>();
1327 format!("{millis:013}-{suffix:016x}")
1328}
1329
1330fn now_rfc3339_millis() -> String {
1332 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1333}
1334
1335fn cwd() -> String {
1337 std::env::current_dir()
1338 .map(|p| p.display().to_string())
1339 .unwrap_or_default()
1340}
1341
1342fn system_user() -> String {
1344 if let Ok(user) = std::env::var("USER") {
1345 if !user.is_empty() {
1346 return user;
1347 }
1348 }
1349 #[cfg(unix)]
1350 {
1351 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1352 return user.name;
1353 }
1354 }
1355 String::new()
1356}
1357
1358fn hostname() -> String {
1360 #[cfg(unix)]
1361 {
1362 if let Ok(name) = nix::unistd::gethostname() {
1363 if let Some(name) = name.to_str() {
1364 if !name.is_empty() {
1365 return name.to_string();
1366 }
1367 }
1368 }
1369 }
1370 std::env::var("HOSTNAME").unwrap_or_default()
1371}
1372
1373const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1376
1377fn whitelisted_env() -> BTreeMap<String, String> {
1379 std::env::vars()
1380 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1381 .map(|(k, v)| {
1382 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1383 let value = if secretish { "REDACTED".to_string() } else { v };
1384 (k, value)
1385 })
1386 .collect()
1387}
1388
1389#[cfg(test)]
1390#[allow(clippy::unwrap_used, clippy::expect_used)]
1391mod tests {
1392 use super::*;
1393
1394 #[test]
1395 fn record_round_trips_through_json() {
1396 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1397 rec.service = Some("jira".to_string());
1398 rec.method = Some("GET".to_string());
1399 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1400 rec.status_code = Some(200);
1401 rec.elapsed_ms = Some(42);
1402
1403 let line = serde_json::to_string(&rec).unwrap();
1404 let back: LogRecord = serde_json::from_str(&line).unwrap();
1405 assert_eq!(back.invocation_id, "inv-1");
1406 assert_eq!(back.kind, RecordKind::Http);
1407 assert_eq!(back.service.as_deref(), Some("jira"));
1408 assert_eq!(back.status_code, Some(200));
1409 }
1410
1411 #[test]
1412 fn reader_tolerates_unknown_fields() {
1413 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1414 "future_field":{"nested":true},"another":42}"#;
1415 let rec: LogRecord = serde_json::from_str(line).unwrap();
1416 assert_eq!(rec.kind, RecordKind::Http);
1417 assert_eq!(rec.method.as_deref(), Some("GET"));
1418 }
1419
1420 #[test]
1421 fn reader_tolerates_missing_newer_fields() {
1422 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1424 let rec: LogRecord = serde_json::from_str(line).unwrap();
1425 assert_eq!(rec.kind, RecordKind::Invocation);
1426 assert_eq!(rec.command, vec!["git", "view"]);
1427 assert!(rec.status_code.is_none());
1428 assert!(rec.id.is_empty());
1429 }
1430
1431 #[test]
1432 fn unknown_kind_and_source_do_not_fail() {
1433 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1434 let rec: LogRecord = serde_json::from_str(line).unwrap();
1435 assert_eq!(rec.kind, RecordKind::Unknown);
1436 assert_eq!(rec.source, Some(Source::Unknown));
1437 }
1438
1439 #[test]
1440 fn optional_fields_are_skipped_when_empty() {
1441 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1442 let line = serde_json::to_string(&rec).unwrap();
1443 assert!(!line.contains("status_code"));
1445 assert!(!line.contains("request_headers"));
1446 assert!(!line.contains("via_daemon"));
1447 assert!(!line.contains("\"env\""));
1448 }
1449
1450 #[test]
1451 fn ids_are_time_sortable() {
1452 let a = new_id();
1453 std::thread::sleep(std::time::Duration::from_millis(2));
1454 let b = new_id();
1455 assert!(a < b, "{a} should sort before {b}");
1456 }
1457
1458 #[test]
1459 fn sensitive_headers_are_redacted() {
1460 let mut headers = BTreeMap::new();
1461 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1462 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1463 headers.insert("Content-Type".to_string(), "application/json".to_string());
1464 let out = redact_headers(&headers);
1465 assert_eq!(out["Authorization"], "REDACTED");
1466 assert_eq!(out["X-Api-Key"], "REDACTED");
1467 assert_eq!(out["Content-Type"], "application/json");
1468 }
1469
1470 fn argv(args: &[&str]) -> Vec<String> {
1471 args.iter().copied().map(String::from).collect()
1472 }
1473
1474 #[test]
1475 fn build_gh_record_stamps_kind_source_and_split_command() {
1476 let ctx = RequestLogContext {
1477 invocation_id: "inv-1".to_string(),
1478 source: Source::Daemon,
1479 mcp_tool: None,
1480 };
1481 let rec = build_gh_record(
1482 GhOutcome {
1483 label: "api graphql".to_string(),
1484 argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1485 exit_code: Some(0),
1486 duration: Duration::from_millis(120),
1487 error: None,
1488 },
1489 ctx,
1490 );
1491 assert_eq!(rec.kind, RecordKind::Gh);
1492 assert_eq!(rec.invocation_id, "inv-1");
1493 assert_eq!(rec.source, Some(Source::Daemon));
1494 assert_eq!(rec.command, argv(&["api", "graphql"]));
1496 assert_eq!(
1497 rec.command_line,
1498 argv(&["api", "graphql", "-f", "query=xyz"])
1499 );
1500 assert_eq!(rec.exit_code, Some(0));
1501 assert_eq!(rec.duration_ms, Some(120));
1502 assert!(rec.error.is_none());
1503 }
1504
1505 #[test]
1506 fn build_gh_record_scrubs_secret_bearing_argv() {
1507 let rec = build_gh_record(
1510 GhOutcome {
1511 label: "api graphql".to_string(),
1512 argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1513 exit_code: Some(0),
1514 duration: Duration::from_millis(5),
1515 error: None,
1516 },
1517 RequestLogContext::default(),
1518 );
1519 assert_eq!(
1520 rec.command_line,
1521 argv(&["api", "--header", "Authorization: REDACTED"])
1522 );
1523 }
1524
1525 #[test]
1526 fn build_worktree_record_stamps_kind_service_command_and_context() {
1527 let ctx = RequestLogContext {
1528 invocation_id: "inv-2".to_string(),
1529 source: Source::Mcp,
1530 mcp_tool: Some("some_tool".to_string()),
1531 };
1532 let mut context = BTreeMap::new();
1533 context.insert("path".to_string(), "/tmp/wt".to_string());
1534 context.insert("branch".to_string(), "demo-wt".to_string());
1535 context.insert("had_uncommitted".to_string(), "true".to_string());
1536 let rec = build_worktree_record(
1537 WorktreeOutcome {
1538 verb: "remove".to_string(),
1539 argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
1540 exit_code: Some(0),
1541 duration: Duration::from_millis(42),
1542 error: None,
1543 context,
1544 },
1545 ctx,
1546 );
1547 assert_eq!(rec.kind, RecordKind::Worktree);
1548 assert_eq!(rec.invocation_id, "inv-2");
1549 assert_eq!(rec.source, Some(Source::Mcp));
1550 assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
1551 assert_eq!(rec.service.as_deref(), Some("worktree"));
1552 assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
1553 assert_eq!(
1554 rec.command_line,
1555 argv(&["worktree", "remove", "--force", "/tmp/wt"])
1556 );
1557 assert_eq!(rec.exit_code, Some(0));
1558 assert_eq!(rec.duration_ms, Some(42));
1559 assert_eq!(
1560 rec.context.get("branch").map(String::as_str),
1561 Some("demo-wt")
1562 );
1563 assert_eq!(
1564 rec.context.get("had_uncommitted").map(String::as_str),
1565 Some("true")
1566 );
1567 }
1568
1569 #[test]
1570 fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
1571 let rec = build_worktree_record(
1572 WorktreeOutcome {
1573 verb: "add".to_string(),
1574 argv: argv(&["worktree", "add", "wt"]),
1575 exit_code: Some(1),
1576 duration: Duration::from_millis(1),
1577 error: Some("boom".to_string()),
1578 context: BTreeMap::new(),
1579 },
1580 RequestLogContext::default(),
1581 );
1582 let line = serde_json::to_string(&rec).unwrap();
1583 assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
1584 assert!(
1585 line.contains("\"service\":\"worktree\""),
1586 "line was: {line}"
1587 );
1588 assert_eq!(RecordKind::Worktree.as_str(), "worktree");
1590 let back: LogRecord = serde_json::from_str(&line).unwrap();
1591 assert_eq!(back.kind, RecordKind::Worktree);
1592 assert_eq!(back.command, argv(&["git", "worktree", "add"]));
1593 assert_eq!(back.error.as_deref(), Some("boom"));
1594 }
1595
1596 #[test]
1597 fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
1598 let ctx = RequestLogContext {
1599 invocation_id: "inv-3".to_string(),
1600 source: Source::Mcp,
1601 mcp_tool: Some("drive_file_move".to_string()),
1602 };
1603 let rec = build_drive_mutation_record(
1604 DriveMutationOutcome {
1605 operation: "move",
1606 file_id: "f1".to_string(),
1607 file_name: "report.pdf".to_string(),
1608 status: "blocked".to_string(),
1609 added_principals: vec!["alice@example.com".to_string()],
1610 removed_principals: vec![],
1611 crosses_drive_boundary: true,
1612 resolved_folder_id: Some("dest1".to_string()),
1613 decided_by_folder_id: Some("dest1".to_string()),
1614 decided_by_depth: Some(0),
1615 error: None,
1616 duration: Duration::from_millis(17),
1617 },
1618 ctx,
1619 );
1620 assert_eq!(rec.kind, RecordKind::DriveMutation);
1621 assert_eq!(rec.invocation_id, "inv-3");
1622 assert_eq!(rec.source, Some(Source::Mcp));
1623 assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
1624 assert_eq!(rec.service.as_deref(), Some("drive"));
1625 assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
1626 assert_eq!(rec.duration_ms, Some(17));
1627 assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
1628 assert_eq!(
1629 rec.context.get("file_name").map(String::as_str),
1630 Some("report.pdf")
1631 );
1632 assert_eq!(
1633 rec.context.get("status").map(String::as_str),
1634 Some("blocked")
1635 );
1636 assert_eq!(
1637 rec.context.get("added_principals").map(String::as_str),
1638 Some("alice@example.com")
1639 );
1640 assert_eq!(rec.context.get("removed_principals"), None);
1641 assert_eq!(
1642 rec.context
1643 .get("crosses_drive_boundary")
1644 .map(String::as_str),
1645 Some("true")
1646 );
1647 assert_eq!(
1648 rec.context.get("resolved_folder_id").map(String::as_str),
1649 Some("dest1")
1650 );
1651 assert_eq!(
1652 rec.context.get("decided_by_folder_id").map(String::as_str),
1653 Some("dest1")
1654 );
1655 assert_eq!(
1656 rec.context.get("decided_by_depth").map(String::as_str),
1657 Some("0")
1658 );
1659 }
1660
1661 #[test]
1662 fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
1663 let rec = build_drive_mutation_record(
1664 DriveMutationOutcome {
1665 operation: "rename",
1666 file_id: "f2".to_string(),
1667 file_name: "old.txt".to_string(),
1668 status: "moved".to_string(),
1669 added_principals: vec![],
1670 removed_principals: vec![],
1671 crosses_drive_boundary: false,
1672 resolved_folder_id: None,
1673 decided_by_folder_id: None,
1674 decided_by_depth: None,
1675 error: None,
1676 duration: Duration::from_millis(5),
1677 },
1678 RequestLogContext::default(),
1679 );
1680 assert_eq!(rec.context.get("added_principals"), None);
1681 assert_eq!(rec.context.get("removed_principals"), None);
1682 assert_eq!(rec.context.get("crosses_drive_boundary"), None);
1683 assert_eq!(rec.context.get("resolved_folder_id"), None);
1684 assert_eq!(rec.context.get("decided_by_folder_id"), None);
1685 assert_eq!(rec.context.get("decided_by_depth"), None);
1686 }
1687
1688 #[test]
1689 fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
1690 let rec = build_drive_mutation_record(
1691 DriveMutationOutcome {
1692 operation: "rename",
1693 file_id: "f1".to_string(),
1694 file_name: "a.txt".to_string(),
1695 status: "failed".to_string(),
1696 added_principals: vec![],
1697 removed_principals: vec![],
1698 crosses_drive_boundary: false,
1699 resolved_folder_id: None,
1700 decided_by_folder_id: None,
1701 decided_by_depth: None,
1702 error: Some("boom".to_string()),
1703 duration: Duration::from_millis(1),
1704 },
1705 RequestLogContext::default(),
1706 );
1707 let line = serde_json::to_string(&rec).unwrap();
1708 assert!(
1709 line.contains("\"kind\":\"drivemutation\""),
1710 "line was: {line}"
1711 );
1712 assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
1713 let back: LogRecord = serde_json::from_str(&line).unwrap();
1714 assert_eq!(back.kind, RecordKind::DriveMutation);
1715 assert_eq!(
1716 back.command,
1717 vec!["drive".to_string(), "rename".to_string()]
1718 );
1719 assert_eq!(back.error.as_deref(), Some("boom"));
1720 }
1721
1722 #[test]
1726 fn build_drive_mutation_record_round_trips_for_create_operation() {
1727 let rec = build_drive_mutation_record(
1728 DriveMutationOutcome {
1729 operation: "create",
1730 file_id: "f1".to_string(),
1731 file_name: "New File".to_string(),
1732 status: "created".to_string(),
1733 added_principals: vec![],
1734 removed_principals: vec![],
1735 crosses_drive_boundary: false,
1736 resolved_folder_id: Some("parent1".to_string()),
1737 decided_by_folder_id: None,
1738 decided_by_depth: None,
1739 error: None,
1740 duration: Duration::from_millis(1),
1741 },
1742 RequestLogContext::default(),
1743 );
1744 assert_eq!(rec.command, vec!["drive".to_string(), "create".to_string()]);
1745 assert_eq!(
1746 rec.context.get("resolved_folder_id").map(String::as_str),
1747 Some("parent1")
1748 );
1749 assert_eq!(rec.context.get("decided_by_folder_id"), None);
1750 let line = serde_json::to_string(&rec).unwrap();
1751 let back: LogRecord = serde_json::from_str(&line).unwrap();
1752 assert_eq!(back.kind, RecordKind::DriveMutation);
1753 }
1754
1755 #[test]
1756 fn record_kind_gh_serializes_as_gh_and_round_trips() {
1757 let rec = build_gh_record(
1758 GhOutcome {
1759 label: "pr list".to_string(),
1760 argv: argv(&["pr", "list"]),
1761 exit_code: Some(1),
1762 duration: Duration::from_millis(1),
1763 error: Some("boom".to_string()),
1764 },
1765 RequestLogContext::default(),
1766 );
1767 let line = serde_json::to_string(&rec).unwrap();
1768 assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1769 let back: LogRecord = serde_json::from_str(&line).unwrap();
1770 assert_eq!(back.kind, RecordKind::Gh);
1771 assert_eq!(back.command, argv(&["pr", "list"]));
1772 assert_eq!(back.error.as_deref(), Some("boom"));
1773 }
1774
1775 #[test]
1776 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1777 let out = scrub_argv(&argv(&[
1778 "omni-dev",
1779 "--header",
1780 "Authorization: Bearer sekret",
1781 "--header=Cookie: session=abc",
1782 ]));
1783 assert_eq!(
1784 out,
1785 argv(&[
1786 "omni-dev",
1787 "--header",
1788 "Authorization: REDACTED",
1789 "--header=Cookie: REDACTED",
1790 ])
1791 );
1792 }
1793
1794 #[test]
1795 fn scrub_argv_keeps_non_sensitive_headers() {
1796 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1797 assert_eq!(scrub_argv(&input), input);
1798 }
1799
1800 #[test]
1801 fn scrub_argv_redacts_colonless_header_wholesale() {
1802 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1803 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1804 }
1805
1806 #[test]
1807 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1808 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1809 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1810
1811 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1812 assert_eq!(scrub_argv(&file_form), file_form);
1813
1814 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1815 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1816 }
1817
1818 #[test]
1819 fn scrub_argv_redacts_secretish_flag_values() {
1820 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1821 assert_eq!(
1822 out,
1823 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1824 );
1825 }
1826
1827 #[test]
1828 fn scrub_argv_exempts_path_flags_and_positionals() {
1829 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1830 assert_eq!(scrub_argv(&input), input);
1831 }
1832
1833 #[test]
1834 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1835 let space = scrub_argv(&argv(&[
1839 "omni-dev",
1840 "browser",
1841 "bridge",
1842 "request",
1843 "--url",
1844 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1845 ]));
1846 assert_eq!(
1847 *space.last().unwrap(),
1848 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1849 );
1850
1851 let eq_form = scrub_argv(&argv(&[
1852 "omni-dev",
1853 "--url=/api/export?access_token=hunter2&page=3",
1854 ]));
1855 assert_eq!(
1856 *eq_form.last().unwrap(),
1857 "--url=/api/export?access_token=REDACTED&page=3"
1858 );
1859
1860 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1861 assert_eq!(
1862 *positional.last().unwrap(),
1863 "https://h/cb#id_token=REDACTED"
1864 );
1865 }
1866
1867 #[test]
1868 fn scrub_argv_leaves_benign_argv_byte_identical() {
1869 let input = argv(&[
1870 "omni-dev",
1871 "browser",
1872 "bridge",
1873 "request",
1874 "--control-port",
1875 "19998",
1876 "--url",
1877 "/api/export?page=3&sort=asc",
1878 ]);
1879 assert_eq!(scrub_argv(&input), input);
1880 }
1881
1882 #[test]
1883 fn scrub_argv_handles_trailing_flag_without_value() {
1884 let input = argv(&["omni-dev", "--body"]);
1885 assert_eq!(scrub_argv(&input), input);
1886 }
1887
1888 #[cfg(unix)]
1889 #[test]
1890 fn append_line_creates_file_owner_only() {
1891 use std::os::unix::fs::PermissionsExt;
1892 let dir = tempfile::tempdir().unwrap();
1893 let path = dir.path().join("log.jsonl");
1894 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1895 assert_eq!(
1896 std::fs::read_to_string(&path).unwrap(),
1897 "{\"kind\":\"http\"}\n"
1898 );
1899 assert_eq!(
1900 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1901 0o600
1902 );
1903 }
1904
1905 #[cfg(unix)]
1906 #[test]
1907 fn append_line_retightens_preexisting_loose_file() {
1908 use std::os::unix::fs::PermissionsExt;
1909 let dir = tempfile::tempdir().unwrap();
1910 let path = dir.path().join("log.jsonl");
1911 std::fs::write(&path, "old\n").unwrap();
1912 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1913 append_line(&path, "new\n").unwrap();
1914 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1915 assert_eq!(
1916 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1917 0o600
1918 );
1919 }
1920
1921 #[test]
1922 fn off_list_secretish_headers_are_redacted() {
1923 let mut headers = BTreeMap::new();
1924 for name in [
1925 "X-Auth-Token",
1926 "x-amz-security-token",
1927 "X-Goog-Api-Key",
1928 "x-csrf-token",
1929 "X-Vendor-Token",
1930 "X-Omni-Bridge",
1931 ] {
1932 headers.insert(name.to_string(), "secret-value".to_string());
1933 }
1934 for name in [
1935 "Content-Type",
1936 "Accept",
1937 "User-Agent",
1938 "x-request-id",
1939 "traceparent",
1940 ] {
1941 headers.insert(name.to_string(), "plain-value".to_string());
1942 }
1943 let out = redact_headers(&headers);
1944 assert_eq!(out["X-Auth-Token"], "REDACTED");
1945 assert_eq!(out["x-amz-security-token"], "REDACTED");
1946 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1947 assert_eq!(out["x-csrf-token"], "REDACTED");
1948 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1949 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1950 assert_eq!(out["Content-Type"], "plain-value");
1951 assert_eq!(out["Accept"], "plain-value");
1952 assert_eq!(out["User-Agent"], "plain-value");
1953 assert_eq!(out["x-request-id"], "plain-value");
1954 assert_eq!(out["traceparent"], "plain-value");
1955 }
1956
1957 #[test]
1958 fn url_without_query_is_unchanged() {
1959 assert_eq!(redact_url("https://h/p"), "https://h/p");
1960 assert_eq!(redact_url("/relative/p"), "/relative/p");
1961 }
1962
1963 #[test]
1964 fn benign_query_is_byte_identical() {
1965 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1966 assert_eq!(redact_url(url), url);
1967 }
1968
1969 #[test]
1970 fn sensitive_query_values_are_redacted() {
1971 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1972 assert_eq!(
1973 redact_url(url),
1974 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1975 &api_key=REDACTED&x=1"
1976 );
1977 }
1978
1979 #[test]
1980 fn presigned_s3_query_is_redacted() {
1981 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1982 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1983 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1984 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1985 assert_eq!(
1986 redact_url(url),
1987 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1988 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1989 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1990 );
1991 }
1992
1993 #[test]
1994 fn key_matching_is_case_insensitive() {
1995 assert_eq!(
1996 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1997 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1998 );
1999 }
2000
2001 #[test]
2002 fn repeated_sensitive_keys_are_each_redacted() {
2003 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
2004 }
2005
2006 #[test]
2007 fn valueless_key_is_left_alone() {
2008 assert_eq!(redact_url("/p?token"), "/p?token");
2009 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
2010 }
2011
2012 #[test]
2013 fn relative_url_query_is_redacted() {
2014 assert_eq!(
2015 redact_url("/api/foo?sig=abc&x=y"),
2016 "/api/foo?sig=REDACTED&x=y"
2017 );
2018 }
2019
2020 #[test]
2021 fn fragment_credentials_are_redacted() {
2022 assert_eq!(
2023 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
2024 "https://h/cb#access_token=REDACTED&token_type=bearer"
2025 );
2026 }
2027
2028 #[test]
2029 fn query_and_fragment_are_scrubbed_independently() {
2030 assert_eq!(
2031 redact_url("/p?sig=a#id_token=b"),
2032 "/p?sig=REDACTED#id_token=REDACTED"
2033 );
2034 }
2035
2036 #[test]
2037 fn question_mark_in_fragment_is_not_parsed_as_query() {
2038 assert_eq!(
2042 redact_url("https://h/p#frag?token=x"),
2043 "https://h/p#frag?token=REDACTED"
2044 );
2045 }
2046
2047 #[test]
2048 fn encoded_sensitive_key_is_decoded_before_matching() {
2049 assert_eq!(
2050 redact_url("/p?access%5Ftoken=v"),
2051 "/p?access%5Ftoken=REDACTED"
2052 );
2053 }
2054
2055 #[test]
2056 fn empty_query_is_unchanged() {
2057 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
2058 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
2059 }
2060
2061 #[test]
2062 fn env_flag_parses_truthy_values() {
2063 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
2064 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2065 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
2066 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2067 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
2068 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2069 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
2070 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2071 }
2072
2073 #[test]
2074 fn parse_size_handles_units_and_bare_bytes() {
2075 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
2076 assert_eq!(parse_size("512b").unwrap(), 512);
2077 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
2078 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
2079 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
2080 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
2081 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
2082 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
2083 }
2084
2085 #[test]
2086 fn parse_size_rejects_garbage() {
2087 assert!(parse_size("").is_err());
2088 assert!(parse_size("mb").is_err());
2089 assert!(parse_size("10tb").is_err());
2090 assert!(parse_size("-5mb").is_err());
2091 }
2092
2093 #[test]
2094 fn sibling_appends_to_final_component() {
2095 let base = Path::new("/tmp/omni/log.jsonl");
2096 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
2097 assert_eq!(
2098 sibling(base, ".lock"),
2099 Path::new("/tmp/omni/log.jsonl.lock")
2100 );
2101 }
2102
2103 #[test]
2104 fn keep_by_size_keeps_most_recent_that_fit() {
2105 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
2107 let refs: Vec<&str> = lines.to_vec();
2108
2109 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
2111 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
2113 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
2115 assert!(keep_by_size(&[], 100).is_empty());
2117 }
2118
2119 #[test]
2120 fn keep_by_age_is_conservative_on_undateable_lines() {
2121 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2122 .unwrap()
2123 .with_timezone(&Utc);
2124 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
2125 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
2126 let undated = r#"{"kind":"http"}"#;
2127 let malformed = "not json at all";
2128
2129 assert!(!keep_by_age(old, Some(cutoff)));
2130 assert!(keep_by_age(new, Some(cutoff)));
2131 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
2132 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
2133 assert!(keep_by_age(old, None), "no filter keeps everything");
2134 }
2135
2136 fn http_line(id: &str, ts: &str) -> String {
2137 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
2138 }
2139
2140 #[test]
2141 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
2142 use std::os::unix::fs::PermissionsExt;
2143
2144 let dir = tempfile::tempdir().unwrap();
2145 let path = dir.path().join("log.jsonl");
2146 let body = format!(
2147 "{}\n{}\n{}\n",
2148 http_line("1", "2026-01-01T00:00:00.000Z"),
2149 http_line("2", "2026-06-15T00:00:00.000Z"),
2150 http_line("3", "2026-12-31T00:00:00.000Z"),
2151 );
2152 std::fs::write(&path, &body).unwrap();
2153 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2154
2155 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2156 .unwrap()
2157 .with_timezone(&Utc);
2158 let outcome = prune(
2159 &path,
2160 &PruneOptions {
2161 older_than: Some(cutoff),
2162 max_size: None,
2163 dry_run: false,
2164 },
2165 )
2166 .unwrap();
2167
2168 assert_eq!(outcome.removed, 1);
2169 assert_eq!(outcome.kept, 2);
2170 let contents = std::fs::read_to_string(&path).unwrap();
2171 assert!(!contents.contains(r#""id":"1""#));
2172 assert!(contents.contains(r#""id":"2""#));
2173 assert!(contents.contains(r#""id":"3""#));
2174 assert_eq!(
2176 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2177 0o600
2178 );
2179 }
2180
2181 #[test]
2182 fn prune_dry_run_reports_without_modifying() {
2183 let dir = tempfile::tempdir().unwrap();
2184 let path = dir.path().join("log.jsonl");
2185 let body = format!(
2186 "{}\n{}\n",
2187 http_line("1", "2026-01-01T00:00:00.000Z"),
2188 http_line("2", "2026-12-31T00:00:00.000Z"),
2189 );
2190 std::fs::write(&path, &body).unwrap();
2191
2192 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2193 .unwrap()
2194 .with_timezone(&Utc);
2195 let outcome = prune(
2196 &path,
2197 &PruneOptions {
2198 older_than: Some(cutoff),
2199 max_size: None,
2200 dry_run: true,
2201 },
2202 )
2203 .unwrap();
2204
2205 assert_eq!(outcome.removed, 1);
2206 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
2208 }
2209
2210 #[test]
2211 fn prune_by_size_keeps_the_newest_that_fit() {
2212 let dir = tempfile::tempdir().unwrap();
2213 let path = dir.path().join("log.jsonl");
2214 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
2215 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
2216 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
2217 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
2218
2219 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
2221 let outcome = prune(
2222 &path,
2223 &PruneOptions {
2224 older_than: None,
2225 max_size: Some(budget),
2226 dry_run: false,
2227 },
2228 )
2229 .unwrap();
2230
2231 assert_eq!(outcome.removed, 1);
2232 assert_eq!(outcome.kept, 2);
2233 let contents = std::fs::read_to_string(&path).unwrap();
2234 assert!(!contents.contains(r#""id":"1""#));
2235 assert!(contents.contains(r#""id":"3""#));
2236 }
2237
2238 #[test]
2239 fn prune_missing_file_is_a_noop() {
2240 let dir = tempfile::tempdir().unwrap();
2241 let path = dir.path().join("absent.jsonl");
2242 let outcome = prune(
2243 &path,
2244 &PruneOptions {
2245 older_than: None,
2246 max_size: Some(1),
2247 dry_run: false,
2248 },
2249 )
2250 .unwrap();
2251 assert_eq!(outcome.removed, 0);
2252 assert_eq!(outcome.kept, 0);
2253 assert!(!path.exists());
2254 }
2255
2256 #[cfg(unix)]
2257 #[test]
2258 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
2259 use std::os::unix::fs::PermissionsExt;
2260
2261 let dir = tempfile::tempdir().unwrap();
2262 let path = dir.path().join("log.jsonl");
2263 let cfg = RotationConfig {
2265 max_size: 20,
2266 keep_files: 2,
2267 };
2268
2269 let line = "0123456789012345\n"; for _ in 0..4 {
2271 append_with_rotation(&path, line, &cfg).unwrap();
2272 }
2273
2274 assert!(path.exists());
2277 assert!(sibling(&path, ".1").exists());
2278 assert!(sibling(&path, ".2").exists());
2279 assert!(!sibling(&path, ".3").exists());
2280 assert_eq!(
2282 std::fs::metadata(sibling(&path, ".1"))
2283 .unwrap()
2284 .permissions()
2285 .mode()
2286 & 0o777,
2287 0o600
2288 );
2289 }
2290
2291 #[cfg(unix)]
2292 #[test]
2293 fn rotation_keep_zero_discards_on_overflow() {
2294 let dir = tempfile::tempdir().unwrap();
2295 let path = dir.path().join("log.jsonl");
2296 let cfg = RotationConfig {
2297 max_size: 20,
2298 keep_files: 0,
2299 };
2300 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
2302 append_with_rotation(&path, line, &cfg).unwrap();
2303 assert!(!sibling(&path, ".1").exists());
2305 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
2306 }
2307
2308 #[test]
2309 fn parse_size_rejects_overflow_to_infinity() {
2310 assert!(parse_size(&"9".repeat(400)).is_err());
2312 }
2313
2314 #[test]
2315 fn prune_surfaces_a_read_error() {
2316 let dir = tempfile::tempdir().unwrap();
2319 let result = prune(
2320 dir.path(),
2321 &PruneOptions {
2322 older_than: None,
2323 max_size: Some(1),
2324 dry_run: false,
2325 },
2326 );
2327 assert!(result.is_err());
2328 }
2329
2330 #[cfg(unix)]
2331 #[test]
2332 fn append_with_rotation_appends_even_when_rotate_fails() {
2333 let dir = tempfile::tempdir().unwrap();
2334 let path = dir.path().join("log.jsonl");
2335 std::fs::write(&path, "0123456789012345\n").unwrap();
2337 std::fs::create_dir(sibling(&path, ".1")).unwrap();
2339 let cfg = RotationConfig {
2340 max_size: 5,
2341 keep_files: 1,
2342 };
2343 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
2345 assert!(
2346 std::fs::read_to_string(&path).unwrap().contains("new-line"),
2347 "the record is appended despite the rotation failure"
2348 );
2349 }
2350
2351 #[test]
2352 fn prune_cleans_up_temp_on_rewrite_failure() {
2353 let dir = tempfile::tempdir().unwrap();
2354 let path = dir.path().join("log.jsonl");
2355 std::fs::write(
2356 &path,
2357 format!(
2358 "{}\n{}\n",
2359 http_line("a", "2999-01-01T00:00:00.000Z"),
2360 http_line("b", "2999-01-01T00:00:00.000Z"),
2361 ),
2362 )
2363 .unwrap();
2364 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
2367 std::fs::create_dir(&tmp).unwrap();
2368
2369 let result = prune(
2370 &path,
2371 &PruneOptions {
2372 older_than: None,
2373 max_size: Some(1),
2374 dry_run: false,
2375 },
2376 );
2377 assert!(result.is_err(), "a failing rewrite surfaces as an error");
2378 let _ = std::fs::remove_dir(&tmp);
2379 }
2380
2381 #[tokio::test]
2382 async fn scope_origin_id_overwrites_id_but_preserves_source() {
2383 let base = RequestLogContext {
2386 invocation_id: "daemon-1".to_string(),
2387 source: Source::Daemon,
2388 mcp_tool: None,
2389 };
2390 CTX.scope(base, async {
2391 scope_origin_id("cli-42".to_string(), async {
2392 let ctx = current_context();
2393 assert_eq!(ctx.invocation_id, "cli-42");
2395 assert_eq!(ctx.source, Source::Daemon);
2397 })
2398 .await;
2399 assert_eq!(current_context().invocation_id, "daemon-1");
2401 })
2402 .await;
2403 }
2404}