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 folder the write-permission gate evaluated against (issue
881    /// #1574) — the `--parent` for `create`/`upload`, the target's current
882    /// parent for `edit`. `None` for `rename`/`move` (never gated) and for
883    /// a `content_edit` outcome that short-circuited before the gate (a
884    /// Google-native-document refusal).
885    pub resolved_folder_id: Option<String>,
886    /// The folder id of the configured rule that decided the write-gate
887    /// verdict, when one did (as opposed to the bare default policy).
888    /// Paired with `decided_by_depth`.
889    pub decided_by_folder_id: Option<String>,
890    /// How many levels above `resolved_folder_id` that rule's folder sits.
891    pub decided_by_depth: Option<usize>,
892    /// The API/validation error, when the attempt failed.
893    pub error: Option<String>,
894    /// Wall time of the attempt.
895    pub duration: Duration,
896}
897
898/// Appends one `kind: "drivemutation"` record from the active context.
899///
900/// Best effort and exit-code-safe: it goes through [`record`], which
901/// swallows every error, so a logging failure can never change the
902/// underlying rename/move result.
903///
904/// Deliberately called from *inside* `src/drive/rename.rs`/
905/// `src/drive/file_move.rs` themselves rather than the CLI layer — unlike
906/// [`record_worktree`], which is safe to call from `src/cli/git/worktree.rs`
907/// only because `git worktree` has no MCP surface at all. Drive move/rename
908/// may grow an MCP caller later, and "every move/rename must be logged" is a
909/// hard invariant that needs to hold for every current and future caller.
910/// This is also additive to (not redundant with) the automatic per-request
911/// `kind: "http"` records `crate::drive::client::DriveClient` already writes
912/// for every call it makes: a `Blocked` outcome makes *no* `files.update`
913/// call at all, so without this record the single most security-relevant
914/// event — "we refused this because visibility would change, here's exactly
915/// why" — would never appear in the log.
916pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
917    record(&build_drive_mutation_record(outcome, current_context()));
918}
919
920/// Builds the `kind: "drivemutation"` record for `outcome` under `ctx`. Split
921/// out from [`record_drive_mutation`] so the record shape is unit-testable
922/// without touching the filesystem or environment.
923fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
924    let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
925    rec.source = Some(ctx.source);
926    rec.mcp_tool = ctx.mcp_tool;
927    rec.service = Some("drive".to_string());
928    rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
929    rec.error = outcome.error;
930    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
931
932    let mut context = BTreeMap::new();
933    context.insert("file_id".to_string(), outcome.file_id);
934    context.insert("file_name".to_string(), outcome.file_name);
935    context.insert("status".to_string(), outcome.status);
936    if !outcome.added_principals.is_empty() {
937        context.insert(
938            "added_principals".to_string(),
939            outcome.added_principals.join(","),
940        );
941    }
942    if !outcome.removed_principals.is_empty() {
943        context.insert(
944            "removed_principals".to_string(),
945            outcome.removed_principals.join(","),
946        );
947    }
948    if outcome.crosses_drive_boundary {
949        context.insert("crosses_drive_boundary".to_string(), "true".to_string());
950    }
951    if let Some(resolved_folder_id) = outcome.resolved_folder_id {
952        context.insert("resolved_folder_id".to_string(), resolved_folder_id);
953    }
954    if let Some(decided_by_folder_id) = outcome.decided_by_folder_id {
955        context.insert("decided_by_folder_id".to_string(), decided_by_folder_id);
956    }
957    if let Some(decided_by_depth) = outcome.decided_by_depth {
958        context.insert("decided_by_depth".to_string(), decided_by_depth.to_string());
959    }
960    rec.context = context;
961    rec
962}
963
964/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
965/// redacted centrally in [`record_http_with`], so callers may pass them freely.
966#[derive(Debug, Clone, Default)]
967pub struct HttpExtra {
968    /// True when served inside the daemon.
969    pub via_daemon: bool,
970    /// Pooled daemon session id that served the request.
971    pub daemon_session_id: Option<String>,
972    /// Non-secret identity used (never the secret).
973    pub auth_principal: Option<String>,
974    /// Raw request headers (redacted + gated before writing).
975    pub request_headers: BTreeMap<String, String>,
976    /// Raw response headers (redacted + gated before writing).
977    pub response_headers: BTreeMap<String, String>,
978    /// Request body (gated before writing).
979    pub request_body: Option<String>,
980    /// Response body (gated before writing).
981    pub response_body: Option<String>,
982    /// Free-form correlation tags.
983    pub context: BTreeMap<String, String>,
984}
985
986/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
987pub fn record_http(
988    service: &str,
989    method: &str,
990    url: &str,
991    started: Instant,
992    status: Option<u16>,
993    error: Option<&str>,
994) {
995    record_http_with(
996        service,
997        method,
998        url,
999        started,
1000        status,
1001        error,
1002        HttpExtra::default(),
1003    );
1004}
1005
1006/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
1007/// `Ok` → status code and `Err` → error message.
1008///
1009/// Collapses the `match result { Ok → status, Err → error }` shape the REST
1010/// clients previously each open-coded around [`record_http`] (#1152).
1011pub fn record_http_result(
1012    service: &str,
1013    method: &str,
1014    url: &str,
1015    started: Instant,
1016    result: &reqwest::Result<reqwest::Response>,
1017) {
1018    match result {
1019        Ok(response) => {
1020            record_http(
1021                service,
1022                method,
1023                url,
1024                started,
1025                Some(response.status().as_u16()),
1026                None,
1027            );
1028        }
1029        Err(error) => {
1030            record_http(
1031                service,
1032                method,
1033                url,
1034                started,
1035                None,
1036                Some(&error.to_string()),
1037            );
1038        }
1039    }
1040}
1041
1042/// Appends one `kind: "http"` record with extra, non-secret fields.
1043///
1044/// Headers and bodies are dropped unless their opt-in env var is set, headers
1045/// are always redacted, and URL query/fragment values under secret-looking
1046/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
1047/// written here under any caller.
1048#[allow(clippy::too_many_arguments)]
1049pub fn record_http_with(
1050    service: &str,
1051    method: &str,
1052    url: &str,
1053    started: Instant,
1054    status: Option<u16>,
1055    error: Option<&str>,
1056    extra: HttpExtra,
1057) {
1058    if disabled() {
1059        return;
1060    }
1061    let ctx = current_context();
1062    let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
1063    rec.source = Some(ctx.source);
1064    rec.mcp_tool = ctx.mcp_tool;
1065    rec.service = Some(service.to_string());
1066    rec.method = Some(method.to_string());
1067    rec.url = Some(redact_url(url));
1068    rec.status_code = status;
1069    rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
1070    rec.error = error.map(str::to_string);
1071    rec.via_daemon = extra.via_daemon;
1072    rec.daemon_session_id = extra.daemon_session_id;
1073    rec.auth_principal = extra.auth_principal;
1074    rec.context = extra.context;
1075    if headers_enabled() {
1076        rec.request_headers = redact_headers(&extra.request_headers);
1077        rec.response_headers = redact_headers(&extra.response_headers);
1078    }
1079    if bodies_enabled() {
1080        rec.request_body = extra.request_body;
1081        rec.response_body = extra.response_body;
1082    }
1083    record(&rec);
1084}
1085
1086/// Header names whose values must never be written (compared lowercased).
1087const SENSITIVE_HEADERS: &[&str] = &[
1088    "authorization",
1089    "proxy-authorization",
1090    "cookie",
1091    "set-cookie",
1092    "x-api-key",
1093    "api-key",
1094    "dd-api-key",
1095    "dd-application-key",
1096    "x-datadog-api-key",
1097    "x-datadog-application-key",
1098    "x-omni-bridge",
1099    "x-omni-bridge-target",
1100];
1101
1102/// Substrings that mark a header name as secret-bearing (compared lowercased),
1103/// guarding against off-list auth headers (e.g. `x-auth-token`,
1104/// `x-goog-api-key`). False positives redact harmlessly.
1105const SENSITIVE_HEADER_MARKERS: &[&str] = &[
1106    "auth",
1107    "token",
1108    "secret",
1109    "key",
1110    "cookie",
1111    "password",
1112    "session",
1113    "signature",
1114    "credential",
1115];
1116
1117/// Replaces sensitive header values with `REDACTED`, passing others through.
1118///
1119/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
1120/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
1121pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1122    headers
1123        .iter()
1124        .map(|(name, value)| {
1125            let lower = name.to_ascii_lowercase();
1126            let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
1127                || SENSITIVE_HEADER_MARKERS
1128                    .iter()
1129                    .any(|marker| lower.contains(marker));
1130            (
1131                name.clone(),
1132                if redacted {
1133                    "REDACTED".to_string()
1134                } else {
1135                    value.clone()
1136                },
1137            )
1138        })
1139        .collect()
1140}
1141
1142/// Flag-name segments marking a long flag's value as secret-bearing — the argv
1143/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
1144/// flag name so `--api-key` is caught but a name like `--keyword` is not.
1145const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
1146
1147/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
1148/// last segment is `file` or `path` carry paths, not secrets, and are exempt
1149/// (e.g. `--token-file`).
1150fn is_secretish_flag(name: &str) -> bool {
1151    let segments: Vec<String> = name
1152        .split(['-', '_'])
1153        .map(str::to_ascii_lowercase)
1154        .collect();
1155    let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
1156    !takes_path
1157        && segments
1158            .iter()
1159            .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
1160}
1161
1162/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
1163/// are redacted keeping the name, other headers pass through (`None`), and a
1164/// value with no colon is redacted wholesale.
1165fn scrub_header_arg(value: &str) -> Option<String> {
1166    let Some((name, _)) = value.split_once(':') else {
1167        return Some("REDACTED".to_string());
1168    };
1169    SENSITIVE_HEADERS
1170        .contains(&name.trim().to_ascii_lowercase().as_str())
1171        .then(|| format!("{}: REDACTED", name.trim()))
1172}
1173
1174/// Returns the scrubbed replacement for the value of flag `--<name>`, or
1175/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
1176/// references (a path, not a secret).
1177fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1178    match name {
1179        "header" => scrub_header_arg(value),
1180        "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1181        _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1182        _ => None,
1183    }
1184}
1185
1186/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
1187/// write-side layers, so the on-disk line is clean and every reader/format is
1188/// covered with no reader changes:
1189///
1190/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
1191///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
1192///    `--flag=value` forms).
1193/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
1194///    fragment parameter on a URL argument (most naturally
1195///    `--url /path?access_token=…`, which no flag-name rule catches) has its
1196///    value redacted, while benign argv passes through byte-identical (#1162).
1197fn scrub_argv(argv: &[String]) -> Vec<String> {
1198    scrub_flag_secrets(argv)
1199        .iter()
1200        .map(|arg| redact_url(arg))
1201        .collect()
1202}
1203
1204/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
1205/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
1206/// `--flag value` and `--flag=value` forms). Everything else passes through to
1207/// the URL layer.
1208fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1209    let mut out = Vec::with_capacity(argv.len());
1210    let mut i = 0;
1211    while i < argv.len() {
1212        let arg = &argv[i];
1213        i += 1;
1214        let Some(flag_body) = arg.strip_prefix("--") else {
1215            out.push(arg.clone());
1216            continue;
1217        };
1218        if let Some((name, value)) = flag_body.split_once('=') {
1219            match scrub_flag_value(name, value) {
1220                Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1221                None => out.push(arg.clone()),
1222            }
1223        } else {
1224            out.push(arg.clone());
1225            let takes_secret_value =
1226                matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1227            if takes_secret_value {
1228                if let Some(value) = argv.get(i) {
1229                    i += 1;
1230                    out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1231                }
1232            }
1233        }
1234    }
1235    out
1236}
1237
1238/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
1239const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1240
1241/// Key suffixes marking the open-ended secret families (`access_token`,
1242/// `client_secret`, `api_key`, …).
1243const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1244    "token",
1245    "secret",
1246    "password",
1247    "passwd",
1248    "signature",
1249    "apikey",
1250    "api_key",
1251    "api-key",
1252];
1253
1254/// Key prefixes for cloud-storage signed-URL parameter families.
1255const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1256
1257/// Returns whether a decoded query/fragment key looks secret-bearing.
1258fn sensitive_query_key(key: &str) -> bool {
1259    let key = key.to_ascii_lowercase();
1260    SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1261        || SENSITIVE_QUERY_KEY_SUFFIXES
1262            .iter()
1263            .any(|suffix| key.ends_with(suffix))
1264        || SENSITIVE_QUERY_KEY_PREFIXES
1265            .iter()
1266            .any(|prefix| key.starts_with(prefix))
1267}
1268
1269/// Rewrites one `&`-separated pair list, replacing the values of
1270/// secret-bearing keys with `REDACTED` and passing every other segment
1271/// through byte-verbatim.
1272fn redact_pairs(pairs: &str) -> String {
1273    pairs
1274        .split('&')
1275        .map(|segment| match segment.split_once('=') {
1276            Some((raw_key, _)) => {
1277                // Decode only the key (handles `access%5Ftoken` and `+`); the
1278                // raw key text is preserved in the output.
1279                let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1280                    .next()
1281                    .is_some_and(|(key, _)| sensitive_query_key(&key));
1282                if sensitive {
1283                    format!("{raw_key}=REDACTED")
1284                } else {
1285                    segment.to_string()
1286                }
1287            }
1288            // A bare key (no `=`) carries no value to leak.
1289            None => segment.to_string(),
1290        })
1291        .collect::<Vec<_>>()
1292        .join("&")
1293}
1294
1295/// Redacts secret-bearing query and fragment parameter values in a URL,
1296/// preserving scheme, host, path, and all parameter keys so `--url` substring
1297/// filtering stays useful. Handles relative URLs (the browser bridge logs
1298/// page-origin targets like `/api/foo?sig=…`), so this never requires the
1299/// input to parse as an absolute [`url::Url`].
1300fn redact_url(url: &str) -> String {
1301    let (rest, fragment) = url
1302        .split_once('#')
1303        .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1304    let (prefix, query) = rest
1305        .split_once('?')
1306        .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1307    let mut out = prefix.to_string();
1308    if let Some(query) = query {
1309        out.push('?');
1310        out.push_str(&redact_pairs(query));
1311    }
1312    if let Some(fragment) = fragment {
1313        out.push('#');
1314        out.push_str(&redact_pairs(fragment));
1315    }
1316    out
1317}
1318
1319/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
1320///
1321/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
1322/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
1323/// crate.
1324pub fn new_id() -> String {
1325    let millis = chrono::Utc::now().timestamp_millis().max(0);
1326    let suffix = rand::random::<u64>();
1327    format!("{millis:013}-{suffix:016x}")
1328}
1329
1330/// Current time as RFC3339 with millisecond precision, in UTC.
1331fn now_rfc3339_millis() -> String {
1332    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1333}
1334
1335/// Best-effort current working directory.
1336fn cwd() -> String {
1337    std::env::current_dir()
1338        .map(|p| p.display().to_string())
1339        .unwrap_or_default()
1340}
1341
1342/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
1343fn system_user() -> String {
1344    if let Ok(user) = std::env::var("USER") {
1345        if !user.is_empty() {
1346            return user;
1347        }
1348    }
1349    #[cfg(unix)]
1350    {
1351        if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1352            return user.name;
1353        }
1354    }
1355    String::new()
1356}
1357
1358/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
1359fn hostname() -> String {
1360    #[cfg(unix)]
1361    {
1362        if let Ok(name) = nix::unistd::gethostname() {
1363            if let Some(name) = name.to_str() {
1364                if !name.is_empty() {
1365                    return name.to_string();
1366                }
1367            }
1368        }
1369    }
1370    std::env::var("HOSTNAME").unwrap_or_default()
1371}
1372
1373/// Names matching these substrings have their env values redacted, guarding
1374/// against any future secret-bearing `OMNI_DEV_*` var.
1375const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1376
1377/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
1378fn whitelisted_env() -> BTreeMap<String, String> {
1379    std::env::vars()
1380        .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1381        .map(|(k, v)| {
1382            let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1383            let value = if secretish { "REDACTED".to_string() } else { v };
1384            (k, value)
1385        })
1386        .collect()
1387}
1388
1389#[cfg(test)]
1390#[allow(clippy::unwrap_used, clippy::expect_used)]
1391mod tests {
1392    use super::*;
1393
1394    #[test]
1395    fn record_round_trips_through_json() {
1396        let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1397        rec.service = Some("jira".to_string());
1398        rec.method = Some("GET".to_string());
1399        rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1400        rec.status_code = Some(200);
1401        rec.elapsed_ms = Some(42);
1402
1403        let line = serde_json::to_string(&rec).unwrap();
1404        let back: LogRecord = serde_json::from_str(&line).unwrap();
1405        assert_eq!(back.invocation_id, "inv-1");
1406        assert_eq!(back.kind, RecordKind::Http);
1407        assert_eq!(back.service.as_deref(), Some("jira"));
1408        assert_eq!(back.status_code, Some(200));
1409    }
1410
1411    #[test]
1412    fn reader_tolerates_unknown_fields() {
1413        let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1414            "future_field":{"nested":true},"another":42}"#;
1415        let rec: LogRecord = serde_json::from_str(line).unwrap();
1416        assert_eq!(rec.kind, RecordKind::Http);
1417        assert_eq!(rec.method.as_deref(), Some("GET"));
1418    }
1419
1420    #[test]
1421    fn reader_tolerates_missing_newer_fields() {
1422        // An "old" line with only a couple of fields present.
1423        let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1424        let rec: LogRecord = serde_json::from_str(line).unwrap();
1425        assert_eq!(rec.kind, RecordKind::Invocation);
1426        assert_eq!(rec.command, vec!["git", "view"]);
1427        assert!(rec.status_code.is_none());
1428        assert!(rec.id.is_empty());
1429    }
1430
1431    #[test]
1432    fn unknown_kind_and_source_do_not_fail() {
1433        let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1434        let rec: LogRecord = serde_json::from_str(line).unwrap();
1435        assert_eq!(rec.kind, RecordKind::Unknown);
1436        assert_eq!(rec.source, Some(Source::Unknown));
1437    }
1438
1439    #[test]
1440    fn optional_fields_are_skipped_when_empty() {
1441        let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1442        let line = serde_json::to_string(&rec).unwrap();
1443        // Empty collections / None options must not appear on the wire.
1444        assert!(!line.contains("status_code"));
1445        assert!(!line.contains("request_headers"));
1446        assert!(!line.contains("via_daemon"));
1447        assert!(!line.contains("\"env\""));
1448    }
1449
1450    #[test]
1451    fn ids_are_time_sortable() {
1452        let a = new_id();
1453        std::thread::sleep(std::time::Duration::from_millis(2));
1454        let b = new_id();
1455        assert!(a < b, "{a} should sort before {b}");
1456    }
1457
1458    #[test]
1459    fn sensitive_headers_are_redacted() {
1460        let mut headers = BTreeMap::new();
1461        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1462        headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1463        headers.insert("Content-Type".to_string(), "application/json".to_string());
1464        let out = redact_headers(&headers);
1465        assert_eq!(out["Authorization"], "REDACTED");
1466        assert_eq!(out["X-Api-Key"], "REDACTED");
1467        assert_eq!(out["Content-Type"], "application/json");
1468    }
1469
1470    fn argv(args: &[&str]) -> Vec<String> {
1471        args.iter().copied().map(String::from).collect()
1472    }
1473
1474    #[test]
1475    fn build_gh_record_stamps_kind_source_and_split_command() {
1476        let ctx = RequestLogContext {
1477            invocation_id: "inv-1".to_string(),
1478            source: Source::Daemon,
1479            mcp_tool: None,
1480        };
1481        let rec = build_gh_record(
1482            GhOutcome {
1483                label: "api graphql".to_string(),
1484                argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1485                exit_code: Some(0),
1486                duration: Duration::from_millis(120),
1487                error: None,
1488            },
1489            ctx,
1490        );
1491        assert_eq!(rec.kind, RecordKind::Gh);
1492        assert_eq!(rec.invocation_id, "inv-1");
1493        assert_eq!(rec.source, Some(Source::Daemon));
1494        // The label is split into `command` for per-subcommand aggregation.
1495        assert_eq!(rec.command, argv(&["api", "graphql"]));
1496        assert_eq!(
1497            rec.command_line,
1498            argv(&["api", "graphql", "-f", "query=xyz"])
1499        );
1500        assert_eq!(rec.exit_code, Some(0));
1501        assert_eq!(rec.duration_ms, Some(120));
1502        assert!(rec.error.is_none());
1503    }
1504
1505    #[test]
1506    fn build_gh_record_scrubs_secret_bearing_argv() {
1507        // Defense in depth: even though `gh` never receives our auth, a
1508        // secret-bearing flag value in the argv is redacted before write.
1509        let rec = build_gh_record(
1510            GhOutcome {
1511                label: "api graphql".to_string(),
1512                argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1513                exit_code: Some(0),
1514                duration: Duration::from_millis(5),
1515                error: None,
1516            },
1517            RequestLogContext::default(),
1518        );
1519        assert_eq!(
1520            rec.command_line,
1521            argv(&["api", "--header", "Authorization: REDACTED"])
1522        );
1523    }
1524
1525    #[test]
1526    fn build_worktree_record_stamps_kind_service_command_and_context() {
1527        let ctx = RequestLogContext {
1528            invocation_id: "inv-2".to_string(),
1529            source: Source::Mcp,
1530            mcp_tool: Some("some_tool".to_string()),
1531        };
1532        let mut context = BTreeMap::new();
1533        context.insert("path".to_string(), "/tmp/wt".to_string());
1534        context.insert("branch".to_string(), "demo-wt".to_string());
1535        context.insert("had_uncommitted".to_string(), "true".to_string());
1536        let rec = build_worktree_record(
1537            WorktreeOutcome {
1538                verb: "remove".to_string(),
1539                argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
1540                exit_code: Some(0),
1541                duration: Duration::from_millis(42),
1542                error: None,
1543                context,
1544            },
1545            ctx,
1546        );
1547        assert_eq!(rec.kind, RecordKind::Worktree);
1548        assert_eq!(rec.invocation_id, "inv-2");
1549        assert_eq!(rec.source, Some(Source::Mcp));
1550        assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
1551        assert_eq!(rec.service.as_deref(), Some("worktree"));
1552        assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
1553        assert_eq!(
1554            rec.command_line,
1555            argv(&["worktree", "remove", "--force", "/tmp/wt"])
1556        );
1557        assert_eq!(rec.exit_code, Some(0));
1558        assert_eq!(rec.duration_ms, Some(42));
1559        assert_eq!(
1560            rec.context.get("branch").map(String::as_str),
1561            Some("demo-wt")
1562        );
1563        assert_eq!(
1564            rec.context.get("had_uncommitted").map(String::as_str),
1565            Some("true")
1566        );
1567    }
1568
1569    #[test]
1570    fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
1571        let rec = build_worktree_record(
1572            WorktreeOutcome {
1573                verb: "add".to_string(),
1574                argv: argv(&["worktree", "add", "wt"]),
1575                exit_code: Some(1),
1576                duration: Duration::from_millis(1),
1577                error: Some("boom".to_string()),
1578                context: BTreeMap::new(),
1579            },
1580            RequestLogContext::default(),
1581        );
1582        let line = serde_json::to_string(&rec).unwrap();
1583        assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
1584        assert!(
1585            line.contains("\"service\":\"worktree\""),
1586            "line was: {line}"
1587        );
1588        // The display name matches the wire form.
1589        assert_eq!(RecordKind::Worktree.as_str(), "worktree");
1590        let back: LogRecord = serde_json::from_str(&line).unwrap();
1591        assert_eq!(back.kind, RecordKind::Worktree);
1592        assert_eq!(back.command, argv(&["git", "worktree", "add"]));
1593        assert_eq!(back.error.as_deref(), Some("boom"));
1594    }
1595
1596    #[test]
1597    fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
1598        let ctx = RequestLogContext {
1599            invocation_id: "inv-3".to_string(),
1600            source: Source::Mcp,
1601            mcp_tool: Some("drive_file_move".to_string()),
1602        };
1603        let rec = build_drive_mutation_record(
1604            DriveMutationOutcome {
1605                operation: "move",
1606                file_id: "f1".to_string(),
1607                file_name: "report.pdf".to_string(),
1608                status: "blocked".to_string(),
1609                added_principals: vec!["alice@example.com".to_string()],
1610                removed_principals: vec![],
1611                crosses_drive_boundary: true,
1612                resolved_folder_id: Some("dest1".to_string()),
1613                decided_by_folder_id: Some("dest1".to_string()),
1614                decided_by_depth: Some(0),
1615                error: None,
1616                duration: Duration::from_millis(17),
1617            },
1618            ctx,
1619        );
1620        assert_eq!(rec.kind, RecordKind::DriveMutation);
1621        assert_eq!(rec.invocation_id, "inv-3");
1622        assert_eq!(rec.source, Some(Source::Mcp));
1623        assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
1624        assert_eq!(rec.service.as_deref(), Some("drive"));
1625        assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
1626        assert_eq!(rec.duration_ms, Some(17));
1627        assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
1628        assert_eq!(
1629            rec.context.get("file_name").map(String::as_str),
1630            Some("report.pdf")
1631        );
1632        assert_eq!(
1633            rec.context.get("status").map(String::as_str),
1634            Some("blocked")
1635        );
1636        assert_eq!(
1637            rec.context.get("added_principals").map(String::as_str),
1638            Some("alice@example.com")
1639        );
1640        assert_eq!(rec.context.get("removed_principals"), None);
1641        assert_eq!(
1642            rec.context
1643                .get("crosses_drive_boundary")
1644                .map(String::as_str),
1645            Some("true")
1646        );
1647        assert_eq!(
1648            rec.context.get("resolved_folder_id").map(String::as_str),
1649            Some("dest1")
1650        );
1651        assert_eq!(
1652            rec.context.get("decided_by_folder_id").map(String::as_str),
1653            Some("dest1")
1654        );
1655        assert_eq!(
1656            rec.context.get("decided_by_depth").map(String::as_str),
1657            Some("0")
1658        );
1659    }
1660
1661    #[test]
1662    fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
1663        let rec = build_drive_mutation_record(
1664            DriveMutationOutcome {
1665                operation: "rename",
1666                file_id: "f2".to_string(),
1667                file_name: "old.txt".to_string(),
1668                status: "moved".to_string(),
1669                added_principals: vec![],
1670                removed_principals: vec![],
1671                crosses_drive_boundary: false,
1672                resolved_folder_id: None,
1673                decided_by_folder_id: None,
1674                decided_by_depth: None,
1675                error: None,
1676                duration: Duration::from_millis(5),
1677            },
1678            RequestLogContext::default(),
1679        );
1680        assert_eq!(rec.context.get("added_principals"), None);
1681        assert_eq!(rec.context.get("removed_principals"), None);
1682        assert_eq!(rec.context.get("crosses_drive_boundary"), None);
1683        assert_eq!(rec.context.get("resolved_folder_id"), None);
1684        assert_eq!(rec.context.get("decided_by_folder_id"), None);
1685        assert_eq!(rec.context.get("decided_by_depth"), None);
1686    }
1687
1688    #[test]
1689    fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
1690        let rec = build_drive_mutation_record(
1691            DriveMutationOutcome {
1692                operation: "rename",
1693                file_id: "f1".to_string(),
1694                file_name: "a.txt".to_string(),
1695                status: "failed".to_string(),
1696                added_principals: vec![],
1697                removed_principals: vec![],
1698                crosses_drive_boundary: false,
1699                resolved_folder_id: None,
1700                decided_by_folder_id: None,
1701                decided_by_depth: None,
1702                error: Some("boom".to_string()),
1703                duration: Duration::from_millis(1),
1704            },
1705            RequestLogContext::default(),
1706        );
1707        let line = serde_json::to_string(&rec).unwrap();
1708        assert!(
1709            line.contains("\"kind\":\"drivemutation\""),
1710            "line was: {line}"
1711        );
1712        assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
1713        let back: LogRecord = serde_json::from_str(&line).unwrap();
1714        assert_eq!(back.kind, RecordKind::DriveMutation);
1715        assert_eq!(
1716            back.command,
1717            vec!["drive".to_string(), "rename".to_string()]
1718        );
1719        assert_eq!(back.error.as_deref(), Some("boom"));
1720    }
1721
1722    /// Confirms the existing `kind: "drivemutation"` record — reused, not a
1723    /// new `RecordKind` — round-trips identically for the new `create`
1724    /// operation issue #1574 adds (ADR-0071 §8).
1725    #[test]
1726    fn build_drive_mutation_record_round_trips_for_create_operation() {
1727        let rec = build_drive_mutation_record(
1728            DriveMutationOutcome {
1729                operation: "create",
1730                file_id: "f1".to_string(),
1731                file_name: "New File".to_string(),
1732                status: "created".to_string(),
1733                added_principals: vec![],
1734                removed_principals: vec![],
1735                crosses_drive_boundary: false,
1736                resolved_folder_id: Some("parent1".to_string()),
1737                decided_by_folder_id: None,
1738                decided_by_depth: None,
1739                error: None,
1740                duration: Duration::from_millis(1),
1741            },
1742            RequestLogContext::default(),
1743        );
1744        assert_eq!(rec.command, vec!["drive".to_string(), "create".to_string()]);
1745        assert_eq!(
1746            rec.context.get("resolved_folder_id").map(String::as_str),
1747            Some("parent1")
1748        );
1749        assert_eq!(rec.context.get("decided_by_folder_id"), None);
1750        let line = serde_json::to_string(&rec).unwrap();
1751        let back: LogRecord = serde_json::from_str(&line).unwrap();
1752        assert_eq!(back.kind, RecordKind::DriveMutation);
1753    }
1754
1755    #[test]
1756    fn record_kind_gh_serializes_as_gh_and_round_trips() {
1757        let rec = build_gh_record(
1758            GhOutcome {
1759                label: "pr list".to_string(),
1760                argv: argv(&["pr", "list"]),
1761                exit_code: Some(1),
1762                duration: Duration::from_millis(1),
1763                error: Some("boom".to_string()),
1764            },
1765            RequestLogContext::default(),
1766        );
1767        let line = serde_json::to_string(&rec).unwrap();
1768        assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1769        let back: LogRecord = serde_json::from_str(&line).unwrap();
1770        assert_eq!(back.kind, RecordKind::Gh);
1771        assert_eq!(back.command, argv(&["pr", "list"]));
1772        assert_eq!(back.error.as_deref(), Some("boom"));
1773    }
1774
1775    #[test]
1776    fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1777        let out = scrub_argv(&argv(&[
1778            "omni-dev",
1779            "--header",
1780            "Authorization: Bearer sekret",
1781            "--header=Cookie: session=abc",
1782        ]));
1783        assert_eq!(
1784            out,
1785            argv(&[
1786                "omni-dev",
1787                "--header",
1788                "Authorization: REDACTED",
1789                "--header=Cookie: REDACTED",
1790            ])
1791        );
1792    }
1793
1794    #[test]
1795    fn scrub_argv_keeps_non_sensitive_headers() {
1796        let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1797        assert_eq!(scrub_argv(&input), input);
1798    }
1799
1800    #[test]
1801    fn scrub_argv_redacts_colonless_header_wholesale() {
1802        let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1803        assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1804    }
1805
1806    #[test]
1807    fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1808        let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1809        assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1810
1811        let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1812        assert_eq!(scrub_argv(&file_form), file_form);
1813
1814        let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1815        assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1816    }
1817
1818    #[test]
1819    fn scrub_argv_redacts_secretish_flag_values() {
1820        let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1821        assert_eq!(
1822            out,
1823            argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1824        );
1825    }
1826
1827    #[test]
1828    fn scrub_argv_exempts_path_flags_and_positionals() {
1829        let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1830        assert_eq!(scrub_argv(&input), input);
1831    }
1832
1833    #[test]
1834    fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1835        // `--url` is not a secret-ish flag name, so its value is caught by the
1836        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
1837        // bare positional URL are covered; the benign `page` param survives.
1838        let space = scrub_argv(&argv(&[
1839            "omni-dev",
1840            "browser",
1841            "bridge",
1842            "request",
1843            "--url",
1844            "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1845        ]));
1846        assert_eq!(
1847            *space.last().unwrap(),
1848            "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1849        );
1850
1851        let eq_form = scrub_argv(&argv(&[
1852            "omni-dev",
1853            "--url=/api/export?access_token=hunter2&page=3",
1854        ]));
1855        assert_eq!(
1856            *eq_form.last().unwrap(),
1857            "--url=/api/export?access_token=REDACTED&page=3"
1858        );
1859
1860        let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1861        assert_eq!(
1862            *positional.last().unwrap(),
1863            "https://h/cb#id_token=REDACTED"
1864        );
1865    }
1866
1867    #[test]
1868    fn scrub_argv_leaves_benign_argv_byte_identical() {
1869        let input = argv(&[
1870            "omni-dev",
1871            "browser",
1872            "bridge",
1873            "request",
1874            "--control-port",
1875            "19998",
1876            "--url",
1877            "/api/export?page=3&sort=asc",
1878        ]);
1879        assert_eq!(scrub_argv(&input), input);
1880    }
1881
1882    #[test]
1883    fn scrub_argv_handles_trailing_flag_without_value() {
1884        let input = argv(&["omni-dev", "--body"]);
1885        assert_eq!(scrub_argv(&input), input);
1886    }
1887
1888    #[cfg(unix)]
1889    #[test]
1890    fn append_line_creates_file_owner_only() {
1891        use std::os::unix::fs::PermissionsExt;
1892        let dir = tempfile::tempdir().unwrap();
1893        let path = dir.path().join("log.jsonl");
1894        append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1895        assert_eq!(
1896            std::fs::read_to_string(&path).unwrap(),
1897            "{\"kind\":\"http\"}\n"
1898        );
1899        assert_eq!(
1900            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1901            0o600
1902        );
1903    }
1904
1905    #[cfg(unix)]
1906    #[test]
1907    fn append_line_retightens_preexisting_loose_file() {
1908        use std::os::unix::fs::PermissionsExt;
1909        let dir = tempfile::tempdir().unwrap();
1910        let path = dir.path().join("log.jsonl");
1911        std::fs::write(&path, "old\n").unwrap();
1912        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1913        append_line(&path, "new\n").unwrap();
1914        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1915        assert_eq!(
1916            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1917            0o600
1918        );
1919    }
1920
1921    #[test]
1922    fn off_list_secretish_headers_are_redacted() {
1923        let mut headers = BTreeMap::new();
1924        for name in [
1925            "X-Auth-Token",
1926            "x-amz-security-token",
1927            "X-Goog-Api-Key",
1928            "x-csrf-token",
1929            "X-Vendor-Token",
1930            "X-Omni-Bridge",
1931        ] {
1932            headers.insert(name.to_string(), "secret-value".to_string());
1933        }
1934        for name in [
1935            "Content-Type",
1936            "Accept",
1937            "User-Agent",
1938            "x-request-id",
1939            "traceparent",
1940        ] {
1941            headers.insert(name.to_string(), "plain-value".to_string());
1942        }
1943        let out = redact_headers(&headers);
1944        assert_eq!(out["X-Auth-Token"], "REDACTED");
1945        assert_eq!(out["x-amz-security-token"], "REDACTED");
1946        assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1947        assert_eq!(out["x-csrf-token"], "REDACTED");
1948        assert_eq!(out["X-Vendor-Token"], "REDACTED");
1949        assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1950        assert_eq!(out["Content-Type"], "plain-value");
1951        assert_eq!(out["Accept"], "plain-value");
1952        assert_eq!(out["User-Agent"], "plain-value");
1953        assert_eq!(out["x-request-id"], "plain-value");
1954        assert_eq!(out["traceparent"], "plain-value");
1955    }
1956
1957    #[test]
1958    fn url_without_query_is_unchanged() {
1959        assert_eq!(redact_url("https://h/p"), "https://h/p");
1960        assert_eq!(redact_url("/relative/p"), "/relative/p");
1961    }
1962
1963    #[test]
1964    fn benign_query_is_byte_identical() {
1965        let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1966        assert_eq!(redact_url(url), url);
1967    }
1968
1969    #[test]
1970    fn sensitive_query_values_are_redacted() {
1971        let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1972        assert_eq!(
1973            redact_url(url),
1974            "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1975             &api_key=REDACTED&x=1"
1976        );
1977    }
1978
1979    #[test]
1980    fn presigned_s3_query_is_redacted() {
1981        let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1982                   &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1983                   &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1984                   &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1985        assert_eq!(
1986            redact_url(url),
1987            "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1988             &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1989             &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1990        );
1991    }
1992
1993    #[test]
1994    fn key_matching_is_case_insensitive() {
1995        assert_eq!(
1996            redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1997            "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1998        );
1999    }
2000
2001    #[test]
2002    fn repeated_sensitive_keys_are_each_redacted() {
2003        assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
2004    }
2005
2006    #[test]
2007    fn valueless_key_is_left_alone() {
2008        assert_eq!(redact_url("/p?token"), "/p?token");
2009        assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
2010    }
2011
2012    #[test]
2013    fn relative_url_query_is_redacted() {
2014        assert_eq!(
2015            redact_url("/api/foo?sig=abc&x=y"),
2016            "/api/foo?sig=REDACTED&x=y"
2017        );
2018    }
2019
2020    #[test]
2021    fn fragment_credentials_are_redacted() {
2022        assert_eq!(
2023            redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
2024            "https://h/cb#access_token=REDACTED&token_type=bearer"
2025        );
2026    }
2027
2028    #[test]
2029    fn query_and_fragment_are_scrubbed_independently() {
2030        assert_eq!(
2031            redact_url("/p?sig=a#id_token=b"),
2032            "/p?sig=REDACTED#id_token=REDACTED"
2033        );
2034    }
2035
2036    #[test]
2037    fn question_mark_in_fragment_is_not_parsed_as_query() {
2038        // The fragment is split off before the query, so `?` inside it never
2039        // starts a query; the pseudo-key `frag?token` still redacts via the
2040        // suffix rule (over-redaction in the safe direction).
2041        assert_eq!(
2042            redact_url("https://h/p#frag?token=x"),
2043            "https://h/p#frag?token=REDACTED"
2044        );
2045    }
2046
2047    #[test]
2048    fn encoded_sensitive_key_is_decoded_before_matching() {
2049        assert_eq!(
2050            redact_url("/p?access%5Ftoken=v"),
2051            "/p?access%5Ftoken=REDACTED"
2052        );
2053    }
2054
2055    #[test]
2056    fn empty_query_is_unchanged() {
2057        assert_eq!(redact_url("https://h/p?"), "https://h/p?");
2058        assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
2059    }
2060
2061    #[test]
2062    fn env_flag_parses_truthy_values() {
2063        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
2064        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2065        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
2066        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2067        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
2068        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2069        std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
2070        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
2071    }
2072
2073    #[test]
2074    fn parse_size_handles_units_and_bare_bytes() {
2075        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
2076        assert_eq!(parse_size("512b").unwrap(), 512);
2077        assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
2078        assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
2079        assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
2080        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
2081        assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
2082        assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
2083    }
2084
2085    #[test]
2086    fn parse_size_rejects_garbage() {
2087        assert!(parse_size("").is_err());
2088        assert!(parse_size("mb").is_err());
2089        assert!(parse_size("10tb").is_err());
2090        assert!(parse_size("-5mb").is_err());
2091    }
2092
2093    #[test]
2094    fn sibling_appends_to_final_component() {
2095        let base = Path::new("/tmp/omni/log.jsonl");
2096        assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
2097        assert_eq!(
2098            sibling(base, ".lock"),
2099            Path::new("/tmp/omni/log.jsonl.lock")
2100        );
2101    }
2102
2103    #[test]
2104    fn keep_by_size_keeps_most_recent_that_fit() {
2105        // Four 10-byte lines (11 bytes on disk each with the newline).
2106        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
2107        let refs: Vec<&str> = lines.to_vec();
2108
2109        // Budget for exactly two lines (22 bytes) keeps the last two.
2110        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
2111        // A budget smaller than one line still keeps the single most recent.
2112        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
2113        // A generous budget keeps everything.
2114        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
2115        // Empty input yields empty output (no panic).
2116        assert!(keep_by_size(&[], 100).is_empty());
2117    }
2118
2119    #[test]
2120    fn keep_by_age_is_conservative_on_undateable_lines() {
2121        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2122            .unwrap()
2123            .with_timezone(&Utc);
2124        let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
2125        let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
2126        let undated = r#"{"kind":"http"}"#;
2127        let malformed = "not json at all";
2128
2129        assert!(!keep_by_age(old, Some(cutoff)));
2130        assert!(keep_by_age(new, Some(cutoff)));
2131        assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
2132        assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
2133        assert!(keep_by_age(old, None), "no filter keeps everything");
2134    }
2135
2136    fn http_line(id: &str, ts: &str) -> String {
2137        format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
2138    }
2139
2140    #[test]
2141    fn prune_by_age_drops_old_records_and_rewrites_atomically() {
2142        use std::os::unix::fs::PermissionsExt;
2143
2144        let dir = tempfile::tempdir().unwrap();
2145        let path = dir.path().join("log.jsonl");
2146        let body = format!(
2147            "{}\n{}\n{}\n",
2148            http_line("1", "2026-01-01T00:00:00.000Z"),
2149            http_line("2", "2026-06-15T00:00:00.000Z"),
2150            http_line("3", "2026-12-31T00:00:00.000Z"),
2151        );
2152        std::fs::write(&path, &body).unwrap();
2153        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2154
2155        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2156            .unwrap()
2157            .with_timezone(&Utc);
2158        let outcome = prune(
2159            &path,
2160            &PruneOptions {
2161                older_than: Some(cutoff),
2162                max_size: None,
2163                dry_run: false,
2164            },
2165        )
2166        .unwrap();
2167
2168        assert_eq!(outcome.removed, 1);
2169        assert_eq!(outcome.kept, 2);
2170        let contents = std::fs::read_to_string(&path).unwrap();
2171        assert!(!contents.contains(r#""id":"1""#));
2172        assert!(contents.contains(r#""id":"2""#));
2173        assert!(contents.contains(r#""id":"3""#));
2174        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
2175        assert_eq!(
2176            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2177            0o600
2178        );
2179    }
2180
2181    #[test]
2182    fn prune_dry_run_reports_without_modifying() {
2183        let dir = tempfile::tempdir().unwrap();
2184        let path = dir.path().join("log.jsonl");
2185        let body = format!(
2186            "{}\n{}\n",
2187            http_line("1", "2026-01-01T00:00:00.000Z"),
2188            http_line("2", "2026-12-31T00:00:00.000Z"),
2189        );
2190        std::fs::write(&path, &body).unwrap();
2191
2192        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
2193            .unwrap()
2194            .with_timezone(&Utc);
2195        let outcome = prune(
2196            &path,
2197            &PruneOptions {
2198                older_than: Some(cutoff),
2199                max_size: None,
2200                dry_run: true,
2201            },
2202        )
2203        .unwrap();
2204
2205        assert_eq!(outcome.removed, 1);
2206        // File is untouched by a dry run.
2207        assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
2208    }
2209
2210    #[test]
2211    fn prune_by_size_keeps_the_newest_that_fit() {
2212        let dir = tempfile::tempdir().unwrap();
2213        let path = dir.path().join("log.jsonl");
2214        let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
2215        let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
2216        let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
2217        std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
2218
2219        // Budget that fits only the last two lines.
2220        let budget = (l2.len() + 1 + l3.len() + 1) as u64;
2221        let outcome = prune(
2222            &path,
2223            &PruneOptions {
2224                older_than: None,
2225                max_size: Some(budget),
2226                dry_run: false,
2227            },
2228        )
2229        .unwrap();
2230
2231        assert_eq!(outcome.removed, 1);
2232        assert_eq!(outcome.kept, 2);
2233        let contents = std::fs::read_to_string(&path).unwrap();
2234        assert!(!contents.contains(r#""id":"1""#));
2235        assert!(contents.contains(r#""id":"3""#));
2236    }
2237
2238    #[test]
2239    fn prune_missing_file_is_a_noop() {
2240        let dir = tempfile::tempdir().unwrap();
2241        let path = dir.path().join("absent.jsonl");
2242        let outcome = prune(
2243            &path,
2244            &PruneOptions {
2245                older_than: None,
2246                max_size: Some(1),
2247                dry_run: false,
2248            },
2249        )
2250        .unwrap();
2251        assert_eq!(outcome.removed, 0);
2252        assert_eq!(outcome.kept, 0);
2253        assert!(!path.exists());
2254    }
2255
2256    #[cfg(unix)]
2257    #[test]
2258    fn rotation_shifts_numbered_files_and_drops_the_oldest() {
2259        use std::os::unix::fs::PermissionsExt;
2260
2261        let dir = tempfile::tempdir().unwrap();
2262        let path = dir.path().join("log.jsonl");
2263        // A tiny cap so every second short line rotates.
2264        let cfg = RotationConfig {
2265            max_size: 20,
2266            keep_files: 2,
2267        };
2268
2269        let line = "0123456789012345\n"; // 17 bytes
2270        for _ in 0..4 {
2271            append_with_rotation(&path, line, &cfg).unwrap();
2272        }
2273
2274        // The live file plus at most keep_files (2) rotated files exist; a .3
2275        // must never appear.
2276        assert!(path.exists());
2277        assert!(sibling(&path, ".1").exists());
2278        assert!(sibling(&path, ".2").exists());
2279        assert!(!sibling(&path, ".3").exists());
2280        // Rotated files keep the 0600 posture.
2281        assert_eq!(
2282            std::fs::metadata(sibling(&path, ".1"))
2283                .unwrap()
2284                .permissions()
2285                .mode()
2286                & 0o777,
2287            0o600
2288        );
2289    }
2290
2291    #[cfg(unix)]
2292    #[test]
2293    fn rotation_keep_zero_discards_on_overflow() {
2294        let dir = tempfile::tempdir().unwrap();
2295        let path = dir.path().join("log.jsonl");
2296        let cfg = RotationConfig {
2297            max_size: 20,
2298            keep_files: 0,
2299        };
2300        let line = "0123456789012345\n"; // 17 bytes
2301        append_with_rotation(&path, line, &cfg).unwrap();
2302        append_with_rotation(&path, line, &cfg).unwrap();
2303        // No .1 is retained; only the current (single-line) file survives.
2304        assert!(!sibling(&path, ".1").exists());
2305        assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
2306    }
2307
2308    #[test]
2309    fn parse_size_rejects_overflow_to_infinity() {
2310        // A number too large for f64 parses to a non-finite value, not a size.
2311        assert!(parse_size(&"9".repeat(400)).is_err());
2312    }
2313
2314    #[test]
2315    fn prune_surfaces_a_read_error() {
2316        // Reading a directory as the log yields an error other than NotFound,
2317        // which prune propagates rather than treating as an empty log.
2318        let dir = tempfile::tempdir().unwrap();
2319        let result = prune(
2320            dir.path(),
2321            &PruneOptions {
2322                older_than: None,
2323                max_size: Some(1),
2324                dry_run: false,
2325            },
2326        );
2327        assert!(result.is_err());
2328    }
2329
2330    #[cfg(unix)]
2331    #[test]
2332    fn append_with_rotation_appends_even_when_rotate_fails() {
2333        let dir = tempfile::tempdir().unwrap();
2334        let path = dir.path().join("log.jsonl");
2335        // Seed a file already over the cap so the next write attempts to rotate.
2336        std::fs::write(&path, "0123456789012345\n").unwrap();
2337        // Make the rotation target a directory so `rename(log, log.1)` fails.
2338        std::fs::create_dir(sibling(&path, ".1")).unwrap();
2339        let cfg = RotationConfig {
2340            max_size: 5,
2341            keep_files: 1,
2342        };
2343        // Rotation fails, but the line is still appended (best effort).
2344        append_with_rotation(&path, "new-line\n", &cfg).unwrap();
2345        assert!(
2346            std::fs::read_to_string(&path).unwrap().contains("new-line"),
2347            "the record is appended despite the rotation failure"
2348        );
2349    }
2350
2351    #[test]
2352    fn prune_cleans_up_temp_on_rewrite_failure() {
2353        let dir = tempfile::tempdir().unwrap();
2354        let path = dir.path().join("log.jsonl");
2355        std::fs::write(
2356            &path,
2357            format!(
2358                "{}\n{}\n",
2359                http_line("a", "2999-01-01T00:00:00.000Z"),
2360                http_line("b", "2999-01-01T00:00:00.000Z"),
2361            ),
2362        )
2363        .unwrap();
2364        // Pre-create the exact temp path (same-process pid) as a directory so
2365        // the atomic rewrite's open fails, exercising the cleanup path.
2366        let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
2367        std::fs::create_dir(&tmp).unwrap();
2368
2369        let result = prune(
2370            &path,
2371            &PruneOptions {
2372                older_than: None,
2373                max_size: Some(1),
2374                dry_run: false,
2375            },
2376        );
2377        assert!(result.is_err(), "a failing rewrite surfaces as an error");
2378        let _ = std::fs::remove_dir(&tmp);
2379    }
2380
2381    #[tokio::test]
2382    async fn scope_origin_id_overwrites_id_but_preserves_source() {
2383        // A daemon-side base context: source = Daemon (so `via_daemon` detection
2384        // keeps working) with the daemon's own invocation id.
2385        let base = RequestLogContext {
2386            invocation_id: "daemon-1".to_string(),
2387            source: Source::Daemon,
2388            mcp_tool: None,
2389        };
2390        CTX.scope(base, async {
2391            scope_origin_id("cli-42".to_string(), async {
2392                let ctx = current_context();
2393                // Correlation id now points at the originating CLI invocation…
2394                assert_eq!(ctx.invocation_id, "cli-42");
2395                // …while the source stays Daemon, so `via_daemon` is unaffected.
2396                assert_eq!(ctx.source, Source::Daemon);
2397            })
2398            .await;
2399            // The override is scoped: outside it, the base id is restored.
2400            assert_eq!(current_context().invocation_id, "daemon-1");
2401        })
2402        .await;
2403    }
2404}