@indigoai-us/hq-cloud 6.11.11 → 6.11.13

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 (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -8,6 +8,7 @@ import * as path from "path";
8
8
  import * as os from "os";
9
9
  import { clearContextCache } from "../context.js";
10
10
  import type { VaultServiceConfig } from "../types.js";
11
+ import { lockPathFor } from "../operation-lock.js";
11
12
 
12
13
  // Mock s3 module at the top level
13
14
  vi.mock("../s3.js", async () => {
@@ -42,7 +43,7 @@ vi.mock("./reindex.js", () => ({
42
43
  reindex: vi.fn(() => ({ status: 0 })),
43
44
  }));
44
45
 
45
- import { sync } from "./sync.js";
46
+ import { sync, reportNewFilesToNotify } from "./sync.js";
46
47
  import * as s3Module from "../s3.js";
47
48
  import { reindex } from "./reindex.js";
48
49
 
@@ -143,6 +144,31 @@ describe("sync", () => {
143
144
  expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
144
145
  });
145
146
 
147
+ it("F15: public sync entrypoint refuses an already-held operation lock", async () => {
148
+ process.env.HQ_OP_LOCK_TIMEOUT = "0";
149
+ const lockPath = lockPathFor(tmpDir);
150
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
151
+ fs.writeFileSync(
152
+ lockPath,
153
+ JSON.stringify({
154
+ pid: 1,
155
+ command: "rescue",
156
+ startedAt: new Date().toISOString(),
157
+ hqRoot: path.resolve(tmpDir),
158
+ }),
159
+ );
160
+
161
+ try {
162
+ await expect(
163
+ sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir }),
164
+ ).rejects.toThrow(/another HQ operation is already running/);
165
+ expect(s3Module.listRemoteFiles).not.toHaveBeenCalled();
166
+ expect(reindex).not.toHaveBeenCalled();
167
+ } finally {
168
+ delete process.env.HQ_OP_LOCK_TIMEOUT;
169
+ }
170
+ });
171
+
146
172
  it("skips reindex when skipReindex is set", async () => {
147
173
  const result = await sync({
148
174
  company: "acme",
@@ -408,6 +434,31 @@ describe("sync", () => {
408
434
  expect(litter).toHaveLength(1);
409
435
  });
410
436
 
437
+ it("pre-primes new-file GET presigns before per-file created-by HEADs (avoids the presign-burst breaker trip)", async () => {
438
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
439
+ { key: "alpha.md", size: 10, lastModified: new Date(), etag: '"a1"' },
440
+ { key: "beta.md", size: 10, lastModified: new Date(), etag: '"b2"' },
441
+ ]);
442
+
443
+ await sync({
444
+ company: "acme",
445
+ vaultConfig: mockConfig,
446
+ hqRoot: tmpDir,
447
+ });
448
+
449
+ // The new-files enrichment must batch-mint the GET presigns once (so the
450
+ // per-file created-by HEADs reuse the cache) instead of minting one presign
451
+ // per file — the burst that trips the presign circuit breaker on big
452
+ // catch-up pulls. Mirrors the tombstone HEAD-verify pre-prime.
453
+ const getPrimedKeys = vi
454
+ .mocked(s3Module.primeObjectTransport)
455
+ .mock.calls.filter(([, op]) => op === "get")
456
+ .flatMap(([, , keys]) => keys as string[]);
457
+ expect(getPrimedKeys).toEqual(
458
+ expect.arrayContaining(["alpha.md", "beta.md"]),
459
+ );
460
+ });
461
+
411
462
  it("anti-rollback: a SMALLER remote does NOT silently clobber a clean local — routes through conflict and preserves local", async () => {
412
463
  // Regression for the 2026-06-10 incident: append-only chat logs were
413
464
  // rolled back when a regressed (older, smaller) S3 object overwrote newer
@@ -522,6 +573,195 @@ describe("sync", () => {
522
573
  expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
523
574
  });
524
575
 
576
+ it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
577
+ const companyRoot = path.join(tmpDir, "companies", "acme");
578
+ const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
579
+ const previousConcurrency = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
580
+ const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
581
+ process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
582
+
583
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
584
+ { key: "docs/setup.md", size: 5, lastModified: new Date(), etag: '"setup"' },
585
+ { key: "trap/secret.md", size: 6, lastModified: new Date(), etag: '"secret"' },
586
+ ]);
587
+ vi.mocked(s3Module.downloadFile).mockImplementation(
588
+ async (_ctx: unknown, key: string, localPath: string) => {
589
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
590
+ if (key === "docs/setup.md") {
591
+ fs.writeFileSync(localPath, "setup");
592
+ fs.symlinkSync(outsideRoot, path.join(companyRoot, "trap"), "dir");
593
+ } else {
594
+ fs.writeFileSync(localPath, "escaped");
595
+ }
596
+ return { metadata: {} };
597
+ },
598
+ );
599
+
600
+ const events: Array<{ type: string; path?: string; message?: string }> = [];
601
+ try {
602
+ const result = await sync({
603
+ company: "acme",
604
+ vaultConfig: mockConfig,
605
+ hqRoot: tmpDir,
606
+ onEvent: (e) => events.push(e),
607
+ });
608
+
609
+ expect(result.filesDownloaded).toBe(1);
610
+ expect(fs.readFileSync(path.join(companyRoot, "docs", "setup.md"), "utf-8")).toBe(
611
+ "setup",
612
+ );
613
+ expect(fs.existsSync(path.join(outsideRoot, "secret.md"))).toBe(false);
614
+ expect(
615
+ events.some(
616
+ (e) =>
617
+ e.type === "error" &&
618
+ e.path === "trap/secret.md" &&
619
+ e.message?.includes("escaped the sync root"),
620
+ ),
621
+ ).toBe(true);
622
+ } finally {
623
+ if (defaultDownload) vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
624
+ if (previousConcurrency === undefined) {
625
+ delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
626
+ } else {
627
+ process.env.HQ_SYNC_TRANSFER_CONCURRENCY = previousConcurrency;
628
+ }
629
+ fs.rmSync(outsideRoot, { recursive: true, force: true });
630
+ }
631
+ });
632
+
633
+ it("RF-F02EXEC-conflict", async () => {
634
+ const companyRoot = path.join(tmpDir, "companies", "acme");
635
+ const companyDocs = path.join(companyRoot, "docs");
636
+ const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-conflict-escape-"));
637
+ const localPath = path.join(companyDocs, "handoff.md");
638
+ fs.mkdirSync(companyDocs, { recursive: true });
639
+ fs.writeFileSync(localPath, "local version");
640
+
641
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
642
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
643
+ ]);
644
+
645
+ fs.writeFileSync(
646
+ journalPath,
647
+ JSON.stringify({
648
+ version: "1",
649
+ lastSync: new Date().toISOString(),
650
+ files: {
651
+ "docs/handoff.md": {
652
+ hash: "stale-hash",
653
+ size: 20,
654
+ remoteEtag: "old-etag",
655
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
656
+ direction: "down",
657
+ },
658
+ },
659
+ }),
660
+ );
661
+
662
+ const events: Array<{ type: string; path?: string; message?: string }> = [];
663
+ let swappedParent = false;
664
+ try {
665
+ const result = await sync({
666
+ company: "acme",
667
+ onConflict: "keep",
668
+ vaultConfig: mockConfig,
669
+ hqRoot: tmpDir,
670
+ onEvent: (e) => {
671
+ events.push(e);
672
+ if (e.type === "plan" && !swappedParent) {
673
+ swappedParent = true;
674
+ fs.rmSync(companyDocs, { recursive: true, force: true });
675
+ fs.symlinkSync(outsideRoot, companyDocs, "dir");
676
+ }
677
+ },
678
+ });
679
+
680
+ expect(swappedParent).toBe(true);
681
+ expect(result.conflicts).toBe(0);
682
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
683
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
684
+ expect(fs.readdirSync(outsideRoot)).toEqual([]);
685
+ expect(
686
+ events.some(
687
+ (e) =>
688
+ e.type === "error" &&
689
+ e.path === "docs/handoff.md" &&
690
+ e.message?.includes("escaped the sync root"),
691
+ ),
692
+ ).toBe(true);
693
+ } finally {
694
+ fs.rmSync(outsideRoot, { recursive: true, force: true });
695
+ }
696
+ });
697
+
698
+ it("RF-F33: FILE_TOMBSTONE planned against absence does not delete a new untracked file", async () => {
699
+ const untrackedKey = "docs/untracked.md";
700
+ const trackedKey = "docs/tracked.md";
701
+ const companyRoot = path.join(tmpDir, "companies", "acme");
702
+ const untrackedPath = path.join(companyRoot, untrackedKey);
703
+ const trackedPath = path.join(companyRoot, trackedKey);
704
+ fs.mkdirSync(path.dirname(trackedPath), { recursive: true });
705
+ fs.writeFileSync(trackedPath, "tracked baseline");
706
+ const { hashFile } = await import("../journal.js");
707
+
708
+ setupFetchMock({
709
+ tombstones: [
710
+ { key: untrackedKey, deletedAt: "2026-06-20T00:00:00.000Z" },
711
+ { key: trackedKey, deletedAt: "2026-06-20T00:00:00.000Z" },
712
+ ],
713
+ });
714
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
715
+ {
716
+ key: untrackedKey,
717
+ size: 10,
718
+ lastModified: new Date("2026-06-19T00:00:00.000Z"),
719
+ etag: '"untracked"',
720
+ },
721
+ {
722
+ key: trackedKey,
723
+ size: 16,
724
+ lastModified: new Date("2026-06-19T00:00:00.000Z"),
725
+ etag: '"tracked"',
726
+ },
727
+ ]);
728
+ fs.writeFileSync(
729
+ journalPath,
730
+ JSON.stringify({
731
+ version: "2",
732
+ lastSync: "2026-06-19T00:00:00.000Z",
733
+ files: {
734
+ [trackedKey]: {
735
+ hash: hashFile(trackedPath),
736
+ size: Buffer.byteLength("tracked baseline"),
737
+ syncedAt: "2026-06-19T00:00:00.000Z",
738
+ direction: "down",
739
+ remoteEtag: "tracked",
740
+ },
741
+ },
742
+ pulls: [],
743
+ }),
744
+ );
745
+
746
+ await sync({
747
+ company: "acme",
748
+ vaultConfig: mockConfig,
749
+ hqRoot: tmpDir,
750
+ onEvent: (e) => {
751
+ if (e.type === "plan") {
752
+ fs.mkdirSync(path.dirname(untrackedPath), { recursive: true });
753
+ fs.writeFileSync(untrackedPath, "brand new local work");
754
+ }
755
+ },
756
+ });
757
+
758
+ expect(fs.readFileSync(untrackedPath, "utf-8")).toBe("brand new local work");
759
+ expect(fs.existsSync(trackedPath)).toBe(false);
760
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
761
+ expect(journal.files[untrackedKey]).toBeUndefined();
762
+ expect(journal.files[trackedKey]).toBeUndefined();
763
+ });
764
+
525
765
  it("aborts on --on-conflict abort", async () => {
526
766
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
527
767
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -1095,6 +1335,60 @@ describe("sync", () => {
1095
1335
  expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
1096
1336
  });
1097
1337
 
1338
+ it("F33: rechecks a tombstone candidate after HEAD verification and preserves a stale local edit", async () => {
1339
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1340
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1341
+ const racedPath = path.join(companyRoot, "docs", "racy-delete.md");
1342
+ fs.writeFileSync(racedPath, "synced baseline");
1343
+
1344
+ const crypto = await import("node:crypto");
1345
+ const baselineHash = crypto
1346
+ .createHash("sha256")
1347
+ .update("synced baseline")
1348
+ .digest("hex");
1349
+
1350
+ fs.writeFileSync(
1351
+ journalPath,
1352
+ JSON.stringify({
1353
+ version: "1",
1354
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
1355
+ files: {
1356
+ "docs/racy-delete.md": {
1357
+ hash: baselineHash,
1358
+ size: 15,
1359
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1360
+ direction: "down",
1361
+ remoteEtag: "remote-before-delete",
1362
+ },
1363
+ },
1364
+ }),
1365
+ );
1366
+
1367
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
1368
+ let editInjected = false;
1369
+ vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async (_ctx, key) => {
1370
+ expect(key).toBe("docs/racy-delete.md");
1371
+ fs.writeFileSync(racedPath, "concurrent local edit");
1372
+ editInjected = true;
1373
+ return null;
1374
+ });
1375
+
1376
+ const result = await sync({
1377
+ company: "acme",
1378
+ vaultConfig: mockConfig,
1379
+ hqRoot: tmpDir,
1380
+ });
1381
+
1382
+ expect(editInjected).toBe(true);
1383
+ expect(result.filesTombstoned).toBe(0);
1384
+ expect(fs.existsSync(racedPath)).toBe(true);
1385
+ expect(fs.readFileSync(racedPath, "utf-8")).toBe("concurrent local edit");
1386
+
1387
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1388
+ expect(journal.files["docs/racy-delete.md"]).toBeDefined();
1389
+ expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
1390
+ });
1391
+
1098
1392
  it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
1099
1393
  // Codex review on PR #24 round 4 caught: the round-3 local-edit
1100
1394
  // divergence guard only covered regular files (`isFile()` is false
@@ -1735,6 +2029,79 @@ describe("sync", () => {
1735
2029
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
1736
2030
  });
1737
2031
 
2032
+ it("F02: rejects traversal remote keys before they can escape the company root", async () => {
2033
+ const escapeName = `${path.basename(tmpDir)}-escaped.md`;
2034
+ const traversalKey = `../../../${escapeName}`;
2035
+ const escapedPath = path.join(path.dirname(tmpDir), escapeName);
2036
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2037
+
2038
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
2039
+ {
2040
+ key: traversalKey,
2041
+ size: 13,
2042
+ lastModified: new Date(),
2043
+ etag: '"traversal"',
2044
+ },
2045
+ ]);
2046
+
2047
+ try {
2048
+ const result = await sync({
2049
+ company: "acme",
2050
+ vaultConfig: mockConfig,
2051
+ hqRoot: tmpDir,
2052
+ });
2053
+
2054
+ expect(result.filesDownloaded).toBe(0);
2055
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2056
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
2057
+ expect(fs.existsSync(escapedPath)).toBe(false);
2058
+ expect(fs.existsSync(path.join(companyRoot, traversalKey))).toBe(false);
2059
+
2060
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2061
+ expect(journal.files[traversalKey]).toBeUndefined();
2062
+ } finally {
2063
+ fs.rmSync(escapedPath, { force: true });
2064
+ }
2065
+ });
2066
+
2067
+ it("R-F02: rejects remote children under an in-root symlink directory", async () => {
2068
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2069
+ const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
2070
+ const linkDir = path.join(companyRoot, "linked-out");
2071
+ const remoteKey = "linked-out/owned-by-remote.md";
2072
+ const escapedPath = path.join(outsideDir, "owned-by-remote.md");
2073
+
2074
+ fs.mkdirSync(companyRoot, { recursive: true });
2075
+ fs.symlinkSync(outsideDir, linkDir, "dir");
2076
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
2077
+ {
2078
+ key: remoteKey,
2079
+ size: 13,
2080
+ lastModified: new Date(),
2081
+ etag: '"symlink-dir-escape"',
2082
+ },
2083
+ ]);
2084
+
2085
+ try {
2086
+ const result = await sync({
2087
+ company: "acme",
2088
+ vaultConfig: mockConfig,
2089
+ hqRoot: tmpDir,
2090
+ });
2091
+
2092
+ expect(result.filesDownloaded).toBe(0);
2093
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2094
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
2095
+ expect(fs.existsSync(escapedPath)).toBe(false);
2096
+ expect(fs.existsSync(path.join(companyRoot, remoteKey))).toBe(false);
2097
+
2098
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2099
+ expect(journal.files[remoteKey]).toBeUndefined();
2100
+ } finally {
2101
+ fs.rmSync(outsideDir, { recursive: true, force: true });
2102
+ }
2103
+ });
2104
+
1738
2105
  it("overwrites local on --on-conflict overwrite", async () => {
1739
2106
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
1740
2107
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -2649,3 +3016,95 @@ describe("sync", () => {
2649
3016
  expect(entry.remoteEtag).toBe(newRemoteEtagNormalized);
2650
3017
  });
2651
3018
  });
