@indigoai-us/hq-cloud 5.33.0 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -417,6 +417,535 @@ describe("sync", () => {
417
417
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
418
418
  });
419
419
 
420
+ it("applies cross-machine deletes via journal-vs-remote-LIST diff (Bug #9 — tombstone propagation)", async () => {
421
+ // Bug #9 (data-loss class — never reaches consistency): peer A
422
+ // deleted a file and pushed; the push side correctly removed the
423
+ // object from S3 (verified post-push with \`aws s3 ls\`). Receiver
424
+ // B's pull then enumerated the remote LIST, didn't see the key, and
425
+ // did NOTHING — the pull walker never consumed "absence from remote"
426
+ // as a delete signal. The file stayed on B forever and
427
+ // \`filesTombstoned: 0\` showed up on every pull (cross-machine
428
+ // delete-propagation impossible).
429
+ //
430
+ // Fix: walk the journal, find every entry whose key is missing from
431
+ // the remote LIST, and apply each as a local delete + journal
432
+ // removal. Same ignore-filter + personalMode gating applied so a
433
+ // path the operator just added to .hqignore can't trigger mass-
434
+ // delete. Excludes ephemeral conflict-mirror paths.
435
+ const companyRoot = path.join(tmpDir, "companies", "acme");
436
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
437
+ // Local file is journal-known but no longer in S3 (peer deleted it).
438
+ fs.writeFileSync(path.join(companyRoot, "docs", "deleted-by-peer.md"), "old content");
439
+ // Local file matches a remote entry — stays after the pull.
440
+ fs.writeFileSync(path.join(companyRoot, "docs", "kept.md"), "current");
441
+ // Compute real sha256 baselines so the local-edit-divergence check
442
+ // (Codex P1 round 3) sees the local files as matching the journal
443
+ // baseline and proceeds with tombstone. Fake-string hashes would
444
+ // be classified as "diverged → defer" by that guard.
445
+ const crypto = await import("node:crypto");
446
+ const baselineDeleted = crypto
447
+ .createHash("sha256")
448
+ .update("old content")
449
+ .digest("hex");
450
+ const baselineKept = crypto
451
+ .createHash("sha256")
452
+ .update("current")
453
+ .digest("hex");
454
+
455
+ // Seed the journal as if the receiver had previously synced both files.
456
+ fs.writeFileSync(
457
+ journalPath,
458
+ JSON.stringify({
459
+ version: "1",
460
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
461
+ files: {
462
+ "docs/deleted-by-peer.md": {
463
+ hash: baselineDeleted,
464
+ size: 11,
465
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
466
+ direction: "down",
467
+ remoteEtag: "remote-old",
468
+ },
469
+ "docs/kept.md": {
470
+ hash: baselineKept,
471
+ size: 7,
472
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
473
+ direction: "down",
474
+ remoteEtag: "remote-current",
475
+ },
476
+ },
477
+ }),
478
+ );
479
+
480
+ // Remote LIST contains kept.md only — deleted-by-peer.md is gone.
481
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
482
+ {
483
+ key: "docs/kept.md",
484
+ size: 7,
485
+ lastModified: new Date(),
486
+ etag: '"remote-current"',
487
+ },
488
+ ]);
489
+
490
+ const result = await sync({
491
+ company: "acme",
492
+ vaultConfig: mockConfig,
493
+ hqRoot: tmpDir,
494
+ });
495
+
496
+ // The peer-deleted file MUST be removed locally.
497
+ expect(fs.existsSync(path.join(companyRoot, "docs", "deleted-by-peer.md"))).toBe(false);
498
+ // The kept file MUST stay.
499
+ expect(fs.existsSync(path.join(companyRoot, "docs", "kept.md"))).toBe(true);
500
+ // Tombstone counter exposed for the runner's `complete` event.
501
+ expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
502
+ // Journal entry for the deleted path must be gone too — otherwise
503
+ // the next sync would tombstone the same key on every run.
504
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
505
+ expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
506
+ expect(journal.files["docs/kept.md"]).toBeDefined();
507
+ });
508
+
509
+ it("does NOT tombstone keys whose local copy has diverged from the journal (Codex P1 — delete-vs-edit race)", async () => {
510
+ // Codex review on PR #24 round 3 caught: in a delete-vs-local-edit
511
+ // race, peer A deletes a file remotely while peer B edits it
512
+ // locally before the next pull. Pre-fix the tombstone executor
513
+ // unlinked the locally-edited file and dropped the journal entry,
514
+ // silently destroying the operator's unsynced work. Fix: hash the
515
+ // local file before tombstoning; if it diverges from the journal
516
+ // baseline, defer the tombstone so the next push surfaces the
517
+ // divergence (or the operator re-pushes deliberately).
518
+ const companyRoot = path.join(tmpDir, "companies", "acme");
519
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
520
+ const editedPath = path.join(companyRoot, "docs", "edited-locally.md");
521
+ // Local file contents DIFFER from the journal-recorded baseline.
522
+ fs.writeFileSync(editedPath, "unsynced LOCAL edit — must not be deleted");
523
+ // Compute the journal's stale baseline hash from different content.
524
+ const crypto = await import("node:crypto");
525
+ const baselineHash = crypto
526
+ .createHash("sha256")
527
+ .update("original synced content (pre-edit)")
528
+ .digest("hex");
529
+ fs.writeFileSync(
530
+ journalPath,
531
+ JSON.stringify({
532
+ version: "1",
533
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
534
+ files: {
535
+ "docs/edited-locally.md": {
536
+ hash: baselineHash,
537
+ size: 33,
538
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
539
+ direction: "down",
540
+ remoteEtag: "e-baseline",
541
+ },
542
+ },
543
+ }),
544
+ );
545
+ // Peer deleted it: LIST is empty AND HEAD returns null (confirmed
546
+ // gone). Without the local-edit check, this would tombstone.
547
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
548
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
549
+
550
+ const result = await sync({
551
+ company: "acme",
552
+ vaultConfig: mockConfig,
553
+ hqRoot: tmpDir,
554
+ });
555
+
556
+ // Local file with unsynced edits MUST survive.
557
+ expect(fs.existsSync(editedPath)).toBe(true);
558
+ expect(fs.readFileSync(editedPath, "utf-8")).toBe(
559
+ "unsynced LOCAL edit — must not be deleted",
560
+ );
561
+ expect(result.filesTombstoned).toBe(0);
562
+ // Journal entry retained — next sync (push) will re-evaluate and
563
+ // either re-upload the divergent local or surface as a conflict.
564
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
565
+ expect(journal.files["docs/edited-locally.md"]).toBeDefined();
566
+ expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
567
+ });
568
+
569
+ it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
570
+ // Codex review on PR #24 round 4 caught: the round-3 local-edit
571
+ // divergence guard only covered regular files (`isFile()` is false
572
+ // for symlinks), so a locally-edited symlink (`ln -sfn new-target
573
+ // old-link` before the peer's remote-delete) fell through the
574
+ // guard and was unlinked silently. Exact same race class —
575
+ // symlinks have target strings that count as content too.
576
+ const companyRoot = path.join(tmpDir, "companies", "acme");
577
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
578
+ const linkPath = path.join(companyRoot, "docs", "link.md");
579
+ // Local symlink now points to a NEW target (the local edit).
580
+ fs.symlinkSync("new-target.md", linkPath);
581
+ // Journal records the OLD target's hash.
582
+ const { hashSymlinkTarget } = await import("../journal.js");
583
+ const baselineHash = hashSymlinkTarget("old-target.md");
584
+ fs.writeFileSync(
585
+ journalPath,
586
+ JSON.stringify({
587
+ version: "1",
588
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
589
+ files: {
590
+ "docs/link.md": {
591
+ hash: baselineHash,
592
+ size: 0,
593
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
594
+ direction: "down",
595
+ remoteEtag: "e-link",
596
+ },
597
+ },
598
+ }),
599
+ );
600
+ // Peer deleted remotely.
601
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
602
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
603
+
604
+ const result = await sync({
605
+ company: "acme",
606
+ vaultConfig: mockConfig,
607
+ hqRoot: tmpDir,
608
+ });
609
+
610
+ // Locally-edited symlink MUST survive + still point at the new
611
+ // target. Use lstatSync — fs.existsSync follows symlinks and
612
+ // returns false for a dangling link, hiding the survival signal.
613
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
614
+ expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
615
+ expect(result.filesTombstoned).toBe(0);
616
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
617
+ expect(journal.files["docs/link.md"]).toBeDefined();
618
+ expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
619
+ });
620
+
621
+ it("does NOT tombstone keys that HEAD-verify as still present (Codex P1 — STS scope gating)", async () => {
622
+ // Codex review on PR #24 caught: `listRemoteFiles` is STS-scoped — a
623
+ // guest session with `allowedPrefixes`, a downgraded role, or any
624
+ // custom sync-mode that narrows prefix coverage can return a LIST
625
+ // that does NOT include keys still genuinely present in the bucket.
626
+ // Pre-fix the tombstone planner classified absence-from-LIST as
627
+ // "peer deleted it"; the executor then unlinked local files for
628
+ // keys outside the session's visibility. Data loss + journal-
629
+ // baseline loss for the next broader-scope sync.
630
+ //
631
+ // Fix: HEAD-verify each candidate. HEAD returns metadata → key
632
+ // exists, just invisible to LIST → SKIP. HEAD returns null
633
+ // (NotFound) → confirmed deleted → proceed.
634
+ const companyRoot = path.join(tmpDir, "companies", "acme");
635
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
636
+ const invisiblePath = path.join(companyRoot, "docs", "invisible-to-list.md");
637
+ const reallyDeletedPath = path.join(companyRoot, "docs", "really-deleted.md");
638
+ fs.writeFileSync(invisiblePath, "still on bucket");
639
+ fs.writeFileSync(reallyDeletedPath, "peer removed");
640
+ // Real sha256 baselines so the local-edit-divergence guard
641
+ // (Codex P1 round 3) doesn't defer both as "diverged".
642
+ const crypto = await import("node:crypto");
643
+ const hInv = crypto.createHash("sha256").update("still on bucket").digest("hex");
644
+ const hDel = crypto.createHash("sha256").update("peer removed").digest("hex");
645
+ fs.writeFileSync(
646
+ journalPath,
647
+ JSON.stringify({
648
+ version: "1",
649
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
650
+ files: {
651
+ "docs/invisible-to-list.md": {
652
+ hash: hInv,
653
+ size: 15,
654
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
655
+ direction: "down",
656
+ remoteEtag: "e-inv",
657
+ },
658
+ "docs/really-deleted.md": {
659
+ hash: hDel,
660
+ size: 12,
661
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
662
+ direction: "down",
663
+ remoteEtag: "e-del",
664
+ },
665
+ },
666
+ }),
667
+ );
668
+ // LIST is empty (narrowed-scope session); both keys are absent.
669
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
670
+ // HEAD says invisible-to-list still exists; really-deleted is gone.
671
+ vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
672
+ if (key === "docs/invisible-to-list.md") {
673
+ return { lastModified: new Date(), etag: "e-inv", size: 15 };
674
+ }
675
+ return null; // NotFound → safe to tombstone
676
+ });
677
+
678
+ const result = await sync({
679
+ company: "acme",
680
+ vaultConfig: mockConfig,
681
+ hqRoot: tmpDir,
682
+ });
683
+
684
+ // The invisible-but-still-present file MUST be retained.
685
+ expect(fs.existsSync(invisiblePath)).toBe(true);
686
+ // The really-deleted file MUST be tombstoned.
687
+ expect(fs.existsSync(reallyDeletedPath)).toBe(false);
688
+ expect(result.filesTombstoned).toBe(1);
689
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
690
+ // Journal entry retained for the invisible key (so next sync at
691
+ // broader scope can re-evaluate).
692
+ expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
693
+ expect(journal.files["docs/invisible-to-list.md"].hash).toBe(hInv);
694
+ // Confirmed-deleted entry dropped.
695
+ expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
696
+ });
697
+
698
+ it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
699
+ // Same Codex P1, AccessDenied branch: a guest STS session may deny
700
+ // both LIST and HEAD on out-of-scope keys. The tombstone planner
701
+ // must treat AccessDenied as "can't tell — defer" not as
702
+ // "confirmed deleted". Otherwise narrow-scope sessions would still
703
+ // tombstone keys they can't even see.
704
+ const companyRoot = path.join(tmpDir, "companies", "acme");
705
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
706
+ const deniedPath = path.join(companyRoot, "docs", "out-of-scope.md");
707
+ fs.writeFileSync(deniedPath, "exists but denied");
708
+ fs.writeFileSync(
709
+ journalPath,
710
+ JSON.stringify({
711
+ version: "1",
712
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
713
+ files: {
714
+ "docs/out-of-scope.md": {
715
+ hash: "h-denied",
716
+ size: 17,
717
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
718
+ direction: "down",
719
+ remoteEtag: "e-denied",
720
+ },
721
+ },
722
+ }),
723
+ );
724
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
725
+ const denied = new Error("Access Denied") as Error & { name: string };
726
+ denied.name = "AccessDenied";
727
+ vi.mocked(s3Module.headRemoteFile).mockRejectedValueOnce(denied);
728
+
729
+ const result = await sync({
730
+ company: "acme",
731
+ vaultConfig: mockConfig,
732
+ hqRoot: tmpDir,
733
+ });
734
+
735
+ expect(fs.existsSync(deniedPath)).toBe(true);
736
+ expect(result.filesTombstoned).toBe(0);
737
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
738
+ expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
739
+ });
740
+
741
+ it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
742
+ // Codex review on PR #24 caught this: pre-fix the tombstone branch
743
+ // dropped the journal entry unconditionally even when the local
744
+ // unlink failed with a non-ENOENT error (EACCES / EPERM / EBUSY).
745
+ // The pull side then forgot the peer's delete, and on a subsequent
746
+ // sync the still-present local file would be classified as a fresh
747
+ // upload candidate and re-pushed to S3 — silently undoing the
748
+ // peer's delete. ENOENT is safe to drop (local is already gone),
749
+ // but any other error MUST keep the journal entry so the next sync
750
+ // retries after the operator fixes the permission.
751
+ const companyRoot = path.join(tmpDir, "companies", "acme");
752
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
753
+ const lockedPath = path.join(companyRoot, "docs", "locked-by-peer.md");
754
+ fs.writeFileSync(lockedPath, "still here");
755
+ fs.writeFileSync(
756
+ journalPath,
757
+ JSON.stringify({
758
+ version: "1",
759
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
760
+ files: {
761
+ "docs/locked-by-peer.md": {
762
+ hash: "locked-hash",
763
+ size: 10,
764
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
765
+ direction: "down",
766
+ remoteEtag: "remote-locked",
767
+ },
768
+ },
769
+ }),
770
+ );
771
+ // Remote LIST is empty → tombstone candidate.
772
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
773
+
774
+ // Strip write perms from the parent dir so the unlink throws EACCES
775
+ // (and EPERM on some POSIX variants — both must be retained). The
776
+ // dir read perm is retained so the planner can still lstat the file.
777
+ const parentDir = path.join(companyRoot, "docs");
778
+ fs.chmodSync(parentDir, 0o500);
779
+ try {
780
+ const result = await sync({
781
+ company: "acme",
782
+ vaultConfig: mockConfig,
783
+ hqRoot: tmpDir,
784
+ });
785
+ // Local file is still present (unlink failed).
786
+ expect(fs.existsSync(lockedPath)).toBe(true);
787
+ // Tombstone NOT counted — the delete wasn't honored.
788
+ expect(result.filesTombstoned).toBe(0);
789
+ // Journal entry MUST be retained so the next sync retries.
790
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
791
+ expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
792
+ expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
793
+ } finally {
794
+ // Restore write perms so afterEach rmSync can clean the tmp dir.
795
+ fs.chmodSync(parentDir, 0o700);
796
+ }
797
+ });
798
+
799
+ it("does NOT tombstone keys that are now ignored by .hqignore (safety guard)", async () => {
800
+ // If the operator just added a path to .hqignore, the local file
801
+ // shouldn't be deleted on the next sync because "remote no longer has
802
+ // it" (the push side stopped uploading it too). Skip-tombstone any
803
+ // key the current ignore filter rejects.
804
+ const companyRoot = path.join(tmpDir, "companies", "acme");
805
+ fs.mkdirSync(companyRoot, { recursive: true });
806
+ fs.writeFileSync(path.join(companyRoot, "secret.txt"), "stays");
807
+ // Ignore secret.txt going forward.
808
+ fs.writeFileSync(path.join(tmpDir, ".hqignore"), "secret.txt\n");
809
+ // Journal says we previously synced it.
810
+ fs.writeFileSync(
811
+ journalPath,
812
+ JSON.stringify({
813
+ version: "1",
814
+ lastSync: new Date().toISOString(),
815
+ files: {
816
+ "secret.txt": {
817
+ hash: "old-hash",
818
+ size: 5,
819
+ syncedAt: new Date().toISOString(),
820
+ direction: "down",
821
+ remoteEtag: "etag",
822
+ },
823
+ },
824
+ }),
825
+ );
826
+ // Remote LIST is empty.
827
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
828
+
829
+ const result = await sync({
830
+ company: "acme",
831
+ vaultConfig: mockConfig,
832
+ hqRoot: tmpDir,
833
+ });
834
+
835
+ // The ignored file MUST NOT be tombstoned — operator's choice.
836
+ expect(fs.existsSync(path.join(companyRoot, "secret.txt"))).toBe(true);
837
+ expect(result.filesTombstoned).toBe(0);
838
+ });
839
+
840
+ it("dir-vs-file collision (local-file, cloud-dir): warns, continues, no ENOTDIR crash (Bug #10)", async () => {
841
+ // The 5.33.0 verification report's V10/Bug #10: cloud has a directory
842
+ // at \`v4-dir-vs-file/inside.txt\`, but local has a regular FILE at
843
+ // \`v4-dir-vs-file\`. Pre-fix, computePullPlan called \`lstatSync\` on the
844
+ // file-as-if-it-were-a-dir path and threw uncaught ENOTDIR, aborting
845
+ // the entire company sync — every later file (including an unrelated
846
+ // direct-injected v2 file) was skipped. Personal company status:
847
+ // \"errored\". Critical regression vector — one collision wedged the
848
+ // whole company.
849
+ //
850
+ // Fix: try/catch the lstat; on ENOTDIR, emit the same "manual
851
+ // reconciliation required" surface as the existing local-dir/cloud-
852
+ // file warning and continue with the rest of the LIST.
853
+ const companyRoot = path.join(tmpDir, "companies", "acme");
854
+ fs.mkdirSync(companyRoot, { recursive: true });
855
+ // Local has a regular file where cloud has a directory.
856
+ fs.writeFileSync(path.join(companyRoot, "v4-dir-vs-file"), "local file content");
857
+
858
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
859
+ // The cloud-dir entry that would crash lstat(...localPath).
860
+ { key: "v4-dir-vs-file/inside.txt", size: 20, lastModified: new Date(), etag: '"a"' },
861
+ // An unrelated file at a sibling prefix — must STILL download after the
862
+ // collision is detected and skipped, proving the company isn't wedged.
863
+ { key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
864
+ ]);
865
+
866
+ const result = await sync({
867
+ company: "acme",
868
+ vaultConfig: mockConfig,
869
+ hqRoot: tmpDir,
870
+ });
871
+
872
+ // No crash, no aborted: true.
873
+ expect(result.aborted).toBe(false);
874
+ // The collision entry is skipped (counted as skip-local-only).
875
+ // The sibling file MUST have downloaded — sync is not wedged.
876
+ expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
877
+ expect(result.filesDownloaded).toBe(1);
878
+ });
879
+
880
+ it("dir-vs-file collision (local-dir, cloud-file): warns, continues, no crash (Bug #4)", async () => {
881
+ // Symmetric to Bug #10: cloud has a single object at a key where local
882
+ // has a directory. The existing code already warned + skipped this case;
883
+ // this test pins the contract so a future refactor doesn't regress it
884
+ // alongside the Bug #10 fix in the opposite topology.
885
+ const companyRoot = path.join(tmpDir, "companies", "acme");
886
+ fs.mkdirSync(path.join(companyRoot, "v4-collision"), { recursive: true });
887
+
888
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
889
+ { key: "v4-collision", size: 50, lastModified: new Date(), etag: '"a"' },
890
+ { key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
891
+ ]);
892
+
893
+ const result = await sync({
894
+ company: "acme",
895
+ vaultConfig: mockConfig,
896
+ hqRoot: tmpDir,
897
+ });
898
+
899
+ expect(result.aborted).toBe(false);
900
+ // The sibling MUST land — wedge regression test.
901
+ expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
902
+ expect(result.filesDownloaded).toBe(1);
903
+ });
904
+
905
+ it("skips remote keys matching EPHEMERAL_PATH_PATTERN (Bug #2 — pull-side ephemeral filter)", async () => {
906
+ // The push side has refused to upload conflict-mirror files since 5.33.0
907
+ // (see `EPHEMERAL_PATH_PATTERN` + collectFiles/walkDir/computeDeletePlan).
908
+ // But the pull walker never filtered the same pattern — so legacy
909
+ // `.conflict-*` files already in cloud staging were downloaded into
910
+ // clean trees on every sync. The verification report's V2 test proved
911
+ // this with a direct `aws s3 cp` inject and observed
912
+ // `filesExcludedByPolicy: 0` on the pull, even though every push-side
913
+ // walker would have refused the same key.
914
+ //
915
+ // Fix: classify ephemeral remote keys as skip-excluded at planning
916
+ // time, increment `filesExcludedByPolicy`, and never call downloadFile.
917
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
918
+ // Legacy conflict mirror — must be filtered.
919
+ {
920
+ key: "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt",
921
+ size: 44,
922
+ lastModified: new Date(),
923
+ etag: '"abc"',
924
+ },
925
+ // Regular file at the same prefix — must still download.
926
+ { key: "docs/notes.md", size: 30, lastModified: new Date(), etag: '"def"' },
927
+ ]);
928
+
929
+ const result = await sync({
930
+ company: "acme",
931
+ vaultConfig: mockConfig,
932
+ hqRoot: tmpDir,
933
+ });
934
+
935
+ // The ephemeral file MUST NOT be downloaded — at any path.
936
+ const companyRoot = path.join(tmpDir, "companies", "acme");
937
+ expect(
938
+ fs.existsSync(
939
+ path.join(companyRoot, "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt"),
940
+ ),
941
+ ).toBe(false);
942
+ // The legitimate file MUST download.
943
+ expect(fs.existsSync(path.join(companyRoot, "docs", "notes.md"))).toBe(true);
944
+
945
+ expect(result.filesDownloaded).toBe(1);
946
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
947
+ });
948
+
420
949
  it("overwrites local on --on-conflict overwrite", async () => {
421
950
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
422
951
  fs.mkdirSync(companyDocs, { recursive: true });