@indigoai-us/hq-cloud 6.14.7 → 6.14.9

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 (104) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-company.js +8 -0
  15. package/dist/bin/sync-runner-company.js.map +1 -1
  16. package/dist/bin/sync-runner-planning.d.ts +1 -1
  17. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-planning.js +15 -1
  19. package/dist/bin/sync-runner-planning.js.map +1 -1
  20. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  21. package/dist/bin/sync-runner-watch-loop.js +28 -0
  22. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +14 -0
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +26 -0
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +68 -1
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  30. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  31. package/dist/cli/rescue-core.d.ts +1 -0
  32. package/dist/cli/rescue-core.d.ts.map +1 -1
  33. package/dist/cli/rescue-core.js +478 -300
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/rescue-snapshot.d.ts +14 -0
  36. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  37. package/dist/cli/rescue-snapshot.js +39 -0
  38. package/dist/cli/rescue-snapshot.js.map +1 -0
  39. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  40. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  41. package/dist/cli/rescue-snapshot.test.js +46 -0
  42. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  43. package/dist/cli/sync-scope.test.js +3 -1
  44. package/dist/cli/sync-scope.test.js.map +1 -1
  45. package/dist/cli/sync.d.ts +6 -6
  46. package/dist/cli/sync.d.ts.map +1 -1
  47. package/dist/cli/sync.js +51 -41
  48. package/dist/cli/sync.js.map +1 -1
  49. package/dist/cli/sync.test.js +102 -2
  50. package/dist/cli/sync.test.js.map +1 -1
  51. package/dist/manifest-reconcile.d.ts +27 -0
  52. package/dist/manifest-reconcile.d.ts.map +1 -0
  53. package/dist/manifest-reconcile.js +120 -0
  54. package/dist/manifest-reconcile.js.map +1 -0
  55. package/dist/manifest-reconcile.test.d.ts +2 -0
  56. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  57. package/dist/manifest-reconcile.test.js +97 -0
  58. package/dist/manifest-reconcile.test.js.map +1 -0
  59. package/dist/object-io.d.ts +19 -2
  60. package/dist/object-io.d.ts.map +1 -1
  61. package/dist/object-io.js +168 -32
  62. package/dist/object-io.js.map +1 -1
  63. package/dist/object-io.test.js +157 -1
  64. package/dist/object-io.test.js.map +1 -1
  65. package/dist/s3.d.ts +12 -1
  66. package/dist/s3.d.ts.map +1 -1
  67. package/dist/s3.js +51 -18
  68. package/dist/s3.js.map +1 -1
  69. package/dist/s3.test.js +12 -1
  70. package/dist/s3.test.js.map +1 -1
  71. package/dist/signals/get.test.js +21 -1
  72. package/dist/signals/get.test.js.map +1 -1
  73. package/dist/signals/list.test.js +21 -1
  74. package/dist/signals/list.test.js.map +1 -1
  75. package/dist/sources/get.test.js +21 -1
  76. package/dist/sources/get.test.js.map +1 -1
  77. package/dist/sources/list.test.js +21 -1
  78. package/dist/sources/list.test.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/backup-prune.test.ts +98 -0
  81. package/src/backup-prune.ts +182 -0
  82. package/src/bin/backup-prune-runner.ts +33 -0
  83. package/src/bin/sync-runner-company.ts +7 -0
  84. package/src/bin/sync-runner-planning.ts +16 -2
  85. package/src/bin/sync-runner-watch-loop.ts +18 -0
  86. package/src/bin/sync-runner.test.ts +82 -1
  87. package/src/bin/sync-runner.ts +45 -0
  88. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  89. package/src/cli/rescue-core.ts +261 -86
  90. package/src/cli/rescue-snapshot.test.ts +57 -0
  91. package/src/cli/rescue-snapshot.ts +51 -0
  92. package/src/cli/sync-scope.test.ts +3 -1
  93. package/src/cli/sync.test.ts +121 -2
  94. package/src/cli/sync.ts +71 -53
  95. package/src/manifest-reconcile.test.ts +107 -0
  96. package/src/manifest-reconcile.ts +160 -0
  97. package/src/object-io.test.ts +175 -0
  98. package/src/object-io.ts +213 -32
  99. package/src/s3.test.ts +18 -0
  100. package/src/s3.ts +64 -30
  101. package/src/signals/get.test.ts +26 -2
  102. package/src/signals/list.test.ts +26 -2
  103. package/src/sources/get.test.ts +26 -2
  104. package/src/sources/list.test.ts +26 -2
