@indigoai-us/hq-cloud 6.14.0 → 6.14.2

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 (167) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +50 -1
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +21 -7
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +6 -2
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +162 -11
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/bin/sync-runner.test.js +155 -17
  14. package/dist/bin/sync-runner.test.js.map +1 -1
  15. package/dist/cli/accept.d.ts.map +1 -1
  16. package/dist/cli/accept.js +29 -0
  17. package/dist/cli/accept.js.map +1 -1
  18. package/dist/cli/invite.d.ts.map +1 -1
  19. package/dist/cli/invite.js +33 -0
  20. package/dist/cli/invite.js.map +1 -1
  21. package/dist/cli/promote.d.ts.map +1 -1
  22. package/dist/cli/promote.js +33 -0
  23. package/dist/cli/promote.js.map +1 -1
  24. package/dist/cli/reindex.d.ts +23 -11
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +39 -10
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +40 -13
  29. package/dist/cli/reindex.test.js.map +1 -1
  30. package/dist/cli/share.js +47 -4
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/share.test.js +123 -2
  33. package/dist/cli/share.test.js.map +1 -1
  34. package/dist/cli/sync-scope.test.js +84 -0
  35. package/dist/cli/sync-scope.test.js.map +1 -1
  36. package/dist/cli/sync.d.ts +44 -3
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +156 -7
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +133 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/company-resolver.d.ts +21 -0
  43. package/dist/company-resolver.d.ts.map +1 -1
  44. package/dist/company-resolver.js +31 -6
  45. package/dist/company-resolver.js.map +1 -1
  46. package/dist/company-resolver.test.js +44 -1
  47. package/dist/company-resolver.test.js.map +1 -1
  48. package/dist/ignore.d.ts +10 -0
  49. package/dist/ignore.d.ts.map +1 -1
  50. package/dist/ignore.js +23 -2
  51. package/dist/ignore.js.map +1 -1
  52. package/dist/ignore.test.js +13 -1
  53. package/dist/ignore.test.js.map +1 -1
  54. package/dist/index.d.ts +4 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/journal.d.ts +12 -0
  59. package/dist/journal.d.ts.map +1 -1
  60. package/dist/journal.js +12 -0
  61. package/dist/journal.js.map +1 -1
  62. package/dist/lib/exit-codes.d.ts +25 -0
  63. package/dist/lib/exit-codes.d.ts.map +1 -0
  64. package/dist/lib/exit-codes.js +25 -0
  65. package/dist/lib/exit-codes.js.map +1 -0
  66. package/dist/object-io.d.ts.map +1 -1
  67. package/dist/object-io.js +45 -14
  68. package/dist/object-io.js.map +1 -1
  69. package/dist/object-io.test.js +61 -1
  70. package/dist/object-io.test.js.map +1 -1
  71. package/dist/operation-lock.d.ts +36 -6
  72. package/dist/operation-lock.d.ts.map +1 -1
  73. package/dist/operation-lock.js +34 -11
  74. package/dist/operation-lock.js.map +1 -1
  75. package/dist/operation-lock.test.js +30 -0
  76. package/dist/operation-lock.test.js.map +1 -1
  77. package/dist/s3.d.ts +1 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +75 -2
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +56 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/scope-shrink.d.ts +12 -0
  84. package/dist/scope-shrink.d.ts.map +1 -1
  85. package/dist/scope-shrink.js +12 -0
  86. package/dist/scope-shrink.js.map +1 -1
  87. package/dist/scope-shrink.test.js +31 -0
  88. package/dist/scope-shrink.test.js.map +1 -1
  89. package/dist/skill-telemetry.d.ts +41 -0
  90. package/dist/skill-telemetry.d.ts.map +1 -1
  91. package/dist/skill-telemetry.js +111 -7
  92. package/dist/skill-telemetry.js.map +1 -1
  93. package/dist/skill-telemetry.test.js +187 -5
  94. package/dist/skill-telemetry.test.js.map +1 -1
  95. package/dist/sync/event-sync.d.ts +3 -0
  96. package/dist/sync/event-sync.d.ts.map +1 -1
  97. package/dist/sync/event-sync.js +23 -0
  98. package/dist/sync/event-sync.js.map +1 -1
  99. package/dist/sync/pull-scope.d.ts +21 -0
  100. package/dist/sync/pull-scope.d.ts.map +1 -1
  101. package/dist/sync/pull-scope.js +46 -13
  102. package/dist/sync/pull-scope.js.map +1 -1
  103. package/dist/telemetry-events.d.ts +31 -0
  104. package/dist/telemetry-events.d.ts.map +1 -0
  105. package/dist/telemetry-events.js +140 -0
  106. package/dist/telemetry-events.js.map +1 -0
  107. package/dist/telemetry-events.test.d.ts +2 -0
  108. package/dist/telemetry-events.test.d.ts.map +1 -0
  109. package/dist/telemetry-events.test.js +73 -0
  110. package/dist/telemetry-events.test.js.map +1 -0
  111. package/dist/telemetry.js +1 -1
  112. package/dist/telemetry.js.map +1 -1
  113. package/dist/vault-client.d.ts +38 -5
  114. package/dist/vault-client.d.ts.map +1 -1
  115. package/dist/vault-client.js +69 -6
  116. package/dist/vault-client.js.map +1 -1
  117. package/dist/vault-client.test.js +31 -1
  118. package/dist/vault-client.test.js.map +1 -1
  119. package/dist/watcher.d.ts +7 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +53 -1
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +57 -0
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
  126. package/pnpm-workspace.yaml +2 -0
  127. package/scripts/presign-transport-e2e.mjs +2 -2
  128. package/src/bin/sync-runner-company.ts +65 -1
  129. package/src/bin/sync-runner-watch-loop.ts +24 -8
  130. package/src/bin/sync-runner.test.ts +194 -16
  131. package/src/bin/sync-runner.ts +209 -11
  132. package/src/cli/accept.ts +27 -0
  133. package/src/cli/invite.ts +31 -0
  134. package/src/cli/promote.ts +31 -0
  135. package/src/cli/reindex.test.ts +44 -13
  136. package/src/cli/reindex.ts +74 -21
  137. package/src/cli/share.test.ts +139 -2
  138. package/src/cli/share.ts +48 -2
  139. package/src/cli/sync-scope.test.ts +90 -0
  140. package/src/cli/sync.test.ts +154 -0
  141. package/src/cli/sync.ts +224 -5
  142. package/src/company-resolver.test.ts +51 -0
  143. package/src/company-resolver.ts +40 -5
  144. package/src/ignore.test.ts +18 -1
  145. package/src/ignore.ts +24 -2
  146. package/src/index.ts +22 -1
  147. package/src/journal.ts +12 -0
  148. package/src/lib/exit-codes.ts +24 -0
  149. package/src/object-io.test.ts +66 -1
  150. package/src/object-io.ts +51 -14
  151. package/src/operation-lock.test.ts +33 -0
  152. package/src/operation-lock.ts +46 -11
  153. package/src/s3.test.ts +87 -0
  154. package/src/s3.ts +93 -2
  155. package/src/scope-shrink.test.ts +32 -0
  156. package/src/scope-shrink.ts +12 -0
  157. package/src/skill-telemetry.test.ts +247 -4
  158. package/src/skill-telemetry.ts +134 -6
  159. package/src/sync/event-sync.ts +37 -0
  160. package/src/sync/pull-scope.ts +62 -13
  161. package/src/telemetry-events.test.ts +88 -0
  162. package/src/telemetry-events.ts +205 -0
  163. package/src/telemetry.ts +1 -1
  164. package/src/vault-client.test.ts +39 -1
  165. package/src/vault-client.ts +115 -14
  166. package/src/watcher.test.ts +82 -0
  167. package/src/watcher.ts +69 -0
