@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
@@ -11,7 +11,8 @@ import type { VaultServiceConfig } from "../types.js";
11
11
  import { lockPathFor } from "../operation-lock.js";
12
12
 
13
13
  // Mock s3 module at the top level
14
- vi.mock("../s3.js", async () => {
14
+ vi.mock("../s3.js", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("../s3.js")>();
15
16
  const { vi: innerVi } = await import("vitest");
16
17
  const innerFs = await import("fs");
17
18
  const innerPath = await import("path");
@@ -22,11 +23,19 @@ vi.mock("../s3.js", async () => {
22
23
  ];
23
24
 
24
25
  return {
26
+ ...actual,
25
27
  toPosixKey: (key: string) => key.split("\\").join("/"),
26
28
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
27
- downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
29
+ uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
30
+ downloadFile: innerVi.fn().mockImplementation(async (
31
+ _ctx: unknown,
32
+ _key: string,
33
+ localPath: string,
34
+ options?: { beforeReplace?: () => void },
35
+ ) => {
28
36
  const dir = innerPath.dirname(localPath);
29
37
  if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
38
+ options?.beforeReplace?.();
30
39
  innerFs.writeFileSync(localPath, "mock file content");
31
40
  return { metadata: {} };
32
41
  }),
@@ -34,6 +43,7 @@ vi.mock("../s3.js", async () => {
34
43
  deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
35
44
  headRemoteFile: innerVi.fn().mockResolvedValue(null),
36
45
  primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
46
+ primeUploads: innerVi.fn().mockResolvedValue(undefined),
37
47
  };
38
48
  });
39
49
 
@@ -44,6 +54,7 @@ vi.mock("./reindex.js", () => ({
44
54
  }));
45
55
 
46
56
  import { sync, reportNewFilesToNotify } from "./sync.js";
57
+ import { share } from "./share.js";
47
58
  import * as s3Module from "../s3.js";
48
59
  import { reindex } from "./reindex.js";
49
60
  import { readSyncProgress } from "../sync-progress.js";
@@ -139,6 +150,10 @@ describe("sync", () => {
139
150
  delete process.env.HQ_STATE_DIR;
140
151
  });
141
152
 
153
+ it("keeps classifyVaultKey available from the partial S3 mock", () => {
154
+ expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
155
+ });
156
+
142
157
  it("runs reindex against hqRoot after a sync that downloaded files", async () => {
143
158
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
144
159
  // skipLock: the surrounding sync run already holds the per-root lock.
@@ -612,6 +627,8 @@ describe("sync", () => {
612
627
  const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
613
628
  fs.writeFileSync(localPath, localContent);
614
629
  const st = fs.statSync(localPath);
630
+ const crypto = await import("node:crypto");
631
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
615
632
 
616
633
  // Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
617
634
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -632,7 +649,7 @@ describe("sync", () => {
632
649
  lastSync: new Date().toISOString(),
633
650
  files: {
634
651
  "docs/handoff.md": {
635
- hash: "clean-local-baseline-hash",
652
+ hash: localHash,
636
653
  size: st.size,
637
654
  mtimeMs: st.mtimeMs,
638
655
  remoteEtag: "old-etag",
@@ -676,6 +693,8 @@ describe("sync", () => {
676
693
  Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
677
694
  fs.writeFileSync(localPath, localContent);
678
695
  const st = fs.statSync(localPath);
696
+ const crypto = await import("node:crypto");
697
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
679
698
 
680
699
  // Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
681
700
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -689,7 +708,7 @@ describe("sync", () => {
689
708
  lastSync: new Date().toISOString(),
690
709
  files: {
691
710
  "docs/handoff.md": {
692
- hash: "clean-local-baseline-hash",
711
+ hash: localHash,
693
712
  size: st.size,
694
713
  mtimeMs: st.mtimeMs,
695
714
  remoteEtag: "old-etag",
@@ -727,6 +746,8 @@ describe("sync", () => {
727
746
  const localContent = "short local\n";
728
747
  fs.writeFileSync(localPath, localContent);
729
748
  const st = fs.statSync(localPath);
749
+ const crypto = await import("node:crypto");
750
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
730
751
 
731
752
  // Remote is LARGER with a changed etag → legitimate advance.
732
753
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -740,7 +761,7 @@ describe("sync", () => {
740
761
  lastSync: new Date().toISOString(),
741
762
  files: {
742
763
  "docs/handoff.md": {
743
- hash: "clean-local-baseline-hash",
764
+ hash: localHash,
744
765
  size: st.size,
745
766
  mtimeMs: st.mtimeMs,
746
767
  remoteEtag: "old-etag",
@@ -954,12 +975,9 @@ describe("sync", () => {
954
975
  expect(journal.files[trackedKey]).toBeUndefined();
955
976
  });
956
977
 
957
- it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
958
- // Repro of the intermittent delete-respawn: the user deleted a clean file,
959
- // its delete has not (yet) cleared the S3 object, and the SERVER
960
- // FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
961
- // The only intent record is the LOCAL journal tombstone (`removedAt`).
962
- // The drift-restore path must honor it and NOT re-download the key.
978
+ it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
979
+ // A legacy `removedReason:local-delete` was inferred from an ENOENT. It
980
+ // has no version-bound explicit intent, so it must never suppress a pull.
963
981
  const key = "docs/handoff.md";
964
982
  const localPath = path.join(tmpDir, "companies", "acme", key);
965
983
 
@@ -1006,20 +1024,16 @@ describe("sync", () => {
1006
1024
  onEvent: () => {},
1007
1025
  });
1008
1026
 
1009
- // The intentionally-deleted key must NOT be re-materialised.
1010
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
1027
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(
1011
1028
  expect.anything(),
1012
1029
  key,
1013
1030
  expect.anything(),
1031
+ expect.anything(),
1014
1032
  );
1015
- expect(fs.existsSync(localPath)).toBe(false);
1033
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
1016
1034
  });
1017
1035
 
1018
- it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
1019
- // The user deleted a genuinely-synced file. Its entry is clean (etag matches
1020
- // the current remote — currency holds), not divergent. The pull leg must
1021
- // treat the absence as an intentional delete: stamp a persisting
1022
- // `local-delete` tombstone and SKIP the re-download — not resurrect it.
1036
+ it("does not infer local-delete intent from a single missing file", async () => {
1023
1037
  const key = "docs/gone.md";
1024
1038
  const localPath = path.join(tmpDir, "companies", "acme", key);
1025
1039
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -1046,15 +1060,15 @@ describe("sync", () => {
1046
1060
 
1047
1061
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1048
1062
 
1049
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
1063
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(
1050
1064
  expect.anything(),
1051
1065
  key,
1052
1066
  expect.anything(),
1067
+ expect.anything(),
1053
1068
  );
1054
- expect(fs.existsSync(localPath)).toBe(false);
1069
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
1055
1070
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1056
- expect(journal.files[key]?.removedAt).toBeTruthy();
1057
- expect(journal.files[key]?.removedReason).toBe("local-delete");
1071
+ expect(journal.files[key]?.removedAt).toBeUndefined();
1058
1072
  });
1059
1073
 
1060
1074
  it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
@@ -1073,7 +1087,14 @@ describe("sync", () => {
1073
1087
  size: 10,
1074
1088
  syncedAt: new Date().toISOString(),
1075
1089
  direction: "down",
1090
+ kind: "file",
1076
1091
  remoteEtag: `e-${k}`,
1092
+ localDeleteIntent: {
1093
+ version: 1,
1094
+ remoteEtag: `e-${k}`,
1095
+ localHash: "h",
1096
+ localKind: "file",
1097
+ },
1077
1098
  };
1078
1099
  }
1079
1100
  fs.writeFileSync(
@@ -1117,9 +1138,16 @@ describe("sync", () => {
1117
1138
  size: 42,
1118
1139
  syncedAt: new Date().toISOString(),
1119
1140
  direction: "down",
1141
+ kind: "file",
1120
1142
  remoteEtag: "cur123",
1121
1143
  removedAt: new Date().toISOString(),
1122
1144
  removedReason: "local-delete",
1145
+ localDeleteIntent: {
1146
+ version: 1,
1147
+ remoteEtag: "cur123",
1148
+ localHash: "h",
1149
+ localKind: "file",
1150
+ },
1123
1151
  },
1124
1152
  },
1125
1153
  pulls: [],
@@ -1765,6 +1793,252 @@ describe("sync", () => {
1765
1793
  expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
1766
1794
  });
1767
1795
 
1796
+ it("CAS: preserves a file created after a remote-delete plan", async () => {
1797
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1798
+ const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
1799
+ const baseline = "synced baseline";
1800
+ const crypto = await import("node:crypto");
1801
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1802
+
1803
+ fs.writeFileSync(
1804
+ journalPath,
1805
+ JSON.stringify({
1806
+ version: "2",
1807
+ lastSync: new Date().toISOString(),
1808
+ files: {
1809
+ "docs/created-after-plan.md": {
1810
+ hash: baselineHash,
1811
+ size: baseline.length,
1812
+ syncedAt: new Date().toISOString(),
1813
+ direction: "down",
1814
+ kind: "file",
1815
+ remoteEtag: "before-delete",
1816
+ },
1817
+ },
1818
+ pulls: [],
1819
+ }),
1820
+ );
1821
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
1822
+ vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
1823
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1824
+ // Same bytes as the journal baseline: hash-only revalidation would unlink
1825
+ // this newly-created file, but the planned snapshot was "absent".
1826
+ fs.writeFileSync(localPath, baseline);
1827
+ return null;
1828
+ });
1829
+
1830
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1831
+
1832
+ expect(result.filesTombstoned).toBe(0);
1833
+ expect(result.conflicts).toBe(1);
1834
+ expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
1835
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1836
+ expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
1837
+ });
1838
+
1839
+ it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
1840
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1841
+ const localPath = path.join(companyRoot, "docs", "staged-race.md");
1842
+ const baseline = "synced baseline";
1843
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1844
+ fs.writeFileSync(localPath, baseline);
1845
+ const crypto = await import("node:crypto");
1846
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1847
+ fs.writeFileSync(
1848
+ journalPath,
1849
+ JSON.stringify({
1850
+ version: "2",
1851
+ lastSync: new Date().toISOString(),
1852
+ files: {
1853
+ "docs/staged-race.md": {
1854
+ hash: baselineHash,
1855
+ size: baseline.length,
1856
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1857
+ direction: "down",
1858
+ kind: "file",
1859
+ remoteEtag: "old-etag",
1860
+ },
1861
+ },
1862
+ pulls: [],
1863
+ }),
1864
+ );
1865
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1866
+ { key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
1867
+ ]);
1868
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (
1869
+ _ctx: unknown,
1870
+ _key: string,
1871
+ destination: string,
1872
+ options?: { beforeReplace?: () => void },
1873
+ ) => {
1874
+ fs.writeFileSync(destination, "concurrent local edit");
1875
+ options?.beforeReplace?.();
1876
+ fs.writeFileSync(destination, "remote replacement");
1877
+ return { metadata: {} };
1878
+ });
1879
+
1880
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1881
+
1882
+ expect(result.filesDownloaded).toBe(0);
1883
+ expect(result.conflicts).toBe(1);
1884
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
1885
+ });
1886
+
1887
+ it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
1888
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
1889
+ const intent = (remoteEtag: string) => ({
1890
+ version: 1 as const,
1891
+ remoteEtag,
1892
+ localHash: "synced-hash",
1893
+ localKind: "file" as const,
1894
+ });
1895
+ fs.writeFileSync(
1896
+ personalJournalPath,
1897
+ JSON.stringify({
1898
+ version: "2",
1899
+ lastSync: new Date().toISOString(),
1900
+ files: {
1901
+ "core/core.yaml": {
1902
+ hash: "synced-hash",
1903
+ size: 1,
1904
+ syncedAt: new Date().toISOString(),
1905
+ direction: "up",
1906
+ kind: "file",
1907
+ remoteEtag: "core-etag",
1908
+ localDeleteIntent: intent("core-etag"),
1909
+ },
1910
+ ".claude/skills/core:demo/SKILL.md": {
1911
+ hash: "synced-hash",
1912
+ size: 1,
1913
+ syncedAt: new Date().toISOString(),
1914
+ direction: "up",
1915
+ kind: "file",
1916
+ remoteEtag: "mirror-etag",
1917
+ localDeleteIntent: intent("mirror-etag"),
1918
+ },
1919
+ },
1920
+ pulls: [],
1921
+ }),
1922
+ );
1923
+
1924
+ await share({
1925
+ company: "acme",
1926
+ vaultConfig: mockConfig,
1927
+ hqRoot: tmpDir,
1928
+ paths: [tmpDir],
1929
+ personalMode: true,
1930
+ journalSlug: "personal-delete",
1931
+ propagateDeletes: true,
1932
+ propagateDeletePolicy: "all",
1933
+ });
1934
+
1935
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1936
+ const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1937
+ expect(journal.files["core/core.yaml"]).toBeDefined();
1938
+ expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
1939
+ });
1940
+
1941
+ it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
1942
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1943
+ fs.mkdirSync(companyRoot, { recursive: true });
1944
+ const entry = (remoteEtag: string) => ({
1945
+ hash: "synced-hash",
1946
+ size: 1,
1947
+ syncedAt: new Date().toISOString(),
1948
+ direction: "up",
1949
+ kind: "file",
1950
+ remoteEtag,
1951
+ });
1952
+ fs.writeFileSync(
1953
+ journalPath,
1954
+ JSON.stringify({
1955
+ version: "2",
1956
+ lastSync: new Date().toISOString(),
1957
+ files: {
1958
+ "docs/a.md": entry("a-etag"),
1959
+ "docs/b.md": entry("b-etag"),
1960
+ },
1961
+ pulls: [],
1962
+ }),
1963
+ );
1964
+
1965
+ const result = await share({
1966
+ company: "acme",
1967
+ vaultConfig: mockConfig,
1968
+ hqRoot: tmpDir,
1969
+ paths: [companyRoot],
1970
+ propagateDeletes: true,
1971
+ propagateDeletePolicy: "all",
1972
+ });
1973
+
1974
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1975
+ expect(result.filesRefusedStale).toBe(2);
1976
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1977
+ expect(journal.files["docs/a.md"]).toBeDefined();
1978
+ expect(journal.files["docs/b.md"]).toBeDefined();
1979
+ });
1980
+
1981
+ it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
1982
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1983
+ const boardPath = path.join(companyRoot, "board.json");
1984
+ const baseline = '{"items":[]}';
1985
+ const localAppend = '{"items":["local append"]}';
1986
+ fs.mkdirSync(companyRoot, { recursive: true });
1987
+ fs.writeFileSync(boardPath, localAppend);
1988
+ const crypto = await import("node:crypto");
1989
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1990
+ fs.writeFileSync(
1991
+ journalPath,
1992
+ JSON.stringify({
1993
+ version: "2",
1994
+ lastSync: new Date().toISOString(),
1995
+ files: {
1996
+ "board.json": {
1997
+ hash: baselineHash,
1998
+ size: baseline.length,
1999
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
2000
+ direction: "up",
2001
+ kind: "file",
2002
+ remoteEtag: "old-etag",
2003
+ },
2004
+ },
2005
+ pulls: [],
2006
+ }),
2007
+ );
2008
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
2009
+ etag: "old-etag",
2010
+ size: baseline.length,
2011
+ lastModified: new Date(),
2012
+ });
2013
+ const preconditionError = Object.assign(new Error("remote changed"), {
2014
+ name: "PreconditionFailed",
2015
+ });
2016
+ vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
2017
+
2018
+ const { isCloudAuthoritative } = await import(
2019
+ "../lib/cloud-authoritative.js",
2020
+ );
2021
+ expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
2022
+
2023
+ const result = await share({
2024
+ company: "acme",
2025
+ vaultConfig: mockConfig,
2026
+ hqRoot: tmpDir,
2027
+ paths: [companyRoot],
2028
+ onConflict: "keep",
2029
+ });
2030
+
2031
+ expect(result.conflictPaths).toContain("board.json");
2032
+ expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
2033
+ expect(s3Module.uploadFile).toHaveBeenCalledWith(
2034
+ expect.anything(),
2035
+ boardPath,
2036
+ "board.json",
2037
+ undefined,
2038
+ { ifMatch: "old-etag" },
2039
+ );
2040
+ });
2041
+
1768
2042
  it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
1769
2043
  // Codex review on PR #24 round 4 caught: the round-3 local-edit
1770
2044
  // divergence guard only covered regular files (`isFile()` is false
@@ -2580,7 +2854,7 @@ describe("sync", () => {
2580
2854
  try {
2581
2855
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2582
2856
  vi.mocked(s3Module.downloadFile).mockImplementation(
2583
- async (ctx, key, localPath) => {
2857
+ async (ctx, key, localPath, options) => {
2584
2858
  if (key === "bulk/file-8.md") {
2585
2859
  // This models the next transfer crashing the process. The first
2586
2860
  // eight entries must already be durable before the final writer.
@@ -2588,7 +2862,7 @@ describe("sync", () => {
2588
2862
  expect(Object.keys(checkpoint.files)).toHaveLength(8);
2589
2863
  throw new Error("simulated crash after checkpoint");
2590
2864
  }
2591
- return defaultDownload(ctx, key, localPath);
2865
+ return defaultDownload(ctx, key, localPath, options);
2592
2866
  },
2593
2867
  );
2594
2868
 
@@ -2621,6 +2895,7 @@ describe("sync", () => {
2621
2895
  expect.anything(),
2622
2896
  "bulk/file-8.md",
2623
2897
  expect.any(String),
2898
+ expect.objectContaining({ beforeReplace: expect.any(Function) }),
2624
2899
  );
2625
2900
  } finally {
2626
2901
  vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
@@ -3815,11 +4090,62 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
3815
4090
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
3816
4091
  const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
3817
4092
  expect(skipEvents).toEqual([
3818
- { type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
4093
+ {
4094
+ type: "skip-invalid-scoped-key",
4095
+ path: "companies/acme/knowledge/poison.md",
4096
+ errorCode: "INVALID_KEY_COMPANIES_SCOPED",
4097
+ },
3819
4098
  ]);
3820
4099
  expect(events.filter((e) => e.type === "error")).toEqual([]);
3821
4100
  });
3822
4101
 
4102
+ it("skips a control-character remote key before presigning while transferring valid keys", async () => {
4103
+ const invalidKey = "docs/bad\x00key.md";
4104
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
4105
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
4106
+ { key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
4107
+ { key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
4108
+ ]);
4109
+ vi.mocked(s3Module.downloadFile).mockReset();
4110
+ vi.mocked(s3Module.downloadFile).mockImplementation(
4111
+ async (_ctx: unknown, key: string, localPath: string) => {
4112
+ if (key === invalidKey) {
4113
+ throw Object.assign(new Error("presign rejected invalid key"), {
4114
+ code: "INVALID_KEY_CONTROL_CHARS",
4115
+ });
4116
+ }
4117
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
4118
+ fs.writeFileSync(localPath, "mock file content");
4119
+ return { metadata: {} };
4120
+ },
4121
+ );
4122
+ const events: Array<{ type: string; [k: string]: unknown }> = [];
4123
+
4124
+ const result = await sync({
4125
+ company: "acme",
4126
+ vaultConfig: mockConfig,
4127
+ hqRoot: tmpDir,
4128
+ onEvent: (event) => events.push(event as never),
4129
+ });
4130
+
4131
+ expect(result.aborted).toBe(false);
4132
+ expect(result.filesDownloaded).toBe(1);
4133
+ expect(
4134
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
4135
+ ).toBe(true);
4136
+ expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
4137
+ "docs/good.md",
4138
+ ]);
4139
+ expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
4140
+ {
4141
+ type: "skip-invalid-scoped-key",
4142
+ path: invalidKey,
4143
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
4144
+ },
4145
+ ]);
4146
+ expect(events.filter((event) => event.type === "error")).toEqual([]);
4147
+ });
4148
+
3823
4149
  it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
3824
4150
  // A doubled local tree (companies/acme/companies/acme/…) previously
3825
4151
  // poisoned the bucket; the poisoned objects were deleted server-side.