@@ -11,7 +11,8 @@ import type { VaultServiceConfig } from "../types.js";
11
11
  import { lockPathFor } from "../operation-lock.js";
12
12
 
13
13
  // Mock s3 module at the top level
14
- vi.mock("../s3.js", async () => {
14
+ vi.mock("../s3.js", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("../s3.js")>();
15
16
  const { vi: innerVi } = await import("vitest");
16
17
  const innerFs = await import("fs");
17
18
  const innerPath = await import("path");
@@ -22,6 +23,7 @@ vi.mock("../s3.js", async () => {
22
23
  ];
23
24
 
24
25
  return {
26
+ ...actual,
25
27
  toPosixKey: (key: string) => key.split("\\").join("/"),
26
28
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
27
29
  downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
@@ -139,6 +141,10 @@ describe("sync", () => {
139
141
  delete process.env.HQ_STATE_DIR;
140
142
  });
141
143
 
144
+ it("keeps classifyVaultKey available from the partial S3 mock", () => {
145
+ expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
146
+ });
147
+
142
148
  it("runs reindex against hqRoot after a sync that downloaded files", async () => {
143
149
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
144
150
  // skipLock: the surrounding sync run already holds the per-root lock.
@@ -2566,6 +2572,68 @@ describe("sync", () => {
2566
2572
  expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
2567
2573
  });
2568
2574
 
2575
+ it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
2576
+ process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
2577
+ const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
2578
+ key: `bulk/file-${index}.md`,
2579
+ size: 10,
2580
+ lastModified: new Date(),
2581
+ etag: `"bulk-${index}"`,
2582
+ }));
2583
+ const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
2584
+ if (!defaultDownload) throw new Error("missing default download mock");
2585
+
2586
+ try {
2587
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2588
+ vi.mocked(s3Module.downloadFile).mockImplementation(
2589
+ async (ctx, key, localPath) => {
2590
+ if (key === "bulk/file-8.md") {
2591
+ // This models the next transfer crashing the process. The first
2592
+ // eight entries must already be durable before the final writer.
2593
+ const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2594
+ expect(Object.keys(checkpoint.files)).toHaveLength(8);
2595
+ throw new Error("simulated crash after checkpoint");
2596
+ }
2597
+ return defaultDownload(ctx, key, localPath);
2598
+ },
2599
+ );
2600
+
2601
+ const first = await sync({
2602
+ company: "acme",
2603
+ vaultConfig: mockConfig,
2604
+ hqRoot: tmpDir,
2605
+ skipReindex: true,
2606
+ onEvent: () => undefined,
2607
+ });
2608
+ expect(first.filesDownloaded).toBe(8);
2609
+
2610
+ const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2611
+ expect(Object.keys(checkpoint.files)).toHaveLength(8);
2612
+
2613
+ vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
2614
+ vi.mocked(s3Module.downloadFile).mockClear();
2615
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2616
+
2617
+ await sync({
2618
+ company: "acme",
2619
+ vaultConfig: mockConfig,
2620
+ hqRoot: tmpDir,
2621
+ skipReindex: true,
2622
+ onEvent: () => undefined,
2623
+ });
2624
+
2625
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
2626
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(
2627
+ expect.anything(),
2628
+ "bulk/file-8.md",
2629
+ expect.any(String),
2630
+ );
2631
+ } finally {
2632
+ vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
2633
+ delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
2634
+ }
2635
+ });
2636
+
2569
2637
  // ── Stage-1 plan event ─────────────────────────────────────────────────
2570
2638
 
2571
2639
  it("emits a plan event before any progress events", async () => {
@@ -3753,11 +3821,62 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
3753
3821
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
3754
3822
  const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
3755
3823
  expect(skipEvents).toEqual([
3756
- { type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
3824
+ {
3825
+ type: "skip-invalid-scoped-key",
3826
+ path: "companies/acme/knowledge/poison.md",
3827
+ errorCode: "INVALID_KEY_COMPANIES_SCOPED",
3828
+ },
3757
3829
  ]);
3758
3830
  expect(events.filter((e) => e.type === "error")).toEqual([]);
3759
3831
  });
3760
3832
 
3833
+ it("skips a control-character remote key before presigning while transferring valid keys", async () => {
3834
+ const invalidKey = "docs/bad\x00key.md";
3835
+ vi.mocked(s3Module.listRemoteFiles).mockReset();
3836
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
3837
+ { key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
3838
+ { key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
3839
+ ]);
3840
+ vi.mocked(s3Module.downloadFile).mockReset();
3841
+ vi.mocked(s3Module.downloadFile).mockImplementation(
3842
+ async (_ctx: unknown, key: string, localPath: string) => {
3843
+ if (key === invalidKey) {
3844
+ throw Object.assign(new Error("presign rejected invalid key"), {
3845
+ code: "INVALID_KEY_CONTROL_CHARS",
3846
+ });
3847
+ }
3848
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
3849
+ fs.writeFileSync(localPath, "mock file content");
3850
+ return { metadata: {} };
3851
+ },
3852
+ );
3853
+ const events: Array<{ type: string; [k: string]: unknown }> = [];
3854
+
3855
+ const result = await sync({
3856
+ company: "acme",
3857
+ vaultConfig: mockConfig,
3858
+ hqRoot: tmpDir,
3859
+ onEvent: (event) => events.push(event as never),
3860
+ });
3861
+
3862
+ expect(result.aborted).toBe(false);
3863
+ expect(result.filesDownloaded).toBe(1);
3864
+ expect(
3865
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
3866
+ ).toBe(true);
3867
+ expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
3868
+ "docs/good.md",
3869
+ ]);
3870
+ expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
3871
+ {
3872
+ type: "skip-invalid-scoped-key",
3873
+ path: invalidKey,
3874
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
3875
+ },
3876
+ ]);
3877
+ expect(events.filter((event) => event.type === "error")).toEqual([]);
3878
+ });
3879
+
3761
3880
  it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