@@ -461,4 +461,94 @@ describe("sync — scope-aware download (US-005)", () => {
461
461
  expect(second.scopeOrphansRemoved).toBe(0);
462
462
  expect(second.scopeOrphansBlocked).toBe(0);
463
463
  });
464
+
465
+ // ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
466
+ // The sessions prefix is PUSH-ONLY: pushed into the vault but never
467
+ // auto-pulled onto grantee laptops in ANY mode (including `all`, which
468
+ // otherwise pulls the whole bucket). The engine expresses this as
469
+ // `excludePrefixes`, applied to the DOWNLOAD filter only — never to
470
+ // scope-shrink, so an authored/pinned session is never orphaned.
471
+ describe("sessions push-only exclusion (US-006)", () => {
472
+ const withSession = () => {
473
+ REMOTE.current = [
474
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
475
+ { key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
476
+ ];
477
+ };
478
+ const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
479
+
480
+ it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
481
+ withSession();
482
+ const result = await sync({
483
+ company: "acme",
484
+ vaultConfig: mockConfig,
485
+ hqRoot: tmpDir,
486
+ syncMode: "all",
487
+ excludePrefixes: ["sessions/"],
488
+ });
489
+ // `all` normally pulls everything; the exclusion carves sessions out.
490
+ expect(result.filesDownloaded).toBe(1);
491
+ expect(result.filesOutOfScope).toBe(1);
492
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
493
+ expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
494
+ });
495
+
496
+ it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
497
+ withSession();
498
+ // 1. Full pull WITHOUT the exclusion: the session materializes and journals
499
+ // as a pulled (direction:"down") entry under an `all`-mode PullRecord.
500
+ const first = await sync({
501
+ company: "acme",
502
+ vaultConfig: mockConfig,
503
+ hqRoot: tmpDir,
504
+ syncMode: "all",
505
+ });
506
+ expect(first.filesDownloaded).toBe(2);
507
+ expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
508
+
509
+ // 2. Full pull WITH the sessions exclusion. The session is now
510
+ // skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
511
+ // set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
512
+ // (covers everything). If it WERE fed in, the file would be orphaned.
513
+ const second = await sync({
514
+ company: "acme",
515
+ vaultConfig: mockConfig,
516
+ hqRoot: tmpDir,
517
+ syncMode: "all",
518
+ excludePrefixes: ["sessions/"],
519
+ });
520
+ expect(second.filesOutOfScope).toBe(1);
521
+ expect(second.scopeOrphansRemoved).toBe(0);
522
+ expect(second.scopeOrphansBlocked).toBe(0);
523
+ expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
524
+ });
525
+
526
+ it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
527
+ withSession();
528
+ // Materialize both under all-mode (knowledge sync + an on-demand session get).
529
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
530
+ expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
531
+
532
+ // A scoped shared pull whose inclusion prefixSet includes the pinned
533
+ // session key (as resolvePullScope would after `hq files get` pins it),
534
+ // plus the push-only sessions exclusion. The session is NOT re-pulled
535
+ // (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
536
+ // it is retained rather than orphaned.
537
+ const scoped = await sync({
538
+ company: "acme",
539
+ vaultConfig: mockConfig,
540
+ hqRoot: tmpDir,
541
+ syncMode: "shared",
542
+ prefixSet: ["knowledge/", sessionPath],
543
+ excludePrefixes: ["sessions/"],
544
+ });
545
+ expect(scoped.scopeOrphansRemoved).toBe(0);
546
+ expect(scoped.scopeOrphansBlocked).toBe(0);
547
+ // Session is not re-downloaded (excluded) but survives on disk...
548
+ expect(scoped.filesOutOfScope).toBe(1);
549
+ expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
550
+ // ...and the in-scope knowledge file is retained too.
551
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
552
+ });
553
+ });
464
554
  });
