@indigoai-us/hq-cloud 6.11.10 → 6.11.12

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 (173) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +330 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +16 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +39 -1
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  13. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  14. package/dist/cli/rescue-core.js +229 -15
  15. package/dist/cli/rescue-core.js.map +1 -1
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  19. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +2 -1
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +100 -32
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +30 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +28 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +188 -59
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +487 -1
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/cognito-auth.d.ts.map +1 -1
  33. package/dist/cognito-auth.js +55 -10
  34. package/dist/cognito-auth.js.map +1 -1
  35. package/dist/cognito-auth.test.js +61 -0
  36. package/dist/cognito-auth.test.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +93 -6
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +59 -0
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/machine-auth.test.js +60 -2
  47. package/dist/machine-auth.test.js.map +1 -1
  48. package/dist/object-io.d.ts +37 -1
  49. package/dist/object-io.d.ts.map +1 -1
  50. package/dist/object-io.js +148 -29
  51. package/dist/object-io.js.map +1 -1
  52. package/dist/object-io.test.js +121 -0
  53. package/dist/object-io.test.js.map +1 -1
  54. package/dist/operation-lock.d.ts +8 -8
  55. package/dist/operation-lock.d.ts.map +1 -1
  56. package/dist/operation-lock.js +99 -32
  57. package/dist/operation-lock.js.map +1 -1
  58. package/dist/operation-lock.test.js +51 -4
  59. package/dist/operation-lock.test.js.map +1 -1
  60. package/dist/personal-vault.d.ts +8 -0
  61. package/dist/personal-vault.d.ts.map +1 -1
  62. package/dist/personal-vault.js +17 -3
  63. package/dist/personal-vault.js.map +1 -1
  64. package/dist/personal-vault.test.js +34 -0
  65. package/dist/personal-vault.test.js.map +1 -1
  66. package/dist/prefix-coalesce.d.ts +20 -9
  67. package/dist/prefix-coalesce.d.ts.map +1 -1
  68. package/dist/prefix-coalesce.js +124 -28
  69. package/dist/prefix-coalesce.js.map +1 -1
  70. package/dist/prefix-coalesce.test.js +57 -2
  71. package/dist/prefix-coalesce.test.js.map +1 -1
  72. package/dist/remote-pull.d.ts +6 -1
  73. package/dist/remote-pull.d.ts.map +1 -1
  74. package/dist/remote-pull.js +62 -13
  75. package/dist/remote-pull.js.map +1 -1
  76. package/dist/remote-pull.test.js +189 -0
  77. package/dist/remote-pull.test.js.map +1 -1
  78. package/dist/s3.d.ts +2 -0
  79. package/dist/s3.d.ts.map +1 -1
  80. package/dist/s3.js +197 -116
  81. package/dist/s3.js.map +1 -1
  82. package/dist/s3.test.js +109 -0
  83. package/dist/s3.test.js.map +1 -1
  84. package/dist/scope-shrink.d.ts +3 -2
  85. package/dist/scope-shrink.d.ts.map +1 -1
  86. package/dist/scope-shrink.js +1 -1
  87. package/dist/scope-shrink.js.map +1 -1
  88. package/dist/skill-telemetry.d.ts +1 -1
  89. package/dist/skill-telemetry.d.ts.map +1 -1
  90. package/dist/skill-telemetry.js +69 -9
  91. package/dist/skill-telemetry.js.map +1 -1
  92. package/dist/skill-telemetry.test.js +86 -0
  93. package/dist/skill-telemetry.test.js.map +1 -1
  94. package/dist/sync/event-sync.d.ts +6 -0
  95. package/dist/sync/event-sync.d.ts.map +1 -1
  96. package/dist/sync/event-sync.js +34 -1
  97. package/dist/sync/event-sync.js.map +1 -1
  98. package/dist/sync/event-sync.test.js +73 -0
  99. package/dist/sync/event-sync.test.js.map +1 -1
  100. package/dist/sync/metrics.d.ts +17 -1
  101. package/dist/sync/metrics.d.ts.map +1 -1
  102. package/dist/sync/metrics.js +32 -1
  103. package/dist/sync/metrics.js.map +1 -1
  104. package/dist/sync/metrics.test.js +74 -1
  105. package/dist/sync/metrics.test.js.map +1 -1
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +15 -7
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/sync/push-receiver.d.ts +6 -5
  110. package/dist/sync/push-receiver.d.ts.map +1 -1
  111. package/dist/sync/push-receiver.js +13 -15
  112. package/dist/sync/push-receiver.js.map +1 -1
  113. package/dist/sync/push-receiver.test.js +36 -1
  114. package/dist/sync/push-receiver.test.js.map +1 -1
  115. package/dist/telemetry.d.ts +1 -1
  116. package/dist/telemetry.d.ts.map +1 -1
  117. package/dist/telemetry.js +59 -6
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/telemetry.test.js +74 -0
  120. package/dist/telemetry.test.js.map +1 -1
  121. package/dist/types.d.ts +8 -0
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/watcher.d.ts +36 -0
  124. package/dist/watcher.d.ts.map +1 -1
  125. package/dist/watcher.js +152 -30
  126. package/dist/watcher.js.map +1 -1
  127. package/dist/watcher.test.js +103 -0
  128. package/dist/watcher.test.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/bin/sync-runner.test.ts +396 -11
  131. package/src/bin/sync-runner.ts +254 -52
  132. package/src/cli/reindex.test.ts +47 -1
  133. package/src/cli/reindex.ts +17 -1
  134. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  135. package/src/cli/rescue-core.ts +261 -15
  136. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  137. package/src/cli/share.test.ts +38 -0
  138. package/src/cli/share.ts +103 -34
  139. package/src/cli/sync.test.ts +594 -1
  140. package/src/cli/sync.ts +229 -65
  141. package/src/cognito-auth.test.ts +77 -0
  142. package/src/cognito-auth.ts +73 -11
  143. package/src/index.ts +8 -0
  144. package/src/journal.test.ts +72 -0
  145. package/src/journal.ts +95 -8
  146. package/src/machine-auth.test.ts +64 -2
  147. package/src/object-io.test.ts +142 -0
  148. package/src/object-io.ts +182 -30
  149. package/src/operation-lock.test.ts +63 -4
  150. package/src/operation-lock.ts +99 -31
  151. package/src/personal-vault.test.ts +42 -0
  152. package/src/personal-vault.ts +18 -3
  153. package/src/prefix-coalesce.test.ts +71 -1
  154. package/src/prefix-coalesce.ts +155 -30
  155. package/src/remote-pull.test.ts +205 -0
  156. package/src/remote-pull.ts +77 -14
  157. package/src/s3.test.ts +126 -0
  158. package/src/s3.ts +237 -122
  159. package/src/scope-shrink.ts +6 -3
  160. package/src/skill-telemetry.test.ts +109 -0
  161. package/src/skill-telemetry.ts +82 -14
  162. package/src/sync/event-sync.test.ts +75 -0
  163. package/src/sync/event-sync.ts +54 -1
  164. package/src/sync/metrics.test.ts +81 -0
  165. package/src/sync/metrics.ts +59 -4
  166. package/src/sync/pull-scope.ts +23 -7
  167. package/src/sync/push-receiver.test.ts +38 -1
  168. package/src/sync/push-receiver.ts +15 -18
  169. package/src/telemetry.test.ts +85 -0
  170. package/src/telemetry.ts +69 -6
  171. package/src/types.ts +8 -0
  172. package/src/watcher.test.ts +117 -0
  173. package/src/watcher.ts +209 -33