3762
3881
  // A doubled local tree (companies/acme/companies/acme/…) previously
3763
3882
  // poisoned the bucket; the poisoned objects were deleted server-side.
package/src/cli/sync.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  headRemoteFile,
27
27
  primeObjectTransport,
28
28
  toPosixKey,
29
+ classifyVaultKey,
29
30
  } from "../s3.js";
30
31
  import type { DownloadModeWarning, RemoteFile } from "../s3.js";
31
32
  import {
@@ -348,12 +349,10 @@ export type SyncProgressEvent =
348
349
  }
349
350
  | {
350
351
  /**
351
- * Emitted by the PULL leg once per remote key skipped because it is
352
- * invalid for the vault's scope — today, a `companies/…`-prefixed key in
353
- * a company-scoped vault (the server rejects these with
354
- * INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
355
- * Such keys are bucket poisoning from a client with a stale doubled
356
- * local tree writing via direct S3 (incident 2026-07-11). Like
352
+ * Emitted by the PULL leg once per remote key skipped because the server
353
+ * will never presign it (for example, a `companies/…`-prefixed key in a
354
+ * company-scoped vault or a key with control characters). Such keys are
355
+ * bucket poisoning from an outdated direct-S3 client. Like
357
356
  * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
358
357
  * object must never error a whole company sync (erroring gated
359
358
  * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
@@ -363,6 +362,8 @@ export type SyncProgressEvent =
363
362
  */
364
363
  type: "skip-invalid-scoped-key";
365
364
  path: string;
365
+ /** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
366
+ errorCode?: string;
366
367
  };
367
368
 
368
369
  export interface SyncOptions {
@@ -602,6 +603,8 @@ interface PullRunContext {
602
603
  */
603
604
  currentExcludeSet: string[];
604
605
  fileTombstones: ReadonlyMap<string, CompanyTombstone>;
606
+ /** Successful downloads since the last durable journal checkpoint. */
607
+ downloadsSinceJournalCheckpoint: number;
605
608
  }