@@ -3589,3 +3589,157 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
3589
3589
  expect(fetchMock).not.toHaveBeenCalled();
3590
3590
  });
3591
3591
  });
3592
+
3593
+ describe("scope-invalid key hardening (companies/ prefix in a company vault — incident 2026-07-11)", () => {
3594
+ let tmpDir: string;
3595
+ let stateDir: string;
3596
+ let journalPath: string;
3597
+
3598
+ beforeEach(() => {
3599
+ clearContextCache();
3600
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-poison-test-"));
3601
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
3602
+ process.env.HQ_STATE_DIR = stateDir;
3603
+ journalPath = path.join(stateDir, "sync-journal.acme.json");
3604
+ setupFetchMock();
3605
+ // Earlier tests in this file replace the downloadFile implementation with
3606
+ // throwing probes; vi.clearAllMocks() keeps implementations, so restore
3607
+ // the default writer here to keep these tests order-independent.
3608
+ vi.mocked(s3Module.downloadFile).mockReset();
3609
+ vi.mocked(s3Module.downloadFile).mockImplementation(
3610
+ async (_ctx: unknown, _key: string, localPath: string) => {
3611
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
3612
+ fs.writeFileSync(localPath, "mock file content");
3613
+ return { metadata: {} };
3614
+ },
3615
+ );
3616
+ });
3617
+
3618
+ afterEach(() => {
3619
+ vi.unstubAllGlobals();
3620
+ vi.clearAllMocks();
3621
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3622
+ fs.rmSync(stateDir, { recursive: true, force: true });
3623
+ delete process.env.HQ_STATE_DIR;
3624
+ });
3625
+
3626
+ it("skips a companies/-prefixed remote key instead of erroring the whole company sync", async () => {
3627
+ // The incident shape: 25 poisoned `companies/indigo/…` objects in the
3628
+ // company bucket; the presign download API rejects them
3629
+ // (INVALID_KEY_COMPANIES_SCOPED), so attempting to download errored the
3630
+ // whole company sync and gated heartbeat toolset refreshes fleet-wide.
3631
+ // One bad object must never take down a company sync.
3632
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
3633
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
3634
+ { key: "companies/acme/knowledge/poison.md", size: 10, lastModified: new Date(), etag: '"p1"' },
3635
+ { key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"g1"' },
3636
+ ]);
3637
+ const events: Array<{ type: string; [k: string]: unknown }> = [];
3638
+ const result = await sync({
3639
+ company: "acme",
3640
+ vaultConfig: mockConfig,
3641
+ hqRoot: tmpDir,
3642
+ onEvent: (e) => events.push(e as never),
3643
+ });
3644
+
3645
+ // The good file syncs; the poisoned key is never downloaded.
3646
+ expect(result.aborted).toBe(false);
3647
+ expect(result.filesDownloaded).toBe(1);
3648
+ expect(
3649
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
3650
+ ).toBe(true);
3651
+ expect(
3652
+ fs.existsSync(
3653
+ path.join(tmpDir, "companies", "acme", "companies", "acme", "knowledge", "poison.md"),
3654
+ ),
3655
+ ).toBe(false);
3656
+ const downloadedKeys = vi
3657
+ .mocked(s3Module.downloadFile)
3658
+ .mock.calls.map((c) => c[1]);
3659
+ expect(downloadedKeys).not.toContain("companies/acme/knowledge/poison.md");
3660
+
3661
+ // Counted as policy-excluded, surfaced as a benign per-key warning event —
3662
+ // deliberately NOT type:"error" (an error event flips the runner to exit 2).
3663
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
3664
+ const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
3665
+ expect(skipEvents).toEqual([
3666
+ { type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
3667
+ ]);
3668
+ expect(events.filter((e) => e.type === "error")).toEqual([]);
3669
+ });
3670
+
3671
+ it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
3672
+ // A doubled local tree (companies/acme/companies/acme/…) previously
3673
+ // poisoned the bucket; the poisoned objects were deleted server-side.
3674
+ // The journal entry at the invalid key must drain as a tombstone —
3675
+ // local copy removed, journal entry dropped — and must NOT be
3676
+ // HEAD-verified (the presign transport rejects the key, which would
3677
+ // defer the tombstone forever) and must NOT be re-uploaded.
3678
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3679
+ const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
3680
+ fs.mkdirSync(doubled, { recursive: true });
3681
+ fs.writeFileSync(path.join(doubled, "poison.md"), "poison content");
3682
+ const crypto = await import("node:crypto");
3683
+ const baseline = crypto
3684
+ .createHash("sha256")
3685
+ .update("poison content")
3686
+ .digest("hex");
3687
+ fs.writeFileSync(
3688
+ journalPath,
3689
+ JSON.stringify({
3690
+ version: "1",
3691
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
3692
+ files: {
3693
+ "companies/acme/knowledge/poison.md": {
3694
+ hash: baseline,
3695
+ size: 14,
3696
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
3697
+ direction: "up",
3698
+ remoteEtag: "poison-etag",
3699
+ },
3700
+ },
3701
+ }),
3702
+ );
3703
+ // Remote LIST no longer contains the poisoned key (manual cleanup done).
3704
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
3705
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([]);
3706
+
3707
+ const result = await sync({
3708
+ company: "acme",
3709
+ vaultConfig: mockConfig,
3710
+ hqRoot: tmpDir,
3711
+ });
3712
+
3713
+ // Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
3714
+ expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
3715
+ expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
3716
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3717
+ expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
3718
+ // No HEAD verify against the invalid key — the server validator would
3719
+ // reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
3720
+ const headKeys = vi
3721
+ .mocked(s3Module.headRemoteFile)
3722
+ .mock.calls.map((c) => c[1]);
3723
+ expect(headKeys).not.toContain("companies/acme/knowledge/poison.md");
3724
+ // Never re-uploaded.
3725
+ expect(vi.mocked(s3Module.uploadFile)).not.toHaveBeenCalled();
3726
+ });
3727
+
3728
+ it("personal-mode pull still downloads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
3729
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
3730
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
3731
+ { key: "companies/localco/notes.md", size: 4, lastModified: new Date(), etag: '"n1"' },
3732
+ ]);
3733
+ const events: Array<{ type: string }> = [];
3734
+ const result = await sync({
3735
+ company: "acme",
3736
+ vaultConfig: mockConfig,
3737
+ hqRoot: tmpDir,
3738
+ personalMode: true,
3739
+ onEvent: (e) => events.push(e as never),
3740
+ });
3741
+ expect(result.filesDownloaded).toBe(1);
3742
+ expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
3743
+ expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
3744
+ });
3745
+ });
package/src/cli/sync.ts CHANGED
@@ -12,7 +12,11 @@ import type {
12
12
  VaultServiceConfig,
13
13
  SyncJournal,
14
14
  } from "../types.js";
