@indigoai-us/hq-cloud 6.14.1 → 6.14.3

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 (175) 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 +85 -12
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +95 -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 +5 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5 -2
  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/personal-vault.d.ts +29 -1
  78. package/dist/personal-vault.d.ts.map +1 -1
  79. package/dist/personal-vault.js +48 -2
  80. package/dist/personal-vault.js.map +1 -1
  81. package/dist/personal-vault.test.js +44 -1
  82. package/dist/personal-vault.test.js.map +1 -1
  83. package/dist/s3.d.ts +1 -0
  84. package/dist/s3.d.ts.map +1 -1
  85. package/dist/s3.js +75 -2
  86. package/dist/s3.js.map +1 -1
  87. package/dist/s3.test.js +56 -1
  88. package/dist/s3.test.js.map +1 -1
  89. package/dist/scope-shrink.d.ts +12 -0
  90. package/dist/scope-shrink.d.ts.map +1 -1
  91. package/dist/scope-shrink.js +12 -0
  92. package/dist/scope-shrink.js.map +1 -1
  93. package/dist/scope-shrink.test.js +31 -0
  94. package/dist/scope-shrink.test.js.map +1 -1
  95. package/dist/skill-telemetry.d.ts +41 -0
  96. package/dist/skill-telemetry.d.ts.map +1 -1
  97. package/dist/skill-telemetry.js +111 -7
  98. package/dist/skill-telemetry.js.map +1 -1
  99. package/dist/skill-telemetry.test.js +187 -5
  100. package/dist/skill-telemetry.test.js.map +1 -1
  101. package/dist/sync/event-sync.d.ts +3 -0
  102. package/dist/sync/event-sync.d.ts.map +1 -1
  103. package/dist/sync/event-sync.js +23 -0
  104. package/dist/sync/event-sync.js.map +1 -1
  105. package/dist/sync/pull-scope.d.ts +21 -0
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +46 -13
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/telemetry-events.d.ts +31 -0
  110. package/dist/telemetry-events.d.ts.map +1 -0
  111. package/dist/telemetry-events.js +140 -0
  112. package/dist/telemetry-events.js.map +1 -0
  113. package/dist/telemetry-events.test.d.ts +2 -0
  114. package/dist/telemetry-events.test.d.ts.map +1 -0
  115. package/dist/telemetry-events.test.js +73 -0
  116. package/dist/telemetry-events.test.js.map +1 -0
  117. package/dist/telemetry.js +1 -1
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/vault-client.d.ts +38 -5
  120. package/dist/vault-client.d.ts.map +1 -1
  121. package/dist/vault-client.js +69 -6
  122. package/dist/vault-client.js.map +1 -1
  123. package/dist/vault-client.test.js +31 -1
  124. package/dist/vault-client.test.js.map +1 -1
  125. package/dist/watcher.d.ts +7 -0
  126. package/dist/watcher.d.ts.map +1 -1
  127. package/dist/watcher.js +49 -1
  128. package/dist/watcher.js.map +1 -1
  129. package/dist/watcher.test.js +49 -0
  130. package/dist/watcher.test.js.map +1 -1
  131. package/package.json +1 -1
  132. package/pnpm-workspace.yaml +2 -0
  133. package/scripts/presign-transport-e2e.mjs +2 -2
  134. package/src/bin/sync-runner-company.ts +65 -1
  135. package/src/bin/sync-runner-watch-loop.ts +24 -8
  136. package/src/bin/sync-runner.test.ts +194 -16
  137. package/src/bin/sync-runner.ts +209 -11
  138. package/src/cli/accept.ts +27 -0
  139. package/src/cli/invite.ts +31 -0
  140. package/src/cli/promote.ts +31 -0
  141. package/src/cli/reindex.test.ts +113 -13
  142. package/src/cli/reindex.ts +123 -23
  143. package/src/cli/share.test.ts +139 -2
  144. package/src/cli/share.ts +48 -2
  145. package/src/cli/sync-scope.test.ts +90 -0
  146. package/src/cli/sync.test.ts +154 -0
  147. package/src/cli/sync.ts +224 -5
  148. package/src/company-resolver.test.ts +51 -0
  149. package/src/company-resolver.ts +40 -5
  150. package/src/ignore.test.ts +18 -1
  151. package/src/ignore.ts +24 -2
  152. package/src/index.ts +24 -1
  153. package/src/journal.ts +12 -0
  154. package/src/lib/exit-codes.ts +24 -0
  155. package/src/object-io.test.ts +66 -1
  156. package/src/object-io.ts +51 -14
  157. package/src/operation-lock.test.ts +33 -0
  158. package/src/operation-lock.ts +46 -11
  159. package/src/personal-vault.test.ts +57 -0
  160. package/src/personal-vault.ts +48 -2
  161. package/src/s3.test.ts +87 -0
  162. package/src/s3.ts +93 -2
  163. package/src/scope-shrink.test.ts +32 -0
  164. package/src/scope-shrink.ts +12 -0
  165. package/src/skill-telemetry.test.ts +247 -4
  166. package/src/skill-telemetry.ts +134 -6
  167. package/src/sync/event-sync.ts +37 -0
  168. package/src/sync/pull-scope.ts +62 -13
  169. package/src/telemetry-events.test.ts +88 -0
  170. package/src/telemetry-events.ts +205 -0
  171. package/src/telemetry.ts +1 -1
  172. package/src/vault-client.test.ts +39 -1
  173. package/src/vault-client.ts +115 -14
  174. package/src/watcher.test.ts +70 -0
  175. package/src/watcher.ts +68 -0
