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 error: Option<String>,
882 pub duration: Duration,
884}
885
886pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
905 record(&build_drive_mutation_record(outcome, current_context()));
906}
907
908fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
912 let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
913 rec.source = Some(ctx.source);
914 rec.mcp_tool = ctx.mcp_tool;
915 rec.service = Some("drive".to_string());
916 rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
917 rec.error = outcome.error;
918 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
919
920 let mut context = BTreeMap::new();
921 context.insert("file_id".to_string(), outcome.file_id);
922 context.insert("file_name".to_string(), outcome.file_name);
923 context.insert("status".to_string(), outcome.status);
924 if !outcome.added_principals.is_empty() {
925 context.insert(
926 "added_principals".to_string(),
927 outcome.added_principals.join(","),
928 );
929 }
930 if !outcome.removed_principals.is_empty() {
931 context.insert(
932 "removed_principals".to_string(),
933 outcome.removed_principals.join(","),
934 );
935 }
936 if outcome.crosses_drive_boundary {
937 context.insert("crosses_drive_boundary".to_string(), "true".to_string());
938 }
939 rec.context = context;
940 rec
941}
942
943#[derive(Debug, Clone, Default)]
946pub struct HttpExtra {
947 pub via_daemon: bool,
949 pub daemon_session_id: Option<String>,
951 pub auth_principal: Option<String>,
953 pub request_headers: BTreeMap<String, String>,
955 pub response_headers: BTreeMap<String, String>,
957 pub request_body: Option<String>,
959 pub response_body: Option<String>,
961 pub context: BTreeMap<String, String>,
963}
964
965pub fn record_http(
967 service: &str,
968 method: &str,
969 url: &str,
970 started: Instant,
971 status: Option<u16>,
972 error: Option<&str>,
973) {
974 record_http_with(
975 service,
976 method,
977 url,
978 started,
979 status,
980 error,
981 HttpExtra::default(),
982 );
983}
984
985pub fn record_http_result(
991 service: &str,
992 method: &str,
993 url: &str,
994 started: Instant,
995 result: &reqwest::Result<reqwest::Response>,
996) {
997 match result {
998 Ok(response) => {
999 record_http(
1000 service,
1001 method,
1002 url,
1003 started,
1004 Some(response.status().as_u16()),
1005 None,
1006 );
1007 }
1008 Err(error) => {
1009 record_http(
1010 service,
1011 method,
1012 url,
1013 started,
1014 None,
1015 Some(&error.to_string()),
1016 );
1017 }
1018 }
1019}
1020
1021#[allow(clippy::too_many_arguments)]
1028pub fn record_http_with(
1029 service: &str,
1030 method: &str,
1031 url: &str,
1032 started: Instant,
1033 status: Option<u16>,
1034 error: Option<&str>,
1035 extra: HttpExtra,
1036) {
1037 if disabled() {
1038 return;
1039 }
1040 let ctx = current_context();
1041 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
1042 rec.source = Some(ctx.source);
1043 rec.mcp_tool = ctx.mcp_tool;
1044 rec.service = Some(service.to_string());
1045 rec.method = Some(method.to_string());
1046 rec.url = Some(redact_url(url));
1047 rec.status_code = status;
1048 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
1049 rec.error = error.map(str::to_string);
1050 rec.via_daemon = extra.via_daemon;
1051 rec.daemon_session_id = extra.daemon_session_id;
1052 rec.auth_principal = extra.auth_principal;
1053 rec.context = extra.context;
1054 if headers_enabled() {
1055 rec.request_headers = redact_headers(&extra.request_headers);
1056 rec.response_headers = redact_headers(&extra.response_headers);
1057 }
1058 if bodies_enabled() {
1059 rec.request_body = extra.request_body;
1060 rec.response_body = extra.response_body;
1061 }
1062 record(&rec);
1063}
1064
1065const SENSITIVE_HEADERS: &[&str] = &[
1067 "authorization",
1068 "proxy-authorization",
1069 "cookie",
1070 "set-cookie",
1071 "x-api-key",
1072 "api-key",
1073 "dd-api-key",
1074 "dd-application-key",
1075 "x-datadog-api-key",
1076 "x-datadog-application-key",
1077 "x-omni-bridge",
1078 "x-omni-bridge-target",
1079];
1080
1081const SENSITIVE_HEADER_MARKERS: &[&str] = &[
1085 "auth",
1086 "token",
1087 "secret",
1088 "key",
1089 "cookie",
1090 "password",
1091 "session",
1092 "signature",
1093 "credential",
1094];
1095
1096pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1101 headers
1102 .iter()
1103 .map(|(name, value)| {
1104 let lower = name.to_ascii_lowercase();
1105 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
1106 || SENSITIVE_HEADER_MARKERS
1107 .iter()
1108 .any(|marker| lower.contains(marker));
1109 (
1110 name.clone(),
1111 if redacted {
1112 "REDACTED".to_string()
1113 } else {
1114 value.clone()
1115 },
1116 )
1117 })
1118 .collect()
1119}
1120
1121const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
1125
1126fn is_secretish_flag(name: &str) -> bool {
1130 let segments: Vec<String> = name
1131 .split(['-', '_'])
1132 .map(str::to_ascii_lowercase)
1133 .collect();
1134 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
1135 !takes_path
1136 && segments
1137 .iter()
1138 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
1139}
1140
1141fn scrub_header_arg(value: &str) -> Option<String> {
1145 let Some((name, _)) = value.split_once(':') else {
1146 return Some("REDACTED".to_string());
1147 };
1148 SENSITIVE_HEADERS
1149 .contains(&name.trim().to_ascii_lowercase().as_str())
1150 .then(|| format!("{}: REDACTED", name.trim()))
1151}
1152
1153fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1157 match name {
1158 "header" => scrub_header_arg(value),
1159 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1160 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1161 _ => None,
1162 }
1163}
1164
1165fn scrub_argv(argv: &[String]) -> Vec<String> {
1177 scrub_flag_secrets(argv)
1178 .iter()
1179 .map(|arg| redact_url(arg))
1180 .collect()
1181}
1182
1183fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1188 let mut out = Vec::with_capacity(argv.len());
1189 let mut i = 0;
1190 while i < argv.len() {
1191 let arg = &argv[i];
1192 i += 1;
1193 let Some(flag_body) = arg.strip_prefix("--") else {
1194 out.push(arg.clone());
1195 continue;
1196 };
1197 if let Some((name, value)) = flag_body.split_once('=') {
1198 match scrub_flag_value(name, value) {
1199 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1200 None => out.push(arg.clone()),
1201 }
1202 } else {
1203 out.push(arg.clone());
1204 let takes_secret_value =
1205 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1206 if takes_secret_value {
1207 if let Some(value) = argv.get(i) {
1208 i += 1;
1209 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1210 }
1211 }
1212 }
1213 }
1214 out
1215}
1216
1217const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1219
1220const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1223 "token",
1224 "secret",
1225 "password",
1226 "passwd",
1227 "signature",
1228 "apikey",
1229 "api_key",
1230 "api-key",
1231];
1232
1233const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1235
1236fn sensitive_query_key(key: &str) -> bool {
1238 let key = key.to_ascii_lowercase();
1239 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1240 || SENSITIVE_QUERY_KEY_SUFFIXES
1241 .iter()
1242 .any(|suffix| key.ends_with(suffix))
1243 || SENSITIVE_QUERY_KEY_PREFIXES
1244 .iter()
1245 .any(|prefix| key.starts_with(prefix))
1246}
1247
1248fn redact_pairs(pairs: &str) -> String {
1252 pairs
1253 .split('&')
1254 .map(|segment| match segment.split_once('=') {
1255 Some((raw_key, _)) => {
1256 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1259 .next()
1260 .is_some_and(|(key, _)| sensitive_query_key(&key));
1261 if sensitive {
1262 format!("{raw_key}=REDACTED")
1263 } else {
1264 segment.to_string()
1265 }
1266 }
1267 None => segment.to_string(),
1269 })
1270 .collect::<Vec<_>>()
1271 .join("&")
1272}
1273
1274fn redact_url(url: &str) -> String {
1280 let (rest, fragment) = url
1281 .split_once('#')
1282 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1283 let (prefix, query) = rest
1284 .split_once('?')
1285 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1286 let mut out = prefix.to_string();
1287 if let Some(query) = query {
1288 out.push('?');
1289 out.push_str(&redact_pairs(query));
1290 }
1291 if let Some(fragment) = fragment {
1292 out.push('#');
1293 out.push_str(&redact_pairs(fragment));
1294 }
1295 out
1296}
1297
1298pub fn new_id() -> String {
1304 let millis = chrono::Utc::now().timestamp_millis().max(0);
1305 let suffix = rand::random::<u64>();
1306 format!("{millis:013}-{suffix:016x}")
1307}
1308
1309fn now_rfc3339_millis() -> String {
1311 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1312}
1313
1314fn cwd() -> String {
1316 std::env::current_dir()
1317 .map(|p| p.display().to_string())
1318 .unwrap_or_default()
1319}
1320
1321fn system_user() -> String {
1323 if let Ok(user) = std::env::var("USER") {
1324 if !user.is_empty() {
1325 return user;
1326 }
1327 }
1328 #[cfg(unix)]
1329 {
1330 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1331 return user.name;
1332 }
1333 }
1334 String::new()
1335}
1336
1337fn hostname() -> String {
1339 #[cfg(unix)]
1340 {
1341 if let Ok(name) = nix::unistd::gethostname() {
1342 if let Some(name) = name.to_str() {
1343 if !name.is_empty() {
1344 return name.to_string();
1345 }
1346 }
1347 }
1348 }
1349 std::env::var("HOSTNAME").unwrap_or_default()
1350}
1351
1352const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1355
1356fn whitelisted_env() -> BTreeMap<String, String> {
1358 std::env::vars()
1359 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1360 .map(|(k, v)| {
1361 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1362 let value = if secretish { "REDACTED".to_string() } else { v };
1363 (k, value)
1364 })
1365 .collect()
1366}
1367
1368#[cfg(test)]
1369#[allow(clippy::unwrap_used, clippy::expect_used)]
1370mod tests {
1371 use super::*;
1372
1373 #[test]
1374 fn record_round_trips_through_json() {
1375 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1376 rec.service = Some("jira".to_string());
1377 rec.method = Some("GET".to_string());
1378 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1379 rec.status_code = Some(200);
1380 rec.elapsed_ms = Some(42);
1381
1382 let line = serde_json::to_string(&rec).unwrap();
1383 let back: LogRecord = serde_json::from_str(&line).unwrap();
1384 assert_eq!(back.invocation_id, "inv-1");
1385 assert_eq!(back.kind, RecordKind::Http);
1386 assert_eq!(back.service.as_deref(), Some("jira"));
1387 assert_eq!(back.status_code, Some(200));
1388 }
1389
1390 #[test]
1391 fn reader_tolerates_unknown_fields() {
1392 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1393 "future_field":{"nested":true},"another":42}"#;
1394 let rec: LogRecord = serde_json::from_str(line).unwrap();
1395 assert_eq!(rec.kind, RecordKind::Http);
1396 assert_eq!(rec.method.as_deref(), Some("GET"));
1397 }
1398
1399 #[test]
1400 fn reader_tolerates_missing_newer_fields() {
1401 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1403 let rec: LogRecord = serde_json::from_str(line).unwrap();
1404 assert_eq!(rec.kind, RecordKind::Invocation);
1405 assert_eq!(rec.command, vec!["git", "view"]);
1406 assert!(rec.status_code.is_none());
1407 assert!(rec.id.is_empty());
1408 }
1409
1410 #[test]
1411 fn unknown_kind_and_source_do_not_fail() {
1412 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1413 let rec: LogRecord = serde_json::from_str(line).unwrap();
1414 assert_eq!(rec.kind, RecordKind::Unknown);
1415 assert_eq!(rec.source, Some(Source::Unknown));
1416 }
1417
1418 #[test]
1419 fn optional_fields_are_skipped_when_empty() {
1420 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1421 let line = serde_json::to_string(&rec).unwrap();
1422 assert!(!line.contains("status_code"));
1424 assert!(!line.contains("request_headers"));
1425 assert!(!line.contains("via_daemon"));
1426 assert!(!line.contains("\"env\""));
1427 }
1428
1429 #[test]
1430 fn ids_are_time_sortable() {
1431 let a = new_id();
1432 std::thread::sleep(std::time::Duration::from_millis(2));
1433 let b = new_id();
1434 assert!(a < b, "{a} should sort before {b}");
1435 }
1436
1437 #[test]
1438 fn sensitive_headers_are_redacted() {
1439 let mut headers = BTreeMap::new();
1440 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1441 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1442 headers.insert("Content-Type".to_string(), "application/json".to_string());
1443 let out = redact_headers(&headers);
1444 assert_eq!(out["Authorization"], "REDACTED");
1445 assert_eq!(out["X-Api-Key"], "REDACTED");
1446 assert_eq!(out["Content-Type"], "application/json");
1447 }
1448
1449 fn argv(args: &[&str]) -> Vec<String> {
1450 args.iter().copied().map(String::from).collect()
1451 }
1452
1453 #[test]
1454 fn build_gh_record_stamps_kind_source_and_split_command() {
1455 let ctx = RequestLogContext {
1456 invocation_id: "inv-1".to_string(),
1457 source: Source::Daemon,
1458 mcp_tool: None,
1459 };
1460 let rec = build_gh_record(
1461 GhOutcome {
1462 label: "api graphql".to_string(),
1463 argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1464 exit_code: Some(0),
1465 duration: Duration::from_millis(120),
1466 error: None,
1467 },
1468 ctx,
1469 );
1470 assert_eq!(rec.kind, RecordKind::Gh);
1471 assert_eq!(rec.invocation_id, "inv-1");
1472 assert_eq!(rec.source, Some(Source::Daemon));
1473 assert_eq!(rec.command, argv(&["api", "graphql"]));
1475 assert_eq!(
1476 rec.command_line,
1477 argv(&["api", "graphql", "-f", "query=xyz"])
1478 );
1479 assert_eq!(rec.exit_code, Some(0));
1480 assert_eq!(rec.duration_ms, Some(120));
1481 assert!(rec.error.is_none());
1482 }
1483
1484 #[test]
1485 fn build_gh_record_scrubs_secret_bearing_argv() {
1486 let rec = build_gh_record(
1489 GhOutcome {
1490 label: "api graphql".to_string(),
1491 argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1492 exit_code: Some(0),
1493 duration: Duration::from_millis(5),
1494 error: None,
1495 },
1496 RequestLogContext::default(),
1497 );
1498 assert_eq!(
1499 rec.command_line,
1500 argv(&["api", "--header", "Authorization: REDACTED"])
1501 );
1502 }
1503
1504 #[test]
1505 fn build_worktree_record_stamps_kind_service_command_and_context() {
1506 let ctx = RequestLogContext {
1507 invocation_id: "inv-2".to_string(),
1508 source: Source::Mcp,
1509 mcp_tool: Some("some_tool".to_string()),
1510 };
1511 let mut context = BTreeMap::new();
1512 context.insert("path".to_string(), "/tmp/wt".to_string());
1513 context.insert("branch".to_string(), "demo-wt".to_string());
1514 context.insert("had_uncommitted".to_string(), "true".to_string());
1515 let rec = build_worktree_record(
1516 WorktreeOutcome {
1517 verb: "remove".to_string(),
1518 argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
1519 exit_code: Some(0),
1520 duration: Duration::from_millis(42),
1521 error: None,
1522 context,
1523 },
1524 ctx,
1525 );
1526 assert_eq!(rec.kind, RecordKind::Worktree);
1527 assert_eq!(rec.invocation_id, "inv-2");
1528 assert_eq!(rec.source, Some(Source::Mcp));
1529 assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
1530 assert_eq!(rec.service.as_deref(), Some("worktree"));
1531 assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
1532 assert_eq!(
1533 rec.command_line,
1534 argv(&["worktree", "remove", "--force", "/tmp/wt"])
1535 );
1536 assert_eq!(rec.exit_code, Some(0));
1537 assert_eq!(rec.duration_ms, Some(42));
1538 assert_eq!(
1539 rec.context.get("branch").map(String::as_str),
1540 Some("demo-wt")
1541 );
1542 assert_eq!(
1543 rec.context.get("had_uncommitted").map(String::as_str),
1544 Some("true")
1545 );
1546 }
1547
1548 #[test]
1549 fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
1550 let rec = build_worktree_record(
1551 WorktreeOutcome {
1552 verb: "add".to_string(),
1553 argv: argv(&["worktree", "add", "wt"]),
1554 exit_code: Some(1),
1555 duration: Duration::from_millis(1),
1556 error: Some("boom".to_string()),
1557 context: BTreeMap::new(),
1558 },
1559 RequestLogContext::default(),
1560 );
1561 let line = serde_json::to_string(&rec).unwrap();
1562 assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
1563 assert!(
1564 line.contains("\"service\":\"worktree\""),
1565 "line was: {line}"
1566 );
1567 assert_eq!(RecordKind::Worktree.as_str(), "worktree");
1569 let back: LogRecord = serde_json::from_str(&line).unwrap();
1570 assert_eq!(back.kind, RecordKind::Worktree);
1571 assert_eq!(back.command, argv(&["git", "worktree", "add"]));
1572 assert_eq!(back.error.as_deref(), Some("boom"));
1573 }
1574
1575 #[test]
1576 fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
1577 let ctx = RequestLogContext {
1578 invocation_id: "inv-3".to_string(),
1579 source: Source::Mcp,
1580 mcp_tool: Some("drive_file_move".to_string()),
1581 };
1582 let rec = build_drive_mutation_record(
1583 DriveMutationOutcome {
1584 operation: "move",
1585 file_id: "f1".to_string(),
1586 file_name: "report.pdf".to_string(),
1587 status: "blocked".to_string(),
1588 added_principals: vec!["alice@example.com".to_string()],
1589 removed_principals: vec![],
1590 crosses_drive_boundary: true,
1591 error: None,
1592 duration: Duration::from_millis(17),
1593 },
1594 ctx,
1595 );
1596 assert_eq!(rec.kind, RecordKind::DriveMutation);
1597 assert_eq!(rec.invocation_id, "inv-3");
1598 assert_eq!(rec.source, Some(Source::Mcp));
1599 assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
1600 assert_eq!(rec.service.as_deref(), Some("drive"));
1601 assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
1602 assert_eq!(rec.duration_ms, Some(17));
1603 assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
1604 assert_eq!(
1605 rec.context.get("file_name").map(String::as_str),
1606 Some("report.pdf")
1607 );
1608 assert_eq!(
1609 rec.context.get("status").map(String::as_str),
1610 Some("blocked")
1611 );
1612 assert_eq!(
1613 rec.context.get("added_principals").map(String::as_str),
1614 Some("alice@example.com")
1615 );
1616 assert_eq!(rec.context.get("removed_principals"), None);
1617 assert_eq!(
1618 rec.context
1619 .get("crosses_drive_boundary")
1620 .map(String::as_str),
1621 Some("true")
1622 );
1623 }
1624
1625 #[test]
1626 fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
1627 let rec = build_drive_mutation_record(
1628 DriveMutationOutcome {
1629 operation: "rename",
1630 file_id: "f2".to_string(),
1631 file_name: "old.txt".to_string(),
1632 status: "moved".to_string(),
1633 added_principals: vec![],
1634 removed_principals: vec![],
1635 crosses_drive_boundary: false,
1636 error: None,
1637 duration: Duration::from_millis(5),
1638 },
1639 RequestLogContext::default(),
1640 );
1641 assert_eq!(rec.context.get("added_principals"), None);
1642 assert_eq!(rec.context.get("removed_principals"), None);
1643 assert_eq!(rec.context.get("crosses_drive_boundary"), None);
1644 }
1645
1646 #[test]
1647 fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
1648 let rec = build_drive_mutation_record(
1649 DriveMutationOutcome {
1650 operation: "rename",
1651 file_id: "f1".to_string(),
1652 file_name: "a.txt".to_string(),
1653 status: "failed".to_string(),
1654 added_principals: vec![],
1655 removed_principals: vec![],
1656 crosses_drive_boundary: false,
1657 error: Some("boom".to_string()),
1658 duration: Duration::from_millis(1),
1659 },
1660 RequestLogContext::default(),
1661 );
1662 let line = serde_json::to_string(&rec).unwrap();
1663 assert!(
1664 line.contains("\"kind\":\"drivemutation\""),
1665 "line was: {line}"
1666 );
1667 assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
1668 let back: LogRecord = serde_json::from_str(&line).unwrap();
1669 assert_eq!(back.kind, RecordKind::DriveMutation);
1670 assert_eq!(
1671 back.command,
1672 vec!["drive".to_string(), "rename".to_string()]
1673 );
1674 assert_eq!(back.error.as_deref(), Some("boom"));
1675 }
1676
1677 #[test]
1678 fn record_kind_gh_serializes_as_gh_and_round_trips() {
1679 let rec = build_gh_record(
1680 GhOutcome {
1681 label: "pr list".to_string(),
1682 argv: argv(&["pr", "list"]),
1683 exit_code: Some(1),
1684 duration: Duration::from_millis(1),
1685 error: Some("boom".to_string()),
1686 },
1687 RequestLogContext::default(),
1688 );
1689 let line = serde_json::to_string(&rec).unwrap();
1690 assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1691 let back: LogRecord = serde_json::from_str(&line).unwrap();
1692 assert_eq!(back.kind, RecordKind::Gh);
1693 assert_eq!(back.command, argv(&["pr", "list"]));
1694 assert_eq!(back.error.as_deref(), Some("boom"));
1695 }
1696
1697 #[test]
1698 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1699 let out = scrub_argv(&argv(&[
1700 "omni-dev",
1701 "--header",
1702 "Authorization: Bearer sekret",
1703 "--header=Cookie: session=abc",
1704 ]));
1705 assert_eq!(
1706 out,
1707 argv(&[
1708 "omni-dev",
1709 "--header",
1710 "Authorization: REDACTED",
1711 "--header=Cookie: REDACTED",
1712 ])
1713 );
1714 }
1715
1716 #[test]
1717 fn scrub_argv_keeps_non_sensitive_headers() {
1718 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1719 assert_eq!(scrub_argv(&input), input);
1720 }
1721
1722 #[test]
1723 fn scrub_argv_redacts_colonless_header_wholesale() {
1724 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1725 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1726 }
1727
1728 #[test]
1729 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1730 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1731 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1732
1733 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1734 assert_eq!(scrub_argv(&file_form), file_form);
1735
1736 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1737 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1738 }
1739
1740 #[test]
1741 fn scrub_argv_redacts_secretish_flag_values() {
1742 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1743 assert_eq!(
1744 out,
1745 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1746 );
1747 }
1748
1749 #[test]
1750 fn scrub_argv_exempts_path_flags_and_positionals() {
1751 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1752 assert_eq!(scrub_argv(&input), input);
1753 }
1754
1755 #[test]
1756 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1757 let space = scrub_argv(&argv(&[
1761 "omni-dev",
1762 "browser",
1763 "bridge",
1764 "request",
1765 "--url",
1766 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1767 ]));
1768 assert_eq!(
1769 *space.last().unwrap(),
1770 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1771 );
1772
1773 let eq_form = scrub_argv(&argv(&[
1774 "omni-dev",
1775 "--url=/api/export?access_token=hunter2&page=3",
1776 ]));
1777 assert_eq!(
1778 *eq_form.last().unwrap(),
1779 "--url=/api/export?access_token=REDACTED&page=3"
1780 );
1781
1782 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1783 assert_eq!(
1784 *positional.last().unwrap(),
1785 "https://h/cb#id_token=REDACTED"
1786 );
1787 }
1788
1789 #[test]
1790 fn scrub_argv_leaves_benign_argv_byte_identical() {
1791 let input = argv(&[
1792 "omni-dev",
1793 "browser",
1794 "bridge",
1795 "request",
1796 "--control-port",
1797 "19998",
1798 "--url",
1799 "/api/export?page=3&sort=asc",
1800 ]);
1801 assert_eq!(scrub_argv(&input), input);
1802 }
1803
1804 #[test]
1805 fn scrub_argv_handles_trailing_flag_without_value() {
1806 let input = argv(&["omni-dev", "--body"]);
1807 assert_eq!(scrub_argv(&input), input);
1808 }
1809
1810 #[cfg(unix)]
1811 #[test]
1812 fn append_line_creates_file_owner_only() {
1813 use std::os::unix::fs::PermissionsExt;
1814 let dir = tempfile::tempdir().unwrap();
1815 let path = dir.path().join("log.jsonl");
1816 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1817 assert_eq!(
1818 std::fs::read_to_string(&path).unwrap(),
1819 "{\"kind\":\"http\"}\n"
1820 );
1821 assert_eq!(
1822 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1823 0o600
1824 );
1825 }
1826
1827 #[cfg(unix)]
1828 #[test]
1829 fn append_line_retightens_preexisting_loose_file() {
1830 use std::os::unix::fs::PermissionsExt;
1831 let dir = tempfile::tempdir().unwrap();
1832 let path = dir.path().join("log.jsonl");
1833 std::fs::write(&path, "old\n").unwrap();
1834 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1835 append_line(&path, "new\n").unwrap();
1836 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1837 assert_eq!(
1838 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1839 0o600
1840 );
1841 }
1842
1843 #[test]
1844 fn off_list_secretish_headers_are_redacted() {
1845 let mut headers = BTreeMap::new();
1846 for name in [
1847 "X-Auth-Token",
1848 "x-amz-security-token",
1849 "X-Goog-Api-Key",
1850 "x-csrf-token",
1851 "X-Vendor-Token",
1852 "X-Omni-Bridge",
1853 ] {
1854 headers.insert(name.to_string(), "secret-value".to_string());
1855 }
1856 for name in [
1857 "Content-Type",
1858 "Accept",
1859 "User-Agent",
1860 "x-request-id",
1861 "traceparent",
1862 ] {
1863 headers.insert(name.to_string(), "plain-value".to_string());
1864 }
1865 let out = redact_headers(&headers);
1866 assert_eq!(out["X-Auth-Token"], "REDACTED");
1867 assert_eq!(out["x-amz-security-token"], "REDACTED");
1868 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1869 assert_eq!(out["x-csrf-token"], "REDACTED");
1870 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1871 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1872 assert_eq!(out["Content-Type"], "plain-value");
1873 assert_eq!(out["Accept"], "plain-value");
1874 assert_eq!(out["User-Agent"], "plain-value");
1875 assert_eq!(out["x-request-id"], "plain-value");
1876 assert_eq!(out["traceparent"], "plain-value");
1877 }
1878
1879 #[test]
1880 fn url_without_query_is_unchanged() {
1881 assert_eq!(redact_url("https://h/p"), "https://h/p");
1882 assert_eq!(redact_url("/relative/p"), "/relative/p");
1883 }
1884
1885 #[test]
1886 fn benign_query_is_byte_identical() {
1887 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1888 assert_eq!(redact_url(url), url);
1889 }
1890
1891 #[test]
1892 fn sensitive_query_values_are_redacted() {
1893 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1894 assert_eq!(
1895 redact_url(url),
1896 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1897 &api_key=REDACTED&x=1"
1898 );
1899 }
1900
1901 #[test]
1902 fn presigned_s3_query_is_redacted() {
1903 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1904 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1905 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1906 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1907 assert_eq!(
1908 redact_url(url),
1909 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1910 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1911 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1912 );
1913 }
1914
1915 #[test]
1916 fn key_matching_is_case_insensitive() {
1917 assert_eq!(
1918 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1919 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1920 );
1921 }
1922
1923 #[test]
1924 fn repeated_sensitive_keys_are_each_redacted() {
1925 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1926 }
1927
1928 #[test]
1929 fn valueless_key_is_left_alone() {
1930 assert_eq!(redact_url("/p?token"), "/p?token");
1931 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1932 }
1933
1934 #[test]
1935 fn relative_url_query_is_redacted() {
1936 assert_eq!(
1937 redact_url("/api/foo?sig=abc&x=y"),
1938 "/api/foo?sig=REDACTED&x=y"
1939 );
1940 }
1941
1942 #[test]
1943 fn fragment_credentials_are_redacted() {
1944 assert_eq!(
1945 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1946 "https://h/cb#access_token=REDACTED&token_type=bearer"
1947 );
1948 }
1949
1950 #[test]
1951 fn query_and_fragment_are_scrubbed_independently() {
1952 assert_eq!(
1953 redact_url("/p?sig=a#id_token=b"),
1954 "/p?sig=REDACTED#id_token=REDACTED"
1955 );
1956 }
1957
1958 #[test]
1959 fn question_mark_in_fragment_is_not_parsed_as_query() {
1960 assert_eq!(
1964 redact_url("https://h/p#frag?token=x"),
1965 "https://h/p#frag?token=REDACTED"
1966 );
1967 }
1968
1969 #[test]
1970 fn encoded_sensitive_key_is_decoded_before_matching() {
1971 assert_eq!(
1972 redact_url("/p?access%5Ftoken=v"),
1973 "/p?access%5Ftoken=REDACTED"
1974 );
1975 }
1976
1977 #[test]
1978 fn empty_query_is_unchanged() {
1979 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1980 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1981 }
1982
1983 #[test]
1984 fn env_flag_parses_truthy_values() {
1985 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1986 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1987 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1988 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1989 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1990 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1991 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1992 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1993 }
1994
1995 #[test]
1996 fn parse_size_handles_units_and_bare_bytes() {
1997 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1998 assert_eq!(parse_size("512b").unwrap(), 512);
1999 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
2000 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
2001 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
2002 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
2003 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
2004 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
2005 }
2006
2007 #[test]
2008 fn parse_size_rejects_garbage() {
2009 assert!(parse_size("").is_err());
2010 assert!(parse_size("mb").is_err());
2011 assert!(parse_size("10tb").is_err());
2012 assert!(parse_size("-5mb").is_err());
2013 }
2014
2015 #[test]
2016 fn sibling_appends_to_final_component() {
2017 let base = Path::new("/tmp/omni/log.jsonl");
2018 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
2019 assert_eq!(
2020 sibling(base, ".lock"),
2021 Path::new("/tmp/omni/log.jsonl.lock")
2022 );
2023 }
2024
2025 #[test]
2026 fn keep_by_size_keeps_most_recent_that_fit() {
2027 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
2029 let refs: Vec<&str> = lines.to_vec();
2030
2031 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
2033 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
2035 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
2037 assert!(keep_by_size(&[], 100).is_empty());
2039 }
2040
2041 #[test]
2042 fn keep_by_age_is_conservative_on_undateable_lines() {
2043 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2044 .unwrap()
2045 .with_timezone(&Utc);
2046 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
2047 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
2048 let undated = r#"{"kind":"http"}"#;
2049 let malformed = "not json at all";
2050
2051 assert!(!keep_by_age(old, Some(cutoff)));
2052 assert!(keep_by_age(new, Some(cutoff)));
2053 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
2054 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
2055 assert!(keep_by_age(old, None), "no filter keeps everything");
2056 }
2057
2058 fn http_line(id: &str, ts: &str) -> String {
2059 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
2060 }
2061
2062 #[test]
2063 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
2064 use std::os::unix::fs::PermissionsExt;
2065
2066 let dir = tempfile::tempdir().unwrap();
2067 let path = dir.path().join("log.jsonl");
2068 let body = format!(
2069 "{}\n{}\n{}\n",
2070 http_line("1", "2026-01-01T00:00:00.000Z"),
2071 http_line("2", "2026-06-15T00:00:00.000Z"),
2072 http_line("3", "2026-12-31T00:00:00.000Z"),
2073 );
2074 std::fs::write(&path, &body).unwrap();
2075 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2076
2077 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2078 .unwrap()
2079 .with_timezone(&Utc);
2080 let outcome = prune(
2081 &path,
2082 &PruneOptions {
2083 older_than: Some(cutoff),
2084 max_size: None,
2085 dry_run: false,
2086 },
2087 )
2088 .unwrap();
2089
2090 assert_eq!(outcome.removed, 1);
2091 assert_eq!(outcome.kept, 2);
2092 let contents = std::fs::read_to_string(&path).unwrap();
2093 assert!(!contents.contains(r#""id":"1""#));
2094 assert!(contents.contains(r#""id":"2""#));
2095 assert!(contents.contains(r#""id":"3""#));
2096 assert_eq!(
2098 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2099 0o600
2100 );
2101 }
2102
2103 #[test]
2104 fn prune_dry_run_reports_without_modifying() {
2105 let dir = tempfile::tempdir().unwrap();
2106 let path = dir.path().join("log.jsonl");
2107 let body = format!(
2108 "{}\n{}\n",
2109 http_line("1", "2026-01-01T00:00:00.000Z"),
2110 http_line("2", "2026-12-31T00:00:00.000Z"),
2111 );
2112 std::fs::write(&path, &body).unwrap();
2113
2114 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2115 .unwrap()
2116 .with_timezone(&Utc);
2117 let outcome = prune(
2118 &path,
2119 &PruneOptions {
2120 older_than: Some(cutoff),
2121 max_size: None,
2122 dry_run: true,
2123 },
2124 )
2125 .unwrap();
2126
2127 assert_eq!(outcome.removed, 1);
2128 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
2130 }
2131
2132 #[test]
2133 fn prune_by_size_keeps_the_newest_that_fit() {
2134 let dir = tempfile::tempdir().unwrap();
2135 let path = dir.path().join("log.jsonl");
2136 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
2137 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
2138 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
2139 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
2140
2141 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
2143 let outcome = prune(
2144 &path,
2145 &PruneOptions {
2146 older_than: None,
2147 max_size: Some(budget),
2148 dry_run: false,
2149 },
2150 )
2151 .unwrap();
2152
2153 assert_eq!(outcome.removed, 1);
2154 assert_eq!(outcome.kept, 2);
2155 let contents = std::fs::read_to_string(&path).unwrap();
2156 assert!(!contents.contains(r#""id":"1""#));
2157 assert!(contents.contains(r#""id":"3""#));
2158 }
2159
2160 #[test]
2161 fn prune_missing_file_is_a_noop() {
2162 let dir = tempfile::tempdir().unwrap();
2163 let path = dir.path().join("absent.jsonl");
2164 let outcome = prune(
2165 &path,
2166 &PruneOptions {
2167 older_than: None,
2168 max_size: Some(1),
2169 dry_run: false,
2170 },
2171 )
2172 .unwrap();
2173 assert_eq!(outcome.removed, 0);
2174 assert_eq!(outcome.kept, 0);
2175 assert!(!path.exists());
2176 }
2177
2178 #[cfg(unix)]
2179 #[test]
2180 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
2181 use std::os::unix::fs::PermissionsExt;
2182
2183 let dir = tempfile::tempdir().unwrap();
2184 let path = dir.path().join("log.jsonl");
2185 let cfg = RotationConfig {
2187 max_size: 20,
2188 keep_files: 2,
2189 };
2190
2191 let line = "0123456789012345\n"; for _ in 0..4 {
2193 append_with_rotation(&path, line, &cfg).unwrap();
2194 }
2195
2196 assert!(path.exists());
2199 assert!(sibling(&path, ".1").exists());
2200 assert!(sibling(&path, ".2").exists());
2201 assert!(!sibling(&path, ".3").exists());
2202 assert_eq!(
2204 std::fs::metadata(sibling(&path, ".1"))
2205 .unwrap()
2206 .permissions()
2207 .mode()
2208 & 0o777,
2209 0o600
2210 );
2211 }
2212
2213 #[cfg(unix)]
2214 #[test]
2215 fn rotation_keep_zero_discards_on_overflow() {
2216 let dir = tempfile::tempdir().unwrap();
2217 let path = dir.path().join("log.jsonl");
2218 let cfg = RotationConfig {
2219 max_size: 20,
2220 keep_files: 0,
2221 };
2222 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
2224 append_with_rotation(&path, line, &cfg).unwrap();
2225 assert!(!sibling(&path, ".1").exists());
2227 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
2228 }
2229
2230 #[test]
2231 fn parse_size_rejects_overflow_to_infinity() {
2232 assert!(parse_size(&"9".repeat(400)).is_err());
2234 }
2235
2236 #[test]
2237 fn prune_surfaces_a_read_error() {
2238 let dir = tempfile::tempdir().unwrap();
2241 let result = prune(
2242 dir.path(),
2243 &PruneOptions {
2244 older_than: None,
2245 max_size: Some(1),
2246 dry_run: false,
2247 },
2248 );
2249 assert!(result.is_err());
2250 }
2251
2252 #[cfg(unix)]
2253 #[test]
2254 fn append_with_rotation_appends_even_when_rotate_fails() {
2255 let dir = tempfile::tempdir().unwrap();
2256 let path = dir.path().join("log.jsonl");
2257 std::fs::write(&path, "0123456789012345\n").unwrap();
2259 std::fs::create_dir(sibling(&path, ".1")).unwrap();
2261 let cfg = RotationConfig {
2262 max_size: 5,
2263 keep_files: 1,
2264 };
2265 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
2267 assert!(
2268 std::fs::read_to_string(&path).unwrap().contains("new-line"),
2269 "the record is appended despite the rotation failure"
2270 );
2271 }
2272
2273 #[test]
2274 fn prune_cleans_up_temp_on_rewrite_failure() {
2275 let dir = tempfile::tempdir().unwrap();
2276 let path = dir.path().join("log.jsonl");
2277 std::fs::write(
2278 &path,
2279 format!(
2280 "{}\n{}\n",
2281 http_line("a", "2999-01-01T00:00:00.000Z"),
2282 http_line("b", "2999-01-01T00:00:00.000Z"),
2283 ),
2284 )
2285 .unwrap();
2286 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
2289 std::fs::create_dir(&tmp).unwrap();
2290
2291 let result = prune(
2292 &path,
2293 &PruneOptions {
2294 older_than: None,
2295 max_size: Some(1),
2296 dry_run: false,
2297 },
2298 );
2299 assert!(result.is_err(), "a failing rewrite surfaces as an error");
2300 let _ = std::fs::remove_dir(&tmp);
2301 }
2302
2303 #[tokio::test]
2304 async fn scope_origin_id_overwrites_id_but_preserves_source() {
2305 let base = RequestLogContext {
2308 invocation_id: "daemon-1".to_string(),
2309 source: Source::Daemon,
2310 mcp_tool: None,
2311 };
2312 CTX.scope(base, async {
2313 scope_origin_id("cli-42".to_string(), async {
2314 let ctx = current_context();
2315 assert_eq!(ctx.invocation_id, "cli-42");
2317 assert_eq!(ctx.source, Source::Daemon);
2319 })
2320 .await;
2321 assert_eq!(current_context().invocation_id, "daemon-1");
2323 })
2324 .await;
2325 }
2326}