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, 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    /// A kind written by a newer version that this reader does not know.
54    #[serde(other)]
55    Unknown,
56}
57
58/// What drove an invocation. Unknown future sources deserialize to
59/// [`Source::Unknown`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Source {
63    /// A direct `omni-dev` CLI invocation.
64    #[default]
65    Cli,
66    /// An `omni-dev-mcp` tool call.
67    Mcp,
68    /// Work performed inside the long-lived daemon process.
69    Daemon,
70    /// A source written by a newer version that this reader does not know.
71    #[serde(other)]
72    Unknown,
73}
74
75/// One line of the log. Used for both writing and reading; every field is
76/// `#[serde(default)]` (tolerant reads) and every optional field is
77/// `skip_serializing_if` (compact, forward-compatible writes).
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct LogRecord {
80    // --- Core fields (present on every record) ---
81    /// Per-record, time-sortable id (see [`new_id`]).
82    #[serde(default)]
83    pub id: String,
84    /// Shared by an invocation record and every HTTP record it spawned.
85    #[serde(default)]
86    pub invocation_id: String,
87    /// Discriminates the record type.
88    #[serde(default)]
89    pub kind: RecordKind,
90    /// RFC3339 timestamp with milliseconds.
91    #[serde(default)]
92    pub timestamp: String,
93    /// Host the record was written on.
94    #[serde(default)]
95    pub hostname: String,
96    /// Writing process id.
97    #[serde(default)]
98    pub pid: u32,
99    /// `omni-dev` version that wrote the record.
100    #[serde(default)]
101    pub omni_dev_version: String,
102    /// Working directory at write time.
103    #[serde(default)]
104    pub cwd: String,
105    /// OS user that owns the process.
106    #[serde(default)]
107    pub system_user: String,
108
109    // --- `kind: "invocation"` fields ---
110    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub command: Vec<String>,
113    /// Full argv.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub command_line: Vec<String>,
116    /// Process exit code (0 success, 1 error — matches `die`).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub exit_code: Option<i32>,
119    /// Wall time of the whole invocation.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub duration_ms: Option<u64>,
122    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
123    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
124    pub env: BTreeMap<String, String>,
125    /// What drove the run (`cli`/`mcp`/`daemon`).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub source: Option<Source>,
128    /// When `source = mcp`, the tool name that drove the run.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub mcp_tool: Option<String>,
131
132    // --- `kind: "http"` fields ---
133    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub service: Option<String>,
136    /// HTTP method.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub method: Option<String>,
139    /// Request URL.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub url: Option<String>,
142    /// Response status; absent on a network/transport error.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub status_code: Option<u16>,
145    /// Elapsed time of the request.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub elapsed_ms: Option<u64>,
148    /// True when the request ran inside the daemon (bridge/Snowflake pool).
149    #[serde(default, skip_serializing_if = "is_false")]
150    pub via_daemon: bool,
151    /// Which pooled daemon session served the request.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub daemon_session_id: Option<String>,
154    /// Non-secret identity actually used (token id / OAuth principal) — never
155    /// the secret itself.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub auth_principal: Option<String>,
158    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160    pub request_headers: BTreeMap<String, String>,
161    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub response_headers: BTreeMap<String, String>,
164    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub request_body: Option<String>,
167    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub response_body: Option<String>,
170    /// Free-form correlation tags.
171    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172    pub context: BTreeMap<String, String>,
173
174    // --- shared optional ---
175    /// Top-level error chain (invocation) or per-request error (http).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub error: Option<String>,
178}
179
180/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
181#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
182fn is_false(b: &bool) -> bool {
183    !*b
184}
185
186impl LogRecord {
187    /// Builds a record carrying only the always-present core fields.
188    fn new(kind: RecordKind, invocation_id: String) -> Self {
189        Self {
190            id: new_id(),
191            invocation_id,
192            kind,
193            timestamp: now_rfc3339_millis(),
194            hostname: hostname(),
195            pid: std::process::id(),
196            omni_dev_version: crate::VERSION.to_string(),
197            cwd: cwd(),
198            system_user: system_user(),
199            ..Self::default()
200        }
201    }
202}
203
204/// The per-invocation context every record is stamped with.
205///
206/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
207/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
208/// invocation without threading state through every call site.
209#[derive(Debug, Clone)]
210pub struct RequestLogContext {
211    /// Shared id linking an invocation to the HTTP it spawned.
212    pub invocation_id: String,
213    /// What drove the run.
214    pub source: Source,
215    /// MCP tool name when `source = mcp`.
216    pub mcp_tool: Option<String>,
217}
218
219impl Default for RequestLogContext {
220    fn default() -> Self {
221        Self {
222            invocation_id: new_id(),
223            source: Source::Cli,
224            mcp_tool: None,
225        }
226    }
227}
228
229impl RequestLogContext {
230    /// A CLI context with a freshly minted invocation id.
231    pub fn cli() -> Self {
232        Self {
233            invocation_id: new_id(),
234            source: Source::Cli,
235            mcp_tool: None,
236        }
237    }
238
239    /// An MCP context for a single tool call.
240    pub fn mcp(tool: impl Into<String>) -> Self {
241        Self {
242            invocation_id: new_id(),
243            source: Source::Mcp,
244            mcp_tool: Some(tool.into()),
245        }
246    }
247}
248
249static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
250
251tokio::task_local! {
252    /// Per-task context override, set around each MCP tool dispatch.
253    pub static CTX: RequestLogContext;
254}
255
256/// Installs the process-global context. The first call wins (the CLI/daemon
257/// shell sets it once, very early); later calls are ignored.
258pub fn set_global(ctx: RequestLogContext) {
259    let _ = GLOBAL.set(ctx);
260}
261
262/// Resolves the active context: task-local override first, then the
263/// process-global default, then a synthesized fallback.
264pub fn current_context() -> RequestLogContext {
265    if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
266        return ctx;
267    }
268    if let Some(ctx) = GLOBAL.get() {
269        return ctx.clone();
270    }
271    RequestLogContext::default()
272}
273
274/// Runs `fut` with the active context's `invocation_id` replaced by
275/// `origin_id`, preserving `source` and `mcp_tool`.
276///
277/// The daemon and the browser bridge scope this around a request they serve on
278/// behalf of a CLI/MCP client, so the HTTP records that request spawns
279/// correlate to the *originating* invocation rather than the server's own
280/// (#1198). `source` is deliberately preserved: a request served inside the
281/// daemon keeps `source = Daemon`, so `via_daemon` detection is unaffected while
282/// `invocation_id` now points at the caller's invocation record.
283pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
284where
285    F: std::future::Future<Output = T>,
286{
287    let mut ctx = current_context();
288    ctx.invocation_id = origin_id;
289    CTX.scope(ctx, fut).await
290}
291
292/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
293pub fn disabled() -> bool {
294    env_flag("OMNI_DEV_LOG_DISABLE")
295}
296
297/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
298pub fn bodies_enabled() -> bool {
299    env_flag("OMNI_DEV_LOG_BODIES")
300}
301
302/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
303pub fn headers_enabled() -> bool {
304    env_flag("OMNI_DEV_LOG_HEADERS")
305}
306
307/// Reads a boolean-ish env var (`1`/`true`/`yes`, case-insensitive).
308fn env_flag(name: &str) -> bool {
309    std::env::var(name).is_ok_and(|v| {
310        let v = v.trim().to_ascii_lowercase();
311        v == "1" || v == "true" || v == "yes"
312    })
313}
314
315/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
316/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
317pub fn log_file_path() -> Option<PathBuf> {
318    if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
319        if !path.is_empty() {
320            return Some(PathBuf::from(path));
321        }
322    }
323    let base = dirs::state_dir().or_else(dirs::data_dir)?;
324    Some(base.join("omni-dev").join(LOG_FILE_NAME))
325}
326
327/// Appends one record. Best effort: every error is swallowed (logged at
328/// `tracing::debug`) so logging can never affect the caller's exit code.
329pub fn record(entry: &LogRecord) {
330    if disabled() {
331        return;
332    }
333    if let Err(e) = try_record(entry) {
334        tracing::debug!("request_log: failed to append record: {e}");
335    }
336}
337
338/// The fallible append used by [`record`]; all errors flow back to be swallowed.
339fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
340    use anyhow::Context;
341
342    let path = log_file_path().context("could not resolve the log file path")?;
343    // Only create and tighten the parent when it's missing — re-`chmod`ing an
344    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
345    // temp dir) is both wrong and may fail; the file itself is always 0600.
346    if let Some(parent) = path.parent() {
347        if !parent.as_os_str().is_empty() && !parent.exists() {
348            crate::daemon::paths::ensure_dir_0700(parent)?;
349        }
350    }
351    let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
352    line.push('\n');
353    append_line(&path, &line)?;
354    Ok(())
355}
356
357/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
358/// A pre-existing looser-perm file (an older version's, or a user-set
359/// `OMNI_DEV_LOG_FILE` target) is re-tightened to `0600` on every open, via
360/// the handle so there is no path race (#1139).
361/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
362/// exclusive lock guards the write; the common no-body path relies on
363/// `O_APPEND` single-write atomicity and takes no lock.
364#[cfg(unix)]
365fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
366    use std::os::unix::fs::OpenOptionsExt;
367
368    // Opt-in size-capped rotation takes over the write: it must stat, maybe
369    // rotate, then open a fresh file, all under a stable-path lock (#1121).
370    if let Some(cfg) = rotation_config() {
371        return append_with_rotation(path, line, &cfg);
372    }
373
374    let file = std::fs::OpenOptions::new()
375        .append(true)
376        .create(true)
377        .mode(0o600)
378        .open(path)?;
379    crate::daemon::paths::ensure_handle_0600(&file)?;
380
381    if bodies_enabled() {
382        match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
383            Ok(mut guard) => {
384                guard.write_all(line.as_bytes())?;
385            }
386            Err((mut file, _)) => {
387                file.write_all(line.as_bytes())?;
388            }
389        }
390    } else {
391        let mut file = file;
392        file.write_all(line.as_bytes())?;
393    }
394    Ok(())
395}
396
397/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
398/// no mode tightening (those are unix concepts). Size-capped rotation is a
399/// unix-only feature and is not applied here.
400#[cfg(not(unix))]
401fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
402    let mut file = std::fs::OpenOptions::new()
403        .append(true)
404        .create(true)
405        .open(path)?;
406    file.write_all(line.as_bytes())?;
407    Ok(())
408}
409
410// --- Size management: rotation on write + `omni-dev log prune` ---
411//
412// The log is default-on for every invocation and every outbound request, so on
413// an active machine it would otherwise grow without bound (#1121). Two bounds
414// are offered, both opt-in:
415//
416//   * Automatic size-capped rotation on write, gated on `OMNI_DEV_LOG_MAX_SIZE`
417//     (+ `OMNI_DEV_LOG_KEEP_FILES`) — numbered `log.jsonl.1`, `.2`, … files.
418//   * The explicit `omni-dev log prune` command (age- and/or size-based), which
419//     rewrites the file in place via a same-dir temp file + atomic rename.
420
421/// Returns `path` with `suffix` appended to its final component (kept in the
422/// same directory), e.g. `…/log.jsonl` + `.1` → `…/log.jsonl.1`.
423fn sibling(path: &Path, suffix: &str) -> PathBuf {
424    let mut name = path.as_os_str().to_owned();
425    name.push(suffix);
426    PathBuf::from(name)
427}
428
429/// Parses a human byte size: a number (with optional decimal) and an optional
430/// unit suffix — `b` (bytes, the default), `k`/`kb`/`kib`, `m`/`mb`/`mib`,
431/// `g`/`gb`/`gib` (case-insensitive, all binary/1024-based).
432pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
433    use anyhow::Context as _;
434
435    let lower = s.trim().to_ascii_lowercase();
436    if lower.is_empty() {
437        anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
438    }
439    let split = lower
440        .find(|c: char| !c.is_ascii_digit() && c != '.')
441        .unwrap_or(lower.len());
442    let (num, unit) = lower.split_at(split);
443    let value: f64 = num
444        .parse()
445        .with_context(|| format!("invalid size number: {s}"))?;
446    if !value.is_finite() || value < 0.0 {
447        anyhow::bail!("invalid size: {s}");
448    }
449    let mult: u64 = match unit.trim() {
450        "" | "b" => 1,
451        "k" | "kb" | "kib" => 1024,
452        "m" | "mb" | "mib" => 1024 * 1024,
453        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
454        other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
455    };
456    Ok((value * mult as f64) as u64)
457}
458
459/// Resolved rotation policy from the environment. `None` means rotation is off
460/// (the default): `OMNI_DEV_LOG_MAX_SIZE` unset, empty, invalid, or `0`.
461/// Rotation on write is a unix-only feature.
462#[cfg(unix)]
463struct RotationConfig {
464    /// Rotate before an append that would push the file past this many bytes.
465    max_size: u64,
466    /// Number of rotated `log.jsonl.N` files to retain.
467    keep_files: u32,
468}
469
470/// Reads the rotation policy from `OMNI_DEV_LOG_MAX_SIZE` /
471/// `OMNI_DEV_LOG_KEEP_FILES`. A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` logs at
472/// debug and disables rotation rather than failing the write.
473#[cfg(unix)]
474fn rotation_config() -> Option<RotationConfig> {
475    let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
476    if raw.trim().is_empty() {
477        return None;
478    }
479    let max_size = match parse_size(&raw) {
480        Ok(0) => return None,
481        Ok(n) => n,
482        Err(e) => {
483            tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
484            return None;
485        }
486    };
487    let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
488        .ok()
489        .and_then(|v| v.trim().parse::<u32>().ok())
490        .unwrap_or(DEFAULT_KEEP_FILES);
491    Some(RotationConfig {
492        max_size,
493        keep_files,
494    })
495}
496
497/// Rotates `log.jsonl` → `log.jsonl.1`, shifting existing numbered files up and
498/// dropping any beyond `keep_files` (`keep_files == 0` simply discards the
499/// current file). Rotated files inherit the `0600` mode of their source.
500#[cfg(unix)]
501fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
502    if keep_files == 0 {
503        // Retain no history: dropping the current file lets the caller start a
504        // fresh one on the following append.
505        let _ = std::fs::remove_file(path);
506        return Ok(());
507    }
508    // Drop the oldest retained file, then shift .(N-1) → .N … .1 → .2.
509    let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
510    for i in (1..keep_files).rev() {
511        let from = sibling(path, &format!(".{i}"));
512        if from.exists() {
513            std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
514        }
515    }
516    std::fs::rename(path, sibling(path, ".1"))?;
517    Ok(())
518}
519
520/// Size-capped append (unix): under an exclusive lock on a stable `<log>.lock`
521/// file — so all rotation-aware writers serialize on an inode that is never
522/// itself rotated — stat the log, rotate if this line would push a non-empty
523/// file past the cap, then append to the (possibly fresh) file. A rotation
524/// failure is logged at debug and the line is still appended (best effort).
525#[cfg(unix)]
526fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
527    use std::os::unix::fs::OpenOptionsExt;
528
529    let lock_path = sibling(path, ".lock");
530    let lock_file = std::fs::OpenOptions::new()
531        .create(true)
532        .write(true)
533        .truncate(false)
534        .mode(0o600)
535        .open(&lock_path)?;
536    crate::daemon::paths::ensure_handle_0600(&lock_file)?;
537    // Hold the lock for the whole check-rotate-append. If the lock cannot be
538    // taken, fall through unlocked rather than dropping the record.
539    let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
540
541    let current = std::fs::metadata(path).map_or(0, |m| m.len());
542    if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
543        if let Err(e) = rotate(path, cfg.keep_files) {
544            tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
545        }
546    }
547
548    let mut file = std::fs::OpenOptions::new()
549        .append(true)
550        .create(true)
551        .mode(0o600)
552        .open(path)?;
553    crate::daemon::paths::ensure_handle_0600(&file)?;
554    file.write_all(line.as_bytes())?;
555    Ok(())
556}
557
558/// Options controlling [`prune`].
559pub struct PruneOptions {
560    /// Drop records whose timestamp is strictly older than this cutoff. A
561    /// record with a missing/unparseable timestamp (or a malformed line) is
562    /// conservatively kept.
563    pub older_than: Option<DateTime<Utc>>,
564    /// After age pruning, drop the oldest records until the file is at most
565    /// this many bytes. At least the single most recent record is always kept.
566    pub max_size: Option<u64>,
567    /// Compute and report the outcome without modifying the file.
568    pub dry_run: bool,
569}
570
571/// What a [`prune`] run did (or, when `dry_run`, would do).
572pub struct PruneOutcome {
573    /// Records removed.
574    pub removed: usize,
575    /// Records retained.
576    pub kept: usize,
577    /// File size before.
578    pub bytes_before: u64,
579    /// File size after (the size the retained records occupy).
580    pub bytes_after: u64,
581}
582
583/// Prunes the log at `path` by age and/or size, rewriting it in place.
584///
585/// Non-empty lines are retained by two successive filters: age (`older_than`)
586/// then size (`max_size`, keeping the most recent records that fit). The kept
587/// lines are written to a same-directory temp file (`0600` on unix) and
588/// atomically renamed over the original, so a reader never sees a half-written
589/// file. A missing log is a no-op; a no-change prune skips the rewrite (leaving
590/// the file's inode — and any concurrent appends — untouched).
591pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
592    use anyhow::Context as _;
593
594    let data = match std::fs::read(path) {
595        Ok(data) => data,
596        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
597            return Ok(PruneOutcome {
598                removed: 0,
599                kept: 0,
600                bytes_before: 0,
601                bytes_after: 0,
602            });
603        }
604        Err(e) => return Err(e).context("failed to read the log file"),
605    };
606    let bytes_before = data.len() as u64;
607    let text = String::from_utf8_lossy(&data);
608
609    // Every non-empty line, then the subset passing the age filter (both in
610    // original — chronological — order).
611    let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
612    let aged: Vec<&str> = all
613        .iter()
614        .copied()
615        .filter(|line| keep_by_age(line, opts.older_than))
616        .collect();
617
618    let kept: &[&str] = match opts.max_size {
619        None => &aged,
620        Some(max) => keep_by_size(&aged, max),
621    };
622
623    let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
624    let outcome = PruneOutcome {
625        removed: all.len() - kept.len(),
626        kept: kept.len(),
627        bytes_before,
628        bytes_after,
629    };
630
631    if !opts.dry_run && outcome.removed > 0 {
632        rewrite_atomically(path, kept)?;
633    }
634    Ok(outcome)
635}
636
637/// Whether a raw line survives the age filter. Absent filter keeps everything;
638/// an undateable or malformed line is conservatively kept.
639fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
640    let Some(cutoff) = older_than else {
641        return true;
642    };
643    match serde_json::from_str::<LogRecord>(line) {
644        Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
645            Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
646            Err(_) => true,
647        },
648        Err(_) => true,
649    }
650}
651
652/// Longest suffix of `lines` whose bytes (each line + its newline) fit in `max`,
653/// but never fewer than the single most recent line.
654fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
655    let mut acc = 0u64;
656    let mut start = lines.len();
657    for (i, line) in lines.iter().enumerate().rev() {
658        acc += line.len() as u64 + 1;
659        if acc > max {
660            break;
661        }
662        start = i;
663    }
664    if start == lines.len() && !lines.is_empty() {
665        start = lines.len() - 1; // keep at least the most recent record
666    }
667    &lines[start..]
668}
669
670/// Writes `lines` (each newline-terminated) to a same-directory temp file and
671/// atomically renames it over `path`, preserving the `0600` posture on unix.
672fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
673    let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
674    let result = (|| -> anyhow::Result<()> {
675        let mut options = std::fs::OpenOptions::new();
676        options.create(true).write(true).truncate(true);
677        #[cfg(unix)]
678        {
679            use std::os::unix::fs::OpenOptionsExt;
680            options.mode(0o600);
681        }
682        let mut file = options.open(&tmp)?;
683        #[cfg(unix)]
684        crate::daemon::paths::ensure_handle_0600(&file)?;
685        for line in lines {
686            file.write_all(line.as_bytes())?;
687            file.write_all(b"\n")?;
688        }
689        file.flush()?;
690        std::fs::rename(&tmp, path)?;
691        Ok(())
692    })();
693    if result.is_err() {
694        let _ = std::fs::remove_file(&tmp);
695    }
696    result
697}
698
699/// The outcome of an invocation, recorded once after `cli.execute()` returns.
700#[derive(Debug, Clone)]
701pub struct InvocationOutcome {
702    /// Resolved clap subcommand path.
703    pub command: Vec<String>,
704    /// Full argv.
705    pub command_line: Vec<String>,
706    /// Process exit code.
707    pub exit_code: i32,
708    /// Rendered error chain, when the command failed.
709    pub error: Option<String>,
710    /// Wall time of the whole invocation.
711    pub duration: Duration,
712}
713
714/// Appends one `kind: "invocation"` record from the active context.
715pub fn record_invocation(outcome: InvocationOutcome) {
716    let ctx = current_context();
717    let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
718    rec.source = Some(ctx.source);
719    rec.mcp_tool = ctx.mcp_tool;
720    rec.command = outcome.command;
721    rec.command_line = scrub_argv(&outcome.command_line);
722    rec.exit_code = Some(outcome.exit_code);
723    rec.error = outcome.error;
724    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
725    rec.env = whitelisted_env();
726    record(&rec);
727}
728
729/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
730/// redacted centrally in [`record_http_with`], so callers may pass them freely.
731#[derive(Debug, Clone, Default)]
732pub struct HttpExtra {
733    /// True when served inside the daemon.
734    pub via_daemon: bool,
735    /// Pooled daemon session id that served the request.
736    pub daemon_session_id: Option<String>,
737    /// Non-secret identity used (never the secret).
738    pub auth_principal: Option<String>,
739    /// Raw request headers (redacted + gated before writing).
740    pub request_headers: BTreeMap<String, String>,
741    /// Raw response headers (redacted + gated before writing).
742    pub response_headers: BTreeMap<String, String>,
743    /// Request body (gated before writing).
744    pub request_body: Option<String>,
745    /// Response body (gated before writing).
746    pub response_body: Option<String>,
747    /// Free-form correlation tags.
748    pub context: BTreeMap<String, String>,
749}
750
751/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
752pub fn record_http(
753    service: &str,
754    method: &str,
755    url: &str,
756    started: Instant,
757    status: Option<u16>,
758    error: Option<&str>,
759) {
760    record_http_with(
761        service,
762        method,
763        url,
764        started,
765        status,
766        error,
767        HttpExtra::default(),
768    );
769}
770
771/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
772/// `Ok` → status code and `Err` → error message.
773///
774/// Collapses the `match result { Ok → status, Err → error }` shape the REST
775/// clients previously each open-coded around [`record_http`] (#1152).
776pub fn record_http_result(
777    service: &str,
778    method: &str,
779    url: &str,
780    started: Instant,
781    result: &reqwest::Result<reqwest::Response>,
782) {
783    match result {
784        Ok(response) => {
785            record_http(
786                service,
787                method,
788                url,
789                started,
790                Some(response.status().as_u16()),
791                None,
792            );
793        }
794        Err(error) => {
795            record_http(
796                service,
797                method,
798                url,
799                started,
800                None,
801                Some(&error.to_string()),
802            );
803        }
804    }
805}
806
807/// Appends one `kind: "http"` record with extra, non-secret fields.
808///
809/// Headers and bodies are dropped unless their opt-in env var is set, headers
810/// are always redacted, and URL query/fragment values under secret-looking
811/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
812/// written here under any caller.
813#[allow(clippy::too_many_arguments)]
814pub fn record_http_with(
815    service: &str,
816    method: &str,
817    url: &str,
818    started: Instant,
819    status: Option<u16>,
820    error: Option<&str>,
821    extra: HttpExtra,
822) {
823    if disabled() {
824        return;
825    }
826    let ctx = current_context();
827    let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
828    rec.source = Some(ctx.source);
829    rec.mcp_tool = ctx.mcp_tool;
830    rec.service = Some(service.to_string());
831    rec.method = Some(method.to_string());
832    rec.url = Some(redact_url(url));
833    rec.status_code = status;
834    rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
835    rec.error = error.map(str::to_string);
836    rec.via_daemon = extra.via_daemon;
837    rec.daemon_session_id = extra.daemon_session_id;
838    rec.auth_principal = extra.auth_principal;
839    rec.context = extra.context;
840    if headers_enabled() {
841        rec.request_headers = redact_headers(&extra.request_headers);
842        rec.response_headers = redact_headers(&extra.response_headers);
843    }
844    if bodies_enabled() {
845        rec.request_body = extra.request_body;
846        rec.response_body = extra.response_body;
847    }
848    record(&rec);
849}
850
851/// Header names whose values must never be written (compared lowercased).
852const SENSITIVE_HEADERS: &[&str] = &[
853    "authorization",
854    "proxy-authorization",
855    "cookie",
856    "set-cookie",
857    "x-api-key",
858    "api-key",
859    "dd-api-key",
860    "dd-application-key",
861    "x-datadog-api-key",
862    "x-datadog-application-key",
863    "x-omni-bridge",
864    "x-omni-bridge-target",
865];
866
867/// Substrings that mark a header name as secret-bearing (compared lowercased),
868/// guarding against off-list auth headers (e.g. `x-auth-token`,
869/// `x-goog-api-key`). False positives redact harmlessly.
870const SENSITIVE_HEADER_MARKERS: &[&str] = &[
871    "auth",
872    "token",
873    "secret",
874    "key",
875    "cookie",
876    "password",
877    "session",
878    "signature",
879    "credential",
880];
881
882/// Replaces sensitive header values with `REDACTED`, passing others through.
883///
884/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
885/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
886pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
887    headers
888        .iter()
889        .map(|(name, value)| {
890            let lower = name.to_ascii_lowercase();
891            let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
892                || SENSITIVE_HEADER_MARKERS
893                    .iter()
894                    .any(|marker| lower.contains(marker));
895            (
896                name.clone(),
897                if redacted {
898                    "REDACTED".to_string()
899                } else {
900                    value.clone()
901                },
902            )
903        })
904        .collect()
905}
906
907/// Flag-name segments marking a long flag's value as secret-bearing — the argv
908/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
909/// flag name so `--api-key` is caught but a name like `--keyword` is not.
910const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
911
912/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
913/// last segment is `file` or `path` carry paths, not secrets, and are exempt
914/// (e.g. `--token-file`).
915fn is_secretish_flag(name: &str) -> bool {
916    let segments: Vec<String> = name
917        .split(['-', '_'])
918        .map(str::to_ascii_lowercase)
919        .collect();
920    let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
921    !takes_path
922        && segments
923            .iter()
924            .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
925}
926
927/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
928/// are redacted keeping the name, other headers pass through (`None`), and a
929/// value with no colon is redacted wholesale.
930fn scrub_header_arg(value: &str) -> Option<String> {
931    let Some((name, _)) = value.split_once(':') else {
932        return Some("REDACTED".to_string());
933    };
934    SENSITIVE_HEADERS
935        .contains(&name.trim().to_ascii_lowercase().as_str())
936        .then(|| format!("{}: REDACTED", name.trim()))
937}
938
939/// Returns the scrubbed replacement for the value of flag `--<name>`, or
940/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
941/// references (a path, not a secret).
942fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
943    match name {
944        "header" => scrub_header_arg(value),
945        "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
946        _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
947        _ => None,
948    }
949}
950
951/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
952/// write-side layers, so the on-disk line is clean and every reader/format is
953/// covered with no reader changes:
954///
955/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
956///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
957///    `--flag=value` forms).
958/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
959///    fragment parameter on a URL argument (most naturally
960///    `--url /path?access_token=…`, which no flag-name rule catches) has its
961///    value redacted, while benign argv passes through byte-identical (#1162).
962fn scrub_argv(argv: &[String]) -> Vec<String> {
963    scrub_flag_secrets(argv)
964        .iter()
965        .map(|arg| redact_url(arg))
966        .collect()
967}
968
969/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
970/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
971/// `--flag value` and `--flag=value` forms). Everything else passes through to
972/// the URL layer.
973fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
974    let mut out = Vec::with_capacity(argv.len());
975    let mut i = 0;
976    while i < argv.len() {
977        let arg = &argv[i];
978        i += 1;
979        let Some(flag_body) = arg.strip_prefix("--") else {
980            out.push(arg.clone());
981            continue;
982        };
983        if let Some((name, value)) = flag_body.split_once('=') {
984            match scrub_flag_value(name, value) {
985                Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
986                None => out.push(arg.clone()),
987            }
988        } else {
989            out.push(arg.clone());
990            let takes_secret_value =
991                matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
992            if takes_secret_value {
993                if let Some(value) = argv.get(i) {
994                    i += 1;
995                    out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
996                }
997            }
998        }
999    }
1000    out
1001}
1002
1003/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
1004const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1005
1006/// Key suffixes marking the open-ended secret families (`access_token`,
1007/// `client_secret`, `api_key`, …).
1008const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1009    "token",
1010    "secret",
1011    "password",
1012    "passwd",
1013    "signature",
1014    "apikey",
1015    "api_key",
1016    "api-key",
1017];
1018
1019/// Key prefixes for cloud-storage signed-URL parameter families.
1020const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1021
1022/// Returns whether a decoded query/fragment key looks secret-bearing.
1023fn sensitive_query_key(key: &str) -> bool {
1024    let key = key.to_ascii_lowercase();
1025    SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1026        || SENSITIVE_QUERY_KEY_SUFFIXES
1027            .iter()
1028            .any(|suffix| key.ends_with(suffix))
1029        || SENSITIVE_QUERY_KEY_PREFIXES
1030            .iter()
1031            .any(|prefix| key.starts_with(prefix))
1032}
1033
1034/// Rewrites one `&`-separated pair list, replacing the values of
1035/// secret-bearing keys with `REDACTED` and passing every other segment
1036/// through byte-verbatim.
1037fn redact_pairs(pairs: &str) -> String {
1038    pairs
1039        .split('&')
1040        .map(|segment| match segment.split_once('=') {
1041            Some((raw_key, _)) => {
1042                // Decode only the key (handles `access%5Ftoken` and `+`); the
1043                // raw key text is preserved in the output.
1044                let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1045                    .next()
1046                    .is_some_and(|(key, _)| sensitive_query_key(&key));
1047                if sensitive {
1048                    format!("{raw_key}=REDACTED")
1049                } else {
1050                    segment.to_string()
1051                }
1052            }
1053            // A bare key (no `=`) carries no value to leak.
1054            None => segment.to_string(),
1055        })
1056        .collect::<Vec<_>>()
1057        .join("&")
1058}
1059
1060/// Redacts secret-bearing query and fragment parameter values in a URL,
1061/// preserving scheme, host, path, and all parameter keys so `--url` substring
1062/// filtering stays useful. Handles relative URLs (the browser bridge logs
1063/// page-origin targets like `/api/foo?sig=…`), so this never requires the
1064/// input to parse as an absolute [`url::Url`].
1065fn redact_url(url: &str) -> String {
1066    let (rest, fragment) = url
1067        .split_once('#')
1068        .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1069    let (prefix, query) = rest
1070        .split_once('?')
1071        .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1072    let mut out = prefix.to_string();
1073    if let Some(query) = query {
1074        out.push('?');
1075        out.push_str(&redact_pairs(query));
1076    }
1077    if let Some(fragment) = fragment {
1078        out.push('#');
1079        out.push_str(&redact_pairs(fragment));
1080    }
1081    out
1082}
1083
1084/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
1085///
1086/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
1087/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
1088/// crate.
1089pub fn new_id() -> String {
1090    let millis = chrono::Utc::now().timestamp_millis().max(0);
1091    let suffix = rand::random::<u64>();
1092    format!("{millis:013}-{suffix:016x}")
1093}
1094
1095/// Current time as RFC3339 with millisecond precision, in UTC.
1096fn now_rfc3339_millis() -> String {
1097    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1098}
1099
1100/// Best-effort current working directory.
1101fn cwd() -> String {
1102    std::env::current_dir()
1103        .map(|p| p.display().to_string())
1104        .unwrap_or_default()
1105}
1106
1107/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
1108fn system_user() -> String {
1109    if let Ok(user) = std::env::var("USER") {
1110        if !user.is_empty() {
1111            return user;
1112        }
1113    }
1114    #[cfg(unix)]
1115    {
1116        if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1117            return user.name;
1118        }
1119    }
1120    String::new()
1121}
1122
1123/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
1124fn hostname() -> String {
1125    #[cfg(unix)]
1126    {
1127        if let Ok(name) = nix::unistd::gethostname() {
1128            if let Some(name) = name.to_str() {
1129                if !name.is_empty() {
1130                    return name.to_string();
1131                }
1132            }
1133        }
1134    }
1135    std::env::var("HOSTNAME").unwrap_or_default()
1136}
1137
1138/// Names matching these substrings have their env values redacted, guarding
1139/// against any future secret-bearing `OMNI_DEV_*` var.
1140const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1141
1142/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
1143fn whitelisted_env() -> BTreeMap<String, String> {
1144    std::env::vars()
1145        .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1146        .map(|(k, v)| {
1147            let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1148            let value = if secretish { "REDACTED".to_string() } else { v };
1149            (k, value)
1150        })
1151        .collect()
1152}
1153
1154#[cfg(test)]
1155#[allow(clippy::unwrap_used, clippy::expect_used)]
1156mod tests {
1157    use super::*;
1158
1159    #[test]
1160    fn record_round_trips_through_json() {
1161        let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1162        rec.service = Some("jira".to_string());
1163        rec.method = Some("GET".to_string());
1164        rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1165        rec.status_code = Some(200);
1166        rec.elapsed_ms = Some(42);
1167
1168        let line = serde_json::to_string(&rec).unwrap();
1169        let back: LogRecord = serde_json::from_str(&line).unwrap();
1170        assert_eq!(back.invocation_id, "inv-1");
1171        assert_eq!(back.kind, RecordKind::Http);
1172        assert_eq!(back.service.as_deref(), Some("jira"));
1173        assert_eq!(back.status_code, Some(200));
1174    }
1175
1176    #[test]
1177    fn reader_tolerates_unknown_fields() {
1178        let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1179            "future_field":{"nested":true},"another":42}"#;
1180        let rec: LogRecord = serde_json::from_str(line).unwrap();
1181        assert_eq!(rec.kind, RecordKind::Http);
1182        assert_eq!(rec.method.as_deref(), Some("GET"));
1183    }
1184
1185    #[test]
1186    fn reader_tolerates_missing_newer_fields() {
1187        // An "old" line with only a couple of fields present.
1188        let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1189        let rec: LogRecord = serde_json::from_str(line).unwrap();
1190        assert_eq!(rec.kind, RecordKind::Invocation);
1191        assert_eq!(rec.command, vec!["git", "view"]);
1192        assert!(rec.status_code.is_none());
1193        assert!(rec.id.is_empty());
1194    }
1195
1196    #[test]
1197    fn unknown_kind_and_source_do_not_fail() {
1198        let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1199        let rec: LogRecord = serde_json::from_str(line).unwrap();
1200        assert_eq!(rec.kind, RecordKind::Unknown);
1201        assert_eq!(rec.source, Some(Source::Unknown));
1202    }
1203
1204    #[test]
1205    fn optional_fields_are_skipped_when_empty() {
1206        let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1207        let line = serde_json::to_string(&rec).unwrap();
1208        // Empty collections / None options must not appear on the wire.
1209        assert!(!line.contains("status_code"));
1210        assert!(!line.contains("request_headers"));
1211        assert!(!line.contains("via_daemon"));
1212        assert!(!line.contains("\"env\""));
1213    }
1214
1215    #[test]
1216    fn ids_are_time_sortable() {
1217        let a = new_id();
1218        std::thread::sleep(std::time::Duration::from_millis(2));
1219        let b = new_id();
1220        assert!(a < b, "{a} should sort before {b}");
1221    }
1222
1223    #[test]
1224    fn sensitive_headers_are_redacted() {
1225        let mut headers = BTreeMap::new();
1226        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1227        headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1228        headers.insert("Content-Type".to_string(), "application/json".to_string());
1229        let out = redact_headers(&headers);
1230        assert_eq!(out["Authorization"], "REDACTED");
1231        assert_eq!(out["X-Api-Key"], "REDACTED");
1232        assert_eq!(out["Content-Type"], "application/json");
1233    }
1234
1235    fn argv(args: &[&str]) -> Vec<String> {
1236        args.iter().copied().map(String::from).collect()
1237    }
1238
1239    #[test]
1240    fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1241        let out = scrub_argv(&argv(&[
1242            "omni-dev",
1243            "--header",
1244            "Authorization: Bearer sekret",
1245            "--header=Cookie: session=abc",
1246        ]));
1247        assert_eq!(
1248            out,
1249            argv(&[
1250                "omni-dev",
1251                "--header",
1252                "Authorization: REDACTED",
1253                "--header=Cookie: REDACTED",
1254            ])
1255        );
1256    }
1257
1258    #[test]
1259    fn scrub_argv_keeps_non_sensitive_headers() {
1260        let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1261        assert_eq!(scrub_argv(&input), input);
1262    }
1263
1264    #[test]
1265    fn scrub_argv_redacts_colonless_header_wholesale() {
1266        let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1267        assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1268    }
1269
1270    #[test]
1271    fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1272        let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1273        assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1274
1275        let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1276        assert_eq!(scrub_argv(&file_form), file_form);
1277
1278        let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1279        assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1280    }
1281
1282    #[test]
1283    fn scrub_argv_redacts_secretish_flag_values() {
1284        let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1285        assert_eq!(
1286            out,
1287            argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1288        );
1289    }
1290
1291    #[test]
1292    fn scrub_argv_exempts_path_flags_and_positionals() {
1293        let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1294        assert_eq!(scrub_argv(&input), input);
1295    }
1296
1297    #[test]
1298    fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1299        // `--url` is not a secret-ish flag name, so its value is caught by the
1300        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
1301        // bare positional URL are covered; the benign `page` param survives.
1302        let space = scrub_argv(&argv(&[
1303            "omni-dev",
1304            "browser",
1305            "bridge",
1306            "request",
1307            "--url",
1308            "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1309        ]));
1310        assert_eq!(
1311            *space.last().unwrap(),
1312            "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1313        );
1314
1315        let eq_form = scrub_argv(&argv(&[
1316            "omni-dev",
1317            "--url=/api/export?access_token=hunter2&page=3",
1318        ]));
1319        assert_eq!(
1320            *eq_form.last().unwrap(),
1321            "--url=/api/export?access_token=REDACTED&page=3"
1322        );
1323
1324        let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1325        assert_eq!(
1326            *positional.last().unwrap(),
1327            "https://h/cb#id_token=REDACTED"
1328        );
1329    }
1330
1331    #[test]
1332    fn scrub_argv_leaves_benign_argv_byte_identical() {
1333        let input = argv(&[
1334            "omni-dev",
1335            "browser",
1336            "bridge",
1337            "request",
1338            "--control-port",
1339            "19998",
1340            "--url",
1341            "/api/export?page=3&sort=asc",
1342        ]);
1343        assert_eq!(scrub_argv(&input), input);
1344    }
1345
1346    #[test]
1347    fn scrub_argv_handles_trailing_flag_without_value() {
1348        let input = argv(&["omni-dev", "--body"]);
1349        assert_eq!(scrub_argv(&input), input);
1350    }
1351
1352    #[cfg(unix)]
1353    #[test]
1354    fn append_line_creates_file_owner_only() {
1355        use std::os::unix::fs::PermissionsExt;
1356        let dir = tempfile::tempdir().unwrap();
1357        let path = dir.path().join("log.jsonl");
1358        append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1359        assert_eq!(
1360            std::fs::read_to_string(&path).unwrap(),
1361            "{\"kind\":\"http\"}\n"
1362        );
1363        assert_eq!(
1364            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1365            0o600
1366        );
1367    }
1368
1369    #[cfg(unix)]
1370    #[test]
1371    fn append_line_retightens_preexisting_loose_file() {
1372        use std::os::unix::fs::PermissionsExt;
1373        let dir = tempfile::tempdir().unwrap();
1374        let path = dir.path().join("log.jsonl");
1375        std::fs::write(&path, "old\n").unwrap();
1376        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1377        append_line(&path, "new\n").unwrap();
1378        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1379        assert_eq!(
1380            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1381            0o600
1382        );
1383    }
1384
1385    #[test]
1386    fn off_list_secretish_headers_are_redacted() {
1387        let mut headers = BTreeMap::new();
1388        for name in [
1389            "X-Auth-Token",
1390            "x-amz-security-token",
1391            "X-Goog-Api-Key",
1392            "x-csrf-token",
1393            "X-Vendor-Token",
1394            "X-Omni-Bridge",
1395        ] {
1396            headers.insert(name.to_string(), "secret-value".to_string());
1397        }
1398        for name in [
1399            "Content-Type",
1400            "Accept",
1401            "User-Agent",
1402            "x-request-id",
1403            "traceparent",
1404        ] {
1405            headers.insert(name.to_string(), "plain-value".to_string());
1406        }
1407        let out = redact_headers(&headers);
1408        assert_eq!(out["X-Auth-Token"], "REDACTED");
1409        assert_eq!(out["x-amz-security-token"], "REDACTED");
1410        assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1411        assert_eq!(out["x-csrf-token"], "REDACTED");
1412        assert_eq!(out["X-Vendor-Token"], "REDACTED");
1413        assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1414        assert_eq!(out["Content-Type"], "plain-value");
1415        assert_eq!(out["Accept"], "plain-value");
1416        assert_eq!(out["User-Agent"], "plain-value");
1417        assert_eq!(out["x-request-id"], "plain-value");
1418        assert_eq!(out["traceparent"], "plain-value");
1419    }
1420
1421    #[test]
1422    fn url_without_query_is_unchanged() {
1423        assert_eq!(redact_url("https://h/p"), "https://h/p");
1424        assert_eq!(redact_url("/relative/p"), "/relative/p");
1425    }
1426
1427    #[test]
1428    fn benign_query_is_byte_identical() {
1429        let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1430        assert_eq!(redact_url(url), url);
1431    }
1432
1433    #[test]
1434    fn sensitive_query_values_are_redacted() {
1435        let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1436        assert_eq!(
1437            redact_url(url),
1438            "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1439             &api_key=REDACTED&x=1"
1440        );
1441    }
1442
1443    #[test]
1444    fn presigned_s3_query_is_redacted() {
1445        let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1446                   &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1447                   &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1448                   &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1449        assert_eq!(
1450            redact_url(url),
1451            "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1452             &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1453             &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1454        );
1455    }
1456
1457    #[test]
1458    fn key_matching_is_case_insensitive() {
1459        assert_eq!(
1460            redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1461            "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1462        );
1463    }
1464
1465    #[test]
1466    fn repeated_sensitive_keys_are_each_redacted() {
1467        assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1468    }
1469
1470    #[test]
1471    fn valueless_key_is_left_alone() {
1472        assert_eq!(redact_url("/p?token"), "/p?token");
1473        assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1474    }
1475
1476    #[test]
1477    fn relative_url_query_is_redacted() {
1478        assert_eq!(
1479            redact_url("/api/foo?sig=abc&x=y"),
1480            "/api/foo?sig=REDACTED&x=y"
1481        );
1482    }
1483
1484    #[test]
1485    fn fragment_credentials_are_redacted() {
1486        assert_eq!(
1487            redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1488            "https://h/cb#access_token=REDACTED&token_type=bearer"
1489        );
1490    }
1491
1492    #[test]
1493    fn query_and_fragment_are_scrubbed_independently() {
1494        assert_eq!(
1495            redact_url("/p?sig=a#id_token=b"),
1496            "/p?sig=REDACTED#id_token=REDACTED"
1497        );
1498    }
1499
1500    #[test]
1501    fn question_mark_in_fragment_is_not_parsed_as_query() {
1502        // The fragment is split off before the query, so `?` inside it never
1503        // starts a query; the pseudo-key `frag?token` still redacts via the
1504        // suffix rule (over-redaction in the safe direction).
1505        assert_eq!(
1506            redact_url("https://h/p#frag?token=x"),
1507            "https://h/p#frag?token=REDACTED"
1508        );
1509    }
1510
1511    #[test]
1512    fn encoded_sensitive_key_is_decoded_before_matching() {
1513        assert_eq!(
1514            redact_url("/p?access%5Ftoken=v"),
1515            "/p?access%5Ftoken=REDACTED"
1516        );
1517    }
1518
1519    #[test]
1520    fn empty_query_is_unchanged() {
1521        assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1522        assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1523    }
1524
1525    #[test]
1526    fn env_flag_parses_truthy_values() {
1527        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1528        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1529        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1530        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1531        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1532        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1533        std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1534        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1535    }
1536
1537    #[test]
1538    fn parse_size_handles_units_and_bare_bytes() {
1539        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1540        assert_eq!(parse_size("512b").unwrap(), 512);
1541        assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1542        assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1543        assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1544        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1545        assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1546        assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1547    }
1548
1549    #[test]
1550    fn parse_size_rejects_garbage() {
1551        assert!(parse_size("").is_err());
1552        assert!(parse_size("mb").is_err());
1553        assert!(parse_size("10tb").is_err());
1554        assert!(parse_size("-5mb").is_err());
1555    }
1556
1557    #[test]
1558    fn sibling_appends_to_final_component() {
1559        let base = Path::new("/tmp/omni/log.jsonl");
1560        assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1561        assert_eq!(
1562            sibling(base, ".lock"),
1563            Path::new("/tmp/omni/log.jsonl.lock")
1564        );
1565    }
1566
1567    #[test]
1568    fn keep_by_size_keeps_most_recent_that_fit() {
1569        // Four 10-byte lines (11 bytes on disk each with the newline).
1570        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1571        let refs: Vec<&str> = lines.to_vec();
1572
1573        // Budget for exactly two lines (22 bytes) keeps the last two.
1574        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1575        // A budget smaller than one line still keeps the single most recent.
1576        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1577        // A generous budget keeps everything.
1578        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1579        // Empty input yields empty output (no panic).
1580        assert!(keep_by_size(&[], 100).is_empty());
1581    }
1582
1583    #[test]
1584    fn keep_by_age_is_conservative_on_undateable_lines() {
1585        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1586            .unwrap()
1587            .with_timezone(&Utc);
1588        let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1589        let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1590        let undated = r#"{"kind":"http"}"#;
1591        let malformed = "not json at all";
1592
1593        assert!(!keep_by_age(old, Some(cutoff)));
1594        assert!(keep_by_age(new, Some(cutoff)));
1595        assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1596        assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1597        assert!(keep_by_age(old, None), "no filter keeps everything");
1598    }
1599
1600    fn http_line(id: &str, ts: &str) -> String {
1601        format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1602    }
1603
1604    #[test]
1605    fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1606        use std::os::unix::fs::PermissionsExt;
1607
1608        let dir = tempfile::tempdir().unwrap();
1609        let path = dir.path().join("log.jsonl");
1610        let body = format!(
1611            "{}\n{}\n{}\n",
1612            http_line("1", "2026-01-01T00:00:00.000Z"),
1613            http_line("2", "2026-06-15T00:00:00.000Z"),
1614            http_line("3", "2026-12-31T00:00:00.000Z"),
1615        );
1616        std::fs::write(&path, &body).unwrap();
1617        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1618
1619        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1620            .unwrap()
1621            .with_timezone(&Utc);
1622        let outcome = prune(
1623            &path,
1624            &PruneOptions {
1625                older_than: Some(cutoff),
1626                max_size: None,
1627                dry_run: false,
1628            },
1629        )
1630        .unwrap();
1631
1632        assert_eq!(outcome.removed, 1);
1633        assert_eq!(outcome.kept, 2);
1634        let contents = std::fs::read_to_string(&path).unwrap();
1635        assert!(!contents.contains(r#""id":"1""#));
1636        assert!(contents.contains(r#""id":"2""#));
1637        assert!(contents.contains(r#""id":"3""#));
1638        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
1639        assert_eq!(
1640            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1641            0o600
1642        );
1643    }
1644
1645    #[test]
1646    fn prune_dry_run_reports_without_modifying() {
1647        let dir = tempfile::tempdir().unwrap();
1648        let path = dir.path().join("log.jsonl");
1649        let body = format!(
1650            "{}\n{}\n",
1651            http_line("1", "2026-01-01T00:00:00.000Z"),
1652            http_line("2", "2026-12-31T00:00:00.000Z"),
1653        );
1654        std::fs::write(&path, &body).unwrap();
1655
1656        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1657            .unwrap()
1658            .with_timezone(&Utc);
1659        let outcome = prune(
1660            &path,
1661            &PruneOptions {
1662                older_than: Some(cutoff),
1663                max_size: None,
1664                dry_run: true,
1665            },
1666        )
1667        .unwrap();
1668
1669        assert_eq!(outcome.removed, 1);
1670        // File is untouched by a dry run.
1671        assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1672    }
1673
1674    #[test]
1675    fn prune_by_size_keeps_the_newest_that_fit() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let path = dir.path().join("log.jsonl");
1678        let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1679        let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1680        let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1681        std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1682
1683        // Budget that fits only the last two lines.
1684        let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1685        let outcome = prune(
1686            &path,
1687            &PruneOptions {
1688                older_than: None,
1689                max_size: Some(budget),
1690                dry_run: false,
1691            },
1692        )
1693        .unwrap();
1694
1695        assert_eq!(outcome.removed, 1);
1696        assert_eq!(outcome.kept, 2);
1697        let contents = std::fs::read_to_string(&path).unwrap();
1698        assert!(!contents.contains(r#""id":"1""#));
1699        assert!(contents.contains(r#""id":"3""#));
1700    }
1701
1702    #[test]
1703    fn prune_missing_file_is_a_noop() {
1704        let dir = tempfile::tempdir().unwrap();
1705        let path = dir.path().join("absent.jsonl");
1706        let outcome = prune(
1707            &path,
1708            &PruneOptions {
1709                older_than: None,
1710                max_size: Some(1),
1711                dry_run: false,
1712            },
1713        )
1714        .unwrap();
1715        assert_eq!(outcome.removed, 0);
1716        assert_eq!(outcome.kept, 0);
1717        assert!(!path.exists());
1718    }
1719
1720    #[cfg(unix)]
1721    #[test]
1722    fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1723        use std::os::unix::fs::PermissionsExt;
1724
1725        let dir = tempfile::tempdir().unwrap();
1726        let path = dir.path().join("log.jsonl");
1727        // A tiny cap so every second short line rotates.
1728        let cfg = RotationConfig {
1729            max_size: 20,
1730            keep_files: 2,
1731        };
1732
1733        let line = "0123456789012345\n"; // 17 bytes
1734        for _ in 0..4 {
1735            append_with_rotation(&path, line, &cfg).unwrap();
1736        }
1737
1738        // The live file plus at most keep_files (2) rotated files exist; a .3
1739        // must never appear.
1740        assert!(path.exists());
1741        assert!(sibling(&path, ".1").exists());
1742        assert!(sibling(&path, ".2").exists());
1743        assert!(!sibling(&path, ".3").exists());
1744        // Rotated files keep the 0600 posture.
1745        assert_eq!(
1746            std::fs::metadata(sibling(&path, ".1"))
1747                .unwrap()
1748                .permissions()
1749                .mode()
1750                & 0o777,
1751            0o600
1752        );
1753    }
1754
1755    #[cfg(unix)]
1756    #[test]
1757    fn rotation_keep_zero_discards_on_overflow() {
1758        let dir = tempfile::tempdir().unwrap();
1759        let path = dir.path().join("log.jsonl");
1760        let cfg = RotationConfig {
1761            max_size: 20,
1762            keep_files: 0,
1763        };
1764        let line = "0123456789012345\n"; // 17 bytes
1765        append_with_rotation(&path, line, &cfg).unwrap();
1766        append_with_rotation(&path, line, &cfg).unwrap();
1767        // No .1 is retained; only the current (single-line) file survives.
1768        assert!(!sibling(&path, ".1").exists());
1769        assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1770    }
1771
1772    #[test]
1773    fn parse_size_rejects_overflow_to_infinity() {
1774        // A number too large for f64 parses to a non-finite value, not a size.
1775        assert!(parse_size(&"9".repeat(400)).is_err());
1776    }
1777
1778    #[test]
1779    fn prune_surfaces_a_read_error() {
1780        // Reading a directory as the log yields an error other than NotFound,
1781        // which prune propagates rather than treating as an empty log.
1782        let dir = tempfile::tempdir().unwrap();
1783        let result = prune(
1784            dir.path(),
1785            &PruneOptions {
1786                older_than: None,
1787                max_size: Some(1),
1788                dry_run: false,
1789            },
1790        );
1791        assert!(result.is_err());
1792    }
1793
1794    #[cfg(unix)]
1795    #[test]
1796    fn append_with_rotation_appends_even_when_rotate_fails() {
1797        let dir = tempfile::tempdir().unwrap();
1798        let path = dir.path().join("log.jsonl");
1799        // Seed a file already over the cap so the next write attempts to rotate.
1800        std::fs::write(&path, "0123456789012345\n").unwrap();
1801        // Make the rotation target a directory so `rename(log, log.1)` fails.
1802        std::fs::create_dir(sibling(&path, ".1")).unwrap();
1803        let cfg = RotationConfig {
1804            max_size: 5,
1805            keep_files: 1,
1806        };
1807        // Rotation fails, but the line is still appended (best effort).
1808        append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1809        assert!(
1810            std::fs::read_to_string(&path).unwrap().contains("new-line"),
1811            "the record is appended despite the rotation failure"
1812        );
1813    }
1814
1815    #[test]
1816    fn prune_cleans_up_temp_on_rewrite_failure() {
1817        let dir = tempfile::tempdir().unwrap();
1818        let path = dir.path().join("log.jsonl");
1819        std::fs::write(
1820            &path,
1821            format!(
1822                "{}\n{}\n",
1823                http_line("a", "2999-01-01T00:00:00.000Z"),
1824                http_line("b", "2999-01-01T00:00:00.000Z"),
1825            ),
1826        )
1827        .unwrap();
1828        // Pre-create the exact temp path (same-process pid) as a directory so
1829        // the atomic rewrite's open fails, exercising the cleanup path.
1830        let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1831        std::fs::create_dir(&tmp).unwrap();
1832
1833        let result = prune(
1834            &path,
1835            &PruneOptions {
1836                older_than: None,
1837                max_size: Some(1),
1838                dry_run: false,
1839            },
1840        );
1841        assert!(result.is_err(), "a failing rewrite surfaces as an error");
1842        let _ = std::fs::remove_dir(&tmp);
1843    }
1844
1845    #[tokio::test]
1846    async fn scope_origin_id_overwrites_id_but_preserves_source() {
1847        // A daemon-side base context: source = Daemon (so `via_daemon` detection
1848        // keeps working) with the daemon's own invocation id.
1849        let base = RequestLogContext {
1850            invocation_id: "daemon-1".to_string(),
1851            source: Source::Daemon,
1852            mcp_tool: None,
1853        };
1854        CTX.scope(base, async {
1855            scope_origin_id("cli-42".to_string(), async {
1856                let ctx = current_context();
1857                // Correlation id now points at the originating CLI invocation…
1858                assert_eq!(ctx.invocation_id, "cli-42");
1859                // …while the source stays Daemon, so `via_daemon` is unaffected.
1860                assert_eq!(ctx.source, Source::Daemon);
1861            })
1862            .await;
1863            // The override is scoped: outside it, the base id is restored.
1864            assert_eq!(current_context().invocation_id, "daemon-1");
1865        })
1866        .await;
1867    }
1868}