@@ -1265,8 +1265,8 @@ describe("share", () => {
1265
1265
  });
1266
1266
 
1267
1267
  it("a file over the size limit is a benign skip-size-limit event, NEVER an error (HQ-SYNC-4 exit-2 flood)", async () => {
1268
- // Regression for the fleet-wide "auto-sync watcher exited unexpectedly
1269
- // (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ machines). An
1268
+ // Regression for the "auto-sync watcher exited unexpectedly
1269
+ // (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ user machines). An
1270
1270
  // oversized file is a permanent, benign skip — it exceeds the 50 MB cap on
1271
1271
  // EVERY pass, forever. Emitting it as `type: "error"` pushed it into the
1272
1272
  // runner's `errors[]`, so the pass returned exit 2, which the menubar
@@ -4992,3 +4992,140 @@ describe("currency-gated: journal version 2 fixtures", () => {
4992
4992
  });
4993
4993
  });
4994
4994
  });
4995
+
4996
+ describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
4997
+ let tmpDir: string;
4998
+ let stateDir: string;
4999
+
5000
+ beforeEach(() => {
5001
+ clearContextCache();
5002
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-poison-test-"));
5003
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
5004
+ process.env.HQ_STATE_DIR = stateDir;
5005
+ setupFetchMock();
5006
+ // Earlier describes in this file don't all clear call history; reset it
5007
+ // here so uploaded-key assertions below are order-independent.
5008
+ vi.mocked(uploadFile).mockClear();
5009
+ vi.mocked(uploadSymlink).mockClear();
5010
+ vi.mocked(headRemoteFile).mockClear();
5011
+ vi.mocked(deleteRemoteFile).mockClear();
5012
+ vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
5013
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
5014
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
5015
+ });
5016
+
5017
+ afterEach(() => {
5018
+ vi.unstubAllGlobals();
5019
+ vi.clearAllMocks();
5020
+ vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
5021
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
5022
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
5023
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5024
+ fs.rmSync(stateDir, { recursive: true, force: true });
5025
+ delete process.env.HQ_STATE_DIR;
5026
+ });
5027
+
5028
+ it("refuses to upload doubled-tree companies/ keys in company mode — clear per-key warning, never an error", async () => {
5029
+ // The incident writer: a stale doubled local tree
5030
+ // companies/acme/companies/acme/… would push literal `companies/…` keys
5031
+ // via the direct-S3 STS transport, poisoning the bucket with keys the
5032
+ // presign download API rejects (INVALID_KEY_COMPANIES_SCOPED) for every
5033
+ // other machine. The push planner must refuse them up front.
5034
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5035
+ const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
5036
+ fs.mkdirSync(doubled, { recursive: true });
5037
+ fs.writeFileSync(path.join(doubled, "poison.md"), "poison");
5038
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
5039
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
5040
+
5041
+ const events: Array<{ type?: string; path?: string }> = [];
5042
+ const result = await share({
5043
+ paths: [companyRoot],
5044
+ company: "acme",
5045
+ vaultConfig: mockConfig,
5046
+ hqRoot: tmpDir,
5047
+ onEvent: (e) => events.push(e as { type?: string }),
5048
+ });
5049
+
5050
+ expect(result.aborted).toBe(false);
5051
+ expect(result.filesUploaded).toBe(1);
5052
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
5053
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
5054
+
5055
+ // Per-key warning on the existing event channel, never type:"error"
5056
+ // (an error event flips the runner pass to exit 2 — the flood class).
5057
+ const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
5058
+ expect(skipEvents.map((e) => e.path)).toEqual([
5059
+ "companies/acme/knowledge/poison.md",
5060
+ ]);
5061
+ expect(events.some((e) => e.type === "error")).toBe(false);
5062
+ });
5063
+
5064
+ it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
5065
+ // Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
5066
+ // companies/-prefixed key would be rejected by the presign transport's
5067
+ // server validator, so the delete plan must route it straight to
5068
+ // toTombstone — journal entry drained, no remote call, no re-upload.
5069
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5070
+ fs.mkdirSync(companyRoot, { recursive: true });
5071
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
5072
+ fs.writeFileSync(
5073
+ journalPath,
5074
+ JSON.stringify({
5075
+ version: "1",
5076
+ lastSync: new Date().toISOString(),
5077
+ files: {
5078
+ "companies/acme/knowledge/poison.md": {
5079
+ hash: "poison-hash",
5080
+ size: 6,
5081
+ syncedAt: new Date().toISOString(),
5082
+ direction: "up",
5083
+ remoteEtag: "poison-etag",
5084
+ },
5085
+ },
5086
+ }),
5087
+ );
5088
+
5089
+ const result = await share({
5090
+ paths: [companyRoot],
5091
+ company: "acme",
5092
+ vaultConfig: mockConfig,
5093
+ hqRoot: tmpDir,
5094
+ skipUnchanged: true,
5095
+ propagateDeletes: true,
5096
+ propagateDeletePolicy: "currency-gated",
5097
+ });
5098
+
5099
+ expect(result.aborted).toBe(false);
5100
+ // Tombstoned: journal entry dropped without any remote HEAD or DELETE.
5101
+ expect(result.filesTombstoned).toBe(1);
5102
+ expect(result.filesDeleted).toBe(0);
5103
+ expect(headRemoteFile).not.toHaveBeenCalled();
5104
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
5105
+ expect(uploadFile).not.toHaveBeenCalled();
5106
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5107
+ expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
5108
+ });
5109
+
5110
+ it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
5111
+ const localCo = path.join(tmpDir, "companies", "localco");
5112
+ fs.mkdirSync(localCo, { recursive: true });
5113
+ fs.writeFileSync(path.join(localCo, "notes.md"), "n");
5114
+
5115
+ const events: Array<{ type?: string }> = [];
5116
+ const result = await share({
5117
+ paths: [localCo],
5118
+ company: "acme",
5119
+ vaultConfig: mockConfig,
5120
+ hqRoot: tmpDir,
5121
+ personalMode: true,
5122
+ journalSlug: "personal-vault",
5123
+ onEvent: (e) => events.push(e as { type?: string }),
5124
+ });
5125
+
5126
+ expect(result.aborted).toBe(false);
5127
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
5128
+ expect(uploadedKeys).toContain("companies/localco/notes.md");
5129
+ expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
5130
+ });
5131
+ });
package/src/cli/share.ts CHANGED
@@ -1047,12 +1047,34 @@ function createShareCounters(): ShareCounters {
1047
1047
  }
