Skip to main content

omni_dev/
request_log.rs

1//! Append-only, local invocation + HTTP request log (`log.jsonl`).
2//!
3//! Every `omni-dev` invocation appends one `kind: "invocation"` line; every
4//! outbound HTTP request made by one of the integration clients appends one
5//! `kind: "http"` line correlated to it by a shared `invocation_id`. The log
6//! is **local-machine state** written under the platform state/data directory
7//! (`0700` dir / `0600` file, the same posture as [`crate::daemon::paths`]).
8//!
9//! Design invariants:
10//!
11//! - **Best effort.** [`record`] swallows every error (logging only at
12//!   `tracing::debug`); a logging failure can never change the program's exit
13//!   code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out.
14//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
15//!   `auth_principal` identity is kept. Headers are redacted centrally
16//!   ([`redact_headers`]), secret-bearing URL query/fragment parameter values
17//!   are redacted (`redact_url`) before writing, and request/response bodies
18//!   are opt-in via `OMNI_DEV_LOG_BODIES=1`.
19//! - **Forward compatible.** A single [`LogRecord`] is used for both writing
20//!   and reading: every field is `#[serde(default)]`, and every optional field
21//!   is `skip_serializing_if`, so a newer reader never chokes on an older line
22//!   and an older reader never chokes on a newer one — the same forward-rolling
23//!   contract the daemon wire types use.
24
25use 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
34/// Default log file name under the runtime directory.
35const LOG_FILE_NAME: &str = "log.jsonl";
36
37/// Number of rotated log files kept by default when
38/// [`OMNI_DEV_LOG_MAX_SIZE`](rotation_config) enables rotation but
39/// `OMNI_DEV_LOG_KEEP_FILES` is unset. (Rotation on write is unix-only.)
40#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43/// Which kind of record a line holds. Unknown future kinds deserialize to
44/// [`RecordKind::Unknown`] rather than failing the read.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48    /// One per process invocation (or per MCP tool call).
49    #[default]
50    Invocation,
51    /// One per outbound HTTP request.
52    Http,
53    /// One per `gh` CLI subprocess invocation (the only path to the GitHub API;
54    /// the token never enters our process, so these are subprocess records, not
55    /// [`RecordKind::Http`]). See `crate::github_metrics`.
56    Gh,
57    /// One per wrapped `git worktree` subprocess invocation, carrying
58    /// recovery-relevant metadata (path/branch/commit) in `context`.
59    /// See `crate::cli::git` worktree subcommands.
60    Worktree,
61    /// One per `drive rename`/`drive move` mutation attempt, including a
62    /// refused `Blocked` move — no `files.update` call happens for those,
63    /// but the refusal is itself the security-relevant event. Covers both
64    /// operations via `command`/`context`, mirroring how
65    /// [`RecordKind::Worktree`] covers multiple verbs rather than one kind
66    /// per verb. See `crate::drive::{rename,file_move}`.
67    DriveMutation,
68    /// A kind written by a newer version that this reader does not know.
69    #[serde(other)]
70    Unknown,
71}
72
73impl RecordKind {
74    /// Stable lowercase name, used for display and JSON map keys (matches the
75    /// `serde(rename_all = "lowercase")` wire form).
76    #[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/// What drove an invocation. Unknown future sources deserialize to
90/// [`Source::Unknown`].
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum Source {
94    /// A direct `omni-dev` CLI invocation.
95    #[default]
96    Cli,
97    /// An `omni-dev-mcp` tool call.
98    Mcp,
99    /// Work performed inside the long-lived daemon process.
100    Daemon,
101    /// A source written by a newer version that this reader does not know.
102    #[serde(other)]
103    Unknown,
104}
105
106/// One line of the log. Used for both writing and reading; every field is
107/// `#[serde(default)]` (tolerant reads) and every optional field is
108/// `skip_serializing_if` (compact, forward-compatible writes).
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct LogRecord {
111    // --- Core fields (present on every record) ---
112    /// Per-record, time-sortable id (see [`new_id`]).
113    #[serde(default)]
114    pub id: String,
115    /// Shared by an invocation record and every HTTP record it spawned.
116    #[serde(default)]
117    pub invocation_id: String,
118    /// Discriminates the record type.
119    #[serde(default)]
120    pub kind: RecordKind,
121    /// RFC3339 timestamp with milliseconds.
122    #[serde(default)]
123    pub timestamp: String,
124    /// Host the record was written on.
125    #[serde(default)]
126    pub hostname: String,
127    /// Writing process id.
128    #[serde(default)]
129    pub pid: u32,
130    /// `omni-dev` version that wrote the record.
131    #[serde(default)]
132    pub omni_dev_version: String,
133    /// Working directory at write time.
134    #[serde(default)]
135    pub cwd: String,
136    /// OS user that owns the process.
137    #[serde(default)]
138    pub system_user: String,
139
140    // --- `kind: "invocation"` fields ---
141    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub command: Vec<String>,
144    /// Full argv.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub command_line: Vec<String>,
147    /// Process exit code (0 success, 1 error — matches `die`).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub exit_code: Option<i32>,
150    /// Wall time of the whole invocation.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub duration_ms: Option<u64>,
153    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
154    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
155    pub env: BTreeMap<String, String>,
156    /// What drove the run (`cli`/`mcp`/`daemon`).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub source: Option<Source>,
159    /// When `source = mcp`, the tool name that drove the run.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub mcp_tool: Option<String>,
162
163    // --- `kind: "http"` fields ---
164    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub service: Option<String>,
167    /// HTTP method.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub method: Option<String>,
170    /// Request URL.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub url: Option<String>,
173    /// Response status; absent on a network/transport error.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub status_code: Option<u16>,
176    /// Elapsed time of the request.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub elapsed_ms: Option<u64>,
179    /// True when the request ran inside the daemon (bridge/Snowflake pool).
180    #[serde(default, skip_serializing_if = "is_false")]
181    pub via_daemon: bool,
182    /// Which pooled daemon session served the request.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub daemon_session_id: Option<String>,
185    /// Non-secret identity actually used (token id / OAuth principal) — never
186    /// the secret itself.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub auth_principal: Option<String>,
189    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
190    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191    pub request_headers: BTreeMap<String, String>,
192    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
193    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
194    pub response_headers: BTreeMap<String, String>,
195    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub request_body: Option<String>,
198    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub response_body: Option<String>,
201    /// Free-form correlation tags.
202    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203    pub context: BTreeMap<String, String>,
204
205    // --- shared optional ---
206    /// Top-level error chain (invocation) or per-request error (http).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub error: Option<String>,
209}
210
211/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
212#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
213fn is_false(b: &bool) -> bool {
214    !*b
215}
216
217impl LogRecord {
218    /// Builds a record carrying only the always-present core fields.
219    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/// The per-invocation context every record is stamped with.
236///
237/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
238/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
239/// invocation without threading state through every call site.
240#[derive(Debug, Clone)]
241pub struct RequestLogContext {
242    /// Shared id linking an invocation to the HTTP it spawned.
243    pub invocation_id: String,
244    /// What drove the run.
245    pub source: Source,
246    /// MCP tool name when `source = mcp`.
247    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    /// A CLI context with a freshly minted invocation id.
262    pub fn cli() -> Self {
263        Self {
264            invocation_id: new_id(),
265            source: Source::Cli,
266            mcp_tool: None,
267        }
268    }
269
270    /// An MCP context for a single tool call.
271    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    /// Per-task context override, set around each MCP tool dispatch.
284    pub static CTX: RequestLogContext;
285}
286
287/// Installs the process-global context. The first call wins (the CLI/daemon
288/// shell sets it once, very early); later calls are ignored.
289pub fn set_global(ctx: RequestLogContext) {
290    let _ = GLOBAL.set(ctx);
291}
292
293/// Resolves the active context: task-local override first, then the
294/// process-global default, then a synthesized fallback.
295pub 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
305/// Runs `fut` with the active context's `invocation_id` replaced by
306/// `origin_id`, preserving `source` and `mcp_tool`.
307///
308/// The daemon and the browser bridge scope this around a request they serve on
309/// behalf of a CLI/MCP client, so the HTTP records that request spawns
310/// correlate to the *originating* invocation rather than the server's own
311/// (#1198). `source` is deliberately preserved: a request served inside the
312/// daemon keeps `source = Daemon`, so `via_daemon` detection is unaffected while
313/// `invocation_id` now points at the caller's invocation record.
314pub 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
323/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
324pub fn disabled() -> bool {
325    env_flag("OMNI_DEV_LOG_DISABLE")
326}
327
328/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
329pub fn bodies_enabled() -> bool {
330    env_flag("OMNI_DEV_LOG_BODIES")
331}
332
333/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
334pub fn headers_enabled() -> bool {
335    env_flag("OMNI_DEV_LOG_HEADERS")
336}
337
338/// Reads a boolean-ish env var (`1`/`true`/`yes`, case-insensitive).
339fn 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
346/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
347/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
348pub 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
358/// Appends one record. Best effort: every error is swallowed (logged at
359/// `tracing::debug`) so logging can never affect the caller's exit code.
360pub 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
369/// The fallible append used by [`record`]; all errors flow back to be swallowed.
370fn 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    // Only create and tighten the parent when it's missing — re-`chmod`ing an
375    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
376    // temp dir) is both wrong and may fail; the file itself is always 0600.
377    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/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
389/// A pre-existing looser-perm file (an older version's, or a user-set
390/// `OMNI_DEV_LOG_FILE` target) is re-tightened to `0600` on every open, via
391/// the handle so there is no path race (#1139).
392/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
393/// exclusive lock guards the write; the common no-body path relies on
394/// `O_APPEND` single-write atomicity and takes no lock.
395#[cfg(unix)]
396fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
397    use std::os::unix::fs::OpenOptionsExt;
398
399    // Opt-in size-capped rotation takes over the write: it must stat, maybe
400    // rotate, then open a fresh file, all under a stable-path lock (#1121).
401    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/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
429/// no mode tightening (those are unix concepts). Size-capped rotation is a
430/// unix-only feature and is not applied here.
431#[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
441// --- Size management: rotation on write + `omni-dev log prune` ---
442//
443// The log is default-on for every invocation and every outbound request, so on
444// an active machine it would otherwise grow without bound (#1121). Two bounds
445// are offered, both opt-in:
446//
447//   * Automatic size-capped rotation on write, gated on `OMNI_DEV_LOG_MAX_SIZE`
448//     (+ `OMNI_DEV_LOG_KEEP_FILES`) — numbered `log.jsonl.1`, `.2`, … files.
449//   * The explicit `omni-dev log prune` command (age- and/or size-based), which
450//     rewrites the file in place via a same-dir temp file + atomic rename.
451
452/// Returns `path` with `suffix` appended to its final component (kept in the
453/// same directory), e.g. `…/log.jsonl` + `.1` → `…/log.jsonl.1`.
454fn 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
460/// Parses a human byte size: a number (with optional decimal) and an optional
461/// unit suffix — `b` (bytes, the default), `k`/`kb`/`kib`, `m`/`mb`/`mib`,
462/// `g`/`gb`/`gib` (case-insensitive, all binary/1024-based).
463pub(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/// Resolved rotation policy from the environment. `None` means rotation is off
491/// (the default): `OMNI_DEV_LOG_MAX_SIZE` unset, empty, invalid, or `0`.
492/// Rotation on write is a unix-only feature.
493#[cfg(unix)]
494struct RotationConfig {
495    /// Rotate before an append that would push the file past this many bytes.
496    max_size: u64,
497    /// Number of rotated `log.jsonl.N` files to retain.
498    keep_files: u32,
499}
500
501/// Reads the rotation policy from `OMNI_DEV_LOG_MAX_SIZE` /
502/// `OMNI_DEV_LOG_KEEP_FILES`. A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` logs at
503/// debug and disables rotation rather than failing the write.
504#[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/// Rotates `log.jsonl` → `log.jsonl.1`, shifting existing numbered files up and
529/// dropping any beyond `keep_files` (`keep_files == 0` simply discards the
530/// current file). Rotated files inherit the `0600` mode of their source.
531#[cfg(unix)]
532fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
533    if keep_files == 0 {
534        // Retain no history: dropping the current file lets the caller start a
535        // fresh one on the following append.
536        let _ = std::fs::remove_file(path);
537        return Ok(());
538    }
539    // Drop the oldest retained file, then shift .(N-1) → .N … .1 → .2.
540    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/// Size-capped append (unix): under an exclusive lock on a stable `<log>.lock`
552/// file — so all rotation-aware writers serialize on an inode that is never
553/// itself rotated — stat the log, rotate if this line would push a non-empty
554/// file past the cap, then append to the (possibly fresh) file. A rotation
555/// failure is logged at debug and the line is still appended (best effort).
556#[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    // Hold the lock for the whole check-rotate-append. If the lock cannot be
569    // taken, fall through unlocked rather than dropping the record.
570    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
589/// Options controlling [`prune`].
590pub struct PruneOptions {
591    /// Drop records whose timestamp is strictly older than this cutoff. A
592    /// record with a missing/unparseable timestamp (or a malformed line) is
593    /// conservatively kept.
594    pub older_than: Option<DateTime<Utc>>,
595    /// After age pruning, drop the oldest records until the file is at most
596    /// this many bytes. At least the single most recent record is always kept.
597    pub max_size: Option<u64>,
598    /// Compute and report the outcome without modifying the file.
599    pub dry_run: bool,
600}
601
602/// What a [`prune`] run did (or, when `dry_run`, would do).
603pub struct PruneOutcome {
604    /// Records removed.
605    pub removed: usize,
606    /// Records retained.
607    pub kept: usize,
608    /// File size before.
609    pub bytes_before: u64,
610    /// File size after (the size the retained records occupy).
611    pub bytes_after: u64,
612}
613
614/// Prunes the log at `path` by age and/or size, rewriting it in place.
615///
616/// Non-empty lines are retained by two successive filters: age (`older_than`)
617/// then size (`max_size`, keeping the most recent records that fit). The kept
618/// lines are written to a same-directory temp file (`0600` on unix) and
619/// atomically renamed over the original, so a reader never sees a half-written
620/// file. A missing log is a no-op; a no-change prune skips the rewrite (leaving
621/// the file's inode — and any concurrent appends — untouched).
622pub 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    // Every non-empty line, then the subset passing the age filter (both in
641    // original — chronological — order).
642    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
668/// Whether a raw line survives the age filter. Absent filter keeps everything;
669/// an undateable or malformed line is conservatively kept.
670fn 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
683/// Longest suffix of `lines` whose bytes (each line + its newline) fit in `max`,
684/// but never fewer than the single most recent line.
685fn 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; // keep at least the most recent record
697    }
698    &lines[start..]
699}
700
701/// Writes `lines` (each newline-terminated) to a same-directory temp file and
702/// atomically renames it over `path`, preserving the `0600` posture on unix.
703fn 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/// The outcome of an invocation, recorded once after `cli.execute()` returns.
731#[derive(Debug, Clone)]
732pub struct InvocationOutcome {
733    /// Resolved clap subcommand path.
734    pub command: Vec<String>,
735    /// Full argv.
736    pub command_line: Vec<String>,
737    /// Process exit code.
738    pub exit_code: i32,
739    /// Rendered error chain, when the command failed.
740    pub error: Option<String>,
741    /// Wall time of the whole invocation.
742    pub duration: Duration,
743}
744
745/// Appends one `kind: "invocation"` record from the active context.
746pub 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/// The outcome of one `gh` subprocess invocation, recorded by
761/// `crate::github_metrics::run_gh` after the process exits.
762#[derive(Debug, Clone)]
763pub struct GhOutcome {
764    /// The semantic subcommand label, e.g. `"api graphql"`, `"pr list"`. Split on
765    /// spaces into the record's `command` so `--command`/`command:` queries work.
766    pub label: String,
767    /// Full argv passed to `gh` (without the binary path). Scrubbed before write.
768    pub argv: Vec<String>,
769    /// Process exit code; `None` when the process could not be spawned.
770    pub exit_code: Option<i32>,
771    /// Wall time of the subprocess.
772    pub duration: Duration,
773    /// Spawn/collection error, when the invocation did not complete.
774    pub error: Option<String>,
775}
776
777/// Appends one `kind: "gh"` record from the active context.
778///
779/// Best effort and exit-code-safe: it goes through [`record`], which swallows
780/// every error, so a logging failure can never change a `gh` caller's result.
781pub fn record_gh(outcome: GhOutcome) {
782    record(&build_gh_record(outcome, current_context()));
783}
784
785/// Builds the `kind: "gh"` record for `outcome` under `ctx`. Split out from
786/// [`record_gh`] so the record shape (source stamping, subcommand split, argv
787/// scrubbing) is unit-testable without touching the filesystem or environment.
788fn 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    // Same scrubbing as invocation records — defense in depth. `gh` manages its
799    // own auth (the token never enters our argv), but any secret-bearing `--flag`
800    // or URL-query value is redacted regardless.
801    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/// The outcome of one wrapped `git worktree` subprocess, recorded by the
809/// `crate::cli::git` worktree subcommands after the process exits.
810#[derive(Debug, Clone)]
811pub struct WorktreeOutcome {
812    /// The verb (`add`/`remove`/`list`/`move`/`prune`/`repair`); the record's
813    /// `command` becomes `["git", "worktree", verb]` so `--command`/`command:`
814    /// queries work.
815    pub verb: String,
816    /// Full argv passed to `git` (without the binary path). Scrubbed before
817    /// write.
818    pub argv: Vec<String>,
819    /// Process exit code; `None` when the process could not be spawned.
820    pub exit_code: Option<i32>,
821    /// Wall time of the subprocess (metadata enrichment excluded).
822    pub duration: Duration,
823    /// Spawn/collection error, when the invocation did not complete.
824    pub error: Option<String>,
825    /// Recovery-relevant per-verb fields (`path`/`branch`/`commit`/
826    /// `had_uncommitted`/`used_force`/…), written to the record's `context`.
827    pub context: BTreeMap<String, String>,
828}
829
830/// Appends one `kind: "worktree"` record from the active context.
831///
832/// Best effort and exit-code-safe: it goes through [`record`], which swallows
833/// every error, so a logging failure can never change the wrapped git
834/// operation's result.
835pub fn record_worktree(outcome: WorktreeOutcome) {
836    record(&build_worktree_record(outcome, current_context()));
837}
838
839/// Builds the `kind: "worktree"` record for `outcome` under `ctx`. Split out
840/// from [`record_worktree`] so the record shape (source stamping, service tag,
841/// context passthrough, argv scrubbing) is unit-testable without touching the
842/// filesystem or environment.
843fn 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    // The service tag makes `--service worktree` the canonical recovery query.
848    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/// The outcome of one `drive rename`/`drive move` attempt.
859#[derive(Debug, Clone)]
860pub struct DriveMutationOutcome {
861    /// `"rename"` or `"move"`; becomes the record's `command`.
862    pub operation: &'static str,
863    /// The Drive file id acted on.
864    pub file_id: String,
865    /// The file's name at the time of the attempt.
866    pub file_name: String,
867    /// The domain outcome (e.g. `"moved"`, `"blocked"`, `"already-in-folder"`,
868    /// `"failed"` — kebab-case, matching `MoveResult`'s
869    /// `#[serde(tag = "status", rename_all = "kebab-case")]`).
870    pub status: String,
871    /// Principals gaining access, when the visibility diff detected an
872    /// increase. Empty for `rename` (which never changes visibility) and
873    /// for a `move` with no visibility change.
874    pub added_principals: Vec<String>,
875    /// Principals losing access — the decrease-side counterpart of
876    /// `added_principals`.
877    pub removed_principals: Vec<String>,
878    /// Whether the file moved across a My Drive / Shared Drive boundary.
879    pub crosses_drive_boundary: bool,
880    /// The API/validation error, when the attempt failed.
881    pub error: Option<String>,
882    /// Wall time of the attempt.
883    pub duration: Duration,
884}
885
886/// Appends one `kind: "drivemutation"` record from the active context.
887///
888/// Best effort and exit-code-safe: it goes through [`record`], which
889/// swallows every error, so a logging failure can never change the
890/// underlying rename/move result.
891///
892/// Deliberately called from *inside* `src/drive/rename.rs`/
893/// `src/drive/file_move.rs` themselves rather than the CLI layer — unlike
894/// [`record_worktree`], which is safe to call from `src/cli/git/worktree.rs`
895/// only because `git worktree` has no MCP surface at all. Drive move/rename
896/// may grow an MCP caller later, and "every move/rename must be logged" is a
897/// hard invariant that needs to hold for every current and future caller.
898/// This is also additive to (not redundant with) the automatic per-request
899/// `kind: "http"` records `crate::drive::client::DriveClient` already writes
900/// for every call it makes: a `Blocked` outcome makes *no* `files.update`
901/// call at all, so without this record the single most security-relevant
902/// event — "we refused this because visibility would change, here's exactly
903/// why" — would never appear in the log.
904pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
905    record(&build_drive_mutation_record(outcome, current_context()));
906}
907
908/// Builds the `kind: "drivemutation"` record for `outcome` under `ctx`. Split
909/// out from [`record_drive_mutation`] so the record shape is unit-testable
910/// without touching the filesystem or environment.
911fn 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/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
944/// redacted centrally in [`record_http_with`], so callers may pass them freely.
945#[derive(Debug, Clone, Default)]
946pub struct HttpExtra {
947    /// True when served inside the daemon.
948    pub via_daemon: bool,
949    /// Pooled daemon session id that served the request.
950    pub daemon_session_id: Option<String>,
951    /// Non-secret identity used (never the secret).
952    pub auth_principal: Option<String>,
953    /// Raw request headers (redacted + gated before writing).
954    pub request_headers: BTreeMap<String, String>,
955    /// Raw response headers (redacted + gated before writing).
956    pub response_headers: BTreeMap<String, String>,
957    /// Request body (gated before writing).
958    pub request_body: Option<String>,
959    /// Response body (gated before writing).
960    pub response_body: Option<String>,
961    /// Free-form correlation tags.
962    pub context: BTreeMap<String, String>,
963}
964
965/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
966pub 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
985/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
986/// `Ok` → status code and `Err` → error message.
987///
988/// Collapses the `match result { Ok → status, Err → error }` shape the REST
989/// clients previously each open-coded around [`record_http`] (#1152).
990pub 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/// Appends one `kind: "http"` record with extra, non-secret fields.
1022///
1023/// Headers and bodies are dropped unless their opt-in env var is set, headers
1024/// are always redacted, and URL query/fragment values under secret-looking
1025/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
1026/// written here under any caller.
1027#[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
1065/// Header names whose values must never be written (compared lowercased).
1066const 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
1081/// Substrings that mark a header name as secret-bearing (compared lowercased),
1082/// guarding against off-list auth headers (e.g. `x-auth-token`,
1083/// `x-goog-api-key`). False positives redact harmlessly.
1084const SENSITIVE_HEADER_MARKERS: &[&str] = &[
1085    "auth",
1086    "token",
1087    "secret",
1088    "key",
1089    "cookie",
1090    "password",
1091    "session",
1092    "signature",
1093    "credential",
1094];
1095
1096/// Replaces sensitive header values with `REDACTED`, passing others through.
1097///
1098/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
1099/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
1100pub 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
1121/// Flag-name segments marking a long flag's value as secret-bearing — the argv
1122/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
1123/// flag name so `--api-key` is caught but a name like `--keyword` is not.
1124const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
1125
1126/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
1127/// last segment is `file` or `path` carry paths, not secrets, and are exempt
1128/// (e.g. `--token-file`).
1129fn 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
1141/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
1142/// are redacted keeping the name, other headers pass through (`None`), and a
1143/// value with no colon is redacted wholesale.
1144fn 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
1153/// Returns the scrubbed replacement for the value of flag `--<name>`, or
1154/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
1155/// references (a path, not a secret).
1156fn 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
1165/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
1166/// write-side layers, so the on-disk line is clean and every reader/format is
1167/// covered with no reader changes:
1168///
1169/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
1170///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
1171///    `--flag=value` forms).
1172/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
1173///    fragment parameter on a URL argument (most naturally
1174///    `--url /path?access_token=…`, which no flag-name rule catches) has its
1175///    value redacted, while benign argv passes through byte-identical (#1162).
1176fn scrub_argv(argv: &[String]) -> Vec<String> {
1177    scrub_flag_secrets(argv)
1178        .iter()
1179        .map(|arg| redact_url(arg))
1180        .collect()
1181}
1182
1183/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
1184/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
1185/// `--flag value` and `--flag=value` forms). Everything else passes through to
1186/// the URL layer.
1187fn 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
1217/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
1218const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1219
1220/// Key suffixes marking the open-ended secret families (`access_token`,
1221/// `client_secret`, `api_key`, …).
1222const 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
1233/// Key prefixes for cloud-storage signed-URL parameter families.
1234const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1235
1236/// Returns whether a decoded query/fragment key looks secret-bearing.
1237fn 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
1248/// Rewrites one `&`-separated pair list, replacing the values of
1249/// secret-bearing keys with `REDACTED` and passing every other segment
1250/// through byte-verbatim.
1251fn redact_pairs(pairs: &str) -> String {
1252    pairs
1253        .split('&')
1254        .map(|segment| match segment.split_once('=') {
1255            Some((raw_key, _)) => {
1256                // Decode only the key (handles `access%5Ftoken` and `+`); the
1257                // raw key text is preserved in the output.
1258                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            // A bare key (no `=`) carries no value to leak.
1268            None => segment.to_string(),
1269        })
1270        .collect::<Vec<_>>()
1271        .join("&")
1272}
1273
1274/// Redacts secret-bearing query and fragment parameter values in a URL,
1275/// preserving scheme, host, path, and all parameter keys so `--url` substring
1276/// filtering stays useful. Handles relative URLs (the browser bridge logs
1277/// page-origin targets like `/api/foo?sig=…`), so this never requires the
1278/// input to parse as an absolute [`url::Url`].
1279fn 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
1298/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
1299///
1300/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
1301/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
1302/// crate.
1303pub 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
1309/// Current time as RFC3339 with millisecond precision, in UTC.
1310fn now_rfc3339_millis() -> String {
1311    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1312}
1313
1314/// Best-effort current working directory.
1315fn cwd() -> String {
1316    std::env::current_dir()
1317        .map(|p| p.display().to_string())
1318        .unwrap_or_default()
1319}
1320
1321/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
1322fn 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
1337/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
1338fn 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
1352/// Names matching these substrings have their env values redacted, guarding
1353/// against any future secret-bearing `OMNI_DEV_*` var.
1354const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1355
1356/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
1357fn 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        // An "old" line with only a couple of fields present.
1402        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        // Empty collections / None options must not appear on the wire.
1423        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        // The label is split into `command` for per-subcommand aggregation.
1474        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        // Defense in depth: even though `gh` never receives our auth, a
1487        // secret-bearing flag value in the argv is redacted before write.
1488        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        // The display name matches the wire form.
1568        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        // `--url` is not a secret-ish flag name, so its value is caught by the
1758        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
1759        // bare positional URL are covered; the benign `page` param survives.
1760        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        // The fragment is split off before the query, so `?` inside it never
1961        // starts a query; the pseudo-key `frag?token` still redacts via the
1962        // suffix rule (over-redaction in the safe direction).
1963        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        // Four 10-byte lines (11 bytes on disk each with the newline).
2028        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
2029        let refs: Vec<&str> = lines.to_vec();
2030
2031        // Budget for exactly two lines (22 bytes) keeps the last two.
2032        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
2033        // A budget smaller than one line still keeps the single most recent.
2034        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
2035        // A generous budget keeps everything.
2036        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
2037        // Empty input yields empty output (no panic).
2038        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        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
2097        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        // File is untouched by a dry run.
2129        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        // Budget that fits only the last two lines.
2142        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        // A tiny cap so every second short line rotates.
2186        let cfg = RotationConfig {
2187            max_size: 20,
2188            keep_files: 2,
2189        };
2190
2191        let line = "0123456789012345\n"; // 17 bytes
2192        for _ in 0..4 {
2193            append_with_rotation(&path, line, &cfg).unwrap();
2194        }
2195
2196        // The live file plus at most keep_files (2) rotated files exist; a .3
2197        // must never appear.
2198        assert!(path.exists());
2199        assert!(sibling(&path, ".1").exists());
2200        assert!(sibling(&path, ".2").exists());
2201        assert!(!sibling(&path, ".3").exists());
2202        // Rotated files keep the 0600 posture.
2203        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"; // 17 bytes
2223        append_with_rotation(&path, line, &cfg).unwrap();
2224        append_with_rotation(&path, line, &cfg).unwrap();
2225        // No .1 is retained; only the current (single-line) file survives.
2226        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        // A number too large for f64 parses to a non-finite value, not a size.
2233        assert!(parse_size(&"9".repeat(400)).is_err());
2234    }
2235
2236    #[test]
2237    fn prune_surfaces_a_read_error() {
2238        // Reading a directory as the log yields an error other than NotFound,
2239        // which prune propagates rather than treating as an empty log.
2240        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        // Seed a file already over the cap so the next write attempts to rotate.
2258        std::fs::write(&path, "0123456789012345\n").unwrap();
2259        // Make the rotation target a directory so `rename(log, log.1)` fails.
2260        std::fs::create_dir(sibling(&path, ".1")).unwrap();
2261        let cfg = RotationConfig {
2262            max_size: 5,
2263            keep_files: 1,
2264        };
2265        // Rotation fails, but the line is still appended (best effort).
2266        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        // Pre-create the exact temp path (same-process pid) as a directory so
2287        // the atomic rewrite's open fails, exercising the cleanup path.
2288        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        // A daemon-side base context: source = Daemon (so `via_daemon` detection
2306        // keeps working) with the daemon's own invocation id.
2307        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                // Correlation id now points at the originating CLI invocation…
2316                assert_eq!(ctx.invocation_id, "cli-42");
2317                // …while the source stays Daemon, so `via_daemon` is unaffected.
2318                assert_eq!(ctx.source, Source::Daemon);
2319            })
2320            .await;
2321            // The override is scoped: outside it, the base id is restored.
2322            assert_eq!(current_context().invocation_id, "daemon-1");
2323        })
2324        .await;
2325    }
2326}