3019
+
3020
+ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", () => {
3021
+ // The /v1/notify/file-added endpoint rejects an oversized batch wholesale.
3022
+ // Without chunking, a first sync with >1000 new files reports NONE of them and
3023
+ // the same oversized batch re-triggers every cycle. These lock that the client
3024
+ // splits into chunks at or under the cap.
3025
+ const cfg: VaultServiceConfig = {
3026
+ apiUrl: "https://vault-api.test",
3027
+ authToken: "test-jwt-token",
3028
+ region: "us-east-1",
3029
+ };
3030
+ const mkFiles = (n: number) =>
3031
+ Array.from({ length: n }, (_v, i) => ({
3032
+ path: `docs/file-${i}.md`,
3033
+ bytes: i,
3034
+ addedBy: null as string | null,
3035
+ }));
3036
+ const notifyBatchSizes = (fetchMock: ReturnType<typeof vi.fn>): number[] =>
3037
+ (fetchMock.mock.calls as Array<[string, RequestInit?]>)
3038
+ .filter(([u]) => String(u).includes("/v1/notify/file-added"))
3039
+ .map(([, init]) => JSON.parse(String(init!.body)).files.length);
3040
+
3041
+ afterEach(() => {
3042
+ vi.unstubAllGlobals();
3043
+ vi.clearAllMocks();
3044
+ });
3045
+
3046
+ it("sends a single request when exactly at the cap (1000 files)", async () => {
3047
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3048
+ vi.stubGlobal("fetch", fetchMock);
3049
+
3050
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1000));
3051
+
3052
+ const sizes = notifyBatchSizes(fetchMock);
3053
+ expect(sizes).toEqual([1000]); // one POST, exactly at the cap
3054
+ });
3055
+
3056
+ it("splits an over-cap report into batches all at or under the cap", async () => {
3057
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3058
+ vi.stubGlobal("fetch", fetchMock);
3059
+
3060
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1001));
3061
+
3062
+ const sizes = notifyBatchSizes(fetchMock);
3063
+ expect(sizes).toEqual([1000, 1]); // 1001 → 1000 + 1, never one oversized POST
3064
+ expect(Math.max(...sizes)).toBeLessThanOrEqual(1000);
3065
+ expect(sizes.reduce((a, b) => a + b, 0)).toBe(1001); // every file reported
3066
+ });
3067
+
3068
+ it("chunks a large report into ceil(n/1000) batches with no file dropped", async () => {
3069
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3070
+ vi.stubGlobal("fetch", fetchMock);
3071
+
3072
+ const all = mkFiles(2500);
3073
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", all);
3074
+
3075
+ const calls = (fetchMock.mock.calls as Array<[string, RequestInit?]>).filter(([u]) =>
3076
+ String(u).includes("/v1/notify/file-added"),
3077
+ );
3078
+ const sizes = calls.map(([, init]) => JSON.parse(String(init!.body)).files.length);
3079
+ expect(sizes).toEqual([1000, 1000, 500]); // 2500 → three batches
3080
+ // Union of all reported paths equals the input, in order, nothing lost.
3081
+ const reported = calls.flatMap(([, init]) =>
3082
+ (JSON.parse(String(init!.body)).files as Array<{ path: string }>).map((f) => f.path),
3083
+ );
3084
+ expect(reported).toEqual(all.map((f) => f.path));
3085
+ });
3086
+
3087
+ it("a failing chunk does not abort the remaining chunks (best-effort per batch)", async () => {
3088
+ let call = 0;
3089
+ const fetchMock = vi.fn().mockImplementation(async () => {
3090
+ call += 1;
3091
+ if (call === 1) throw new Error("notify endpoint down");
3092
+ return { ok: true, status: 200, text: async () => "" };
3093
+ });
3094
+ vi.stubGlobal("fetch", fetchMock);
3095
+
3096
+ // Must not reject even though the first chunk throws.
3097
+ await expect(reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(2001))).resolves.toBeUndefined();
3098
+ // All three chunks were still attempted (1000 + 1000 + 1).
3099
+ expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
3100
+ });
3101
+
3102
+ it("no request at all when there are no new files", async () => {
3103
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3104
+ vi.stubGlobal("fetch", fetchMock);
3105
+
3106
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", []);
3107
+
3108
+ expect(fetchMock).not.toHaveBeenCalled();
3109
+ });
3110
+ });