1048
1048
 
1049
1049
  async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1050
- const filesToShare = collectFiles(
1050
+ const collected = collectFiles(
1051
1051
  run.paths,
1052
1052
  run.hqRoot,
1053
1053
  run.syncRoot,
1054
1054
  run.shouldSync,
1055
1055
  );
1056
+ // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1057
+ // root IS the company folder, so a local entry whose vault key starts with
1058
+ // `companies/` can only come from a stale doubled local tree
1059
+ // (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
1060
+ // with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
1061
+ // the direct-S3 STS transport bypasses server validation. Refuse them here
1062
+ // with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
1063
+ // is the belt-and-suspenders backstop. The pull leg's journal-tombstone
1064
+ // path then CLEANS the doubled tree (local delete + journal drop) instead
1065
+ // of this leg ever re-uploading it. Personal vaults legitimately carry
1066
+ // `companies/{slug}/…` keys, so this only applies in company mode.
1067
+ const filesToShare =
1068
+ run.options.personalMode === true
1069
+ ? collected
1070
+ : collected.filter((entry) => {
1071
+ if (!entry.relativePath.startsWith("companies/")) return true;
1072
+ run.emit({
1073
+ type: "skip-invalid-scoped-key",
1074
+ path: entry.relativePath,
1075
+ });
1076
+ return false;
1077
+ });
1056
1078
  const pushPlan = computePushPlan(
1057
1079
  filesToShare,
1058
1080
  run.journal,
@@ -1069,6 +1091,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1069
1091
  run.shouldSync,
1070
1092
  run.propagateDeletePolicy,
1071
1093
  run.ctx,
1094
+ run.options.personalMode !== true,
1072
1095
  )
1073
1096
  : { toDelete: [], toTombstone: [], refusedStale: [] };
1074
1097
  const decommissionPlan =
@@ -1139,7 +1162,7 @@ async function executeUploads(
1139
1162
  // Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
1140
1163
  // so every watch pass returned exit 2 and the menubar reported an
1141
1164
  // "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
1142
- // tick (the fleet-wide HQ-SYNC-4 flood). Surface it for visibility, but
1165
+ // tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
1143
1166
  // do not let it flip the pass to a non-zero exit.
1144
1167
  let bytes = 0;
1145
1168
  try {
@@ -1676,6 +1699,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1676
1699
  ` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
1677
1700
  ` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
1678
1701
  );
1702
+ } else if (event.type === "skip-invalid-scoped-key") {
1703
+ console.warn(
1704
+ ` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
1705
+ );
1679
1706
  } else if (event.type === "ignore-excluded") {
1680
1707
  // The "not silent" surface: an ignore rule dropped content that doesn't
1681
1708
  // look like expected build/VCS/cache noise, so it never reached the vault.
@@ -2178,6 +2205,11 @@ async function computeDeletePlan(
2178
2205
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
2179
2206
  policy: "currency-gated" | "owned-only" | "all",
2180
2207
  ctx: EntityContext,
2208
+ // True for a COMPANY-scoped push (personalMode !== true): the sync root is
2209
+ // the company folder and journal keys are bucket-relative, so a literal
2210
+ // `companies/…` key is scope-invalid (see the poisoned-key branch below).
2211
+ // Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
2212
+ companyScoped: boolean = true,
2181
2213
  ): Promise<DeletePlan> {
2182
2214
  const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
2183
2215
  if (scopeRoots.length === 0) return plan;
@@ -2249,6 +2281,20 @@ async function computeDeletePlan(
2249
2281
  continue;
2250
2282
  }
2251
2283
 
2284
+ // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
2285
+ // context, a journal entry at a literal `companies/…` key records a
2286
+ // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
2287
+ // presign transport is rejected by the server validator
2288
+ // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
2289
+ // straight to `toTombstone` (journal drop, no remote call) — the local
2290
+ // journal entry drains; server-side cleanup of any poisoned object is an
2291
+ // operator action. Personal-vault pushes (personalMode) carry legitimate
2292
+ // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
2293
+ if (companyScoped && relativeKey.startsWith("companies/")) {
2294
+ plan.toTombstone.push(relativeKey);
2295
+ continue;
2296
+ }
2297
+
2252
2298
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
2253
2299
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
2254
2300
  // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
@@ -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
+ });