返回 CodeWhale
telemetry_contract.rs
根目录 / crates / tui / tests / integration / telemetry_contract.rs
1 //! Process-level telemetry contract.
2 //!
3 //! Everything here drives the real `codewhale-tui` binary inside a sealed
4 //! `HOME`/`CODEWHALE_HOME`, with a loopback recorder standing in for the
5 //! telemetry endpoint. The unit tests in `codewhale-telemetry` prove the
6 //! predicate; these prove that the *emitting process* consults it — which is
7 //! the thing v1 of this design got wrong, because `resolve_runtime_options`
8 //! had no non-test caller and so neither `telemetry = false` in the config file
9 //! nor `CODEWHALE_TELEMETRY=0` was ever read by a process that would have sent.
10 //!
11 //! Two disciplines make the zero-request assertions non-vacuous:
12 //!
13 //! 1. Short CLI tests require complete local sessions after an acknowledged
14 //! flush, or an explicit bounded-writer timeout. Disabled runs create no
15 //! telemetry state. Short commands do not wait for network delivery.
16 //! 2. Full `exec` tests prove the buffered events reach a live recorder while
17 //! respecting the same persistent and run-scoped opt-outs.
18 //!
19 //! The recorder is `http://127.0.0.1:<port>` — loopback, which is the one place
20 //! `validate_endpoint` permits plaintext, and a packet that never leaves the
21 //! machine.
22
23 #![cfg(unix)]
24
25 use std::io::Read;
26 use std::path::{Path, PathBuf};
27 use std::process::{Command, Output, Stdio};
28 use std::sync::{Mutex, MutexGuard, OnceLock};
29 use std::time::{Duration, Instant};
30
31 use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
32 use futures_util::FutureExt;
33 use serde_json::{Value, json};
34 use tempfile::TempDir;
35 use wait_timeout::ChildExt;
36 use wiremock::matchers::{method, path as path_matcher};
37 use wiremock::{Mock, MockServer, ResponseTemplate};
38
39 /// Where the recorder listens for batches.
40 const TELEMETRY_PATH: &str = "/v1/telemetry";
41 /// Where the mock model listens.
42 const MODEL_PATH: &str = "/v1/chat/completions";
43 const TEST_MODEL: &str = "telemetry-contract-model";
44
45 /// Sentinels planted through real inputs. None of these may appear in a batch.
46 ///
47 /// Deliberately low-entropy: `crates/tui/src/fleet/ledger.rs` notes that
48 /// realistic-looking tokens trip secret scanning at push time.
49 const SENTINEL_PROMPT: &str = "tc-prompt-sentinel-do-not-collect";
50 const SENTINEL_FILENAME: &str = "tc-workspace-sentinel-file.txt";
51 const SENTINEL_PROVIDER_TABLE: &str = "tc_custom_provider_sentinel";
52 const SENTINEL_MCP_SERVER: &str = "tc-mcp-server-sentinel";
53 const SENTINEL_API_KEY: &str = "tc-api-key-sentinel-not-a-real-key";
54 /// Planted by writing to `buffer.jsonl` directly, which is what any other
55 /// process running as this user can do.
56 const SENTINEL_INJECTED: &str = "tc-injected-sentinel-/Users/victim/secret-repo";
57 /// The key lives only in the child's environment, never in a file, so the
58 /// "absent from every written file" assertion means something.
59 const SENTINEL_API_KEY_ENV: &str = "TC_SENTINEL_API_KEY";
60
61 const EXEC_TIMEOUT: Duration = Duration::from_secs(90);
62
63 // ── Fixture ──────────────────────────────────────────────────────────────
64
65 struct Fixture {
66 // The consolidated integration target runs tests in parallel. Each test in
67 // this module launches the full Codewhale binary, and low-resource CI
68 // runners can fail those children before they reach either loopback server.
69 // These tests exercise telemetry contracts, not launch concurrency, so one
70 // process fixture at a time keeps their non-vacuity assertions meaningful.
71 _process_test_guard: MutexGuard<'static, ()>,
72 _root: TempDir,
73 home: PathBuf,
74 codewhale_home: PathBuf,
75 workspace: PathBuf,
76 config_path: PathBuf,
77 endpoint: Option<String>,
78 }
79
80 impl Fixture {
81 fn new() -> Self {
82 let process_test_guard = telemetry_process_test_lock()
83 .lock()
84 .unwrap_or_else(|poisoned| poisoned.into_inner());
85 let root = TempDir::new().expect("fixture root");
86 let home = root.path().join("home");
87 let codewhale_home = root.path().join("codewhale-home");
88 let workspace = root.path().join("workspace");
89 for dir in [&home, &codewhale_home, &workspace] {
90 std::fs::create_dir_all(dir).expect("create fixture dir");
91 }
92 let config_path = root.path().join("config.toml");
93 std::fs::write(&config_path, "").expect("write config");
94 Self {
95 _process_test_guard: process_test_guard,
96 _root: root,
97 home,
98 codewhale_home,
99 workspace,
100 config_path,
101 endpoint: None,
102 }
103 }
104
105 /// Point this fixture at a loopback recorder.
106 fn with_endpoint(mut self, base_url: &str) -> Self {
107 self.endpoint = Some(format!("{base_url}{TELEMETRY_PATH}"));
108 self
109 }
110
111 fn write_config(&self, body: &str) {
112 std::fs::write(&self.config_path, body).expect("write config");
113 }
114
115 /// Record the answer a user would have given on a TTY.
116 ///
117 /// Explicit preferences are machine-scoped and preserve historical no
118 /// across TTY and headless launches. Presentation records no preference.
119 fn record_notice(&self, opt_in: bool) {
120 let mut state = SetupState::default();
121 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, opt_in);
122 state
123 .save_to(&self.codewhale_home.join("setup_state.json"))
124 .expect("write setup state");
125 }
126
127 fn setup_state_path(&self) -> PathBuf {
128 self.codewhale_home.join("setup_state.json")
129 }
130
131 fn telemetry_root(&self) -> PathBuf {
132 self.codewhale_home.join("telemetry")
133 }
134
135 fn command(&self) -> Command {
136 let mut command = Command::new(codewhale_tui_binary());
137 command
138 .current_dir(&self.workspace)
139 .env_clear()
140 .env("PATH", std::env::var_os("PATH").expect("PATH"))
141 .env("HOME", &self.home)
142 .env("USERPROFILE", &self.home)
143 .env("XDG_CONFIG_HOME", self.home.join(".config"))
144 .env("XDG_DATA_HOME", self.home.join(".local").join("share"))
145 .env("XDG_CACHE_HOME", self.home.join(".cache"))
146 .env("CODEWHALE_HOME", &self.codewhale_home)
147 .env("CODEWHALE_SECRET_BACKEND", "file")
148 .env("CODEWHALE_MEMORY", "false")
149 // A pinned mirror version keeps the release crate from issuing a
150 // metadata request, so the only egress a test can observe is the
151 // one this file is about.
152 .env(
153 "CODEWHALE_RELEASE_BASE_URL",
154 "https://example.invalid/releases",
155 )
156 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"))
157 .env("RUST_LOG", "warn")
158 .stdin(Stdio::null());
159 if let Some(endpoint) = &self.endpoint {
160 command.env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
161 }
162 command
163 }
164
165 /// The cheapest subcommand that still traverses the whole telemetry
166 /// lifecycle: arm, `session_start`, dispatch, `session_end`, bounded local
167 /// persistence. Verbose logging exposes the actual persistence outcome.
168 /// `completions` loads no config of its own, so its telemetry state cannot
169 /// be confused with subcommand-owned state.
170 fn run_completions(&self) -> Output {
171 let mut command = self.command();
172 command.args([
173 "--verbose",
174 "--config",
175 self.config_path.to_str().expect("config path"),
176 "completions",
177 "bash",
178 ]);
179 let output = command.output().expect("run codewhale-tui completions");
180 assert!(
181 output.status.success(),
182 "completions failed\nstdout:\n{}\nstderr:\n{}",
183 String::from_utf8_lossy(&output.stdout),
184 String::from_utf8_lossy(&output.stderr)
185 );
186 output
187 }
188
189 /// Every regular file under the sealed roots, for leak scanning.
190 fn written_files(&self) -> Vec<PathBuf> {
191 let mut out = Vec::new();
192 for base in [&self.home, &self.codewhale_home, &self.workspace] {
193 collect_files(base, &mut out);
194 }
195 out.push(self.config_path.clone());
196 out
197 }
198 }
199
200 fn telemetry_process_test_lock() -> &'static Mutex<()> {
201 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
202 LOCK.get_or_init(Mutex::default)
203 }
204
205 fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
206 let Ok(entries) = std::fs::read_dir(dir) else {
207 return;
208 };
209 for entry in entries.flatten() {
210 let path = entry.path();
211 match entry.file_type() {
212 Ok(kind) if kind.is_dir() => collect_files(&path, out),
213 Ok(kind) if kind.is_file() => out.push(path),
214 _ => {}
215 }
216 }
217 }
218
219 fn codewhale_tui_binary() -> PathBuf {
220 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
221 return PathBuf::from(path);
222 }
223 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
224 return PathBuf::from(path);
225 }
226 let mut path = std::env::current_exe().expect("current test executable path");
227 path.pop();
228 if path.ends_with("deps") {
229 path.pop();
230 }
231 path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
232 path
233 }
234
235 // ── Recorder ─────────────────────────────────────────────────────────────
236
237 /// A loopback endpoint that accepts every batch and keeps the body.
238 ///
239 /// The body is the point. The recorder `crates/tui/tests/diagnostic_read_only.rs`
240 /// copies deliberately drops it; a telemetry contract that cannot read what was
241 /// sent can only assert "something happened".
242 async fn start_recorder() -> MockServer {
243 let server = MockServer::start().await;
244 Mock::given(method("POST"))
245 .and(path_matcher(TELEMETRY_PATH))
246 .respond_with(ResponseTemplate::new(200))
247 .mount(&server)
248 .await;
249 server
250 }
251
252 /// Every request the recorder saw, batch bodies included.
253 async fn recorded_batches(server: &MockServer) -> Vec<Value> {
254 let requests = server
255 .received_requests()
256 .await
257 .expect("the recorder must retain its request log");
258 requests
259 .iter()
260 .filter(|request| request.url.path() == TELEMETRY_PATH)
261 .map(|request| {
262 serde_json::from_slice::<Value>(&request.body).unwrap_or_else(|error| {
263 panic!(
264 "a telemetry batch must be JSON: {error}\nbody: {}",
265 String::from_utf8_lossy(&request.body)
266 )
267 })
268 })
269 .collect()
270 }
271
272 /// How many chat completions the mock model served.
273 ///
274 /// The sentinel test's whole claim is that a prompt which *did* reach a model
275 /// did not reach a batch, so a run where the turn never happened would be
276 /// vacuous.
277 async fn model_request_count(server: &MockServer) -> usize {
278 server
279 .received_requests()
280 .await
281 .expect("the recorder must retain its request log")
282 .iter()
283 .filter(|request| request.url.path() == MODEL_PATH)
284 .count()
285 }
286
287 async fn assert_no_batches(server: &MockServer, why: &str) {
288 let batches = recorded_batches(server).await;
289 assert!(
290 batches.is_empty(),
291 "{why}: expected zero telemetry requests, recorded {}:\n{}",
292 batches.len(),
293 serde_json::to_string_pretty(&batches).unwrap_or_default()
294 );
295 }
296
297 fn buffered_events(fixture: &Fixture) -> Vec<Value> {
298 let path = fixture.telemetry_root().join("buffer.jsonl");
299 let body = std::fs::read_to_string(&path).unwrap_or_else(|error| {
300 panic!(
301 "read locally buffered telemetry at {}: {error}",
302 path.display()
303 )
304 });
305 body.lines()
306 .filter(|line| !line.trim().is_empty())
307 .map(|line| {
308 serde_json::from_str::<Value>(line).unwrap_or_else(|error| {
309 panic!("buffered telemetry must be JSON: {error}\nline: {line}")
310 })
311 })
312 .collect()
313 }
314
315 async fn assert_short_cli_persistence_without_network(
316 fixture: &Fixture,
317 server: &MockServer,
318 output: &Output,
319 why: &str,
320 ) {
321 assert_no_batches(server, why).await;
322 let stderr = String::from_utf8_lossy(&output.stderr);
323 let outcomes: Vec<_> = stderr
324 .lines()
325 .filter_map(|line| {
326 line.split_once("telemetry local persistence outcome=")
327 .map(|(_, outcome)| outcome)
328 })
329 .collect();
330 assert_eq!(
331 outcomes.len(),
332 1,
333 "{why}: require exactly one persistence outcome, not an inference from exit time: {stderr}"
334 );
335 match outcomes[0] {
336 // The detached writer may still be queued when the bounded CLI exits.
337 // Only the real timeout receipt excuses an incomplete local session.
338 "TimedOut" => return,
339 "Buffered" => {}
340 outcome => panic!("{why}: unexpected local persistence outcome: {outcome}"),
341 }
342 let events = buffered_events(fixture);
343 assert!(
344 events.iter().any(|event| event["event"] == "session_start"),
345 "{why}: the local buffer must carry the session it describes: {events:?}"
346 );
347 assert!(
348 events.iter().any(|event| event["event"] == "session_end"),
349 "{why}: local persistence must carry session_end: {events:?}"
350 );
351 }
352
353 // ── Short CLI persistence is bounded and observable ──────────────────────
354
355 /// An acknowledged short CLI flush must preserve a complete session. A slow
356 /// local writer must report its deadline without waiting for the endpoint.
357 #[tokio::test(flavor = "current_thread")]
358 async fn current_explicit_consent_buffers_one_complete_session_without_network() {
359 let server = start_recorder().await;
360 let fixture = Fixture::new().with_endpoint(&server.uri());
361
362 fixture.write_config("telemetry = true\n");
363 fixture.record_notice(true);
364 let output = fixture.run_completions();
365
366 assert_short_cli_persistence_without_network(
367 &fixture,
368 &server,
369 &output,
370 "current explicit consent",
371 )
372 .await;
373 }
374
375 #[tokio::test(flavor = "current_thread")]
376 async fn default_on_reports_local_persistence_without_network() {
377 let server = start_recorder().await;
378 let fixture = Fixture::new().with_endpoint(&server.uri());
379
380 let output = fixture.run_completions();
381
382 assert_short_cli_persistence_without_network(
383 &fixture,
384 &server,
385 &output,
386 "the documented default",
387 )
388 .await;
389 }
390
391 #[tokio::test(flavor = "current_thread")]
392 async fn incomplete_short_cli_sessions_require_an_explicit_timeout() {
393 use std::os::unix::process::ExitStatusExt;
394
395 let server = start_recorder().await;
396 let fixture = Fixture::new().with_endpoint(&server.uri());
397 std::fs::create_dir_all(fixture.telemetry_root()).expect("create local buffer root");
398 std::fs::write(
399 fixture.telemetry_root().join("buffer.jsonl"),
400 "{\"event\":\"session_start\",\"source\":\"unknown\"}\n",
401 )
402 .expect("write incomplete session");
403
404 // A successful process exit, an acknowledged but incomplete append, or an
405 // unrecognized outcome must not be relabelled as an allowed deadline.
406 for diagnostic in [
407 "",
408 "info telemetry local persistence outcome=Buffered\n",
409 "info telemetry local persistence outcome=Dropped\n",
410 "info telemetry local persistence outcome=TimedOutLater\n",
411 "info telemetry local persistence outcome=TimedOut\ninfo telemetry local persistence outcome=Buffered\n",
412 ] {
413 let output = Output {
414 status: std::process::ExitStatus::from_raw(0),
415 stdout: Vec::new(),
416 stderr: diagnostic.as_bytes().to_vec(),
417 };
418 let rejected = std::panic::AssertUnwindSafe(assert_short_cli_persistence_without_network(
419 &fixture,
420 &server,
421 &output,
422 "unexplained missing session_end",
423 ))
424 .catch_unwind()
425 .await;
426 assert!(
427 rejected.is_err(),
428 "accepted invalid receipt: {diagnostic:?}"
429 );
430 }
431
432 let timed_out = Output {
433 status: std::process::ExitStatus::from_raw(0),
434 stdout: Vec::new(),
435 stderr: b"info telemetry local persistence outcome=TimedOut\n".to_vec(),
436 };
437 assert_short_cli_persistence_without_network(
438 &fixture,
439 &server,
440 &timed_out,
441 "explicit bounded writer timeout",
442 )
443 .await;
444 }
445
446 // ── Off is real ──────────────────────────────────────────────────────────
447
448 /// The only test that proves the emitting process reads the config *file*.
449 ///
450 /// No environment variable is set here on purpose. `CODEWHALE_TELEMETRY=0` and
451 /// the config key travel different paths, and v1 of this design shipped a
452 /// kill switch that only the env half ever reached.
453 #[tokio::test(flavor = "current_thread")]
454 async fn config_file_only_opt_out_sends_zero_requests() {
455 let server = start_recorder().await;
456 let fixture = Fixture::new().with_endpoint(&server.uri());
457 fixture.write_config("telemetry = false\n");
458 fixture.record_notice(true);
459
460 let output = fixture.run_completions();
461 assert!(output.status.success());
462
463 assert_no_batches(&server, "`telemetry = false` in the config file").await;
464 assert!(
465 !fixture.telemetry_root().exists(),
466 "a fresh config-file opt-out must create no telemetry state"
467 );
468 }
469
470 #[tokio::test(flavor = "current_thread")]
471 async fn telemetry_disabled_by_env_sends_zero_requests() {
472 let server = start_recorder().await;
473 let fixture = Fixture::new().with_endpoint(&server.uri());
474 fixture.write_config("telemetry = true\n");
475 fixture.record_notice(true);
476
477 let mut command = fixture.command();
478 command
479 .env("CODEWHALE_TELEMETRY", "0")
480 .args([
481 "--config",
482 fixture.config_path.to_str().expect("config path"),
483 "completions",
484 "bash",
485 ])
486 .output()
487 .expect("run codewhale-tui completions");
488
489 assert_no_batches(&server, "`CODEWHALE_TELEMETRY=0`").await;
490 assert!(
491 !fixture.telemetry_root().exists(),
492 "a fresh run-scoped opt-out must create no telemetry state"
493 );
494 }
495
496 /// An unparseable env value fails **closed**, rather than falling through to
497 /// the config file's `true`.
498 #[tokio::test(flavor = "current_thread")]
499 async fn an_unparseable_telemetry_env_value_sends_zero_requests() {
500 let server = start_recorder().await;
501 let fixture = Fixture::new().with_endpoint(&server.uri());
502 fixture.write_config("telemetry = true\n");
503 fixture.record_notice(true);
504
505 fixture
506 .command()
507 .env("CODEWHALE_TELEMETRY", "maybe")
508 .args([
509 "--config",
510 fixture.config_path.to_str().expect("config path"),
511 "completions",
512 "bash",
513 ])
514 .output()
515 .expect("run codewhale-tui completions");
516
517 assert_no_batches(&server, "`CODEWHALE_TELEMETRY=maybe`").await;
518 assert!(
519 !fixture.telemetry_root().exists(),
520 "a fresh forced-off run must create no telemetry state"
521 );
522 }
523
524 /// A fresh headless run defaults on and records presentation, not acceptance.
525 #[tokio::test(flavor = "current_thread")]
526 async fn telemetry_defaults_on_without_notice_buffers_a_complete_session() {
527 let server = start_recorder().await;
528 let fixture = Fixture::new().with_endpoint(&server.uri());
529 // Deliberately no `record_notice`.
530
531 let output = fixture.run_completions();
532
533 assert_short_cli_persistence_without_network(&fixture, &server, &output, "default-on usage")
534 .await;
535 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
536 assert_eq!(
537 state.telemetry_notice_shown_for.as_deref(),
538 Some(TELEMETRY_NOTICE_VERSION)
539 );
540 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
541 }
542
543 /// A previous acceptance remains enabled under the default-on policy.
544 #[tokio::test(flavor = "current_thread")]
545 async fn a_stale_accepted_notice_remains_on_without_synthesizing_current_acceptance() {
546 let server = start_recorder().await;
547 let fixture = Fixture::new().with_endpoint(&server.uri());
548 fixture.write_config("telemetry = true\n");
549 let mut state = SetupState::default();
550 state.record_telemetry_notice("0", true);
551 state
552 .save_to(&fixture.setup_state_path())
553 .expect("write setup state");
554
555 let output = fixture.run_completions();
556
557 assert_short_cli_persistence_without_network(&fixture, &server, &output, "default-on usage")
558 .await;
559 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
560 assert_eq!(
561 state.telemetry_notice_shown_for.as_deref(),
562 Some(TELEMETRY_NOTICE_VERSION)
563 );
564 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
565 }
566
567 // ── Nothing survives a disable ───────────────────────────────────────────
568
569 /// A human's "off" wipes: tombstone first, data truncated, lock file left in
570 /// place, identity removed.
571 #[tokio::test(flavor = "current_thread")]
572 async fn disabling_after_buffering_wipes_and_sends_nothing() {
573 let server = start_recorder().await;
574 let fixture = Fixture::new().with_endpoint(&server.uri());
575 let root = fixture.telemetry_root();
576 seed_consenting_home(&root);
577 fixture.write_config("telemetry = false\n");
578 fixture.record_notice(true);
579
580 fixture.run_completions();
581
582 assert_no_batches(&server, "an explicit opt-out with a populated buffer").await;
583 assert!(
584 root.join("disabled").exists(),
585 "the tombstone is written first and never removed"
586 );
587 assert_eq!(
588 std::fs::read(root.join("buffer.jsonl")).expect("buffer survives as an empty file"),
589 Vec::<u8>::new(),
590 "buffered events must be truncated, not sent"
591 );
592 assert!(
593 root.join("buffer.jsonl.lock").exists(),
594 "the lock file is never unlinked: replacing it would leave appenders \
595 and compactors holding different inodes"
596 );
597 assert!(
598 !root.join("install_id.json").exists(),
599 "the install identity must not survive an opt-out"
600 );
601 }
602
603 /// A non-persistent forced-off result must preserve an existing identity and
604 /// unflushed buffer.
605 #[tokio::test(flavor = "current_thread")]
606 async fn forced_off_run_preserves_a_consenting_users_state() {
607 let server = start_recorder().await;
608 let fixture = Fixture::new().with_endpoint(&server.uri());
609 let root = fixture.telemetry_root();
610 seed_consenting_home(&root);
611 let before = snapshot(&root);
612 let mut command = fixture.command();
613 command
614 .env("CODEWHALE_TELEMETRY", "not-a-bool")
615 .args([
616 "--config",
617 fixture.config_path.to_str().expect("config path"),
618 "completions",
619 "bash",
620 ])
621 .output()
622 .expect("run codewhale-tui completions");
623
624 assert_no_batches(&server, "a forced-off run").await;
625 assert_eq!(
626 snapshot(&root),
627 before,
628 "a forced-off run must leave a consenting user's telemetry state byte-identical"
629 );
630 }
631
632 /// The documented one-command kill switch stops collection and destroys
633 /// nothing.
634 ///
635 /// `CODEWHALE_TELEMETRY=0` used to resolve as an *answer*, so it took the
636 /// destructive opt-out branch: an agent harness that set it for one command
637 /// deleted the install id and truncated the dry-run records of the person who
638 /// owns the machine, and the "permanent" tombstone it left was cleared by the
639 /// user's very next ordinary run. Off for the run, and only for the run.
640 #[tokio::test(flavor = "current_thread")]
641 async fn a_run_scoped_kill_switch_preserves_a_consenting_users_state() {
642 let server = start_recorder().await;
643 let fixture = Fixture::new().with_endpoint(&server.uri());
644 let root = fixture.telemetry_root();
645 seed_consenting_home(&root);
646 let before = snapshot(&root);
647 fixture.write_config("telemetry = true\n");
648 fixture.record_notice(true);
649
650 for value in ["0", "off", "false"] {
651 fixture
652 .command()
653 .env("CODEWHALE_TELEMETRY", value)
654 .args([
655 "--config",
656 fixture.config_path.to_str().expect("config path"),
657 "completions",
658 "bash",
659 ])
660 .output()
661 .expect("run codewhale-tui completions");
662
663 assert_no_batches(&server, "a run-scoped kill switch").await;
664 assert!(
665 !root.join("disabled").exists(),
666 "`CODEWHALE_TELEMETRY={value}` tombstoned a machine nobody opted out"
667 );
668 assert_eq!(
669 snapshot(&root),
670 before,
671 "`CODEWHALE_TELEMETRY={value}` touched a consenting user's telemetry state"
672 );
673 }
674
675 // And the persistent switch still is the destructive one, on the same
676 // home, so the two are not merely both no-ops here.
677 fixture.write_config("telemetry = false\n");
678 fixture.run_completions();
679 assert!(
680 root.join("disabled").exists(),
681 "the config-file opt-out must still wipe and tombstone"
682 );
683 assert!(!root.join("install_id.json").exists());
684 }
685
686 /// A run that was never permitted to collect creates no directory at all —
687 /// which is also what makes the process panic hook, installed before the
688 /// command line is even parsed, write nothing for a disabled user.
689 #[tokio::test(flavor = "current_thread")]
690 async fn a_disabled_run_creates_no_telemetry_directory() {
691 let server = start_recorder().await;
692 let fixture = Fixture::new().with_endpoint(&server.uri());
693 fixture.write_config("telemetry = false\n");
694 fixture.record_notice(false);
695
696 fixture.run_completions();
697
698 assert_no_batches(&server, "a declined run").await;
699 assert!(
700 !fixture.telemetry_root().exists(),
701 "nothing may be created for a user who declined on a fresh home"
702 );
703 }
704
705 // ── The notice is never answered by silence ──────────────────────────────
706
707 /// Deferral is not a decision. `--skip-onboarding` records and prints no notice
708 /// decision, while the non-interactive run still follows the documented
709 /// default — unlike the constitution checkpoint, which persists a `Deferred`
710 /// completion.
711 #[tokio::test(flavor = "current_thread")]
712 async fn skip_onboarding_writes_no_telemetry_decision() {
713 let server = start_recorder().await;
714 let fixture = Fixture::new().with_endpoint(&server.uri());
715 fixture.write_config("telemetry = true\n");
716
717 let mut command = fixture.command();
718 let output = command
719 .args([
720 "--verbose",
721 "--config",
722 fixture.config_path.to_str().expect("config path"),
723 "--skip-onboarding",
724 "completions",
725 "bash",
726 ])
727 .output()
728 .expect("run codewhale-tui completions");
729 assert!(output.status.success());
730
731 let stdout = String::from_utf8_lossy(&output.stdout);
732 let stderr = String::from_utf8_lossy(&output.stderr);
733 for stream in [&stdout, &stderr] {
734 assert!(
735 !stream.contains("keep telemetry off"),
736 "the notice must not be rendered on a path that cannot answer it"
737 );
738 }
739
740 if let Some(state) = SetupState::load_from(&fixture.setup_state_path()) {
741 assert_eq!(
742 state.telemetry_notice_decided_for, None,
743 "skip-onboarding must leave the telemetry decision unset"
744 );
745 }
746 assert_short_cli_persistence_without_network(
747 &fixture,
748 &server,
749 &output,
750 "`--skip-onboarding` follows the default",
751 )
752 .await;
753 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
754 assert_eq!(
755 state.telemetry_notice_shown_for.as_deref(),
756 Some(TELEMETRY_NOTICE_VERSION)
757 );
758 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
759 }
760
761 // ── Payload red lines, through a real turn ───────────────────────────────
762
763 /// Five sentinel classes planted through real inputs — the prompt, a workspace
764 /// filename, a custom `[providers.<name>]` table key, an MCP server name, and
765 /// the API key — asserted absent from every recorded batch.
766 ///
767 /// The API key is held to the stricter standard the harness at
768 /// `crates/tui/tests/verifiers_harness_contract.rs` applies: absent from stdout,
769 /// stderr, and every file under the sealed roots as well. The other four
770 /// legitimately appear in files the product owns — a prompt is in the session
771 /// transcript, a provider table key is in the config the user wrote — so the
772 /// claim about them is precisely that they never reach a *batch*.
773 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
774 async fn batch_contains_no_planted_sentinel() {
775 let server = start_recorder().await;
776 mount_model(&server, Duration::ZERO).await;
777 let fixture = Fixture::new().with_endpoint(&server.uri());
778 plant_sentinels(&fixture, &server.uri());
779
780 let output = run_exec(&fixture, SENTINEL_PROMPT);
781 assert_exec_succeeded(&output, "sentinel payload run");
782
783 assert!(
784 model_request_count(&server).await > 0,
785 "the sentinel prompt must actually have reached a model, or this test \
786 proves nothing\nstdout:\n{}\nstderr:\n{}",
787 String::from_utf8_lossy(&output.stdout),
788 String::from_utf8_lossy(&output.stderr)
789 );
790 let batches = recorded_batches(&server).await;
791 assert!(
792 !batches.is_empty(),
793 "this test is only meaningful against a batch that was actually sent"
794 );
795 for batch in &batches {
796 assert_eq!(batch["schema_version"], 3);
797 assert_eq!(batch["notice_version"], 5);
798 assert!(batch.get("consent_version").is_none());
799 }
800 let shown = SetupState::load_from(&fixture.setup_state_path()).expect("disclosure marker");
801 assert_eq!(shown.telemetry_notice_decided_for, None);
802 assert!(!shown.telemetry_opt_in);
803 let serialized = serde_json::to_string(&batches).expect("serialize batches");
804 for sentinel in [
805 SENTINEL_PROMPT,
806 SENTINEL_FILENAME,
807 SENTINEL_PROVIDER_TABLE,
808 SENTINEL_MCP_SERVER,
809 SENTINEL_API_KEY,
810 ] {
811 assert!(
812 !serialized.contains(sentinel),
813 "sentinel `{sentinel}` reached a telemetry batch:\n{serialized}"
814 );
815 }
816
817 let stdout = String::from_utf8_lossy(&output.stdout);
818 let stderr = String::from_utf8_lossy(&output.stderr);
819 assert!(
820 !stdout.contains(SENTINEL_API_KEY),
821 "the API key leaked into stdout"
822 );
823 assert!(
824 !stderr.contains(SENTINEL_API_KEY),
825 "the API key leaked into stderr"
826 );
827 for file in fixture.written_files() {
828 let Ok(bytes) = std::fs::read(&file) else {
829 continue;
830 };
831 assert!(
832 !String::from_utf8_lossy(&bytes).contains(SENTINEL_API_KEY),
833 "the API key leaked into {}",
834 file.display()
835 );
836 }
837 }
838
839 /// The buffer file is an **untrusted input**, and this is the test that says so.
840 ///
841 /// Every bound in `codewhale-telemetry`'s schema is a property of how a payload
842 /// is *built*: closed enums, `u32`s, `ProviderKind::as_str()`,
843 /// `reduce_panic_site`. None of that survives the round trip, because `flush`
844 /// re-reads `buffer.jsonl` and deserializes it — and `$CODEWHALE_HOME` is a
845 /// predictable path that anything running as the user can append to. The
846 /// realistic writer is not an intruder: it is a `Bash` tool call this very
847 /// session made on the model's behalf, or an MCP server, or a hook. Without a
848 /// drain-path re-check, telemetry is a confused deputy that POSTs whatever that
849 /// writer chooses to the configured endpoint, under the user's install id, past
850 /// every egress control the user has on the provider route.
851 ///
852 /// The injection happens **after** the session has armed, on purpose: `init`
853 /// truncates the buffer, so a pre-arming plant proves nothing.
854 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
855 async fn a_hostile_buffer_line_never_reaches_a_batch() {
856 let server = start_recorder().await;
857 // A slow first token holds the session open long enough to append.
858 mount_model(&server, Duration::from_secs(5)).await;
859 let fixture = Fixture::new().with_endpoint(&server.uri());
860 plant_sentinels(&fixture, &server.uri());
861
862 let mut command = exec_command(&fixture, "hello");
863 let mut child = command.spawn().expect("spawn codewhale-tui exec");
864 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
865 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
866
867 let buffer = fixture.telemetry_root().join("buffer.jsonl");
868 wait_until(Duration::from_secs(30), || buffer.exists());
869 append_lines(
870 &buffer,
871 &[
872 // The path-bearing field, carrying a path it was never meant to.
873 json!({"event": "panic", "site": SENTINEL_INJECTED}).to_string(),
874 // The one event field read back from `state.json`.
875 json!({"event": "install_or_upgrade", "kind": "upgrade",
876 "previous_version": SENTINEL_INJECTED})
877 .to_string(),
878 // The provider set, whose whole design is that it cannot carry a
879 // customer's `[providers.<name>]` table key.
880 json!({"event": "session_end", "duration_bucket": "lt_1m",
881 "exit_class": "clean", "cold_start_bucket": null,
882 "providers": [SENTINEL_INJECTED],
883 "counters": {"turns": 0, "tool_calls": 0, "fleet_dispatch": 0,
884 "workflow_run": 0, "subagent_spawn": 0,
885 "mcp_server_connected": 0, "memory_search": 0,
886 "approval_modal_shown": 0, "approval_auto_allowed": 0,
887 "command_palette_open": 0},
888 "errors": {"auth_preflight_failed": 0, "provider_http_4xx": 0,
889 "provider_http_5xx": 0, "tool_denied_by_policy": 0,
890 "tool_timeout": 0, "network_error": 0},
891 "turn_wall": {"lt_5s": 0, "5_30s": 0, "30_120s": 0, "gte_120s": 0}})
892 .to_string(),
893 ],
894 );
895
896 let status = child
897 .wait_timeout(EXEC_TIMEOUT)
898 .expect("wait for codewhale-tui exec")
899 .expect("codewhale-tui exec must exit");
900 let output = Output {
901 status,
902 stdout: stdout.join().expect("stdout reader"),
903 stderr: stderr.join().expect("stderr reader"),
904 };
905 assert_exec_succeeded(&output, "hostile-buffer payload run");
906
907 let batches = recorded_batches(&server).await;
908 assert!(
909 !batches.is_empty(),
910 "this test is only meaningful against a batch that was actually sent"
911 );
912 let serialized = serde_json::to_string(&batches).expect("serialize batches");
913 assert!(
914 !serialized.contains(SENTINEL_INJECTED),
915 "a line appended to buffer.jsonl was POSTed verbatim:\n{serialized}"
916 );
917 }
918
919 /// Append raw lines to a sink, the way any other process on the machine would.
920 fn append_lines(path: &Path, lines: &[String]) {
921 use std::io::Write as _;
922 let mut file = std::fs::OpenOptions::new()
923 .append(true)
924 .open(path)
925 .expect("open the telemetry buffer");
926 for line in lines {
927 writeln!(file, "{line}").expect("append to the telemetry buffer");
928 }
929 }
930
931 /// The documented mid-session opt-out — `codewhale config set telemetry false`,
932 /// written by another process — must be observed by a session that is already
933 /// running. The flush re-resolves from disk before it sends anything.
934 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
935 async fn mid_session_opt_out_stops_the_shutdown_flush() {
936 let server = start_recorder().await;
937 // A slow first token gives the second writer a window while the session is
938 // armed and buffering.
939 mount_model(&server, Duration::from_secs(4)).await;
940 let fixture = Fixture::new().with_endpoint(&server.uri());
941 plant_sentinels(&fixture, &server.uri());
942
943 let mut command = exec_command(&fixture, "hello");
944 let mut child = command.spawn().expect("spawn codewhale-tui exec");
945 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
946 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
947
948 // Wait until this session's event is actually buffered, then leave enough
949 // time for any accidental background flush to reach the recorder. Nothing
950 // may be sent before shutdown.
951 let buffer = fixture.telemetry_root().join("buffer.jsonl");
952 wait_until(Duration::from_secs(30), || {
953 std::fs::read_to_string(&buffer)
954 .map(|body| body.contains("\"event\":\"session_start\""))
955 .unwrap_or(false)
956 });
957 tokio::time::sleep(Duration::from_millis(250)).await;
958 assert_no_batches(&server, "before the shutdown flush").await;
959
960 // Now take the documented way out from outside the process. The only
961 // flush, at shutdown, must re-resolve this write and suppress the batch.
962 fixture.write_config(&sentinel_config(&server.uri(), false));
963
964 let status = child
965 .wait_timeout(EXEC_TIMEOUT)
966 .expect("wait for codewhale-tui exec")
967 .expect("codewhale-tui exec must exit");
968 let output = Output {
969 status,
970 stdout: stdout.join().expect("stdout reader"),
971 stderr: stderr.join().expect("stderr reader"),
972 };
973 assert_exec_succeeded(&output, "mid-session opt-out run");
974
975 assert_no_batches(&server, "an opt-out written mid-session").await;
976 let root = fixture.telemetry_root();
977 assert!(
978 root.join("disabled").exists(),
979 "the opt-out wipe must leave a tombstone the next run also honours"
980 );
981 }
982
983 /// Ctrl-C must not wait on a lock a second Codewhale process is holding.
984 ///
985 /// This is why appends never take the compaction lock: `flock` is per-fd within
986 /// a process, so a blocking acquisition on the signal path would hang exit for
987 /// as long as any other holder lives.
988 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
989 async fn ctrl_c_exits_while_a_second_process_holds_the_lock() {
990 let server = start_recorder().await;
991 mount_model(&server, Duration::from_secs(30)).await;
992 let fixture = Fixture::new().with_endpoint(&server.uri());
993 plant_sentinels(&fixture, &server.uri());
994
995 let mut command = exec_command(&fixture, "hello");
996 let mut child = command.spawn().expect("spawn codewhale-tui exec");
997
998 let root = fixture.telemetry_root();
999 let lock_path = root.join("buffer.jsonl.lock");
1000 // Arming itself takes the lock, so the holder below must wait until the
1001 // session is armed — otherwise this test would pin a deadlock it created.
1002 wait_until(Duration::from_secs(30), || {
1003 root.join("buffer.jsonl").exists()
1004 });
1005 let _holder = LockHolder::take(&lock_path);
1006
1007 // SIGINT to the child alone; the process group belongs to the test runner.
1008 let pid = child.id() as libc::pid_t;
1009 // SAFETY: `kill` with a pid this process spawned and has not reaped.
1010 unsafe {
1011 libc::kill(pid, libc::SIGINT);
1012 }
1013
1014 let started = Instant::now();
1015 let status = child
1016 .wait_timeout(Duration::from_secs(10))
1017 .expect("wait for codewhale-tui exec");
1018 let status = status.unwrap_or_else(|| {
1019 let _ = child.kill();
1020 panic!(
1021 "Ctrl-C blocked for {:?} while another process held the telemetry lock — \
1022 the signal path must append without taking it",
1023 started.elapsed()
1024 )
1025 });
1026 assert_eq!(
1027 status.code(),
1028 Some(130),
1029 "SIGINT must still exit 130 with the telemetry lock held elsewhere"
1030 );
1031 assert!(
1032 started.elapsed() < Duration::from_secs(1),
1033 "Ctrl-C took {:?} while the telemetry lock was held elsewhere",
1034 started.elapsed()
1035 );
1036 }
1037
1038 /// Holds the telemetry compaction lock for the lifetime of the value.
1039 struct LockHolder {
1040 file: std::fs::File,
1041 }
1042
1043 impl LockHolder {
1044 fn take(path: &Path) -> Self {
1045 let file = std::fs::OpenOptions::new()
1046 .create(true)
1047 .read(true)
1048 .write(true)
1049 .truncate(false)
1050 .open(path)
1051 .expect("open the telemetry lock");
1052 let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
1053 let started = Instant::now();
1054 loop {
1055 // SAFETY: `fd` is owned by `file` and outlives the call.
1056 let taken = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
1057 if taken == 0 {
1058 break;
1059 }
1060
1061 let err = std::io::Error::last_os_error();
1062 let retryable = err
1063 .raw_os_error()
1064 .is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN);
1065 assert!(retryable, "failed to take the telemetry lock: {err}");
1066 assert!(
1067 started.elapsed() < Duration::from_secs(5),
1068 "the telemetry arming lock remained held for {:?}",
1069 started.elapsed()
1070 );
1071 std::thread::sleep(Duration::from_millis(10));
1072 }
1073 Self { file }
1074 }
1075 }
1076
1077 impl Drop for LockHolder {
1078 fn drop(&mut self) {
1079 let fd = std::os::unix::io::AsRawFd::as_raw_fd(&self.file);
1080 // SAFETY: same fd, still owned by `self`.
1081 unsafe {
1082 libc::flock(fd, libc::LOCK_UN);
1083 }
1084 }
1085 }
1086
1087 // ── exec harness ─────────────────────────────────────────────────────────
1088
1089 fn sse_chunk(value: Value) -> String {
1090 format!(
1091 "data: {}\n\n",
1092 serde_json::to_string(&value).expect("SSE JSON")
1093 )
1094 }
1095
1096 fn text_sse(text: &str) -> String {
1097 [
1098 sse_chunk(json!({
1099 "id": "chatcmpl-tc",
1100 "object": "chat.completion.chunk",
1101 "model": TEST_MODEL,
1102 "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
1103 })),
1104 sse_chunk(json!({
1105 "id": "chatcmpl-tc",
1106 "object": "chat.completion.chunk",
1107 "model": TEST_MODEL,
1108 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
1109 "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
1110 })),
1111 "data: [DONE]\n\n".to_string(),
1112 ]
1113 .join("")
1114 }
1115
1116 async fn mount_model(server: &MockServer, delay: Duration) {
1117 Mock::given(method("GET"))
1118 .and(path_matcher("/v1/models"))
1119 .respond_with(
1120 ResponseTemplate::new(200)
1121 .insert_header("content-type", "application/json")
1122 .set_body_json(json!({
1123 "object": "list",
1124 "data": [{"id": TEST_MODEL, "object": "model"}]
1125 })),
1126 )
1127 .mount(server)
1128 .await;
1129 Mock::given(method("POST"))
1130 .and(path_matcher(MODEL_PATH))
1131 .respond_with(
1132 ResponseTemplate::new(200)
1133 .insert_header("content-type", "text/event-stream")
1134 .insert_header("cache-control", "no-cache")
1135 .set_body_string(text_sse("acknowledged"))
1136 .set_delay(delay),
1137 )
1138 .mount(server)
1139 .await;
1140 }
1141
1142 /// A config whose provider table key, MCP server name, and workspace file are
1143 /// all sentinels, so a leak has somewhere to come from.
1144 fn sentinel_config(base_url: &str, telemetry: bool) -> String {
1145 format!(
1146 "telemetry = {telemetry}\nprovider = \"{SENTINEL_PROVIDER_TABLE}\"\n\n\
1147 [providers.{SENTINEL_PROVIDER_TABLE}]\n\
1148 kind = \"openai-compatible\"\n\
1149 base_url = \"{base_url}/v1\"\n\
1150 model = \"{TEST_MODEL}\"\n\
1151 api_key_env = \"{SENTINEL_API_KEY_ENV}\"\n"
1152 )
1153 }
1154
1155 fn plant_sentinels(fixture: &Fixture, base_url: &str) {
1156 let config = sentinel_config(base_url, true);
1157 fixture.write_config(
1158 config
1159 .strip_prefix("telemetry = true\n")
1160 .expect("fixture preference"),
1161 );
1162 // No saved preference or acceptance: the real exec path must use default-on.
1163 std::fs::write(
1164 fixture.workspace.join(SENTINEL_FILENAME),
1165 "sentinel workspace file\n",
1166 )
1167 .expect("plant workspace file");
1168 std::fs::write(
1169 fixture.codewhale_home.join("mcp.json"),
1170 // Keep the MCP name in a real parsed config without starting a process
1171 // that exits before CodeWhale can write its initialize request. The
1172 // telemetry contract is about name redaction, not broken-pipe handling.
1173 json!({"mcpServers": {SENTINEL_MCP_SERVER: {
1174 "command": "/bin/true",
1175 "args": [],
1176 "disabled": true
1177 }}})
1178 .to_string(),
1179 )
1180 .expect("plant MCP config");
1181 }
1182
1183 fn exec_command(fixture: &Fixture, prompt: &str) -> Command {
1184 let mut command = fixture.command();
1185 command
1186 .env(
1187 "CODEWHALE_MCP_CONFIG",
1188 fixture.codewhale_home.join("mcp.json"),
1189 )
1190 .env(SENTINEL_API_KEY_ENV, SENTINEL_API_KEY)
1191 .args([
1192 "--config",
1193 fixture.config_path.to_str().expect("config path"),
1194 "--workspace",
1195 fixture.workspace.to_str().expect("workspace path"),
1196 "--no-project-config",
1197 "--skip-onboarding",
1198 "exec",
1199 "--auto",
1200 "--output-format",
1201 "stream-json",
1202 "--",
1203 prompt,
1204 ])
1205 .stdout(Stdio::piped())
1206 .stderr(Stdio::piped());
1207 command
1208 }
1209
1210 fn run_exec(fixture: &Fixture, prompt: &str) -> Output {
1211 let mut command = exec_command(fixture, prompt);
1212 let mut child = command.spawn().expect("spawn codewhale-tui exec");
1213 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
1214 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
1215 let status = match child.wait_timeout(EXEC_TIMEOUT).expect("wait for exec") {
1216 Some(status) => status,
1217 None => {
1218 let _ = child.kill();
1219 panic!("codewhale-tui exec did not exit within {EXEC_TIMEOUT:?}");
1220 }
1221 };
1222 Output {
1223 status,
1224 stdout: stdout.join().expect("stdout reader"),
1225 stderr: stderr.join().expect("stderr reader"),
1226 }
1227 }
1228
1229 fn assert_exec_succeeded(output: &Output, context: &str) {
1230 assert!(
1231 output.status.success(),
1232 "{context} exited with {}\nstdout:\n{}\nstderr:\n{}",
1233 output.status,
1234 String::from_utf8_lossy(&output.stdout),
1235 String::from_utf8_lossy(&output.stderr)
1236 );
1237 }
1238
1239 fn read_in_background(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Vec<u8>> {
1240 std::thread::spawn(move || {
1241 let mut buffer = Vec::new();
1242 let _ = pipe.read_to_end(&mut buffer);
1243 buffer
1244 })
1245 }
1246
1247 fn wait_until(limit: Duration, mut ready: impl FnMut() -> bool) {
1248 let started = Instant::now();
1249 while started.elapsed() < limit {
1250 if ready() {
1251 return;
1252 }
1253 std::thread::sleep(Duration::from_millis(25));
1254 }
1255 panic!("condition was not reached within {limit:?}");
1256 }
1257
1258 // ── Seeded state ─────────────────────────────────────────────────────────
1259
1260 /// A home that already belongs to a consenting user: an identity, a populated
1261 /// buffer, a flush record, and the lock file.
1262 fn seed_consenting_home(root: &Path) {
1263 std::fs::create_dir_all(root).expect("create telemetry root");
1264 std::fs::write(
1265 root.join("install_id.json"),
1266 json!({
1267 "schema_version": 1,
1268 "install_id": "11111111-2222-3333-4444-555555555555",
1269 "rotated_at": "2026-01-01T00:00:00Z"
1270 })
1271 .to_string(),
1272 )
1273 .expect("seed install id");
1274 std::fs::write(
1275 root.join("state.json"),
1276 json!({"schema_version": 1, "last_version": "0.0.1"}).to_string(),
1277 )
1278 .expect("seed state");
1279 std::fs::write(
1280 root.join("buffer.jsonl"),
1281 format!(
1282 "{}\n",
1283 json!({"event": "session_start", "source": "unknown"})
1284 ),
1285 )
1286 .expect("seed buffer");
1287 std::fs::write(root.join("buffer.jsonl.lock"), b"").expect("seed lock file");
1288 }
1289
1290 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
1291 let mut files = Vec::new();
1292 collect_files(root, &mut files);
1293 let mut out: Vec<(String, Vec<u8>)> = files
1294 .into_iter()
1295 .map(|path| {
1296 let name = path
1297 .strip_prefix(root)
1298 .unwrap_or(&path)
1299 .to_string_lossy()
1300 .into_owned();
1301 let bytes = std::fs::read(&path).unwrap_or_default();
1302 (name, bytes)
1303 })
1304 .collect();
1305 out.sort();
1306 out
1307 }
1308
1308 lines RUST