@indigoai-us/hq-cloud 5.32.0 → 5.34.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.
Files changed (60) hide show
  1. package/dist/bin/sync-runner.d.ts +9 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +53 -27
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +69 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +60 -4
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +129 -8
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +104 -6
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +20 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +260 -7
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +469 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/ignore.d.ts.map +1 -1
  20. package/dist/ignore.js +7 -1
  21. package/dist/ignore.js.map +1 -1
  22. package/dist/ignore.test.js +19 -3
  23. package/dist/ignore.test.js.map +1 -1
  24. package/dist/lib/conflict-file.d.ts +7 -6
  25. package/dist/lib/conflict-file.d.ts.map +1 -1
  26. package/dist/lib/conflict-file.js +7 -27
  27. package/dist/lib/conflict-file.js.map +1 -1
  28. package/dist/lib/conflict.test.d.ts +4 -3
  29. package/dist/lib/conflict.test.d.ts.map +1 -1
  30. package/dist/lib/conflict.test.js +5 -33
  31. package/dist/lib/conflict.test.js.map +1 -1
  32. package/dist/lib/machine-id.d.ts +108 -0
  33. package/dist/lib/machine-id.d.ts.map +1 -0
  34. package/dist/lib/machine-id.js +170 -0
  35. package/dist/lib/machine-id.js.map +1 -0
  36. package/dist/lib/machine-id.test.d.ts +8 -0
  37. package/dist/lib/machine-id.test.d.ts.map +1 -0
  38. package/dist/lib/machine-id.test.js +195 -0
  39. package/dist/lib/machine-id.test.js.map +1 -0
  40. package/dist/s3.d.ts +21 -0
  41. package/dist/s3.d.ts.map +1 -1
  42. package/dist/s3.js +69 -2
  43. package/dist/s3.js.map +1 -1
  44. package/dist/s3.test.js +129 -2
  45. package/dist/s3.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/bin/sync-runner.test.ts +85 -0
  48. package/src/bin/sync-runner.ts +62 -25
  49. package/src/cli/share.test.ts +115 -6
  50. package/src/cli/share.ts +149 -9
  51. package/src/cli/sync.test.ts +529 -0
  52. package/src/cli/sync.ts +295 -8
  53. package/src/ignore.test.ts +20 -3
  54. package/src/ignore.ts +7 -1
  55. package/src/lib/conflict-file.ts +7 -27
  56. package/src/lib/conflict.test.ts +4 -40
  57. package/src/lib/machine-id.test.ts +221 -0
  58. package/src/lib/machine-id.ts +175 -0
  59. package/src/s3.test.ts +142 -2
  60. package/src/s3.ts +71 -2
@@ -350,6 +350,475 @@ describe("sync", () => {
350
350
  expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
351
351
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
352
352
  });