606
609
 
607
610
  interface PullCounters {
@@ -647,6 +650,8 @@ export function resolveAutoPruneCap(): number {
647
650
 
648
651
  /** Max time to wait on the best-effort new-files notification POST. */
649
652
  const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
653
+ /** Bound crash recovery to at most this many completed pull downloads. */
654
+ const DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE = 8;
650
655
 
651
656
  /**
652
657
  * Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
@@ -812,7 +817,7 @@ async function syncWithOperationLockHeld(
812
817
  const plan = planPull(run);
813
818
 
814
819
  emitPullPlan(run.emit, plan);
815
- reportInvalidScopedKeys(run, plan);
820
+ reportInvalidRemoteKeys(run, plan);
816
821
 
817
822
  const scopePlan = planScopeShrink(run);
818
823
  const scopeRun = executeScopeShrink(run, scopePlan);
@@ -915,6 +920,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
915
920
  currentPrefixSet,
916
921
  currentExcludeSet,
917
922
  fileTombstones,
923
+ downloadsSinceJournalCheckpoint: 0,
918
924
  };
919
925
  }
920
926
 
@@ -946,30 +952,30 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
946
952
  }
947
953
 
948
954
  /**
949
- * Surface scope-invalid remote keys the planner skipped (see the
950
- * `invalidScopedKeys` doc on PullPlan). One warning event per key on the
955
+ * Surface permanently-unpresignable remote keys the planner skipped (see the
956
+ * `invalidRemoteKeys` doc on PullPlan). One warning event per key on the
951
957
  * existing sync event channel (deliberately NOT `type: "error"` — see the
952
958
  * `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
953
959
  * event on the channel sync already uses (emitCloudTelemetry). Per policy
954
960
  * hq-alert-baseline-calibration the documented healthy baseline is ZERO
955
- * INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
956
- * on any non-zero count with no threshold tuning.
961
+ * invalid-key occurrences fleet-wide, so consumers can alert on any non-zero
962
+ * count with no threshold tuning.
957
963
  */
958
- function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
959
- if (plan.invalidScopedKeys.length === 0) return;
960
- for (const key of plan.invalidScopedKeys) {
961
- run.emit({ type: "skip-invalid-scoped-key", path: key });
964
+ function reportInvalidRemoteKeys(run: PullRunContext, plan: PullPlan): void {
965
+ if (plan.invalidRemoteKeys.length === 0) return;
966
+ const countsByErrorCode = new Map<string, number>();
967
+ for (const { path, errorCode } of plan.invalidRemoteKeys) {
968
+ run.emit({ type: "skip-invalid-scoped-key", path, errorCode });
969
+ countsByErrorCode.set(errorCode, (countsByErrorCode.get(errorCode) ?? 0) + 1);
970
+ }
971
+ for (const [errorCode, count] of countsByErrorCode) {
972
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
973
+ eventName: "sync_invalid_scoped_key_skipped",
974
+ source: "hq-sync",
975
+ companyUid: run.ctx.uid,
976
+ properties: { count, errorCode, leg: "pull" },
977
+ }, { claims: run.options.telemetryClaims });
962
978
  }
963
- void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
964
- eventName: "sync_invalid_scoped_key_skipped",
965
- source: "hq-sync",
966
- companyUid: run.ctx.uid,
967
- properties: {
968
- count: plan.invalidScopedKeys.length,
969
- errorCode: "INVALID_KEY_COMPANIES_SCOPED",
970
- leg: "pull",
971
- },
972
- }, { claims: run.options.telemetryClaims });
973
979
  }
