1use std::path::{Path, PathBuf};
39use std::time::SystemTime;
40
41use crate::backend::providers::{all_providers, get_provider};
42use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
43
44#[derive(Debug, PartialEq, Eq)]
47pub enum SecretCleanup {
48 KeychainRemoved,
50 KeychainFailed(String),
52 OAuthDirRemoved(PathBuf),
54 OAuthDirHistoryKept { dir: PathBuf, history: PathBuf },
58 OAuthDirAbsent(PathBuf),
60 OAuthDirSkipped { dir: PathBuf, reason: String },
64 OAuthDirFailed { dir: PathBuf, error: String },
66 NoOp,
68}
69
70impl SecretCleanup {
71 pub fn report(&self) -> serde_json::Value {
77 let path = |p: &Path| p.to_string_lossy().to_string();
78 match self {
79 SecretCleanup::KeychainRemoved => serde_json::json!({ "outcome": "removed" }),
80 SecretCleanup::KeychainFailed(e) => serde_json::json!({ "outcome": "failed", "detail": e }),
81 SecretCleanup::OAuthDirRemoved(d) => serde_json::json!({ "outcome": "removed", "path": path(d) }),
82 SecretCleanup::OAuthDirHistoryKept { dir, history } => serde_json::json!({
83 "outcome": "removed", "path": path(dir), "historyKept": true, "historyPath": path(history),
84 }),
85 SecretCleanup::OAuthDirAbsent(d) => serde_json::json!({ "outcome": "absent", "path": path(d) }),
86 SecretCleanup::OAuthDirSkipped { dir, reason } => {
87 serde_json::json!({ "outcome": "skipped", "path": path(dir), "detail": reason })
88 }
89 SecretCleanup::OAuthDirFailed { dir, error } => {
90 serde_json::json!({ "outcome": "failed", "path": path(dir), "detail": error })
91 }
92 SecretCleanup::NoOp => serde_json::json!({ "outcome": "none" }),
93 }
94 }
95}
96
97pub fn cleanup_account_secrets(
107 acct: &IdentityAccount,
108 identities_root: Option<&Path>,
109) -> SecretCleanup {
110 match &acct.secret_ref {
111 SecretRef::Keychain { .. } => match crate::identity::secret_store::delete(&acct.id) {
112 Ok(()) => {
113 tracing::info!(
114 account_id = %acct.id,
115 provider = %acct.provider,
116 "identity.delete: keychain secret removed"
117 );
118 SecretCleanup::KeychainRemoved
119 }
120 Err(e) => {
121 tracing::warn!(
122 account_id = %acct.id,
123 provider = %acct.provider,
124 error = %e,
125 "identity.delete: keychain secret delete failed"
126 );
127 SecretCleanup::KeychainFailed(e)
128 }
129 },
130 SecretRef::OAuthConfigDir { dir } => cleanup_oauth_dir(acct, Path::new(dir), identities_root),
131 SecretRef::Env { .. } | SecretRef::SecretsManager { .. } | SecretRef::PlaintextDev { .. } => {
132 SecretCleanup::NoOp
133 }
134 }
135}
136
137fn cleanup_oauth_dir(
138 acct: &IdentityAccount,
139 dir: &Path,
140 identities_root: Option<&Path>,
141) -> SecretCleanup {
142 let skip = |reason: String| -> SecretCleanup {
143 tracing::warn!(
144 account_id = %acct.id,
145 provider = %acct.provider,
146 dir = %dir.display(),
147 reason = %reason,
148 "identity.delete: oauth config dir outside data root — skipped"
149 );
150 SecretCleanup::OAuthDirSkipped { dir: dir.to_path_buf(), reason }
151 };
152
153 let root = match identities_root {
154 Some(r) => r,
155 None => return skip("identities root unresolved (DataPaths::from_env() = None)".into()),
156 };
157 if !dir.exists() {
158 tracing::info!(
160 account_id = %acct.id,
161 provider = %acct.provider,
162 dir = %dir.display(),
163 "identity.delete: oauth config dir already absent"
164 );
165 return SecretCleanup::OAuthDirAbsent(dir.to_path_buf());
166 }
167 let canon_root = match std::fs::canonicalize(root) {
170 Ok(p) => p,
171 Err(e) => return skip(format!("identities root not canonicalizable: {e}")),
172 };
173 let canon_dir = match std::fs::canonicalize(dir) {
174 Ok(p) => p,
175 Err(e) => return skip(format!("dir not canonicalizable: {e}")),
176 };
177 if canon_dir == canon_root || !canon_dir.starts_with(&canon_root) {
180 return skip(format!(
181 "resolved path {} is not strictly inside identities root {}",
182 canon_dir.display(),
183 canon_root.display()
184 ));
185 }
186 let history = history_subdir_for(acct, &canon_dir);
187 match remove_credentials_keep_history(&canon_dir, history) {
188 Ok(Some(kept)) => {
189 tracing::info!(
190 account_id = %acct.id,
191 provider = %acct.provider,
192 dir = %dir.display(),
193 history = %kept.display(),
194 "identity.delete: oauth credentials removed, conversation history kept"
195 );
196 SecretCleanup::OAuthDirHistoryKept { dir: dir.to_path_buf(), history: kept }
197 }
198 Ok(None) => {
199 if canon_dir.exists() {
204 tracing::warn!(
205 account_id = %acct.id,
206 provider = %acct.provider,
207 dir = %dir.display(),
208 "identity.delete: oauth config dir STILL PRESENT after remove_dir_all — credential not cleared"
209 );
210 } else {
211 tracing::info!(
212 account_id = %acct.id,
213 provider = %acct.provider,
214 dir = %dir.display(),
215 "identity.delete: oauth config dir removed (verified absent)"
216 );
217 }
218 SecretCleanup::OAuthDirRemoved(dir.to_path_buf())
219 }
220 Err(e) if e.kind() == std::io::ErrorKind::NotFound => SecretCleanup::OAuthDirAbsent(dir.to_path_buf()),
223 Err(e) => {
224 tracing::warn!(
225 account_id = %acct.id,
226 provider = %acct.provider,
227 dir = %dir.display(),
228 error = %e,
229 "identity.delete: oauth config dir removal failed"
230 );
231 SecretCleanup::OAuthDirFailed { dir: dir.to_path_buf(), error: e.to_string() }
232 }
233 }
234}
235
236fn history_subdir_for(acct: &IdentityAccount, dir: &Path) -> Option<&'static str> {
240 if let Some(sub) = get_provider(&acct.provider).and_then(|p| p.history_native_subdir) {
241 return Some(sub);
242 }
243 history_subdir_for_auth_dir(dir.file_name()?.to_str()?)
244}
245
246fn history_subdir_for_auth_dir(auth_dir_name: &str) -> Option<&'static str> {
247 all_providers()
248 .find(|p| p.auth_dir_name == auth_dir_name)
249 .and_then(|p| p.history_native_subdir)
250}
251
252fn remove_credentials_keep_history(dir: &Path, history: Option<&str>) -> std::io::Result<Option<PathBuf>> {
266 let keep = history.map(|h| dir.join(h)).filter(|h| {
267 std::fs::symlink_metadata(h).map(|m| m.file_type().is_dir()).unwrap_or(false)
268 && std::fs::read_dir(h).map(|mut it| it.next().is_some()).unwrap_or(false)
269 });
270 let Some(keep) = keep else {
271 std::fs::remove_dir_all(dir)?;
272 return Ok(None);
273 };
274 let mut first_err: Option<std::io::Error> = None;
275 for entry in std::fs::read_dir(dir)? {
276 let entry = entry?;
277 let path = entry.path();
278 if path == keep {
279 continue;
280 }
281 let res = match entry.file_type() {
282 Ok(ft) if ft.is_dir() => std::fs::remove_dir_all(&path),
283 _ => std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path)),
286 };
287 if let Err(e) = res {
288 if e.kind() != std::io::ErrorKind::NotFound && first_err.is_none() {
289 first_err = Some(std::io::Error::new(e.kind(), format!("{}: {e}", path.display())));
290 }
291 }
292 }
293 match first_err {
294 Some(e) => Err(e),
295 None => Ok(Some(keep)),
296 }
297}
298
299enum PruneOutcome {
300 Removed,
302 HistoryKept,
304 NothingToRemove,
306}
307
308fn prune_orphan_keeping_history(dir: &Path, root: &Path) -> Result<PruneOutcome, String> {
315 let canon_root = std::fs::canonicalize(root).map_err(|e| format!("root not canonicalizable: {e}"))?;
316 let canon_dir = std::fs::canonicalize(dir).map_err(|e| format!("dir not canonicalizable: {e}"))?;
317 if canon_dir == canon_root || !canon_dir.starts_with(&canon_root) {
318 return Err(format!("{} is not strictly inside {}", canon_dir.display(), canon_root.display()));
319 }
320 let mut kept_any = false;
321 let mut removed_any = false;
322 for entry in std::fs::read_dir(&canon_dir).map_err(|e| e.to_string())? {
323 let entry = entry.map_err(|e| e.to_string())?;
324 let path = entry.path();
325 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
326 let history = if is_dir {
327 path.file_name().and_then(|n| n.to_str()).and_then(history_subdir_for_auth_dir)
328 } else {
329 None
330 };
331 if let Some(h) = history {
332 let only_history = std::fs::read_dir(&path)
334 .map(|it| it.flatten().all(|e| e.file_name() == h))
335 .unwrap_or(false);
336 if only_history && path.join(h).is_dir() {
337 kept_any = true;
338 continue;
339 }
340 }
341 if is_dir {
342 if remove_credentials_keep_history(&path, history).map_err(|e| e.to_string())?.is_some() {
343 kept_any = true;
344 }
345 } else {
346 std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path)).map_err(|e| e.to_string())?;
347 }
348 removed_any = true;
349 }
350 if kept_any {
351 return Ok(if removed_any { PruneOutcome::HistoryKept } else { PruneOutcome::NothingToRemove });
352 }
353 std::fs::remove_dir_all(&canon_dir).map_err(|e| e.to_string())?;
354 Ok(PruneOutcome::Removed)
355}
356
357pub fn sweep_orphaned_account_dirs(
379 store: &Store,
380 identities_root: &Path,
381 min_age_secs: u64,
382) -> Vec<PathBuf> {
383 let mut removed = Vec::new();
384 let entries = match std::fs::read_dir(identities_root) {
385 Ok(e) => e,
386 Err(_) => return removed, };
388 for entry in entries.flatten() {
389 let path = entry.path();
390 if !path.is_dir() {
391 continue;
392 }
393 let Some(account_id) = path.file_name().and_then(|n| n.to_str()) else {
394 continue;
395 };
396 match store.identity_get(account_id) {
397 Ok(Some(_)) => continue,
399 Ok(None) => {}
400 Err(e) => {
401 tracing::warn!(
402 account_id,
403 error = %e,
404 "identity.sweep: identity_get failed — skipping candidate rather than risk a false orphan"
405 );
406 continue;
407 }
408 }
409 let age_secs = match entry.metadata().and_then(|m| m.modified()) {
410 Ok(mtime) => SystemTime::now()
411 .duration_since(mtime)
412 .map(|d| d.as_secs())
413 .unwrap_or(0),
414 Err(_) => continue, };
416 if age_secs < min_age_secs {
417 continue; }
419 match prune_orphan_keeping_history(&path, identities_root) {
420 Ok(PruneOutcome::NothingToRemove) => {}
422 Ok(PruneOutcome::HistoryKept) => {
423 tracing::info!(
424 account_id,
425 dir = %path.display(),
426 age_secs,
427 "identity.sweep: orphaned account dir pruned, conversation history kept"
428 );
429 removed.push(path);
430 }
431 Ok(PruneOutcome::Removed) => {
432 tracing::info!(
433 account_id,
434 dir = %path.display(),
435 age_secs,
436 "identity.sweep: orphaned account dir removed (no matching account row, past age threshold)"
437 );
438 removed.push(path);
439 }
440 Err(e) => {
441 tracing::debug!(account_id, dir = %path.display(), error = %e, "identity.sweep: candidate not removed");
442 }
443 }
444 }
445 removed
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn acct_with(secret_ref: SecretRef) -> IdentityAccount {
453 IdentityAccount {
454 id: "acct-test".to_string(),
455 name: "asaf-anthropic".to_string(),
456 provider: "anthropic".to_string(),
457 kind: "oauth".to_string(),
458 display_name: String::new(),
459 secret_ref,
460 context: serde_json::json!({}),
461 status: "ok".to_string(),
462 created_at: 0,
463 updated_at: 0,
464 }
465 }
466
467 #[test]
469 fn oauth_dir_inside_data_root_is_removed() {
470 let tmp = tempfile::tempdir().unwrap();
471 let root = tmp.path().join("identities");
472 let dir = root.join("acct-test").join("claude");
473 std::fs::create_dir_all(&dir).unwrap();
474 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
475
476 let acct = acct_with(SecretRef::OAuthConfigDir {
477 dir: dir.to_string_lossy().to_string(),
478 });
479 let out = cleanup_account_secrets(&acct, Some(&root));
480
481 assert!(matches!(out, SecretCleanup::OAuthDirRemoved(_)), "got {out:?}");
482 assert!(!dir.exists(), "token dir must be gone");
483 assert!(root.exists(), "identities root itself must survive");
486 }
487
488 #[test]
491 fn oauth_dir_outside_data_root_is_skipped() {
492 let tmp = tempfile::tempdir().unwrap();
493 let root = tmp.path().join("identities");
494 std::fs::create_dir_all(&root).unwrap();
495 let ambient = tmp.path().join("dot-claude");
497 std::fs::create_dir_all(&ambient).unwrap();
498 std::fs::write(ambient.join(".credentials.json"), "{}").unwrap();
499
500 let acct = acct_with(SecretRef::OAuthConfigDir {
501 dir: ambient.to_string_lossy().to_string(),
502 });
503 let out = cleanup_account_secrets(&acct, Some(&root));
504
505 assert!(
506 matches!(out, SecretCleanup::OAuthDirSkipped { .. }),
507 "must refuse to delete outside the identities root, got {out:?}"
508 );
509 assert!(ambient.exists(), "ambient CLI dir must be untouched");
510 assert!(ambient.join(".credentials.json").exists());
511 }
512
513 #[test]
516 fn oauth_dir_dotdot_escape_is_skipped() {
517 let tmp = tempfile::tempdir().unwrap();
518 let root = tmp.path().join("identities");
519 std::fs::create_dir_all(&root).unwrap();
520 let ambient = tmp.path().join("dot-claude");
521 std::fs::create_dir_all(&ambient).unwrap();
522
523 let sneaky = root.join("..").join("dot-claude");
524 let acct = acct_with(SecretRef::OAuthConfigDir {
525 dir: sneaky.to_string_lossy().to_string(),
526 });
527 let out = cleanup_account_secrets(&acct, Some(&root));
528
529 assert!(matches!(out, SecretCleanup::OAuthDirSkipped { .. }), "got {out:?}");
530 assert!(ambient.exists());
531 }
532
533 #[test]
536 fn oauth_dir_with_no_root_is_skipped() {
537 let tmp = tempfile::tempdir().unwrap();
538 let dir = tmp.path().join("identities").join("acct-test").join("claude");
539 std::fs::create_dir_all(&dir).unwrap();
540
541 let acct = acct_with(SecretRef::OAuthConfigDir {
542 dir: dir.to_string_lossy().to_string(),
543 });
544 let out = cleanup_account_secrets(&acct, None);
545
546 assert!(matches!(out, SecretCleanup::OAuthDirSkipped { .. }), "got {out:?}");
547 assert!(dir.exists(), "dir must be untouched when the root is unknown");
548 }
549
550 #[test]
552 fn oauth_dir_absent_is_noop() {
553 let tmp = tempfile::tempdir().unwrap();
554 let root = tmp.path().join("identities");
555 std::fs::create_dir_all(&root).unwrap();
556 let dir = root.join("acct-test").join("claude");
557
558 let acct = acct_with(SecretRef::OAuthConfigDir {
559 dir: dir.to_string_lossy().to_string(),
560 });
561 let out = cleanup_account_secrets(&acct, Some(&root));
562
563 assert!(matches!(out, SecretCleanup::OAuthDirAbsent(_)), "got {out:?}");
564 assert!(!dir.exists());
565 }
566
567 #[test]
569 fn env_and_dev_variants_touch_nothing() {
570 let tmp = tempfile::tempdir().unwrap();
571 let root = tmp.path().join("identities");
572 let canary = root.join("acct-test").join("claude");
573 std::fs::create_dir_all(&canary).unwrap();
574
575 for secret_ref in [
576 SecretRef::Env { env_var: "ANTHROPIC_API_KEY".to_string() },
577 SecretRef::PlaintextDev { plaintext_dev: "sk-dev".to_string() },
578 SecretRef::SecretsManager { sm_path: "path/x".to_string(), sm_json_path: None },
579 ] {
580 let out = cleanup_account_secrets(&acct_with(secret_ref), Some(&root));
581 assert_eq!(out, SecretCleanup::NoOp);
582 }
583 assert!(canary.exists(), "no filesystem side effects for non-oauth variants");
584 }
585
586 #[test]
590 fn keychain_variant_touches_no_filesystem() {
591 let tmp = tempfile::tempdir().unwrap();
592 let root = tmp.path().join("identities");
593 let canary = root.join("acct-test").join("claude");
594 std::fs::create_dir_all(&canary).unwrap();
595
596 let mut acct = acct_with(SecretRef::Keychain {
599 service: "agentmux".to_string(),
600 account: "acct:zz-unit-test-never-provisioned".to_string(),
601 });
602 acct.id = "zz-unit-test-never-provisioned".to_string();
603 let out = cleanup_account_secrets(&acct, Some(&root));
604
605 assert!(
606 matches!(out, SecretCleanup::KeychainRemoved | SecretCleanup::KeychainFailed(_)),
607 "got {out:?}"
608 );
609 assert!(canary.exists(), "keychain cleanup must not touch the filesystem");
610 }
611
612 fn make_store() -> Store {
613 Store::open_in_memory().unwrap()
614 }
615
616 #[test]
619 fn sweep_removes_orphaned_dir_past_age_threshold() {
620 let store = make_store();
621 let tmp = tempfile::tempdir().unwrap();
622 let root = tmp.path().join("identities");
623 let orphan = root.join("orphan-uuid").join("claude");
624 std::fs::create_dir_all(&orphan).unwrap();
625 std::fs::write(orphan.join(".credentials.json"), "{}").unwrap();
626
627 let removed = sweep_orphaned_account_dirs(&store, &root, 0);
628
629 assert_eq!(removed, vec![root.join("orphan-uuid")]);
630 assert!(!root.join("orphan-uuid").exists(), "orphaned dir must be gone");
631 }
632
633 #[test]
637 fn sweep_never_removes_a_dir_with_a_real_account_row() {
638 let store = make_store();
639 let tmp = tempfile::tempdir().unwrap();
640 let root = tmp.path().join("identities");
641 let real = root.join("real-account-id").join("claude");
642 std::fs::create_dir_all(&real).unwrap();
643
644 store
645 .identity_upsert(&IdentityAccount {
646 id: "real-account-id".to_string(),
647 name: "asaf-claude".to_string(),
648 provider: "claude".to_string(),
649 kind: "oauth".to_string(),
650 display_name: String::new(),
651 secret_ref: SecretRef::OAuthConfigDir {
652 dir: real.to_string_lossy().to_string(),
653 },
654 context: serde_json::json!({}),
655 status: "valid".to_string(),
656 created_at: 0,
657 updated_at: 0,
658 })
659 .unwrap();
660
661 let removed = sweep_orphaned_account_dirs(&store, &root, 0);
662
663 assert!(removed.is_empty());
664 assert!(real.exists(), "a real account's dir must survive the sweep");
665 }
666
667 #[test]
670 fn sweep_never_removes_a_dir_younger_than_the_age_threshold() {
671 let store = make_store();
672 let tmp = tempfile::tempdir().unwrap();
673 let root = tmp.path().join("identities");
674 let fresh = root.join("fresh-uuid").join("claude");
675 std::fs::create_dir_all(&fresh).unwrap();
676
677 let removed = sweep_orphaned_account_dirs(&store, &root, 999_999);
679
680 assert!(removed.is_empty());
681 assert!(root.join("fresh-uuid").exists(), "a fresh in-progress dir must survive");
682 }
683
684 #[test]
687 fn sweep_on_missing_root_returns_empty_without_panicking() {
688 let store = make_store();
689 let tmp = tempfile::tempdir().unwrap();
690 let root = tmp.path().join("identities-never-created");
691
692 let removed = sweep_orphaned_account_dirs(&store, &root, 0);
693
694 assert!(removed.is_empty());
695 }
696
697 fn claude_dir_with_history(root: &Path, account: &str) -> PathBuf {
698 let dir = root.join(account).join("claude");
699 std::fs::create_dir_all(dir.join("projects").join("C--work")).unwrap();
700 std::fs::create_dir_all(dir.join("sessions")).unwrap();
701 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
702 std::fs::write(dir.join("settings.json"), "{}").unwrap();
703 std::fs::write(dir.join("sessions").join("s.json"), "{}").unwrap();
704 std::fs::write(dir.join("projects").join("C--work").join("t.jsonl"), "{}").unwrap();
705 dir
706 }
707
708 #[test]
712 fn oauth_delete_keeps_conversation_history() {
713 let tmp = tempfile::tempdir().unwrap();
714 let root = tmp.path().join("identities");
715 let dir = claude_dir_with_history(&root, "acct-test");
716 let mut acct = acct_with(SecretRef::OAuthConfigDir {
717 dir: dir.to_string_lossy().to_string(),
718 });
719 acct.provider = "claude".to_string();
720
721 let out = cleanup_account_secrets(&acct, Some(&root));
722
723 assert!(matches!(out, SecretCleanup::OAuthDirHistoryKept { .. }), "got {out:?}");
724 assert!(dir.join("projects").join("C--work").join("t.jsonl").exists(), "history must survive");
725 assert!(!dir.join(".credentials.json").exists(), "the login must be gone");
726 assert!(!dir.join("settings.json").exists());
727 assert!(!dir.join("sessions").exists());
728 }
729
730 #[test]
733 fn oauth_delete_finds_history_by_dir_name() {
734 let tmp = tempfile::tempdir().unwrap();
735 let root = tmp.path().join("identities");
736 let dir = claude_dir_with_history(&root, "acct-test");
737 let acct = acct_with(SecretRef::OAuthConfigDir {
739 dir: dir.to_string_lossy().to_string(),
740 });
741
742 let out = cleanup_account_secrets(&acct, Some(&root));
743
744 assert!(matches!(out, SecretCleanup::OAuthDirHistoryKept { .. }), "got {out:?}");
745 assert!(dir.join("projects").join("C--work").join("t.jsonl").exists());
746 assert!(!dir.join(".credentials.json").exists());
747 }
748
749 #[test]
751 fn oauth_delete_with_empty_history_removes_everything() {
752 let tmp = tempfile::tempdir().unwrap();
753 let root = tmp.path().join("identities");
754 let dir = root.join("acct-test").join("claude");
755 std::fs::create_dir_all(dir.join("projects")).unwrap();
756 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
757 let mut acct = acct_with(SecretRef::OAuthConfigDir {
758 dir: dir.to_string_lossy().to_string(),
759 });
760 acct.provider = "claude".to_string();
761
762 let out = cleanup_account_secrets(&acct, Some(&root));
763
764 assert!(matches!(out, SecretCleanup::OAuthDirRemoved(_)), "got {out:?}");
765 assert!(!dir.exists());
766 }
767
768 #[test]
771 fn sweep_keeps_history_of_an_orphaned_account_dir() {
772 let store = make_store();
773 let tmp = tempfile::tempdir().unwrap();
774 let root = tmp.path().join("identities");
775 let dir = claude_dir_with_history(&root, "deleted-uuid");
776
777 let removed = sweep_orphaned_account_dirs(&store, &root, 0);
778
779 assert_eq!(removed, vec![root.join("deleted-uuid")]);
780 assert!(dir.join("projects").join("C--work").join("t.jsonl").exists(), "history must survive the sweep");
781 assert!(!dir.join(".credentials.json").exists());
782 assert!(!dir.join("sessions").exists());
783
784 let again = sweep_orphaned_account_dirs(&store, &root, 0);
785 assert!(again.is_empty(), "history-only dir must not be reported again: {again:?}");
786 assert!(dir.join("projects").join("C--work").join("t.jsonl").exists());
787 }
788}