package/src/cli/sync.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  PERSONAL_VAULT_JOURNAL_SLUG,
36
36
  migratePersonalVaultJournal,
37
37
  } from "../journal.js";
38
+ import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
38
39
  import {
39
40
  buildScopeShrinkPlan,
40
41
  applyScopeShrink,
@@ -42,7 +43,11 @@ import {
42
43
  ScopeShrinkLargePruneError,
43
44
  type ScopeShrinkAdviceContext,
44
45
  } from "../scope-shrink.js";
45
- import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
46
+ import {
47
+ coalescePrefixes,
48
+ isCoveredByAny,
49
+ type ScopePrefixInput,
50
+ } from "../prefix-coalesce.js";
46
51
  import { createIgnoreFilter } from "../ignore.js";
47
52
  import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
48
53
  import { resolveConflict } from "./conflict.js";
@@ -54,6 +59,7 @@ import {
54
59
  } from "../lib/conflict-file.js";
55
60
  import { appendConflictEntry } from "../lib/conflict-index.js";
56
61
  import { reindex } from "./reindex.js";
62
+ import { withOperationLock } from "../operation-lock.js";
57
63
  import {
58
64
  fetchCompanyTombstones,
59
65
  type CompanyTombstone,
@@ -368,7 +374,7 @@ export interface SyncOptions {
368
374
  * (not empty `"shared"`) on any grant-resolution error, so a transient
369
375
  * failure can never silently prune the local tree.
370
376
  */
371
- prefixSet?: string[];
377
+ prefixSet?: ScopePrefixInput[];
372
378
  /**
373
379
  * When the effective scope shrinks relative to the last pull and the shrink
374
380
  * would orphan locally-modified ("dirty") files, `sync()` aborts with a
@@ -413,6 +419,11 @@ export interface SyncOptions {
413
419
  * this and run `reindex()` once itself instead of per-company.
414
420
  */
415
421
  skipReindex?: boolean;
422
+ /**
423
+ * Internal runner seam: true only when the caller already holds the
424
+ * per-root operation lock for this sync pass.
425
+ */
426
+ operationLockAlreadyHeld?: boolean;
416
427
  }
417
428
 
418
429
  export interface SyncResult {
@@ -496,6 +507,16 @@ export function resolveAutoPruneCap(): number {
496
507
  /** Max time to wait on the best-effort new-files notification POST. */
497
508
  const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
498
509
 
510
+ /**
511
+ * Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
512
+ * an oversized batch wholesale, so the client MUST split a large report into
513
+ * chunks at or under this size — otherwise a first sync with more than this many
514
+ * new files reports none of them, and the same oversized batch re-triggers every
515
+ * sync cycle (wasted work + dropped notifications). Keep in lockstep with the
516
+ * server-side limit.
517
+ */
518
+ const NOTIFY_FILE_ADDED_MAX_BATCH = 1000;
519
+
499
520
  /**
500
521
  * Best-effort report of the files that were new to this drive during the sync,
501
522
  * so the HQ Sync app can show a persistent cross-session "new files" history.
@@ -504,22 +525,36 @@ const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
504
525
  * FILE_EVENT rows for the calling user (the one the files are new for). Fully
505
526
  * non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal
506
527
  * is the synced file itself; this is only a notification mirror. Bounded by a
507
- * 5s timeout so a hung endpoint can't stall sync completion. No-op when there
508
- * are no new files.
528
+ * 5s timeout PER request so a hung endpoint can't stall sync completion. No-op
529
+ * when there are no new files.
530
+ *
531
+ * Large reports are split into chunks of at most NOTIFY_FILE_ADDED_MAX_BATCH
532
+ * files (the server's per-report cap). Each chunk is POSTed independently and
533
+ * best-effort, so one failing/oversized batch can never block the others or the
534
+ * sync. Exported only so the chunking can be unit-tested directly.
509
535
  */
510
- async function reportNewFilesToNotify(
536
+ export async function reportNewFilesToNotify(
511
537
  vaultConfig: VaultServiceConfig,
512
538
  companyUid: string,
513
539
  companySlug: string,
514
540
  files: Array<{ path: string; bytes: number; addedBy: string | null }>,
515
541
  ): Promise<void> {
516
542
  if (files.length === 0) return;
543
+
544
+ let token: string;
517
545
  try {
518
- const token =
546
+ token =
519
547
  typeof vaultConfig.authToken === "function"
520
548
  ? await vaultConfig.authToken()
521
549
  : vaultConfig.authToken;
522
- const base = vaultConfig.apiUrl.replace(/\/+$/, "");
550
+ } catch (err) {
551
+ logNotifyFailure(err);
552
+ return;
553
+ }
554
+ const base = vaultConfig.apiUrl.replace(/\/+$/, "");
555
+
556
+ for (let i = 0; i < files.length; i += NOTIFY_FILE_ADDED_MAX_BATCH) {
557
+ const batch = files.slice(i, i + NOTIFY_FILE_ADDED_MAX_BATCH);
523
558
  const controller = new AbortController();
524
559
  const timer = setTimeout(
525
560
  () => controller.abort(),
@@ -535,7 +570,7 @@ async function reportNewFilesToNotify(
535
570
  body: JSON.stringify({
536
571
  companyUid,
537
572
  companySlug,
538
- files: files.map((f) => ({
573
+ files: batch.map((f) => ({
539
574
  path: f.path,
540
575
  bytes: f.bytes,
541
576
  ...(f.addedBy ? { addedBy: f.addedBy } : {}),
@@ -543,20 +578,26 @@ async function reportNewFilesToNotify(
543
578
  }),
544
579
  signal: controller.signal,
545
580
  });
581
+ } catch (err) {
582
+ // Best-effort per chunk: never let notification reporting affect the sync
583
+ // result, and a failed chunk must not abort the remaining chunks.
584
+ logNotifyFailure(err);
546
585
  } finally {
547
586
  clearTimeout(timer);
548
587
  }
549
- } catch (err) {
550
- // Best-effort: never let notification reporting affect the sync result.
551
- try {
552
- console.error(
553
- `[hq-sync] new-files notify report failed (non-fatal): ${
554
- err instanceof Error ? err.message : String(err)
555
- }`,
556
- );
557
- } catch {
558
- // swallow — logging must never break sync
559
- }
588
+ }
589
+ }
590
+
591
+ /** Log a non-fatal notify failure without ever throwing out of the logger. */
592
+ function logNotifyFailure(err: unknown): void {
593
+ try {
594
+ console.error(
595
+ `[hq-sync] new-files notify report failed (non-fatal): ${
596
+ err instanceof Error ? err.message : String(err)
597
+ }`,
598
+ );
599
+ } catch {
600
+ // swallow — logging must never break sync
560
601
  }
561
602
  }
562
603
 
@@ -564,6 +605,17 @@ async function reportNewFilesToNotify(
564
605
  * Sync (pull) all allowed files from the entity vault.
565
606
  */
566
607
  export async function sync(options: SyncOptions): Promise<SyncResult> {
608
+ if (options.operationLockAlreadyHeld) {
609
+ return syncWithOperationLockHeld(options);
610
+ }
611
+ return withOperationLock(options.hqRoot, "sync", () =>
612
+ syncWithOperationLockHeld(options),
613
+ );
614
+ }
615
+
616
+ async function syncWithOperationLockHeld(
617
+ options: SyncOptions,
618
+ ): Promise<SyncResult> {
567
619
  const { company, onConflict, vaultConfig, hqRoot } = options;
568
620
  const emit = options.onEvent ?? defaultConsoleLogger;
569
621
 
@@ -897,16 +949,17 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
897
949
  const tombstoneKey = item.remoteFile.key;
898
950
  // Same Windows-backslash landmine guard as the journal-tombstone executor:
899
951
  // a malformed key must never reach fs.unlinkSync (path.join collapses the
900
- // backslashes onto a REAL POSIX file). Drop the poisoned journal entry
901
- // without touching disk.
902
- if (isMalformedVaultKey(tombstoneKey)) {
903
- removeEntry(journal, tombstoneKey);
904
- continue;
905
- }
952
+ // backslashes onto a REAL POSIX file). Traversal keys are likewise
953
+ // refused before any local filesystem or journal mutation.
954
+ const tombstonePath = resolveContainedVaultPath(companyRoot, tombstoneKey);
955
+ if (tombstonePath === null) continue;
906
956
  try {
907
- const lstat = fs.lstatSync(item.localPath);
957
+ const lstat = fs.lstatSync(tombstonePath);
958
+ if (tombstoneTargetDiverged(journal, tombstoneKey, tombstonePath, lstat)) {
959
+ continue;
960
+ }
908
961
  if (lstat.isSymbolicLink() || lstat.isFile()) {
909
- fs.unlinkSync(item.localPath);
962
+ fs.unlinkSync(tombstonePath);
910
963
  }
911
964
  // A directory at the key: don't recursively rm-rf the operator's dir;
912
965
  // just drop the journal entry (safe-by-default, same as the other path).
@@ -984,11 +1037,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
984
1037
  machineId,
985
1038
  );
986
1039
  const conflictAbs = path.join(hqRoot, conflictRelative);
1040
+ const conflictKey = toPosixKey(path.relative(companyRoot, conflictAbs));
1041
+
1042
+ if (!isDownloadWritePathStillContained(companyRoot, conflictKey, conflictAbs)) {
1043
+ filesSkipped++;
1044
+ emit({
1045
+ type: "error",
1046
+ path: remoteFile.key,
1047
+ message: "conflict mirror skipped: local parent escaped the sync root",
1048
+ });
1049
+ continue;
1050
+ }
987
1051
 
988
1052
  let remoteFetched = false;
989
1053
  let converged = false;
990
1054
  try {
991
- await downloadFile(ctx, remoteFile.key, conflictAbs);
1055
+ const downloaded = await downloadFile(ctx, remoteFile.key, conflictAbs);
992
1056
  remoteFetched = true;
993
1057
  // Hash the fetched remote exactly the way the planner hashed local
994
1058
  // (symlink-aware) so the two hashes are directly comparable. A
@@ -996,7 +1060,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
996
1060
  // target string matches `hashSymlinkTarget(localPath)`.
997
1061
  const remoteHash = fs.lstatSync(conflictAbs).isSymbolicLink()
998
1062
  ? hashSymlinkTarget(fs.readlinkSync(conflictAbs))
999
- : hashFile(conflictAbs);
1063
+ : (downloaded.contentHash ?? hashFile(conflictAbs));
1000
1064
  converged = remoteHash === item.localHash;
1001
1065
  } catch (probeErr) {
1002
1066
  // Couldn't fetch or hash the remote — fail safe by falling through to
@@ -1211,8 +1275,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1211
1275
  ctx = await refreshEntityContext(companyRef, vaultConfig);
1212
1276
  }
1213
1277
 
1278
+ if (!isDownloadWritePathStillContained(companyRoot, remoteFile.key, localPath)) {
1279
+ filesSkipped++;
1280
+ emit({
1281
+ type: "error",
1282
+ path: remoteFile.key,
1283
+ message: "download skipped: local parent escaped the sync root",
1284
+ });
1285
+ return;
1286
+ }
1287
+
1214
1288
  try {
1215
- const { metadata } = await downloadFile(ctx, remoteFile.key, localPath);
1289
+ const { metadata, contentHash, contentSize } = await downloadFile(
1290
+ ctx,
1291
+ remoteFile.key,
1292
+ localPath,
1293
+ );
1216
1294
  const author = metadata?.["created-by"] ?? null;
1217
1295
  // Author sub for the scope-shrink authorship guard — same field the
1218
1296
  // upload side stamps, read straight off the GET response metadata.
@@ -1231,8 +1309,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1231
1309
  const isLocalSymlink = localLstat.isSymbolicLink();
1232
1310
  const hash = isLocalSymlink
1233
1311
  ? hashSymlinkTarget(fs.readlinkSync(localPath))
1234
- : hashFile(localPath);
1235
- const size = isLocalSymlink ? 0 : fs.statSync(localPath).size;
1312
+ : (contentHash ?? hashFile(localPath));
1313
+ const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
1236
1314
  // Capture the listing's ETag so subsequent syncs can detect remote
1237
1315
  // drift independently of mtime drift. Stamp mtimeMs from localLstat
1238
1316
  // (5.36.0) so the next push planner's lstat fast-path can skip the
@@ -1429,21 +1507,16 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1429
1507
  // converged. Failures are reported but non-fatal — the entry stays in
1430
1508
  // the journal and the next run retries.
1431
1509
  for (const key of plan.tombstones) {
1432
- // Last line of defense against the Windows backslash-key landmine: a
1433
- // malformed key must NEVER reach fs.unlinkSync. path.join(companyRoot, key)
1434
- // collapses the backslashes and resolves onto the REAL POSIX file, so
1435
- // unlinking here destroys live data (ridge incident, feedback_b8d09d0f).
1436
- // The planner already refuses to enqueue malformed keys; if one still
1437
- // arrives, drop the poisoned journal entry without touching disk —
1438
- // normalizeJournalKeys rewrites it to its POSIX form on load.
1439
- if (isMalformedVaultKey(key)) {
1440
- removeEntry(journal, key);
1441
- continue;
1442
- }
1443
- const localPath = path.join(companyRoot, key);
1510
+ // Last line of defense: a malformed or traversal key must NEVER reach
1511
+ // fs.unlinkSync or journal mutation for a path outside the sync root.
1512
+ const localPath = resolveContainedVaultPath(companyRoot, key);
1513
+ if (localPath === null) continue;
1444
1514
  let removedSomething = false;
1445
1515
  try {
1446
1516
  const lstat = fs.lstatSync(localPath);
1517
+ if (tombstoneTargetDiverged(journal, key, localPath, lstat)) {
1518
+ continue;
1519
+ }
1447
1520
  if (lstat.isSymbolicLink() || lstat.isFile()) {
1448
1521
  fs.unlinkSync(localPath);
1449
1522
  removedSomething = true;
@@ -1614,6 +1687,99 @@ function isRemoteRecreateAfterTombstone(
1614
1687
  return remoteMs > deletedAtMs;
1615
1688
  }
1616
1689
 
1690
+ function hasTraversalSegment(key: string): boolean {
1691
+ return key.split("/").some((segment) => segment === "..");
1692
+ }
1693
+
1694
+ function isPathWithin(root: string, candidate: string): boolean {
1695
+ const relative = path.relative(root, candidate);
1696
+ return (
1697
+ relative === "" ||
1698
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
1699
+ );
1700
+ }
1701
+
1702
+ function deepestExistingAncestor(start: string): string | null {
1703
+ let current = start;
1704
+ for (;;) {
1705
+ try {
1706
+ fs.lstatSync(current);
1707
+ return current;
1708
+ } catch (err: unknown) {
1709
+ const code =
1710
+ err && typeof err === "object" && "code" in err
1711
+ ? (err as { code?: string }).code
1712
+ : undefined;
1713
+ if (code !== "ENOENT" && code !== "ENOTDIR") return null;
1714
+ }
1715
+
1716
+ const parent = path.dirname(current);
1717
+ if (parent === current) return null;
1718
+ current = parent;
1719
+ }
1720
+ }
1721
+
1722
+ function resolveContainedVaultPath(root: string, key: string): string | null {
1723
+ if (isMalformedVaultKey(key) || hasTraversalSegment(key)) return null;
1724
+
1725
+ const resolvedRoot = path.resolve(root);
1726
+ const resolvedLocal = path.resolve(resolvedRoot, key);
1727
+ if (!isPathWithin(resolvedRoot, resolvedLocal)) return null;
1728
+
1729
+ let realRoot: string;
1730
+ try {
1731
+ realRoot = fs.realpathSync.native(resolvedRoot);
1732
+ } catch {
1733
+ // If the vault root does not exist yet, no below-root symlink component can
1734
+ // already exist to redirect this key. Preserve first-pull behavior.
1735
+ return resolvedLocal;
1736
+ }
1737
+
1738
+ const existingAncestor = deepestExistingAncestor(path.dirname(resolvedLocal));
1739
+ if (existingAncestor === null) return null;
1740
+ try {
1741
+ const realAncestor = fs.realpathSync.native(existingAncestor);
1742
+ if (!isPathWithin(realRoot, realAncestor)) return null;
1743
+ } catch {
1744
+ return null;
1745
+ }
1746
+ return resolvedLocal;
1747
+ }
1748
+
1749
+ function isDownloadWritePathStillContained(
1750
+ root: string,
1751
+ key: string,
1752
+ localPath: string,
1753
+ ): boolean {
1754
+ const resolved = resolveContainedVaultPath(root, key);
1755
+ return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
1756
+ }
1757
+
1758
+ function tombstoneTargetDiverged(
1759
+ journal: SyncJournal,
1760
+ key: string,
1761
+ localPath: string,
1762
+ lstat: fs.Stats,
1763
+ ): boolean {
1764
+ const journalEntry = journal.files[key];
1765
+ if (!journalEntry?.hash) {
1766
+ return lstat.isSymbolicLink() || lstat.isFile();
1767
+ }
1768
+
1769
+ try {
1770
+ if (lstat.isSymbolicLink()) {
1771
+ return hashSymlinkTarget(fs.readlinkSync(localPath)) !== journalEntry.hash;
1772
+ }
1773
+ if (lstat.isFile()) {
1774
+ return hashFile(localPath) !== journalEntry.hash;
1775
+ }
1776
+ } catch {
1777
+ return true;
1778
+ }
1779
+
1780
+ return false;
1781
+ }
1782
+
1617
1783
  /**
1618
1784
  * Stage-1 classification for a single remote object. Each remote file falls
1619
1785
  * into exactly one bucket; the executor in `sync()` switches on `action` to
@@ -1715,7 +1881,7 @@ function computePullPlan(
1715
1881
  // Coalesced, company-relative prefixes the pull is scoped to (US-005).
1716
1882
  // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
1717
1883
  // the scope filter below becomes a no-op and legacy behavior is preserved.
1718
- prefixSet: string[],
1884
+ prefixSet: readonly ScopePrefixInput[],
1719
1885
  // FILE_TOMBSTONE records (POSIX-keyed) for the company — the durable
1720
1886
  // "this key was intentionally deleted" signal the planner consults before
1721
1887
  // re-downloading a key, so a deleted folder does not resync back in
@@ -1727,7 +1893,11 @@ function computePullPlan(
1727
1893
  const items: PullPlanItem[] = [];
1728
1894
 
1729
1895
  for (const remoteFile of remoteFiles) {
1730
- const localPath = path.join(companyRoot, remoteFile.key);
1896
+ const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
1897
+ if (localPath === null) {
1898
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
1899
+ continue;
1900
+ }
1731
1901
 
1732
1902
  // Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
1733
1903
  // in the 5.33.0 deep-test: the push side has refused to upload conflict
@@ -1740,18 +1910,17 @@ function computePullPlan(
1740
1910
  continue;
1741
1911
  }
1742
1912
 
1743
- // Malformed-key filter — keys with backslash separators pushed by
1744
- // pre-5.47.2 Windows clients. Downloading one materializes a junk local
1745
- // file whose NAME contains backslashes (it is not a path on POSIX), which
1746
- // then churns conflict mirrors forever. Refuse at planning time, same
1747
- // policy bucket as the ephemeral filter above. The bogus keys themselves
1748
- // are cleaned server-side; this keeps clean trees clean in the meantime.
1749
- if (isMalformedVaultKey(remoteFile.key)) {
1750
- items.push({ action: "skip-excluded-policy", remoteFile, localPath });
1751
- continue;
1752
- }
1753
-
1754
- if (personalMode && remoteFile.key.startsWith("companies/")) {
1913
+ if (
1914
+ personalMode &&
1915
+ remoteFile.key.startsWith("companies/") &&
1916
+ // EXEMPTION: companies/manifest.yaml is the routing source-of-truth
1917
+ // carved INTO the personal vault on the push side
1918
+ // (computePersonalVaultPaths). It must round-trip on the pull leg too
1919
+ // skipping it here leaves it forever unjournaled, which re-fires a
1920
+ // transient push-side conflict every sync (no journal baseline). Let it
1921
+ // fall through to download + journal like any personal file.
1922
+ remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
1923
+ ) {
1755
1924
  // Default: drop every `companies/...` key — the legacy contract
1756
1925
  // is that the personal bucket should never contain them.
1757
1926
  //
@@ -2101,12 +2270,8 @@ function computePullPlan(
2101
2270
  // POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
2102
2271
  const posixKey = toPosixKey(key);
2103
2272
  if (remoteKeySet.has(posixKey)) continue;
2104
- // Never tombstone-delete via a malformed (backslash) key: the executor's
2105
- // path.join(companyRoot, key) collapses backslashes back onto the REAL
2106
- // POSIX file and unlinks live data. Leave it for normalizeJournalKeys to
2107
- // rewrite to POSIX on the next write; the canonical key is re-evaluated
2108
- // (and correctly tombstoned if genuinely remote-deleted) on a later pull.
2109
- if (isMalformedVaultKey(key)) continue;
2273
+ const localPath = resolveContainedVaultPath(companyRoot, key);
2274
+ if (localPath === null) continue;
2110
2275
  // PersonalMode key gating — mirror the download branch.
2111
2276
  if (personalMode && key.startsWith("companies/")) {
2112
2277
  const slug = key.split("/")[1] ?? "";
@@ -2120,7 +2285,6 @@ function computePullPlan(
2120
2285
  // Honor the current ignore filter — if a path was previously synced
2121
2286
  // but is now ignored (operator edited .hqignore), do NOT delete
2122
2287
  // the local copy. They're keeping it deliberately.
2123
- const localPath = path.join(companyRoot, key);
2124
2288
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
2125
2289
  // Codex P1 (PR #24 round 3): detect local edits before tombstoning.
2126
2290
  // Delete-vs-local-edit race: peer deleted the file remotely while
@@ -198,6 +198,83 @@ describe("getValidAccessToken stale-pool detection", () => {
198
198
  });
199
199
  });
200
200
 
201
+ // ---------------------------------------------------------------------------
202
+ // Machine identity cache isolation — machine mode must not reuse human tokens
203
+ // from the shared Cognito cache even when the app client matches.
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe("machine identity cache isolation", () => {
207
+ it("F06: machine mode re-mints instead of reusing a cached human token", async () => {
208
+ const { saveCachedTokens, getValidAccessToken } = await importModule();
209
+
210
+ const cachedHumanAccessToken = makeAccessToken({
211
+ token_use: "access",
212
+ client_id: PROD_CLIENT,
213
+ username: "human@example.com",
214
+ sub: "human-sub",
215
+ });
216
+ const cachedHumanIdToken = makeAccessToken({
217
+ token_use: "id",
218
+ aud: PROD_CLIENT,
219
+ email: "human@example.com",
220
+ "cognito:username": "human@example.com",
221
+ sub: "human-sub",
222
+ });
223
+ saveCachedTokens({
224
+ ...baseTokens,
225
+ accessToken: cachedHumanAccessToken,
226
+ idToken: cachedHumanIdToken,
227
+ expiresAt: Date.now() + 60 * 60 * 1000,
228
+ });
229
+
230
+ const machineUid = "agt_01JZ0000000000000000000000";
231
+ const machineCredsDir = path.join(tmpHome, ".hq-agent");
232
+ fs.mkdirSync(machineCredsDir, { recursive: true });
233
+ fs.writeFileSync(
234
+ path.join(machineCredsDir, "machine-creds.json"),
235
+ JSON.stringify({
236
+ username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
237
+ secret: "machine-secret",
238
+ clientId: PROD_CLIENT,
239
+ region: "us-east-1",
240
+ }),
241
+ );
242
+
243
+ const mintedMachineAccessToken = makeAccessToken({
244
+ token_use: "access",
245
+ client_id: PROD_CLIENT,
246
+ username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
247
+ sub: machineUid,
248
+ });
249
+ const mintedMachineIdToken = makeAccessToken({
250
+ token_use: "id",
251
+ aud: PROD_CLIENT,
252
+ "custom:entityType": "agent",
253
+ "custom:entityUid": machineUid,
254
+ "cognito:username": "agt-01jz0000000000000000000000@agents.getindigo.ai",
255
+ sub: machineUid,
256
+ });
257
+ const fetchMock = vi.fn(async () =>
258
+ new Response(
259
+ JSON.stringify({
260
+ AuthenticationResult: {
261
+ AccessToken: mintedMachineAccessToken,
262
+ IdToken: mintedMachineIdToken,
263
+ ExpiresIn: 3600,
264
+ },
265
+ }),
266
+ { status: 200, headers: { "Content-Type": "application/json" } },
267
+ ),
268
+ );
269
+ vi.stubGlobal("fetch", fetchMock);
270
+
271
+ await expect(
272
+ getValidAccessToken(baseConfig, { interactive: false }),
273
+ ).resolves.toBe(mintedMachineIdToken);
274
+ expect(fetchMock).toHaveBeenCalledTimes(1);
275
+ });
276
+ });
277
+
201
278
  // ---------------------------------------------------------------------------
202
279
  // Round-trip: writers emit epoch-ms, readers read epoch-ms
203
280
  // ---------------------------------------------------------------------------
@@ -134,19 +134,80 @@ export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
134
134
  * forcing a re-login is the only safe self-heal.
135
135
  */
136
136
  export function decodeAccessTokenClientId(accessToken: string): string | null {
137
+ const claims = decodeJwtClaims(accessToken);
138
+ return typeof claims?.client_id === "string" ? claims.client_id : null;
139
+ }
140
+
141
+ function decodeJwtClaims(token: string): Record<string, unknown> | null {
137
142
  try {
138
- const parts = accessToken.split(".");
143
+ const parts = token.split(".");
139
144
  if (parts.length < 2) return null;
140
145
  const payloadB64 = parts[1];
141
- const padded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4);
146
+ const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
147
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
142
148
  const json = Buffer.from(padded, "base64").toString("utf-8");
143
- const claims = JSON.parse(json) as { client_id?: unknown };
144
- return typeof claims.client_id === "string" ? claims.client_id : null;
149
+ const claims = JSON.parse(json);
150
+ return claims && typeof claims === "object" && !Array.isArray(claims)
151
+ ? (claims as Record<string, unknown>)
152
+ : null;
145
153
  } catch {
146
154
  return null;
147
155
  }
148
156
  }
149
157
 
158
+ function cachedTokensMatchMachineIdentity(
159
+ tokens: CognitoTokens,
160
+ creds: MachineCreds,
161
+ expectedClientId: string,
162
+ ): boolean {
163
+ const accessClaims = decodeJwtClaims(tokens.accessToken);
164
+ const idClaims = decodeJwtClaims(tokens.idToken);
165
+ if (!accessClaims || !idClaims) return false;
166
+
167
+ const stringClaim = (
168
+ claims: Record<string, unknown>,
169
+ key: string,
170
+ ): string | null => {
171
+ const value = claims[key];
172
+ return typeof value === "string" && value.length > 0 ? value : null;
173
+ };
174
+
175
+ const accessClientId = accessClaims.client_id;
176
+ const idAudience = idClaims.aud;
177
+ const accessUsername =
178
+ stringClaim(accessClaims, "username") ??
179
+ stringClaim(accessClaims, "cognito:username");
180
+ const accessSub = stringClaim(accessClaims, "sub");
181
+ const idUsername =
182
+ stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
183
+ const idSub = stringClaim(idClaims, "sub");
184
+ const entityType = idClaims["custom:entityType"];
185
+ const entityUid = idClaims["custom:entityUid"];
186
+ const idTokenMatchesCreds =
187
+ idUsername === creds.username || idSub === creds.username;
188
+ const subjectBindings: boolean[] = [];
189
+ if (accessUsername !== null && idUsername !== null) {
190
+ subjectBindings.push(accessUsername === idUsername);
191
+ }
192
+ if (accessSub !== null && idSub !== null) {
193
+ subjectBindings.push(accessSub === idSub);
194
+ }
195
+ const tokensShareSubject =
196
+ subjectBindings.length > 0 && subjectBindings.every(Boolean);
197
+
198
+ return (
199
+ accessClaims.token_use === "access" &&
200
+ accessClientId === expectedClientId &&
201
+ idClaims.token_use === "id" &&
202
+ idAudience === expectedClientId &&
203
+ entityType === "agent" &&
204
+ typeof entityUid === "string" &&
205
+ entityUid.startsWith("agt_") &&
206
+ idTokenMatchesCreds &&
207
+ tokensShareSubject
208
+ );
209
+ }
210
+
150
211
  // ---------------------------------------------------------------------------
151
212
  // Machine identity (company agents)
152
213
  // ---------------------------------------------------------------------------
@@ -309,17 +370,18 @@ export async function mintMachineTokens(
309
370
  export async function getValidMachineTokens(
310
371
  config: CognitoAuthConfig,
311
372
  ): Promise<CognitoTokens> {
373
+ const machineCreds = loadMachineCreds();
312
374
  const cached = loadCachedTokens();
313
- if (cached && !isExpiring(cached, 120)) {
314
- const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
315
- // Compare against the client we'd actually mint with (creds-file
316
- // clientId wins over config).
317
- const expectedClientId = loadMachineCreds()?.clientId ?? config.clientId;
318
- if (cachedClientId === null || cachedClientId === expectedClientId) {
375
+ if (machineCreds && cached && !isExpiring(cached, 120)) {
376
+ // Compare against the client we'd actually mint with (creds-file clientId
377
+ // wins over config), and require the cached ID token to prove this exact
378
+ // agent identity. Opaque/missing/human-shaped claims are treated as stale.
379
+ const expectedClientId = machineCreds.clientId ?? config.clientId;
380
+ if (cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)) {
319
381
  return cached;
320
382
  }
321
383
  }
322
- return mintMachineTokens(config);
384
+ return mintMachineTokens(config, machineCreds ?? undefined);
323
385
  }
324
386
 
325
387
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -62,6 +62,14 @@ export {
62
62
  coalescePrefixes,
63
63
  isCoveredByAny,
64
64
  grantPathToPrefix,
65
+ grantPathToScopePrefix,
66
+ pathToScopePrefix,
67
+ toScopePrefixEntries,
68
+ } from "./prefix-coalesce.js";
69
+ export type {
70
+ ScopePrefixEntry,
71
+ ScopePrefixInput,
72
+ ScopePrefixMatch,
65
73
  } from "./prefix-coalesce.js";
66
74
 
67
75
  // Scope-shrink detection + application (US-005)