353
+ it("applies cross-machine deletes via journal-vs-remote-LIST diff (Bug #9 — tombstone propagation)", async () => {
354
+ // Bug #9 (data-loss class — never reaches consistency): peer A
355
+ // deleted a file and pushed; the push side correctly removed the
356
+ // object from S3 (verified post-push with \`aws s3 ls\`). Receiver
357
+ // B's pull then enumerated the remote LIST, didn't see the key, and
358
+ // did NOTHING — the pull walker never consumed "absence from remote"
359
+ // as a delete signal. The file stayed on B forever and
360
+ // \`filesTombstoned: 0\` showed up on every pull (cross-machine
361
+ // delete-propagation impossible).
362
+ //
363
+ // Fix: walk the journal, find every entry whose key is missing from
364
+ // the remote LIST, and apply each as a local delete + journal
365
+ // removal. Same ignore-filter + personalMode gating applied so a
366
+ // path the operator just added to .hqignore can't trigger mass-
367
+ // delete. Excludes ephemeral conflict-mirror paths.
368
+ const companyRoot = path.join(tmpDir, "companies", "acme");
369
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
370
+ // Local file is journal-known but no longer in S3 (peer deleted it).
371
+ fs.writeFileSync(path.join(companyRoot, "docs", "deleted-by-peer.md"), "old content");
372
+ // Local file matches a remote entry — stays after the pull.
373
+ fs.writeFileSync(path.join(companyRoot, "docs", "kept.md"), "current");
374
+ // Compute real sha256 baselines so the local-edit-divergence check
375
+ // (Codex P1 round 3) sees the local files as matching the journal
376
+ // baseline and proceeds with tombstone. Fake-string hashes would
377
+ // be classified as "diverged → defer" by that guard.
378
+ const crypto = await import("node:crypto");
379
+ const baselineDeleted = crypto
380
+ .createHash("sha256")
381
+ .update("old content")
382
+ .digest("hex");
383
+ const baselineKept = crypto
384
+ .createHash("sha256")
385
+ .update("current")
386
+ .digest("hex");
387
+ // Seed the journal as if the receiver had previously synced both files.
388
+ fs.writeFileSync(journalPath, JSON.stringify({
389
+ version: "1",
390
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
391
+ files: {
392
+ "docs/deleted-by-peer.md": {
393
+ hash: baselineDeleted,
394
+ size: 11,
395
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
396
+ direction: "down",
397
+ remoteEtag: "remote-old",
398
+ },
399
+ "docs/kept.md": {
400
+ hash: baselineKept,
401
+ size: 7,
402
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
403
+ direction: "down",
404
+ remoteEtag: "remote-current",
405
+ },
406
+ },
407
+ }));
408
+ // Remote LIST contains kept.md only — deleted-by-peer.md is gone.
409
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
410
+ {
411
+ key: "docs/kept.md",
412
+ size: 7,
413
+ lastModified: new Date(),
414
+ etag: '"remote-current"',
415
+ },
416
+ ]);
417
+ const result = await sync({
418
+ company: "acme",
419
+ vaultConfig: mockConfig,
420
+ hqRoot: tmpDir,
421
+ });
422
+ // The peer-deleted file MUST be removed locally.
423
+ expect(fs.existsSync(path.join(companyRoot, "docs", "deleted-by-peer.md"))).toBe(false);
424
+ // The kept file MUST stay.
425
+ expect(fs.existsSync(path.join(companyRoot, "docs", "kept.md"))).toBe(true);
426
+ // Tombstone counter exposed for the runner's `complete` event.
427
+ expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
428
+ // Journal entry for the deleted path must be gone too — otherwise
429
+ // the next sync would tombstone the same key on every run.
430
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
431
+ expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
432
+ expect(journal.files["docs/kept.md"]).toBeDefined();
433
+ });
434
+ it("does NOT tombstone keys whose local copy has diverged from the journal (Codex P1 — delete-vs-edit race)", async () => {
435
+ // Codex review on PR #24 round 3 caught: in a delete-vs-local-edit
436
+ // race, peer A deletes a file remotely while peer B edits it
437
+ // locally before the next pull. Pre-fix the tombstone executor
438
+ // unlinked the locally-edited file and dropped the journal entry,
439
+ // silently destroying the operator's unsynced work. Fix: hash the
440
+ // local file before tombstoning; if it diverges from the journal
441
+ // baseline, defer the tombstone so the next push surfaces the
442
+ // divergence (or the operator re-pushes deliberately).
443
+ const companyRoot = path.join(tmpDir, "companies", "acme");
444
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
445
+ const editedPath = path.join(companyRoot, "docs", "edited-locally.md");
446
+ // Local file contents DIFFER from the journal-recorded baseline.
447
+ fs.writeFileSync(editedPath, "unsynced LOCAL edit — must not be deleted");
448
+ // Compute the journal's stale baseline hash from different content.
449
+ const crypto = await import("node:crypto");
450
+ const baselineHash = crypto
451
+ .createHash("sha256")
452
+ .update("original synced content (pre-edit)")
453
+ .digest("hex");
454
+ fs.writeFileSync(journalPath, JSON.stringify({
455
+ version: "1",
456
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
457
+ files: {
458
+ "docs/edited-locally.md": {
459
+ hash: baselineHash,
460
+ size: 33,
461
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
462
+ direction: "down",
463
+ remoteEtag: "e-baseline",
464
+ },
465
+ },
466
+ }));
467
+ // Peer deleted it: LIST is empty AND HEAD returns null (confirmed
468
+ // gone). Without the local-edit check, this would tombstone.
469
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
470
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
471
+ const result = await sync({
472
+ company: "acme",
473
+ vaultConfig: mockConfig,
474
+ hqRoot: tmpDir,
475
+ });
476
+ // Local file with unsynced edits MUST survive.
477
+ expect(fs.existsSync(editedPath)).toBe(true);
478
+ expect(fs.readFileSync(editedPath, "utf-8")).toBe("unsynced LOCAL edit — must not be deleted");
479
+ expect(result.filesTombstoned).toBe(0);
480
+ // Journal entry retained — next sync (push) will re-evaluate and
481
+ // either re-upload the divergent local or surface as a conflict.
482
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
483
+ expect(journal.files["docs/edited-locally.md"]).toBeDefined();
484
+ expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
485
+ });
486
+ it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
487
+ // Codex review on PR #24 round 4 caught: the round-3 local-edit
488
+ // divergence guard only covered regular files (`isFile()` is false
489
+ // for symlinks), so a locally-edited symlink (`ln -sfn new-target
490
+ // old-link` before the peer's remote-delete) fell through the
491
+ // guard and was unlinked silently. Exact same race class —
492
+ // symlinks have target strings that count as content too.
493
+ const companyRoot = path.join(tmpDir, "companies", "acme");
494
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
495
+ const linkPath = path.join(companyRoot, "docs", "link.md");
496
+ // Local symlink now points to a NEW target (the local edit).
497
+ fs.symlinkSync("new-target.md", linkPath);
498
+ // Journal records the OLD target's hash.
499
+ const { hashSymlinkTarget } = await import("../journal.js");
500
+ const baselineHash = hashSymlinkTarget("old-target.md");
501
+ fs.writeFileSync(journalPath, JSON.stringify({
502
+ version: "1",
503
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
504
+ files: {
505
+ "docs/link.md": {
506
+ hash: baselineHash,
507
+ size: 0,
508
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
509
+ direction: "down",
510
+ remoteEtag: "e-link",
511
+ },
512
+ },
513
+ }));
514
+ // Peer deleted remotely.
515
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
516
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
517
+ const result = await sync({
518
+ company: "acme",
519
+ vaultConfig: mockConfig,
520
+ hqRoot: tmpDir,
521
+ });
522
+ // Locally-edited symlink MUST survive + still point at the new
523
+ // target. Use lstatSync — fs.existsSync follows symlinks and
524
+ // returns false for a dangling link, hiding the survival signal.
525
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
526
+ expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
527
+ expect(result.filesTombstoned).toBe(0);
528
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
529
+ expect(journal.files["docs/link.md"]).toBeDefined();
530
+ expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
531
+ });
532
+ it("does NOT tombstone keys that HEAD-verify as still present (Codex P1 — STS scope gating)", async () => {
533
+ // Codex review on PR #24 caught: `listRemoteFiles` is STS-scoped — a
534
+ // guest session with `allowedPrefixes`, a downgraded role, or any
535
+ // custom sync-mode that narrows prefix coverage can return a LIST
536
+ // that does NOT include keys still genuinely present in the bucket.
537
+ // Pre-fix the tombstone planner classified absence-from-LIST as
538
+ // "peer deleted it"; the executor then unlinked local files for
539
+ // keys outside the session's visibility. Data loss + journal-
540
+ // baseline loss for the next broader-scope sync.
541
+ //
542
+ // Fix: HEAD-verify each candidate. HEAD returns metadata → key
543
+ // exists, just invisible to LIST → SKIP. HEAD returns null
544
+ // (NotFound) → confirmed deleted → proceed.
545
+ const companyRoot = path.join(tmpDir, "companies", "acme");
546
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
547
+ const invisiblePath = path.join(companyRoot, "docs", "invisible-to-list.md");
548
+ const reallyDeletedPath = path.join(companyRoot, "docs", "really-deleted.md");
549
+ fs.writeFileSync(invisiblePath, "still on bucket");
550
+ fs.writeFileSync(reallyDeletedPath, "peer removed");
551
+ // Real sha256 baselines so the local-edit-divergence guard
552
+ // (Codex P1 round 3) doesn't defer both as "diverged".
553
+ const crypto = await import("node:crypto");
554
+ const hInv = crypto.createHash("sha256").update("still on bucket").digest("hex");
555
+ const hDel = crypto.createHash("sha256").update("peer removed").digest("hex");
556
+ fs.writeFileSync(journalPath, JSON.stringify({
557
+ version: "1",
558
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
559
+ files: {
560
+ "docs/invisible-to-list.md": {
561
+ hash: hInv,
562
+ size: 15,
563
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
564
+ direction: "down",
565
+ remoteEtag: "e-inv",
566
+ },
567
+ "docs/really-deleted.md": {
568
+ hash: hDel,
569
+ size: 12,
570
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
571
+ direction: "down",
572
+ remoteEtag: "e-del",
573
+ },
574
+ },
575
+ }));
576
+ // LIST is empty (narrowed-scope session); both keys are absent.
577
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
578
+ // HEAD says invisible-to-list still exists; really-deleted is gone.
579
+ vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
580
+ if (key === "docs/invisible-to-list.md") {
581
+ return { lastModified: new Date(), etag: "e-inv", size: 15 };
582
+ }
583
+ return null; // NotFound → safe to tombstone
584
+ });
585
+ const result = await sync({
586
+ company: "acme",
587
+ vaultConfig: mockConfig,
588
+ hqRoot: tmpDir,
589
+ });
590
+ // The invisible-but-still-present file MUST be retained.
591
+ expect(fs.existsSync(invisiblePath)).toBe(true);
592
+ // The really-deleted file MUST be tombstoned.
593
+ expect(fs.existsSync(reallyDeletedPath)).toBe(false);
594
+ expect(result.filesTombstoned).toBe(1);
595
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
596
+ // Journal entry retained for the invisible key (so next sync at
597
+ // broader scope can re-evaluate).
598
+ expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
599
+ expect(journal.files["docs/invisible-to-list.md"].hash).toBe(hInv);
600
+ // Confirmed-deleted entry dropped.
601
+ expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
602
+ });
603
+ it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
604
+ // Same Codex P1, AccessDenied branch: a guest STS session may deny
605
+ // both LIST and HEAD on out-of-scope keys. The tombstone planner
606
+ // must treat AccessDenied as "can't tell — defer" not as
607
+ // "confirmed deleted". Otherwise narrow-scope sessions would still
608
+ // tombstone keys they can't even see.
609
+ const companyRoot = path.join(tmpDir, "companies", "acme");
610
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
611
+ const deniedPath = path.join(companyRoot, "docs", "out-of-scope.md");
612
+ fs.writeFileSync(deniedPath, "exists but denied");
613
+ fs.writeFileSync(journalPath, JSON.stringify({
614
+ version: "1",
615
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
616
+ files: {
617
+ "docs/out-of-scope.md": {
618
+ hash: "h-denied",
619
+ size: 17,
620
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
621
+ direction: "down",
622
+ remoteEtag: "e-denied",
623
+ },
624
+ },
625
+ }));
626
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
627
+ const denied = new Error("Access Denied");
628
+ denied.name = "AccessDenied";
629
+ vi.mocked(s3Module.headRemoteFile).mockRejectedValueOnce(denied);
630
+ const result = await sync({
631
+ company: "acme",
632
+ vaultConfig: mockConfig,
633
+ hqRoot: tmpDir,
634
+ });
635
+ expect(fs.existsSync(deniedPath)).toBe(true);
636
+ expect(result.filesTombstoned).toBe(0);
637
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
638
+ expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
639
+ });
640
+ it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
641
+ // Codex review on PR #24 caught this: pre-fix the tombstone branch
642
+ // dropped the journal entry unconditionally even when the local
643
+ // unlink failed with a non-ENOENT error (EACCES / EPERM / EBUSY).
644
+ // The pull side then forgot the peer's delete, and on a subsequent
645
+ // sync the still-present local file would be classified as a fresh
646
+ // upload candidate and re-pushed to S3 — silently undoing the
647
+ // peer's delete. ENOENT is safe to drop (local is already gone),
648
+ // but any other error MUST keep the journal entry so the next sync
649
+ // retries after the operator fixes the permission.
650
+ const companyRoot = path.join(tmpDir, "companies", "acme");
651
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
652
+ const lockedPath = path.join(companyRoot, "docs", "locked-by-peer.md");
653
+ fs.writeFileSync(lockedPath, "still here");
654
+ fs.writeFileSync(journalPath, JSON.stringify({
655
+ version: "1",
656
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
657
+ files: {
658
+ "docs/locked-by-peer.md": {
659
+ hash: "locked-hash",
660
+ size: 10,
661
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
662
+ direction: "down",
663
+ remoteEtag: "remote-locked",
664
+ },
665
+ },
666
+ }));
667
+ // Remote LIST is empty → tombstone candidate.
668
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
669
+ // Strip write perms from the parent dir so the unlink throws EACCES
670
+ // (and EPERM on some POSIX variants — both must be retained). The
671
+ // dir read perm is retained so the planner can still lstat the file.
672
+ const parentDir = path.join(companyRoot, "docs");
673
+ fs.chmodSync(parentDir, 0o500);
674
+ try {
675
+ const result = await sync({
676
+ company: "acme",
677
+ vaultConfig: mockConfig,
678
+ hqRoot: tmpDir,
679
+ });
680
+ // Local file is still present (unlink failed).
681
+ expect(fs.existsSync(lockedPath)).toBe(true);
682
+ // Tombstone NOT counted — the delete wasn't honored.
683
+ expect(result.filesTombstoned).toBe(0);
684
+ // Journal entry MUST be retained so the next sync retries.
685
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
686
+ expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
687
+ expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
688
+ }
689
+ finally {
690
+ // Restore write perms so afterEach rmSync can clean the tmp dir.
691
+ fs.chmodSync(parentDir, 0o700);
692
+ }
693
+ });
694
+ it("does NOT tombstone keys that are now ignored by .hqignore (safety guard)", async () => {
695
+ // If the operator just added a path to .hqignore, the local file
696
+ // shouldn't be deleted on the next sync because "remote no longer has
697
+ // it" (the push side stopped uploading it too). Skip-tombstone any
698
+ // key the current ignore filter rejects.
699
+ const companyRoot = path.join(tmpDir, "companies", "acme");
700
+ fs.mkdirSync(companyRoot, { recursive: true });
701
+ fs.writeFileSync(path.join(companyRoot, "secret.txt"), "stays");
702
+ // Ignore secret.txt going forward.
703
+ fs.writeFileSync(path.join(tmpDir, ".hqignore"), "secret.txt\n");
704
+ // Journal says we previously synced it.
705
+ fs.writeFileSync(journalPath, JSON.stringify({
706
+ version: "1",
707
+ lastSync: new Date().toISOString(),
708
+ files: {
709
+ "secret.txt": {
710
+ hash: "old-hash",
711
+ size: 5,
712
+ syncedAt: new Date().toISOString(),
713
+ direction: "down",
714
+ remoteEtag: "etag",
715
+ },
716
+ },
717
+ }));
718
+ // Remote LIST is empty.
719
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
720
+ const result = await sync({
721
+ company: "acme",
722
+ vaultConfig: mockConfig,
723
+ hqRoot: tmpDir,
724
+ });
725
+ // The ignored file MUST NOT be tombstoned — operator's choice.
726
+ expect(fs.existsSync(path.join(companyRoot, "secret.txt"))).toBe(true);
727
+ expect(result.filesTombstoned).toBe(0);
728
+ });
729
+ it("dir-vs-file collision (local-file, cloud-dir): warns, continues, no ENOTDIR crash (Bug #10)", async () => {
730
+ // The 5.33.0 verification report's V10/Bug #10: cloud has a directory
731
+ // at \`v4-dir-vs-file/inside.txt\`, but local has a regular FILE at
732
+ // \`v4-dir-vs-file\`. Pre-fix, computePullPlan called \`lstatSync\` on the
733
+ // file-as-if-it-were-a-dir path and threw uncaught ENOTDIR, aborting
734
+ // the entire company sync — every later file (including an unrelated
735
+ // direct-injected v2 file) was skipped. Personal company status:
736
+ // \"errored\". Critical regression vector — one collision wedged the
737
+ // whole company.
738
+ //
739
+ // Fix: try/catch the lstat; on ENOTDIR, emit the same "manual
740
+ // reconciliation required" surface as the existing local-dir/cloud-
741
+ // file warning and continue with the rest of the LIST.
742
+ const companyRoot = path.join(tmpDir, "companies", "acme");
743
+ fs.mkdirSync(companyRoot, { recursive: true });
744
+ // Local has a regular file where cloud has a directory.
745
+ fs.writeFileSync(path.join(companyRoot, "v4-dir-vs-file"), "local file content");
746
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
747
+ // The cloud-dir entry that would crash lstat(...localPath).
748
+ { key: "v4-dir-vs-file/inside.txt", size: 20, lastModified: new Date(), etag: '"a"' },
749
+ // An unrelated file at a sibling prefix — must STILL download after the
750
+ // collision is detected and skipped, proving the company isn't wedged.
751
+ { key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
752
+ ]);
753
+ const result = await sync({
754
+ company: "acme",
755
+ vaultConfig: mockConfig,
756
+ hqRoot: tmpDir,
757
+ });
758
+ // No crash, no aborted: true.
759
+ expect(result.aborted).toBe(false);
760
+ // The collision entry is skipped (counted as skip-local-only).
761
+ // The sibling file MUST have downloaded — sync is not wedged.
762
+ expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
763
+ expect(result.filesDownloaded).toBe(1);
764
+ });
765
+ it("dir-vs-file collision (local-dir, cloud-file): warns, continues, no crash (Bug #4)", async () => {
766
+ // Symmetric to Bug #10: cloud has a single object at a key where local
767
+ // has a directory. The existing code already warned + skipped this case;
768
+ // this test pins the contract so a future refactor doesn't regress it
769
+ // alongside the Bug #10 fix in the opposite topology.
770
+ const companyRoot = path.join(tmpDir, "companies", "acme");
771
+ fs.mkdirSync(path.join(companyRoot, "v4-collision"), { recursive: true });
772
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
773
+ { key: "v4-collision", size: 50, lastModified: new Date(), etag: '"a"' },
774
+ { key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
775
+ ]);
776
+ const result = await sync({
777
+ company: "acme",
778
+ vaultConfig: mockConfig,
779
+ hqRoot: tmpDir,
780
+ });
781
+ expect(result.aborted).toBe(false);
782
+ // The sibling MUST land — wedge regression test.
783
+ expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
784
+ expect(result.filesDownloaded).toBe(1);
785
+ });
786
+ it("skips remote keys matching EPHEMERAL_PATH_PATTERN (Bug #2 — pull-side ephemeral filter)", async () => {
787
+ // The push side has refused to upload conflict-mirror files since 5.33.0
788
+ // (see `EPHEMERAL_PATH_PATTERN` + collectFiles/walkDir/computeDeletePlan).
789
+ // But the pull walker never filtered the same pattern — so legacy
790
+ // `.conflict-*` files already in cloud staging were downloaded into
791
+ // clean trees on every sync. The verification report's V2 test proved
792
+ // this with a direct `aws s3 cp` inject and observed
793
+ // `filesExcludedByPolicy: 0` on the pull, even though every push-side
794
+ // walker would have refused the same key.
795
+ //
796
+ // Fix: classify ephemeral remote keys as skip-excluded at planning
797
+ // time, increment `filesExcludedByPolicy`, and never call downloadFile.
798
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
799
+ // Legacy conflict mirror — must be filtered.
800
+ {
801
+ key: "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt",
802
+ size: 44,
803
+ lastModified: new Date(),
804
+ etag: '"abc"',
805
+ },
806
+ // Regular file at the same prefix — must still download.
807
+ { key: "docs/notes.md", size: 30, lastModified: new Date(), etag: '"def"' },
808
+ ]);
809
+ const result = await sync({
810
+ company: "acme",
811
+ vaultConfig: mockConfig,
812
+ hqRoot: tmpDir,
813
+ });
814
+ // The ephemeral file MUST NOT be downloaded — at any path.
815
+ const companyRoot = path.join(tmpDir, "companies", "acme");
816
+ expect(fs.existsSync(path.join(companyRoot, "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt"))).toBe(false);
817
+ // The legitimate file MUST download.
818
+ expect(fs.existsSync(path.join(companyRoot, "docs", "notes.md"))).toBe(true);
819
+ expect(result.filesDownloaded).toBe(1);
820
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
821
+ });
353
822
  it("overwrites local on --on-conflict overwrite", async () => {
354
823
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
355
824
  fs.mkdirSync(companyDocs, { recursive: true });