974
980
 
975
981
  /**
@@ -1534,6 +1540,19 @@ async function downloadOne(
1534
1540
  message: err instanceof Error ? err.message : String(err),
1535
1541
  });
1536
1542
  }
1543
+ return;
1544
+ }
1545
+
1546
+ run.downloadsSinceJournalCheckpoint++;
1547
+ if (
1548
+ run.downloadsSinceJournalCheckpoint >=
1549
+ DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE
1550
+ ) {
1551
+ // writeJournal is already fsync + rename atomic. Persist completed work
1552
+ // before scheduling more objects so an abrupt process exit only replays a
1553
+ // small tail, not an entire bulk download.
1554
+ writeJournal(run.journalSlug, run.journal);
1555
+ run.downloadsSinceJournalCheckpoint = 0;
1537
1556
  }
1538
1557
  }
1539
1558
 
@@ -1973,14 +1992,13 @@ interface PullPlan {
1973
1992
  */
1974
1993
  filesToTombstoneDelete: number;
1975
1994
  /**
1976
- * Remote keys skipped because they are invalid for the vault's scope
1977
- * (company mode only: literal `companies/…` keys bucket poisoning from a
1978
- * doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
1979
- * carried by key so the caller can log one warning per key and emit
1980
- * telemetry. Healthy baseline is an empty array (policy
1995
+ * Remote keys skipped because the server will never presign them. Counted in
1996
+ * `filesExcludedByPolicy`; carried with the server-compatible error code so
1997
+ * the caller can log one warning per key and emit telemetry. Healthy
1998
+ * baseline is an empty array (policy
1981
1999
  * hq-alert-baseline-calibration: zero expected, any occurrence is signal).
1982
2000
  */
1983
- invalidScopedKeys: string[];
2001
+ invalidRemoteKeys: Array<{ path: string; errorCode: string }>;
1984
2002
  }
1985
2003
 