15
- import type { SyncMode } from "../vault-client.js";
15
+ import { VaultClient, type SyncMode } from "../vault-client.js";
16
+ import {
17
+ emitCloudTelemetry,
18
+ type TelemetryClaims,
19
+ } from "../telemetry-events.js";
16
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
17
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
18
22
  import {
@@ -333,13 +337,31 @@ export type SyncProgressEvent =
333
337
  * pushed it into the runner's `errors[]` and made every watch pass return
334
338
  * exit 2; the menubar supervisor then reported that as
335
339
  * "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick —
336
- * the fleet-wide HQ-SYNC-4 flood. Surfaced here purely for visibility
340
+ * the HQ-SYNC-4 flood across user machines. Surfaced here purely for visibility
337
341
  * (`bytes` is the offending file's size) so a UI/CLI can tell the user
338
342
  * which file was too big without treating it as a failure.
339
343
  */
340
344
  type: "skip-size-limit";
341
345
  path: string;
342
346
  bytes: number;
347
+ }
348
+ | {
349
+ /**
350
+ * Emitted by the PULL leg once per remote key skipped because it is
351
+ * invalid for the vault's scope — today, a `companies/…`-prefixed key in
352
+ * a company-scoped vault (the server rejects these with
353
+ * INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
354
+ * Such keys are bucket poisoning from a client with a stale doubled
355
+ * local tree writing via direct S3 (incident 2026-07-11). Like
356
+ * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
357
+ * object must never error a whole company sync (erroring gated
358
+ * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
359
+ * visibility + cloud telemetry; per policy
360
+ * hq-alert-baseline-calibration the healthy baseline is ZERO such keys,
361
+ * so any occurrence is signal.
362
+ */
363
+ type: "skip-invalid-scoped-key";
364
+ path: string;
343
365
  };
344
366
 
345
367
  export interface SyncOptions {
@@ -409,6 +431,23 @@ export interface SyncOptions {
409
431
  * failure can never silently prune the local tree.
410
432
  */
411
433
  prefixSet?: ScopePrefixInput[];
434
+ /**
435
+ * Company-relative prefixes SUBTRACTED from the effective pull scope in EVERY
436
+ * mode — the push-only set (US-006). Carries `sessions/`: session transcripts
437
+ * are pushed into the vault but never auto-pulled onto grantee laptops, even
438
+ * under `syncMode: "all"` (which otherwise pulls the whole bucket). A remote
439
+ * key downloads iff it is covered by the inclusion scope (`prefixSet`, or all
440
+ * of `all` mode) AND NOT covered by any `excludePrefixes` entry; excluded
441
+ * keys are classified `skip-out-of-scope`.
442
+ *
443
+ * CRITICAL: this set is applied ONLY to the download filter. It is
444
+ * deliberately NOT fed into the scope-shrink pass (which keeps using the
445
+ * inclusion `prefixSet` + pins), so a locally-authored (`direction:"up"`) or
446
+ * pinned on-demand-materialized session is NEVER orphaned/pruned — it is
447
+ * merely not re-pulled. The runner derives this from
448
+ * `resolvePullScope().excludePrefixes`.
449
+ */
450
+ excludePrefixes?: ScopePrefixInput[];
412
451
  /**
413
452
  * When the effective scope shrinks relative to the last pull and the shrink
414
453
  * would orphan locally-modified ("dirty") files, `sync()` aborts with a
@@ -444,6 +483,12 @@ export interface SyncOptions {
444
483
  * it from disk, so it stays pure/hermetic; undefined degrades safely.
445
484
  */
446
485
  callerSub?: string;
486
+ /**
487
+ * Optional decoded Cognito claims used only for action telemetry attribution
488
+ * (agentUid when `custom:entityType=agent`). Sync behavior never branches on
489
+ * this field.
490
+ */
491
+ telemetryClaims?: TelemetryClaims | null;
447
492
  /**
448
493
  * Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
449
494
  * mirrors + workers registry). By default, when a sync changes on-disk
@@ -547,6 +592,14 @@ interface PullRunContext {
547
592
  remoteFiles: RemoteFile[];
548
593
  syncMode: SyncMode;
549
594
  currentPrefixSet: string[];
595
+ /**
596
+ * Coalesced push-only exclusion prefixes (US-006: `sessions/`), subtracted
597
+ * from the download filter in EVERY mode. Kept SEPARATE from
598
+ * `currentPrefixSet` on purpose: the scope-shrink pass reads only
599
+ * `currentPrefixSet` (+ pins), so excluded-but-authored/pinned session files
600
+ * are never orphaned.
601
+ */
602
+ currentExcludeSet: string[];
550
603
  fileTombstones: ReadonlyMap<string, CompanyTombstone>;
551
604
  }
552
605
 
@@ -625,6 +678,7 @@ export async function reportNewFilesToNotify(
625
678
  companyUid: string,
626
679
  companySlug: string,
627
680
  files: Array<{ path: string; bytes: number; addedBy: string | null }>,
681
+ telemetryClaims?: TelemetryClaims | null,
628
682
  ): Promise<void> {
629
683
  if (files.length === 0) return;
630
684
 
@@ -639,16 +693,19 @@ export async function reportNewFilesToNotify(
639
693
  return;
640
694
  }
641
695
  const base = vaultConfig.apiUrl.replace(/\/+$/, "");
696
+ const telemetryClient = new VaultClient(vaultConfig);
697
+ const batchCount = Math.ceil(files.length / NOTIFY_FILE_ADDED_MAX_BATCH);
642
698
 
643
699
  for (let i = 0; i < files.length; i += NOTIFY_FILE_ADDED_MAX_BATCH) {
644
700
  const batch = files.slice(i, i + NOTIFY_FILE_ADDED_MAX_BATCH);
701
+ const batchIndex = Math.floor(i / NOTIFY_FILE_ADDED_MAX_BATCH) + 1;
645
702
  const controller = new AbortController();
646
703
  const timer = setTimeout(
647
704
  () => controller.abort(),
648
705
  NOTIFY_FILE_ADDED_TIMEOUT_MS,
649
706
  );
650
707
  try {
651
- await fetch(`${base}/v1/notify/file-added`, {
708
+ const response = await fetch(`${base}/v1/notify/file-added`, {
652
709
  method: "POST",
653
710
  headers: {
654
711
  Authorization: `Bearer ${token}`,
@@ -665,16 +722,44 @@ export async function reportNewFilesToNotify(
665
722
  }),
666
723
  signal: controller.signal,
667
724
  });
725
+ void emitCloudTelemetry(telemetryClient, {
726
+ eventName: "new_files_notification_reported",
727
+ source: "hq-sync",
728
+ companyUid,
729
+ properties: {
730
+ result: response.ok ? "success" : "failure",
731
+ fileCount: batch.length,
732
+ batchIndex,
733
+ batchCount,
734
+ statusCode: response.status,
735
+ },
736
+ }, { claims: telemetryClaims });
668
737
  } catch (err) {
669
738
  // Best-effort per chunk: never let notification reporting affect the sync
670
739
  // result, and a failed chunk must not abort the remaining chunks.
671
740
  logNotifyFailure(err);
741
+ void emitCloudTelemetry(telemetryClient, {
742
+ eventName: "new_files_notification_reported",
743
+ source: "hq-sync",
744
+ companyUid,
745
+ properties: {
746
+ result: "failure",
747
+ fileCount: batch.length,
748
+ batchIndex,
749
+ batchCount,
750
+ errorCode: safeErrorName(err),
751
+ },
752
+ }, { claims: telemetryClaims });
672
753
  } finally {
673
754
  clearTimeout(timer);
674
755
  }
675
756
  }
676
757
  }
677
758
 
759
+ function safeErrorName(err: unknown): string {
760
+ return err instanceof Error && err.name ? err.name : "error";
761
+ }
762
+
678
763
  /** Log a non-fatal notify failure without ever throwing out of the logger. */
679
764
  function logNotifyFailure(err: unknown): void {
680
765
  try {
@@ -724,6 +809,7 @@ async function syncWithOperationLockHeld(
724
809
  const plan = planPull(run);
725
810
 
726
811
  emitPullPlan(run.emit, plan);
812
+ reportInvalidScopedKeys(run, plan);
727
813
 
728
814
  const scopePlan = planScopeShrink(run);
729
815
  const scopeRun = executeScopeShrink(run, scopePlan);
@@ -795,6 +881,13 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
795
881
  const syncMode: SyncMode = options.syncMode ?? "all";
796
882
  const currentPrefixSet: string[] =
797
883
  syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
884
+ // Push-only exclusions (US-006). Coalesced independently of `currentPrefixSet`
885
+ // and applied to EVERY mode's download filter — including `all` — but kept out
886
+ // of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
887
+ // passes nothing (legacy behavior: no exclusions).
888
+ const currentExcludeSet: string[] = coalescePrefixes(
889
+ options.excludePrefixes ?? [],
890
+ );
798
891
 
799
892
  const remoteFiles = await listRemoteFiles(ctx);
800
893
  const fileTombstones =
@@ -817,6 +910,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
817
910
  remoteFiles,
818
911
  syncMode,
819
912
  currentPrefixSet,
913
+ currentExcludeSet,
820
914
  fileTombstones,
821
915
  };
822
916
  }
@@ -831,6 +925,7 @@ function planPull(run: PullRunContext): PullPlan {
831
925
  run.options.teamSyncedSlugs ?? null,
832
926
  run.currentPrefixSet,
833
927
  run.fileTombstones,
928
+ run.currentExcludeSet,
834
929
  );
835
930
  }
836
931
 
@@ -847,6 +942,33 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
847
942
  });
848
943
  }
849
944
 
945
+ /**
946
+ * Surface scope-invalid remote keys the planner skipped (see the
947
+ * `invalidScopedKeys` doc on PullPlan). One warning event per key on the
948
+ * existing sync event channel (deliberately NOT `type: "error"` — see the
949
+ * `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
950
+ * event on the channel sync already uses (emitCloudTelemetry). Per policy
951
+ * hq-alert-baseline-calibration the documented healthy baseline is ZERO
952
+ * INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
953
+ * on any non-zero count with no threshold tuning.
954
+ */
955
+ function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
956
+ if (plan.invalidScopedKeys.length === 0) return;
957
+ for (const key of plan.invalidScopedKeys) {
958
+ run.emit({ type: "skip-invalid-scoped-key", path: key });
959
+ }
960
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
961
+ eventName: "sync_invalid_scoped_key_skipped",
962
+ source: "hq-sync",
963
+ companyUid: run.ctx.uid,
964
+ properties: {
965
+ count: plan.invalidScopedKeys.length,
966
+ errorCode: "INVALID_KEY_COMPANIES_SCOPED",
967
+ leg: "pull",
968
+ },
969
+ }, { claims: run.options.telemetryClaims });
970
+ }
971
+
850
972
  function createPullCounters(): PullCounters {
851
973
  return {
852
974
  filesDownloaded: 0,
@@ -870,6 +992,12 @@ function planScopeShrink(run: PullRunContext): ScopeShrinkPlanState {
870
992
  journal: run.journal,
871
993
  hqRoot: run.companyRoot,
872
994
  lastPrefixSet,
995
+ // INCLUSION scope only (+ pins, already folded into currentPrefixSet by the
996
+ // resolver). The push-only exclude set (US-006: `sessions/`) is DELIBERATELY
997
+ // NOT subtracted here: a session file that was authored locally
998
+ // (`direction:"up"`) or pinned + materialized on demand must never be
999
+ // orphaned just because sessions are excluded from the download filter.
1000
+ // Feeding `currentExcludeSet` in here would prune exactly those files.
873
1001
  currentPrefixSet: run.currentPrefixSet,
874
1002
  callerSub: run.options.callerSub,
875
1003
  protectUnknownAuthors: true,
@@ -1429,6 +1557,7 @@ async function emitAndReportNewFiles(
1429
1557
  run.ctx.uid,
1430
1558
  run.ctx.slug,
1431
1559
  enrichedNewFiles,
1560
+ run.options.telemetryClaims,
1432
1561
  );
1433
1562
  }
1434
1563
 
@@ -1438,6 +1567,29 @@ async function verifyPlannedJournalTombstones(
1438
1567
  ): Promise<void> {
1439
1568
  if (plan.tombstones.length === 0) return;
1440
1569
 
1570
+ // Scope-invalid journal keys (company mode, `companies/…` prefix) skip the
1571
+ // HEAD verify: the presign transport's server validator rejects such keys
1572
+ // outright (INVALID_KEY_COMPANIES_SCOPED), so a HEAD can never succeed and
1573
+ // would defer the tombstone forever — leaving the poisoned journal entry
1574
+ // (and its doubled-tree local file) re-firing on every sync. The key can
1575
+ // never legitimately exist in a company vault, so treating it as verified
1576
+ // is safe: the executor cleans the local copy + journal entry instead of
1577
+ // the push leg ever re-uploading it (tombstone-not-reupload).
1578
+ const autoVerified: string[] = [];
1579
+ const toHeadVerify: string[] = [];
1580
+ for (const key of plan.tombstones) {
1581
+ if (run.options.personalMode !== true && key.startsWith("companies/")) {
1582
+ autoVerified.push(key);
1583
+ } else {
1584
+ toHeadVerify.push(key);
1585
+ }
1586
+ }
1587
+ plan.tombstones = toHeadVerify;
1588
+ if (plan.tombstones.length === 0) {
1589
+ plan.tombstones = autoVerified;
1590
+ return;
1591
+ }
1592
+
1441
1593
  await primeObjectTransport(run.ctx, "get", plan.tombstones);
1442
1594
 
1443
1595
  const HEAD_VERIFY_CONCURRENCY = 5;
@@ -1466,7 +1618,7 @@ async function verifyPlannedJournalTombstones(
1466
1618
  if (k !== null) verified.push(k);
1467
1619
  }
1468
1620
  }
1469
- plan.tombstones = verified;
1621
+ plan.tombstones = [...autoVerified, ...verified];
1470
1622
  }
1471
1623
 
1472
1624
  function executeJournalTombstoneDeletes(
@@ -1545,7 +1697,19 @@ function finalizePullRun(
1545
1697
  scopeRun.scopeOrphansRemoved > 0;
1546
1698
  if (!run.options.skipReindex && changedOnDisk) {
1547
1699
  try {
1548
- reindex({ repoRoot: run.hqRoot, skipLock: true });
1700
+ const result = reindex({ repoRoot: run.hqRoot, skipLock: true });
1701
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
1702
+ eventName: "reindex_run",
1703
+ source: "hq-sync",
1704
+ companyUid: run.ctx.uid,
1705
+ properties: {
1706
+ result: result.status === 0 ? "completed" : "failed",
1707
+ statusCode: result.status,
1708
+ lockResult: "skipped",
1709
+ changedCount:
1710
+ counters.changedPaths.length + scopeRun.changedPaths.length,
1711
+ },
1712
+ }, { claims: run.options.telemetryClaims });
1549
1713
  } catch (err) {
1550
1714
  logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
1551
1715
  // best-effort: a post-sync refresh failure never fails the sync
@@ -1780,6 +1944,15 @@ interface PullPlan {
1780
1944
  * `filesToDelete` axis; the per-item executor applies the local delete.
1781
1945
  */
1782
1946
  filesToTombstoneDelete: number;
1947
+ /**
1948
+ * Remote keys skipped because they are invalid for the vault's scope
1949
+ * (company mode only: literal `companies/…` keys — bucket poisoning from a
1950
+ * doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
1951
+ * carried by key so the caller can log one warning per key and emit
1952
+ * telemetry. Healthy baseline is an empty array (policy
1953
+ * hq-alert-baseline-calibration: zero expected, any occurrence is signal).
1954
+ */
1955
+ invalidScopedKeys: string[];
1783
1956
  }
1784
1957
 
1785
1958
  /**
@@ -1847,8 +2020,18 @@ function computePullPlan(
1847
2020
  // preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
1848
2021
  // shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
1849
2022
  fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
2023
+ // Push-only exclusion prefixes (US-006: `sessions/`). Keys covered by any of
2024
+ // these are classified `skip-out-of-scope` in EVERY mode — including `all` —
2025
+ // AFTER passing the inclusion check. Empty (the default) preserves legacy
2026
+ // behavior bit-for-bit. Applied ONLY here (the download filter), never to
2027
+ // scope-shrink, so an authored/pinned excluded key is not re-pulled yet also
2028
+ // never orphaned.
2029
+ excludePrefixes: readonly ScopePrefixInput[] = [],
1850
2030
  ): PullPlan {
1851
2031
  const items: PullPlanItem[] = [];
2032
+ // Company-mode remote keys invalid for the vault's scope (`companies/…` in a
2033
+ // company bucket) — collected so the caller can warn + emit telemetry.
2034
+ const invalidScopedKeys: string[] = [];
1852
2035
  // Clean/current/non-divergent files missing locally — collected here, then
1853
2036
  // resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
1854
2037
  const intentionalDeleteCandidates: Array<{
@@ -1874,6 +2057,23 @@ function computePullPlan(
1874
2057
  continue;
1875
2058
  }
1876
2059
 
2060
+ // Scope-invalid key filter (incident 2026-07-11). A company vault bucket
2061
+ // is already company-scoped, so a literal `companies/…` key can only be
2062
+ // bucket poisoning — a client with a stale doubled local tree
2063
+ // (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
2064
+ // transport, which bypasses the server validator. The presign download
2065
+ // API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
2066
+ // download them errors the WHOLE company sync. Skip them here — one bad
2067
+ // object must never take down a company sync — and record them on the
2068
+ // plan so the caller can warn + emit telemetry (baseline is zero; any
2069
+ // occurrence is signal). Mirrors the personalMode/malformed-key filters
2070
+ // above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
2071
+ if (!personalMode && remoteFile.key.startsWith("companies/")) {
2072
+ invalidScopedKeys.push(remoteFile.key);
2073
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2074
+ continue;
2075
+ }
2076
+
1877
2077
  if (
1878
2078
  personalMode &&
1879
2079
  remoteFile.key.startsWith("companies/") &&
@@ -1917,6 +2117,20 @@ function computePullPlan(
1917
2117
  continue;
1918
2118
  }
1919
2119
 
2120
+ // Push-only exclusion filter (US-006). A key that PASSED the inclusion check
2121
+ // above is STILL not downloaded if it falls under a push-only exclude prefix
2122
+ // (`sessions/`). This is what makes sessions push-only in EVERY mode — it
2123
+ // subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
2124
+ // and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
2125
+ // default) makes this a no-op. Deliberately AFTER inclusion and reported on
2126
+ // the same `filesOutOfScope` axis. The matching local file (if a session was
2127
+ // previously pulled, authored locally, or pinned+materialized) is NOT pruned
2128
+ // here or by scope-shrink — see `planScopeShrink`, which never sees this set.
2129
+ if (isCoveredByAny(remoteFile.key, excludePrefixes)) {
2130
+ items.push({ action: "skip-out-of-scope", remoteFile, localPath });
2131
+ continue;
2132
+ }
2133
+
1920
2134
  // LIST gives us no kind signal for the remote object — we don't
1921
2135
  // know whether this key is a regular file or a symlink record
1922
2136
  // until we either HEAD it (expensive — N extra calls per pull) or
@@ -2420,6 +2634,7 @@ function computePullPlan(
2420
2634
  filesOutOfScope,
2421
2635
  filesToTombstoneDelete,
2422
2636
  tombstones,
2637
+ invalidScopedKeys,
2423
2638
  };
2424
2639
  }
2425
2640
 
@@ -2502,5 +2717,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2502
2717
  console.warn(
2503
2718
  ` ! ${event.path} skipped — ${mb} MB exceeds the 50 MB sync size limit`,
2504
2719
  );
2720
+ } else if (event.type === "skip-invalid-scoped-key") {
2721
+ console.warn(
2722
+ ` ! ${event.path} skipped — invalid 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
2723
+ );
2505
2724
  }
2506
2725
  }