@indigoai-us/hq-cloud 6.14.8 → 6.14.10

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 (67) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +8 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +15 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner.d.ts +12 -0
  9. package/dist/bin/sync-runner.d.ts.map +1 -1
  10. package/dist/bin/sync-runner.js +26 -0
  11. package/dist/bin/sync-runner.js.map +1 -1
  12. package/dist/bin/sync-runner.test.js +68 -1
  13. package/dist/bin/sync-runner.test.js.map +1 -1
  14. package/dist/cli/share.d.ts.map +1 -1
  15. package/dist/cli/share.js +89 -14
  16. package/dist/cli/share.js.map +1 -1
  17. package/dist/cli/share.test.js +105 -3
  18. package/dist/cli/share.test.js.map +1 -1
  19. package/dist/cli/sync-scope.test.js +3 -1
  20. package/dist/cli/sync-scope.test.js.map +1 -1
  21. package/dist/cli/sync.d.ts +7 -7
  22. package/dist/cli/sync.d.ts.map +1 -1
  23. package/dist/cli/sync.js +166 -82
  24. package/dist/cli/sync.js.map +1 -1
  25. package/dist/cli/sync.test.js +292 -27
  26. package/dist/cli/sync.test.js.map +1 -1
  27. package/dist/journal.d.ts +7 -1
  28. package/dist/journal.d.ts.map +1 -1
  29. package/dist/journal.js +22 -1
  30. package/dist/journal.js.map +1 -1
  31. package/dist/manifest-reconcile.d.ts +27 -0
  32. package/dist/manifest-reconcile.d.ts.map +1 -0
  33. package/dist/manifest-reconcile.js +120 -0
  34. package/dist/manifest-reconcile.js.map +1 -0
  35. package/dist/manifest-reconcile.test.d.ts +2 -0
  36. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  37. package/dist/manifest-reconcile.test.js +97 -0
  38. package/dist/manifest-reconcile.test.js.map +1 -0
  39. package/dist/personal-vault.d.ts +6 -0
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +12 -0
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/s3.d.ts +15 -2
  44. package/dist/s3.d.ts.map +1 -1
  45. package/dist/s3.js +54 -19
  46. package/dist/s3.js.map +1 -1
  47. package/dist/s3.test.js +12 -1
  48. package/dist/s3.test.js.map +1 -1
  49. package/dist/types.d.ts +13 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/bin/sync-runner-company.ts +7 -0
  53. package/src/bin/sync-runner-planning.ts +16 -2
  54. package/src/bin/sync-runner.test.ts +82 -1
  55. package/src/bin/sync-runner.ts +43 -0
  56. package/src/cli/share.test.ts +129 -3
  57. package/src/cli/share.ts +114 -15
  58. package/src/cli/sync-scope.test.ts +3 -1
  59. package/src/cli/sync.test.ts +352 -26
  60. package/src/cli/sync.ts +226 -103
  61. package/src/journal.ts +28 -0
  62. package/src/manifest-reconcile.test.ts +107 -0
  63. package/src/manifest-reconcile.ts +160 -0
  64. package/src/personal-vault.ts +15 -0
  65. package/src/s3.test.ts +18 -0
  66. package/src/s3.ts +67 -30
  67. package/src/types.ts +13 -0
@@ -8,7 +8,8 @@ import * as os from "os";
8
8
  import { clearContextCache } from "../context.js";
9
9
  import { lockPathFor } from "../operation-lock.js";
10
10
  // Mock s3 module at the top level