1986
2004
  /**
@@ -2057,9 +2075,9 @@ function computePullPlan(
2057
2075
  excludePrefixes: readonly ScopePrefixInput[] = [],
2058
2076
  ): PullPlan {
2059
2077
  const items: PullPlanItem[] = [];
2060
- // Company-mode remote keys invalid for the vault's scope (`companies/…` in a
2061
- // company bucket) collected so the caller can warn + emit telemetry.
2062
- const invalidScopedKeys: string[] = [];
2078
+ // Remote keys the server will never presign collected so the caller can
2079
+ // warn + emit telemetry without allowing one poison object to fail a pull.
2080
+ const invalidRemoteKeys: Array<{ path: string; errorCode: string }> = [];
2063
2081
  // Clean/current/non-divergent files missing locally — collected here, then
2064
2082
  // resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
2065
2083
  const intentionalDeleteCandidates: Array<{
@@ -2068,6 +2086,23 @@ function computePullPlan(
2068
2086
  }> = [];
2069
2087
 
2070
2088
  for (const remoteFile of remoteFiles) {
2089
+ const invalidKey = classifyVaultKey(
2090
+ remoteFile.key,
2091
+ personalMode ? "personal" : "company",
2092
+ );
2093
+ if (invalidKey !== null) {
2094
+ invalidRemoteKeys.push({
2095
+ path: remoteFile.key,
2096
+ errorCode: invalidKey.code,
2097
+ });
2098
+ items.push({
2099
+ action: "skip-excluded-policy",
2100
+ remoteFile,
2101
+ localPath: companyRoot,
2102
+ });
2103
+ continue;
2104
+ }
2105
+
2071
2106
  const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
2072
2107
  if (localPath === null) {
2073
2108
  items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
@@ -2085,23 +2120,6 @@ function computePullPlan(
2085
2120
  continue;
2086
2121
  }
2087
2122
 
2088
- // Scope-invalid key filter (incident 2026-07-11). A company vault bucket
2089
- // is already company-scoped, so a literal `companies/…` key can only be
2090
- // bucket poisoning — a client with a stale doubled local tree
2091
- // (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
2092
- // transport, which bypasses the server validator. The presign download
2093
- // API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
2094
- // download them errors the WHOLE company sync. Skip them here — one bad
2095
- // object must never take down a company sync — and record them on the
2096
- // plan so the caller can warn + emit telemetry (baseline is zero; any
2097
- // occurrence is signal). Mirrors the personalMode/malformed-key filters
2098
- // above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
2099
- if (!personalMode && remoteFile.key.startsWith("companies/")) {
2100
- invalidScopedKeys.push(remoteFile.key);
2101
- items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2102
- continue;
2103
- }
2104
-
2105
2123
  if (
2106
2124
  personalMode &&
2107
2125
  remoteFile.key.startsWith("companies/") &&
@@ -2662,7 +2680,7 @@ function computePullPlan(
2662
2680
  filesOutOfScope,
2663
2681
  filesToTombstoneDelete,
2664
2682
  tombstones,
2665
- invalidScopedKeys,
2683
+ invalidRemoteKeys,
2666
2684
  };
2667
2685
  }
2668
2686
 
@@ -2747,7 +2765,7 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2747
2765
  );
2748
2766
  } else if (event.type === "skip-invalid-scoped-key") {
2749
2767
  console.warn(
2750
- ` ! ${event.path} skipped — invalid 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
2768
+ ` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
2751
2769
  );
2752
2770
  }
2753
2771
  }
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import yaml from "js-yaml";
6
+
7
+ import { reconcileCompanyManifest } from "./manifest-reconcile.js";
8
+ import type { EntityInfo } from "./vault-client.js";
9
+
10
+ let hqRoot: string | undefined;
11
+
12
+ afterEach(() => {
13
+ if (hqRoot) {
14
+ fs.rmSync(hqRoot, { recursive: true, force: true });
15
+ hqRoot = undefined;
16
+ }
17
+ });
18
+
19
+ describe("reconcileCompanyManifest", () => {
20
+ it("atomically restores one successful cloud entry after the personal leg without replacing existing manifest state", async () => {
21
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
22
+ const companiesDir = path.join(hqRoot, "companies");
23
+ fs.mkdirSync(path.join(companiesDir, "acme"), { recursive: true });
24
+ fs.mkdirSync(path.join(companiesDir, "failed"), { recursive: true });
25
+ fs.mkdirSync(path.join(companiesDir, "deleted"), { recursive: true });
26
+
27
+ // This is the older manifest the personal-vault leg pulled after the
28
+ // cloud-company legs completed. The final merge must retain this state and
29
+ // add only the live, successful cloud target.
30
+ const manifestPath = path.join(companiesDir, "manifest.yaml");
31
+ fs.writeFileSync(
32
+ manifestPath,
33
+ `version: 3\nsettings:\n keep: true\ncompanies:\n local-only:\n name: Local Only\n repos:\n - repos/local\n preserved:\n name: Keep Me\n cloud_uid: cmp_preserved\n`,
34
+ );
35
+
36
+ const entities: Record<string, EntityInfo> = {
37
+ cmp_acme: {
38
+ uid: "cmp_acme",
39
+ slug: "acme",
40
+ type: "company",
41
+ status: "active",
42
+ name: "Acme Inc",
43
+ bucketName: "hq-vault-cmp-acme",
44
+ createdAt: "2026-01-01T00:00:00Z",
45
+ },
46
+ cmp_failed: {
47
+ uid: "cmp_failed",
48
+ slug: "failed",
49
+ type: "company",
50
+ status: "active",
51
+ name: "Failed Corp",
52
+ bucketName: "hq-vault-cmp-failed",
53
+ createdAt: "2026-01-01T00:00:00Z",
54
+ },
55
+ cmp_deleted: {
56
+ uid: "cmp_deleted",
57
+ slug: "deleted",
58
+ type: "company",
59
+ status: "deleted",
60
+ name: "Deleted Corp",
61
+ bucketName: "hq-vault-cmp-deleted",
62
+ createdAt: "2026-01-01T00:00:00Z",
63
+ },
64
+ };
65
+ const options = {
66
+ hqRoot,
67
+ targets: [
68
+ { uid: "cmp_acme", slug: "acme" },
69
+ { uid: "cmp_failed", slug: "failed" },
70
+ { uid: "cmp_deleted", slug: "deleted" },
71
+ { uid: "cmp_missing-directory", slug: "missing-directory" },
72
+ { uid: "prs_me", slug: "personal", personalMode: true },
73
+ ],
74
+ completedCompanySlugs: new Set(["acme", "deleted", "missing-directory", "personal"]),
75
+ getEntity: async (uid: string) => {
76
+ const entity = entities[uid];
77
+ if (!entity) throw new Error("entity not found");
78
+ return entity;
79
+ },
80
+ };
81
+
82
+ await reconcileCompanyManifest(options);
83
+ const firstWrite = fs.readFileSync(manifestPath, "utf8");
84
+ await reconcileCompanyManifest(options);
85
+
86
+ expect(fs.readFileSync(manifestPath, "utf8")).toBe(firstWrite);
87
+ expect(yaml.load(firstWrite)).toEqual({
88
+ version: 3,
89
+ settings: { keep: true },
90
+ companies: {
91
+ "local-only": {
92
+ name: "Local Only",
93
+ repos: ["repos/local"],
94
+ },
95
+ preserved: {
96
+ name: "Keep Me",
97
+ cloud_uid: "cmp_preserved",
98
+ },
99
+ acme: {
100
+ name: "Acme Inc",
101
+ cloud_uid: "cmp_acme",
102
+ bucket_name: "hq-vault-cmp-acme",
103
+ },
104
+ },
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Reconcile successfully pulled cloud companies into the local manifest.
3
+ *
4
+ * The personal-vault leg also carries `companies/manifest.yaml`, so this runs
5
+ * only after the complete fanout has settled. That makes the locally
6
+ * materialized cloud companies authoritative for their own manifest entries
7
+ * without discarding entries the personal vault already had.
8
+ */
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import yaml from "js-yaml";
14
+
15
+ import type { EntityInfo } from "./vault-client.js";
16
+
17
+ export interface ManifestReconcileTarget {
18
+ uid: string;
19
+ slug: string;
20
+ personalMode?: boolean;
21
+ }
22
+
23
+ export interface ManifestReconcileOptions {
24
+ hqRoot: string;
25
+ targets: readonly ManifestReconcileTarget[];
26
+ completedCompanySlugs: ReadonlySet<string>;
27
+ getEntity: (uid: string) => Promise<EntityInfo>;
28
+ }
29
+
30
+ interface ManifestCompany {
31
+ name?: unknown;
32
+ cloud_uid?: unknown;
33
+ bucket_name?: unknown;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ interface ManifestDocument {
38
+ companies?: Record<string, ManifestCompany>;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === "object" && !Array.isArray(value);
44
+ }
45
+
46
+ function isLiveCompany(entity: EntityInfo, uid: string): boolean {
47
+ return (
48
+ entity.uid === uid &&
49
+ entity.type === "company" &&
50
+ entity.status === "active" &&
51
+ typeof entity.name === "string" &&
52
+ entity.name.length > 0 &&
53
+ typeof entity.bucketName === "string" &&
54
+ entity.bucketName.length > 0
55
+ );
56
+ }
57
+
58
+ function hasCompanyDirectory(companiesDir: string, slug: string): boolean {
59
+ const resolvedCompaniesDir = path.resolve(companiesDir);
60
+ const companyDir = path.resolve(resolvedCompaniesDir, slug);
61
+ if (path.dirname(companyDir) !== resolvedCompaniesDir) return false;
62
+ try {
63
+ return fs.statSync(companyDir).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function loadManifest(manifestPath: string): ManifestDocument | null {
70
+ try {
71
+ const raw = fs.readFileSync(manifestPath, "utf8");
72
+ const parsed = yaml.load(raw);
73
+ return isRecord(parsed) ? (parsed as ManifestDocument) : null;
74
+ } catch (err) {
75
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeManifestAtomically(manifestPath: string, document: ManifestDocument): void {
81
+ const temporaryPath = path.join(
82
+ path.dirname(manifestPath),
83
+ `.manifest-${process.pid}-${randomUUID()}.yaml`,
84
+ );
85
+ fs.writeFileSync(temporaryPath, yaml.dump(document, { lineWidth: -1 }), "utf8");
86
+ fs.renameSync(temporaryPath, manifestPath);
87
+ }
88
+
89
+ /**
90
+ * Atomically merge live, successfully pulled company targets into
91
+ * `companies/manifest.yaml`. Targets that are personal, incomplete, deleted,
92
+ * no longer resolvable, or not materialized on disk are intentionally ignored.
93
+ */
94
+ export async function reconcileCompanyManifest(
95
+ options: ManifestReconcileOptions,
96
+ ): Promise<void> {
97
+ const companiesDir = path.join(options.hqRoot, "companies");
98
+ const candidates: Array<{ slug: string; entity: EntityInfo }> = [];
99
+
100
+ for (const target of options.targets) {
101
+ if (
102
+ target.personalMode === true ||
103
+ !options.completedCompanySlugs.has(target.slug) ||
104
+ !hasCompanyDirectory(companiesDir, target.slug)
105
+ ) {
106
+ continue;
107
+ }
108
+
109
+ try {
110
+ const entity = await options.getEntity(target.uid);
111
+ if (isLiveCompany(entity, target.uid)) {
112
+ candidates.push({ slug: target.slug, entity });
113
+ }
114
+ } catch {
115
+ // The target is not presently a live, resolvable company. Never mint a
116
+ // manifest entry from stale fanout data.
117
+ }
118
+ }
119
+
120
+ if (candidates.length === 0) return;
121
+
122
+ const manifestPath = path.join(companiesDir, "manifest.yaml");
123
+ const document = loadManifest(manifestPath);
124
+ if (!document) return;
125
+
126
+ let companies: Record<string, ManifestCompany>;
127
+ if (document.companies === undefined) {
128
+ companies = {};
129
+ document.companies = companies;
130
+ } else if (isRecord(document.companies)) {
131
+ companies = document.companies as Record<string, ManifestCompany>;
132
+ } else {
133
+ // A malformed `companies` key is user state; refusing to replace it is
134
+ // safer than losing a manifest that cannot be interpreted unambiguously.
135
+ return;
136
+ }
137
+
138
+ let changed = false;
139
+ for (const { slug, entity } of candidates) {
140
+ const existing = companies[slug];
141
+ if (existing !== undefined && !isRecord(existing)) continue;
142
+ const entry: ManifestCompany = existing ?? {};
143
+ if (
144
+ entry.name === entity.name &&
145
+ entry.cloud_uid === entity.uid &&
146
+ entry.bucket_name === entity.bucketName
147
+ ) {
148
+ continue;
149
+ }
150
+ companies[slug] = {
151
+ ...entry,
152
+ name: entity.name,
153
+ cloud_uid: entity.uid,
154
+ bucket_name: entity.bucketName,
155
+ };
156
+ changed = true;
157
+ }
158
+
159
+ if (changed) writeManifestAtomically(manifestPath, document);
160
+ }