11
- vi.mock("../s3.js", async () => {
11
+ vi.mock("../s3.js", async (importOriginal) => {
12
+ const actual = await importOriginal();
12
13
  const { vi: innerVi } = await import("vitest");
13
14
  const innerFs = await import("fs");
14
15
  const innerPath = await import("path");
@@ -17,12 +18,15 @@ vi.mock("../s3.js", async () => {
17
18
  { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
18
19
  ];
19
20
  return {
21
+ ...actual,
20
22
  toPosixKey: (key) => key.split("\\").join("/"),
21
23
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
22
- downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath) => {
24
+ uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
25
+ downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath, options) => {
23
26
  const dir = innerPath.dirname(localPath);
24
27
  if (!innerFs.existsSync(dir))
25
28
  innerFs.mkdirSync(dir, { recursive: true });
29
+ options?.beforeReplace?.();
26
30
  innerFs.writeFileSync(localPath, "mock file content");
27
31
  return { metadata: {} };
28
32
  }),
@@ -30,6 +34,7 @@ vi.mock("../s3.js", async () => {
30
34
  deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
31
35
  headRemoteFile: innerVi.fn().mockResolvedValue(null),
32
36
  primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
37
+ primeUploads: innerVi.fn().mockResolvedValue(undefined),
33
38
  };
34
39
  });
35
40
  // Mock the post-sync refresh so tests neither spawn the real reindex.sh nor
@@ -38,6 +43,7 @@ vi.mock("./reindex.js", () => ({
38
43
  reindex: vi.fn(() => ({ status: 0 })),
39
44
  }));
40
45
  import { sync, reportNewFilesToNotify } from "./sync.js";
46
+ import { share } from "./share.js";
41
47
  import * as s3Module from "../s3.js";
42
48
  import { reindex } from "./reindex.js";
43
49
  import { readSyncProgress } from "../sync-progress.js";
@@ -123,6 +129,9 @@ describe("sync", () => {
123
129
  fs.rmSync(stateDir, { recursive: true, force: true });
124
130
  delete process.env.HQ_STATE_DIR;
125
131
  });
132
+ it("keeps classifyVaultKey available from the partial S3 mock", () => {
133
+ expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
134
+ });
126
135
  it("runs reindex against hqRoot after a sync that downloaded files", async () => {
127
136
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
128
137
  // skipLock: the surrounding sync run already holds the per-root lock.
@@ -504,6 +513,8 @@ describe("sync", () => {
504
513
  const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
505
514
  fs.writeFileSync(localPath, localContent);
506
515
  const st = fs.statSync(localPath);
516
+ const crypto = await import("node:crypto");
517
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
507
518
  // Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
508
519
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
509
520
  { key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"regressed-etag"' },
@@ -520,7 +531,7 @@ describe("sync", () => {
520
531
  lastSync: new Date().toISOString(),
521
532
  files: {
522
533
  "docs/handoff.md": {
523
- hash: "clean-local-baseline-hash",
534
+ hash: localHash,
524
535
  size: st.size,
525
536
  mtimeMs: st.mtimeMs,
526
537
  remoteEtag: "old-etag",
@@ -559,6 +570,8 @@ describe("sync", () => {
559
570
  const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
560
571
  fs.writeFileSync(localPath, localContent);
561
572
  const st = fs.statSync(localPath);
573
+ const crypto = await import("node:crypto");
574
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
562
575
  // Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
563
576
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
564
577
  { key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"shorter-etag"' },
@@ -568,7 +581,7 @@ describe("sync", () => {
568
581
  lastSync: new Date().toISOString(),
569
582
  files: {
570
583
  "docs/handoff.md": {
571
- hash: "clean-local-baseline-hash",
584
+ hash: localHash,
572
585
  size: st.size,
573
586
  mtimeMs: st.mtimeMs,
574
587
  remoteEtag: "old-etag",
@@ -602,6 +615,8 @@ describe("sync", () => {
602
615
  const localContent = "short local\n";
603
616
  fs.writeFileSync(localPath, localContent);
604
617
  const st = fs.statSync(localPath);
618
+ const crypto = await import("node:crypto");
619
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
605
620
  // Remote is LARGER with a changed etag → legitimate advance.
606
621
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
607
622
  { key: "docs/handoff.md", size: 9999, lastModified: new Date(), etag: '"advanced-etag"' },
@@ -611,7 +626,7 @@ describe("sync", () => {
611
626
  lastSync: new Date().toISOString(),
612
627
  files: {
613
628
  "docs/handoff.md": {
614
- hash: "clean-local-baseline-hash",
629
+ hash: localHash,
615
630
  size: st.size,
616
631
  mtimeMs: st.mtimeMs,
617
632
  remoteEtag: "old-etag",
@@ -793,12 +808,9 @@ describe("sync", () => {
793
808
  expect(journal.files[untrackedKey]).toBeUndefined();
794
809
  expect(journal.files[trackedKey]).toBeUndefined();
795
810
  });
796
- it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
797
- // Repro of the intermittent delete-respawn: the user deleted a clean file,
798
- // its delete has not (yet) cleared the S3 object, and the SERVER
799
- // FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
800
- // The only intent record is the LOCAL journal tombstone (`removedAt`).
801
- // The drift-restore path must honor it and NOT re-download the key.
811
+ it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
812
+ // A legacy `removedReason:local-delete` was inferred from an ENOENT. It
813
+ // has no version-bound explicit intent, so it must never suppress a pull.
802
814
  const key = "docs/handoff.md";
803
815
  const localPath = path.join(tmpDir, "companies", "acme", key);
804
816
  // Remote still lists the object, etag matching the journal, lastModified
@@ -838,15 +850,10 @@ describe("sync", () => {
838
850
  hqRoot: tmpDir,
839
851
  onEvent: () => { },
840
852
  });
841
- // The intentionally-deleted key must NOT be re-materialised.
842
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
843
- expect(fs.existsSync(localPath)).toBe(false);
853
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
854
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
844
855
  });
845
- it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
846
- // The user deleted a genuinely-synced file. Its entry is clean (etag matches
847
- // the current remote — currency holds), not divergent. The pull leg must
848
- // treat the absence as an intentional delete: stamp a persisting
849
- // `local-delete` tombstone and SKIP the re-download — not resurrect it.
856
+ it("does not infer local-delete intent from a single missing file", async () => {
850
857
  const key = "docs/gone.md";
851
858
  const localPath = path.join(tmpDir, "companies", "acme", key);
852
859
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -868,11 +875,10 @@ describe("sync", () => {
868
875
  pulls: [],
869
876
  }));
870
877
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
871
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
872
- expect(fs.existsSync(localPath)).toBe(false);
878
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
879
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
873
880
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
874
- expect(journal.files[key]?.removedAt).toBeTruthy();
875
- expect(journal.files[key]?.removedReason).toBe("local-delete");
881
+ expect(journal.files[key]?.removedAt).toBeUndefined();
876
882
  });
877
883
  it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
878
884
  // 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
@@ -888,7 +894,14 @@ describe("sync", () => {
888
894
  size: 10,
889
895
  syncedAt: new Date().toISOString(),
890
896
  direction: "down",
897
+ kind: "file",
891
898
  remoteEtag: `e-${k}`,
899
+ localDeleteIntent: {
900
+ version: 1,
901
+ remoteEtag: `e-${k}`,
902
+ localHash: "h",
903
+ localKind: "file",
904
+ },
892
905
  };
893
906
  }
894
907
  fs.writeFileSync(journalPath, JSON.stringify({
@@ -924,9 +937,16 @@ describe("sync", () => {
924
937
  size: 42,
925
938
  syncedAt: new Date().toISOString(),
926
939
  direction: "down",
940
+ kind: "file",
927
941
  remoteEtag: "cur123",
928
942
  removedAt: new Date().toISOString(),
929
943
  removedReason: "local-delete",
944
+ localDeleteIntent: {
945
+ version: 1,
946
+ remoteEtag: "cur123",
947
+ localHash: "h",
948
+ localKind: "file",
949
+ },
930
950
  },
931
951
  },
932
952
  pulls: [],
@@ -1460,6 +1480,207 @@ describe("sync", () => {
1460
1480
  expect(journal.files["docs/racy-delete.md"]).toBeDefined();
1461
1481
  expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
1462
1482
  });
1483
+ it("CAS: preserves a file created after a remote-delete plan", async () => {
1484
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1485
+ const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
1486
+ const baseline = "synced baseline";
1487
+ const crypto = await import("node:crypto");
1488
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1489
+ fs.writeFileSync(journalPath, JSON.stringify({
1490
+ version: "2",
1491
+ lastSync: new Date().toISOString(),
1492
+ files: {
1493
+ "docs/created-after-plan.md": {
1494
+ hash: baselineHash,
1495
+ size: baseline.length,
1496
+ syncedAt: new Date().toISOString(),
1497
+ direction: "down",
1498
+ kind: "file",
1499
+ remoteEtag: "before-delete",
1500
+ },
1501
+ },
1502
+ pulls: [],
1503
+ }));
1504
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
1505
+ vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
1506
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1507
+ // Same bytes as the journal baseline: hash-only revalidation would unlink
1508
+ // this newly-created file, but the planned snapshot was "absent".
1509
+ fs.writeFileSync(localPath, baseline);
1510
+ return null;
1511
+ });
1512
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1513
+ expect(result.filesTombstoned).toBe(0);
1514
+ expect(result.conflicts).toBe(1);
1515
+ expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
1516
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1517
+ expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
1518
+ });
1519
+ it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
1520
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1521
+ const localPath = path.join(companyRoot, "docs", "staged-race.md");
1522
+ const baseline = "synced baseline";
1523
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1524
+ fs.writeFileSync(localPath, baseline);
1525
+ const crypto = await import("node:crypto");
1526
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1527
+ fs.writeFileSync(journalPath, JSON.stringify({
1528
+ version: "2",
1529
+ lastSync: new Date().toISOString(),
1530
+ files: {
1531
+ "docs/staged-race.md": {
1532
+ hash: baselineHash,
1533
+ size: baseline.length,
1534
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1535
+ direction: "down",
1536
+ kind: "file",
1537
+ remoteEtag: "old-etag",
1538
+ },
1539
+ },
1540
+ pulls: [],
1541
+ }));
1542
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1543
+ { key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
1544
+ ]);
1545
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination, options) => {
1546
+ fs.writeFileSync(destination, "concurrent local edit");
1547
+ options?.beforeReplace?.();
1548
+ fs.writeFileSync(destination, "remote replacement");
1549
+ return { metadata: {} };
1550
+ });
1551
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1552
+ expect(result.filesDownloaded).toBe(0);
1553
+ expect(result.conflicts).toBe(1);
1554
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
1555
+ });
1556
+ it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
1557
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
1558
+ const intent = (remoteEtag) => ({
1559
+ version: 1,
1560
+ remoteEtag,
1561
+ localHash: "synced-hash",
1562
+ localKind: "file",
1563
+ });
1564
+ fs.writeFileSync(personalJournalPath, JSON.stringify({
1565
+ version: "2",
1566
+ lastSync: new Date().toISOString(),
1567
+ files: {
1568
+ "core/core.yaml": {
1569
+ hash: "synced-hash",
1570
+ size: 1,
1571
+ syncedAt: new Date().toISOString(),
1572
+ direction: "up",
1573
+ kind: "file",
1574
+ remoteEtag: "core-etag",
1575
+ localDeleteIntent: intent("core-etag"),
1576
+ },
1577
+ ".claude/skills/core:demo/SKILL.md": {
1578
+ hash: "synced-hash",
1579
+ size: 1,
1580
+ syncedAt: new Date().toISOString(),
1581
+ direction: "up",
1582
+ kind: "file",
1583
+ remoteEtag: "mirror-etag",
1584
+ localDeleteIntent: intent("mirror-etag"),
1585
+ },
1586
+ },
1587
+ pulls: [],
1588
+ }));
1589
+ await share({
1590
+ company: "acme",
1591
+ vaultConfig: mockConfig,
1592
+ hqRoot: tmpDir,
1593
+ paths: [tmpDir],
1594
+ personalMode: true,
1595
+ journalSlug: "personal-delete",
1596
+ propagateDeletes: true,
1597
+ propagateDeletePolicy: "all",
1598
+ });
1599
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1600
+ const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1601
+ expect(journal.files["core/core.yaml"]).toBeDefined();
1602
+ expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
1603
+ });
1604
+ it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
1605
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1606
+ fs.mkdirSync(companyRoot, { recursive: true });
1607
+ const entry = (remoteEtag) => ({
1608
+ hash: "synced-hash",
1609
+ size: 1,
1610
+ syncedAt: new Date().toISOString(),
1611
+ direction: "up",
1612
+ kind: "file",
1613
+ remoteEtag,
1614
+ });
1615
+ fs.writeFileSync(journalPath, JSON.stringify({
1616
+ version: "2",
1617
+ lastSync: new Date().toISOString(),
1618
+ files: {
1619
+ "docs/a.md": entry("a-etag"),
1620
+ "docs/b.md": entry("b-etag"),
1621
+ },
1622
+ pulls: [],
1623
+ }));
1624
+ const result = await share({
1625
+ company: "acme",
1626
+ vaultConfig: mockConfig,
1627
+ hqRoot: tmpDir,
1628
+ paths: [companyRoot],
1629
+ propagateDeletes: true,
1630
+ propagateDeletePolicy: "all",
1631
+ });
1632
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1633
+ expect(result.filesRefusedStale).toBe(2);
1634
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1635
+ expect(journal.files["docs/a.md"]).toBeDefined();
1636
+ expect(journal.files["docs/b.md"]).toBeDefined();
1637
+ });
1638
+ it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
1639
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1640
+ const boardPath = path.join(companyRoot, "board.json");
1641
+ const baseline = '{"items":[]}';
1642
+ const localAppend = '{"items":["local append"]}';
1643
+ fs.mkdirSync(companyRoot, { recursive: true });
1644
+ fs.writeFileSync(boardPath, localAppend);
1645
+ const crypto = await import("node:crypto");
1646
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1647
+ fs.writeFileSync(journalPath, JSON.stringify({
1648
+ version: "2",
1649
+ lastSync: new Date().toISOString(),
1650
+ files: {
1651
+ "board.json": {
1652
+ hash: baselineHash,
1653
+ size: baseline.length,
1654
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1655
+ direction: "up",
1656
+ kind: "file",
1657
+ remoteEtag: "old-etag",
1658
+ },
1659
+ },
1660
+ pulls: [],
1661
+ }));
1662
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
1663
+ etag: "old-etag",
1664
+ size: baseline.length,
1665
+ lastModified: new Date(),
1666
+ });
1667
+ const preconditionError = Object.assign(new Error("remote changed"), {
1668
+ name: "PreconditionFailed",
1669
+ });
1670
+ vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
1671
+ const { isCloudAuthoritative } = await import("../lib/cloud-authoritative.js");
1672
+ expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
1673
+ const result = await share({
1674
+ company: "acme",
1675
+ vaultConfig: mockConfig,
1676
+ hqRoot: tmpDir,
1677
+ paths: [companyRoot],
1678
+ onConflict: "keep",
1679
+ });
1680
+ expect(result.conflictPaths).toContain("board.json");
1681
+ expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
1682
+ expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
1683
+ });
1463
1684
  it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
1464
1685
  // Codex review on PR #24 round 4 caught: the round-3 local-edit
1465
1686
  // divergence guard only covered regular files (`isFile()` is false
@@ -2174,7 +2395,7 @@ describe("sync", () => {
2174
2395
  throw new Error("missing default download mock");
2175
2396
  try {
2176
2397
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2177
- vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath) => {
2398
+ vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
2178
2399
  if (key === "bulk/file-8.md") {
2179
2400
  // This models the next transfer crashing the process. The first
2180
2401
  // eight entries must already be durable before the final writer.
@@ -2182,7 +2403,7 @@ describe("sync", () => {
2182
2403
  expect(Object.keys(checkpoint.files)).toHaveLength(8);
2183
2404
  throw new Error("simulated crash after checkpoint");
2184
2405
  }
2185
- return defaultDownload(ctx, key, localPath);
2406
+ return defaultDownload(ctx, key, localPath, options);
2186
2407
  });
2187
2408
  const first = await sync({
2188
2409
  company: "acme",
@@ -2205,7 +2426,7 @@ describe("sync", () => {
2205
2426
  onEvent: () => undefined,
2206
2427
  });
2207
2428
  expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
2208
- expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String));
2429
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String), expect.objectContaining({ beforeReplace: expect.any(Function) }));
2209
2430
  }
2210
2431
  finally {
2211
2432
  vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
@@ -3206,10 +3427,54 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
3206
3427
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
3207
3428
  const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
3208
3429
  expect(skipEvents).toEqual([
3209
- { type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
3430
+ {
3431
+ type: "skip-invalid-scoped-key",
3432
+ path: "companies/acme/knowledge/poison.md",
3433
+ errorCode: "INVALID_KEY_COMPANIES_SCOPED",
3434
+ },
3210
3435
  ]);
3211
3436
  expect(events.filter((e) => e.type === "error")).toEqual([]);
3212
3437
  });
3438
+ it("skips a control-character remote key before presigning while transferring valid keys", async () => {
3439
+ const invalidKey = "docs/bad\x00key.md";
3440
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
3441
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
3442
+ { key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
3443
+ { key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
3444
+ ]);
3445
+ vi.mocked(s3Module.downloadFile).mockReset();
3446
+ vi.mocked(s3Module.downloadFile).mockImplementation(async (_ctx, key, localPath) => {
3447
+ if (key === invalidKey) {
3448
+ throw Object.assign(new Error("presign rejected invalid key"), {
3449
+ code: "INVALID_KEY_CONTROL_CHARS",
3450
+ });
3451
+ }
3452
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
3453
+ fs.writeFileSync(localPath, "mock file content");
3454
+ return { metadata: {} };
3455
+ });
3456
+ const events = [];
3457
+ const result = await sync({
3458
+ company: "acme",
3459
+ vaultConfig: mockConfig,
3460
+ hqRoot: tmpDir,
3461
+ onEvent: (event) => events.push(event),
3462
+ });
3463
+ expect(result.aborted).toBe(false);
3464
+ expect(result.filesDownloaded).toBe(1);
3465
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md"))).toBe(true);
3466
+ expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
3467
+ "docs/good.md",
3468
+ ]);
3469
+ expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
3470
+ {
3471
+ type: "skip-invalid-scoped-key",
3472
+ path: invalidKey,
3473
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
3474
+ },
3475
+ ]);
3476
+ expect(events.filter((event) => event.type === "error")).toEqual([]);
3477
+ });
3213
3478
  it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
3214
3479
  // A doubled local tree (companies/acme/companies/acme/…) previously
3215
3480
  // poisoned the bucket; the poisoned objects were deleted server-side.