@indigoai-us/hq-cloud 6.14.5 → 6.14.7

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 (41) hide show
  1. package/dist/cli/reindex.d.ts.map +1 -1
  2. package/dist/cli/reindex.js +5 -3
  3. package/dist/cli/reindex.js.map +1 -1
  4. package/dist/cli/rescue-core.d.ts +6 -0
  5. package/dist/cli/rescue-core.d.ts.map +1 -1
  6. package/dist/cli/rescue-core.js +55 -6
  7. package/dist/cli/rescue-core.js.map +1 -1
  8. package/dist/cli/rescue.test.js +24 -0
  9. package/dist/cli/rescue.test.js.map +1 -1
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +27 -21
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +47 -0
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts.map +1 -1
  16. package/dist/cli/sync.js +28 -6
  17. package/dist/cli/sync.js.map +1 -1
  18. package/dist/local-path-codec.d.ts +19 -0
  19. package/dist/local-path-codec.d.ts.map +1 -0
  20. package/dist/local-path-codec.js +69 -0
  21. package/dist/local-path-codec.js.map +1 -0
  22. package/dist/s3.d.ts +8 -0
  23. package/dist/s3.d.ts.map +1 -1
  24. package/dist/s3.js +52 -22
  25. package/dist/s3.js.map +1 -1
  26. package/dist/scope-shrink.d.ts.map +1 -1
  27. package/dist/scope-shrink.js +4 -3
  28. package/dist/scope-shrink.js.map +1 -1
  29. package/package.json +1 -1
  30. package/scripts/presign-transport-e2e.mjs +57 -10
  31. package/scripts/vault-rebaseline.sh +49 -1
  32. package/scripts/vault-rescue.sh +42 -1
  33. package/src/cli/reindex.ts +5 -3
  34. package/src/cli/rescue-core.ts +57 -10
  35. package/src/cli/rescue.test.ts +34 -0
  36. package/src/cli/share.test.ts +54 -0
  37. package/src/cli/share.ts +28 -21
  38. package/src/cli/sync.ts +33 -7
  39. package/src/local-path-codec.ts +95 -0
  40. package/src/s3.ts +61 -12
  41. package/src/scope-shrink.ts +4 -3
@@ -595,6 +595,60 @@ describe("share", () => {
595
595
  });
596
596
  });
597
597
 
598
+ it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
599
+ // Regression for the gardener watermark clobber. A second HQ root sharing this
600
+ // machine's sync journal can leave the local mirror holding a STALE value A while
601
+ // the shared journal has already been advanced to the gardener's value B (etag EB).
602
+ // On this root's push leg that reads as localChanged (A != journal B) &&
603
+ // !remoteChanged (remote etag EB == journal remoteEtag EB). Previously that
604
+ // one-sided combination slipped past the CONFLICT-scoped isCloudAuthoritative
605
+ // fence and PUT the stale local value back over the server's — rewinding it. The
606
+ // fence now runs at the TOP of processUploadItem, so any ontology/ (server-owned)
607
+ // path is skipped before HEAD/upload regardless of the divergence shape.
608
+ const companyRoot = path.join(tmpDir, "companies", "acme");
609
+ fs.mkdirSync(path.join(companyRoot, "ontology"), { recursive: true });
610
+ const testFile = path.join(companyRoot, "ontology", ".last-run");
611
+ fs.writeFileSync(testFile, "1782275168000"); // stale value A
612
+
613
+ // The journal is already advanced to the gardener's value B (a sibling HQ root's
614
+ // pull stamped the shared journal), so localChanged=true. The fence skips the file
615
+ // BEFORE any HEAD/upload, so we deliberately queue NO headRemoteFile response and
616
+ // assert it is never consulted below — the skip is cheap, and a leftover
617
+ // mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
618
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
619
+ fs.writeFileSync(
620
+ journalPath,
621
+ JSON.stringify({
622
+ version: "1",
623
+ lastSync: new Date().toISOString(),
624
+ files: {
625
+ "ontology/.last-run": {
626
+ hash: "advanced-value-B-hash", // != hash(A) → localChanged=true
627
+ size: 13,
628
+ syncedAt: new Date().toISOString(),
629
+ direction: "up",
630
+ remoteEtag: "eb-advanced-etag", // == remote → remoteChanged=false
631
+ },
632
+ },
633
+ }),
634
+ );
635
+
636
+ const result = await share({
637
+ paths: [testFile],
638
+ company: "acme",
639
+ vaultConfig: mockConfig,
640
+ hqRoot: tmpDir,
641
+ });
642
+
643
+ // Server-owned file: push is skipped BEFORE HEAD, the stale local value is NEVER
644
+ // uploaded (the pull leg refreshes local from cloud instead), and it is not a conflict.
645
+ expect(uploadFile).not.toHaveBeenCalled();
646
+ expect(headRemoteFile).not.toHaveBeenCalled();
647
+ expect(result.filesUploaded).toBe(0);
648
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
649
+ expect(result.conflictPaths).toEqual([]);
650
+ });
651
+
598
652
  it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
599
653
  // Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
600
654
  // NO prior journal entry (fresh upload from this machine) but the
package/src/cli/share.ts CHANGED
@@ -11,6 +11,7 @@ import * as path from "path";
11
11
  import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
12
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
13
13
  import { createSyncProgressRecorder } from "../sync-progress.js";
14
+ import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
14
15
  import {
15
16
  uploadFile,
16
17
  uploadSymlink,
@@ -788,7 +789,7 @@ function wrapFilterWithScope(
788
789
  ): (absPath: string, isDir?: boolean) => boolean {
789
790
  return (absPath: string, isDir?: boolean) => {
790
791
  if (!underlying(absPath, isDir)) return false;
791
- const rel = path.relative(syncRoot, absPath).split(path.sep).join("/");
792
+ const rel = vaultKeyForLocalPath(syncRoot, absPath);
792
793
  if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
793
794
  if (isDir) {
794
795
  if (isDirInScope(rel, prefixSet)) return true;
@@ -819,7 +820,7 @@ export function wrapFilterWithIgnoreVisibility(
819
820
  if (allowed) return true;
820
821
 
821
822
  onAnyExcluded?.();
822
- const rel = path.relative(hqRoot, absPath).split(path.sep).join("/");
823
+ const rel = vaultKeyForLocalPath(hqRoot, absPath);
823
824
  if (rel === "" || rel.startsWith("..")) return false;
824
825
  if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
825
826
  return false;
@@ -1232,6 +1233,19 @@ async function executeUploads(
1232
1233
  if (aborted) return;
1233
1234
  const { absolutePath, relativePath, localHash } = item;
1234
1235
 
1236
+ // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
1237
+ // ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
1238
+ // NEVER upload the local copy over them — not just on a two-sided conflict, but on
1239
+ // ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
1240
+ // second HQ root that shares this machine's sync journal) would otherwise read as
1241
+ // `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
1242
+ // rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
1243
+ if (isCloudAuthoritative(relativePath)) {
1244
+ run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1245
+ counters.filesSkipped++;
1246
+ return;
1247
+ }
1248
+
1235
1249
  if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1236
1250
  run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1237
1251
  }
@@ -1286,16 +1300,9 @@ async function executeUploads(
1286
1300
  }
1287
1301
 
1288
1302
  if ((localChanged && remoteChanged) || isFreshCollision) {
1289
- // Cloud-authoritative paths (server-regenerated: company-brief.md,
1290
- // board.json, ontology/ signals/ sources/) are PULL-WINS. Never push the
1291
- // local copy over the server-owned version and never mint a `.conflict-`
1292
- // mirror — the pull leg overwrites local from cloud. Declining here is
1293
- // what stops the per-sync conflict-mirror loop on these files.
1294
- if (isCloudAuthoritative(relativePath)) {
1295
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1296
- counters.filesSkipped++;
1297
- return;
1298
- }
1303
+ // Cloud-authoritative paths are already skipped at the top of
1304
+ // processUploadItem (before HEAD), so anything reaching here is a genuine
1305
+ // conflict on a client-owned file.
1299
1306
  conflictPaths.push(relativePath);
1300
1307
 
1301
1308
  const resolution = await resolveConflictSerialized({
@@ -1450,13 +1457,13 @@ async function writePushConflictMirror(
1450
1457
  try {
1451
1458
  const detectedAt = new Date().toISOString();
1452
1459
  const machineId = readShortMachineId(run.hqRoot);
1453
- const originalRelative = path.relative(run.hqRoot, item.absolutePath);
1460
+ const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
1454
1461
  const conflictRelative = buildConflictPath(
1455
1462
  originalRelative,
1456
1463
  detectedAt,
1457
1464
  machineId,
1458
1465
  );
1459
- const conflictAbs = path.join(run.hqRoot, conflictRelative);
1466
+ const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1460
1467
  if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
1461
1468
  run.emit({
1462
1469
  type: "error",
@@ -1781,7 +1788,7 @@ function collectFiles(
1781
1788
  console.error(` Warning: ${p} is outside company folder, skipping.`);
1782
1789
  continue;
1783
1790
  }
1784
- const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1791
+ const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1785
1792
  // Probe the filter with both isDir hints — we don't know whether
1786
1793
  // the link's target is a file or a directory without
1787
1794
  // stat-following the link, which we explicitly avoid (it would
@@ -1811,7 +1818,7 @@ function collectFiles(
1811
1818
  if (!filter(absolutePath, true)) continue;
1812
1819
  results.push(...walkDir(absolutePath, syncRoot, filter));
1813
1820
  } else if (lstat.isFile()) {
1814
- const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1821
+ const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1815
1822
  if (filter(absolutePath)) {
1816
1823
  results.push({ kind: "file", absolutePath, relativePath });
1817
1824
  }
@@ -1861,7 +1868,7 @@ function walkDir(
1861
1868
  results.push({
1862
1869
  kind: "symlink",
1863
1870
  absolutePath,
1864
- relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1871
+ relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
1865
1872
  target: fs.readlinkSync(absolutePath),
1866
1873
  });
1867
1874
  continue;
@@ -1877,7 +1884,7 @@ function walkDir(
1877
1884
  results.push({
1878
1885
  kind: "file",
1879
1886
  absolutePath,
1880
- relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1887
+ relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
1881
1888
  });
1882
1889
  }
1883
1890
  }
@@ -1996,11 +2003,11 @@ function resolveDeleteScopeRoots(
1996
2003
  if (!isWithin(syncRoot, absolutePath)) continue;
1997
2004
  const stat = fs.statSync(absolutePath);
1998
2005
  if (!stat.isDirectory()) continue;
1999
- const rel = path.relative(syncRoot, absolutePath);
2006
+ const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
2000
2007
  if (rel === "" || rel === ".") {
2001
2008
  return [""];
2002
2009
  }
2003
- prefixes.add(rel.split(path.sep).join("/"));
2010
+ prefixes.add(rel);
2004
2011
  }
2005
2012
  return Array.from(prefixes);
2006
2013
  }
@@ -2238,7 +2245,7 @@ async function computeDeletePlan(
2238
2245
  );
2239
2246
  if (!inScope) continue;
2240
2247
  inScopeJournalEntries++;
2241
- const localPath = path.join(syncRoot, relativeKey);
2248
+ const localPath = localPathForVaultKey(syncRoot, relativeKey);
2242
2249
  let presentLocally = true;
2243
2250
  try {
2244
2251
  fs.lstatSync(localPath);
package/src/cli/sync.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "../telemetry-events.js";
20
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
21
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
22
+ import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
22
23
  import {
23
24
  downloadFile,
24
25
  listRemoteFiles,
@@ -26,7 +27,7 @@ import {
26
27
  primeObjectTransport,
27
28
  toPosixKey,
28
29
  } from "../s3.js";
29
- import type { RemoteFile } from "../s3.js";
30
+ import type { DownloadModeWarning, RemoteFile } from "../s3.js";
30
31
  import {
31
32
  readJournal,
32
33
  writeJournal,
@@ -971,6 +972,30 @@ function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
971
972
  }, { claims: run.options.telemetryClaims });
972
973
  }
973
974
 
975
+ /**
976
+ * A legacy object without hq-mode can otherwise downgrade a hook to the
977
+ * receiver umask with no durable signal. downloadFile already prints a local
978
+ * warning for every caller; the sync path also records a path-free telemetry
979
+ * event so operators can find vaults that still need the one-time backfill.
980
+ */
981
+ function reportModeWarnings(
982
+ run: PullRunContext,
983
+ warnings: DownloadModeWarning[] | undefined,
984
+ ): void {
985
+ for (const warning of warnings ?? []) {
986
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
987
+ eventName: "sync_mode_guardrail_warning",
988
+ source: "hq-sync",
989
+ companyUid: run.ctx.uid,
990
+ properties: {
991
+ leg: "pull",
992
+ reason: warning.reason,
993
+ fallback: warning.fallback ?? null,
994
+ },
995
+ }, { claims: run.options.telemetryClaims });
996
+ }
997
+ }
998
+
974
999
  function createPullCounters(): PullCounters {
975
1000
  return {
976
1001
  filesDownloaded: 0,
@@ -1220,7 +1245,7 @@ async function executeConflictItem(
1220
1245
  // the REMOTE hash/etag) and skip the conflict path entirely. This removes both
1221
1246
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
1222
1247
  // divergent local content) that the keep path produced for these files.
1223
- if (isCloudAuthoritative(path.relative(run.hqRoot, localPath))) {
1248
+ if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
1224
1249
  downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
1225
1250
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1226
1251
  return null;
@@ -1230,14 +1255,14 @@ async function executeConflictItem(
1230
1255
 
1231
1256
  const detectedAt = new Date().toISOString();
1232
1257
  const machineId = readShortMachineId(run.hqRoot);
1233
- const originalRelative = path.relative(run.hqRoot, localPath);
1258
+ const originalRelative = vaultKeyForLocalPath(run.hqRoot, localPath);
1234
1259
  const conflictRelative = buildConflictPath(
1235
1260
  originalRelative,
1236
1261
  detectedAt,
1237
1262
  machineId,
1238
1263
  );
1239
- const conflictAbs = path.join(run.hqRoot, conflictRelative);
1240
- const conflictKey = toPosixKey(path.relative(run.companyRoot, conflictAbs));
1264
+ const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1265
+ const conflictKey = vaultKeyForLocalPath(run.companyRoot, conflictAbs);
1241
1266
 
1242
1267
  if (!isDownloadWritePathStillContained(run.companyRoot, conflictKey, conflictAbs)) {
1243
1268
  counters.filesSkipped++;
@@ -1459,11 +1484,12 @@ async function downloadOne(
1459
1484
  }
1460
1485
 
1461
1486
  try {
1462
- const { metadata, contentHash, contentSize } = await downloadFile(
1487
+ const { metadata, contentHash, contentSize, modeWarnings } = await downloadFile(
1463
1488
  run.ctx,
1464
1489
  remoteFile.key,
1465
1490
  localPath,
1466
1491
  );
1492
+ reportModeWarnings(run, modeWarnings);
1467
1493
  const author = metadata?.["created-by"] ?? null;
1468
1494
  const createdBySub = metadata?.["created-by-sub"];
1469
1495
 
@@ -1803,7 +1829,7 @@ function resolveContainedVaultPath(root: string, key: string): string | null {
1803
1829
  if (isMalformedVaultKey(key) || hasTraversalSegment(key)) return null;
1804
1830
 
1805
1831
  const resolvedRoot = path.resolve(root);
1806
- const resolvedLocal = path.resolve(resolvedRoot, key);
1832
+ const resolvedLocal = localPathForVaultKey(resolvedRoot, key);
1807
1833
  if (!isPathWithin(resolvedRoot, resolvedLocal)) return null;
1808
1834
 
1809
1835
  let realRoot: string;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Reversible local filename adapter for canonical vault keys on Win32.
3
+ *
4
+ * Vault keys are POSIX identifiers and may contain ':' (for example
5
+ * `factory:slack`). Win32 cannot materialize those strings as filename
6
+ * segments, so keep the key canonical in S3 and journals while encoding only
7
+ * the local segment that reaches the filesystem.
8
+ */
9
+ import * as path from "path";
10
+
11
+ const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
12
+ const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
13
+
14
+ function isWin32ReservedCharacter(char: string): boolean {
15
+ return char.charCodeAt(0) <= 0x1f || WIN32_RESERVED_CHARS.test(char);
16
+ }
17
+
18
+ function percentEscape(char: string): string {
19
+ return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
20
+ }
21
+
22
+ /**
23
+ * Encode one canonical vault-key segment for a Win32 filename. Percent is
24
+ * escaped before every other character so decoding cannot collide with a
25
+ * literal `%3A` name. The optional flag makes platform-specific behavior
26
+ * directly testable without mutating `process.platform`.
27
+ */
28
+ export function encodeLocalVaultSegment(
29
+ segment: string,
30
+ win32: boolean = process.platform === "win32",
31
+ ): string {
32
+ if (!win32) return segment;
33
+
34
+ let encoded = "";
35
+ for (let index = 0; index < segment.length; index++) {
36
+ const char = segment[index]!;
37
+ const isTrailingDotOrSpace =
38
+ index === segment.length - 1 && (char === "." || char === " ");
39
+ const isDotSegment = (segment === "." || segment === "..") && index === 0;
40
+ const isReservedDeviceFirstChar = WIN32_DEVICE_NAME.test(segment) && index === 0;
41
+ if (
42
+ char === "%" ||
43
+ isWin32ReservedCharacter(char) ||
44
+ isTrailingDotOrSpace ||
45
+ isDotSegment ||
46
+ isReservedDeviceFirstChar
47
+ ) {
48
+ encoded += percentEscape(char);
49
+ } else {
50
+ encoded += char;
51
+ }
52
+ }
53
+ return encoded;
54
+ }
55
+
56
+ /** Decode one local filename segment back to its canonical vault-key form. */
57
+ export function decodeLocalVaultSegment(
58
+ segment: string,
59
+ win32: boolean = process.platform === "win32",
60
+ ): string {
61
+ if (!win32) return segment;
62
+ return segment.replace(/%([0-9a-f]{2})/gi, (_whole, hex: string) =>
63
+ String.fromCharCode(Number.parseInt(hex, 16)),
64
+ );
65
+ }
66
+
67
+ /** Materialize a canonical slash-delimited vault key beneath a local root. */
68
+ export function localPathForVaultKey(
69
+ root: string,
70
+ key: string,
71
+ win32: boolean = process.platform === "win32",
72
+ ): string {
73
+ return path.join(
74
+ root,
75
+ ...key.split("/").map((segment) => encodeLocalVaultSegment(segment, win32)),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Turn a local, root-relative path back into its canonical vault key. This
81
+ * recognizes legacy percent-encoded Windows materializations as well as the
82
+ * current codec, so they converge on one remote/journal key instead of
83
+ * creating duplicates.
84
+ */
85
+ export function vaultKeyForLocalPath(
86
+ root: string,
87
+ localPath: string,
88
+ win32: boolean = process.platform === "win32",
89
+ ): string {
90
+ return path
91
+ .relative(root, localPath)
92
+ .split(path.sep)
93
+ .map((segment) => decodeLocalVaultSegment(segment, win32))
94
+ .join("/");
95
+ }
package/src/s3.ts CHANGED
@@ -764,6 +764,13 @@ export async function uploadSymlink(
764
764
  * return it to callers — e.g. the pull loop reads `created-by` to
765
765
  * attribute downloaded files to their author with zero extra network.
766
766
  */
767
+ export interface DownloadModeWarning {
768
+ /** The mode guardrail that could not be applied exactly. */
769
+ reason: "missing-hq-mode" | "invalid-hq-mode" | "chmod-failed";
770
+ /** Legacy objects can retain a local file's known-good permission bits. */
771
+ fallback?: "preserved-local-mode" | "receiver-default";
772
+ }
773
+
767
774
  export async function downloadFile(
768
775
  ctx: EntityContext,
769
776
  key: string,
@@ -772,6 +779,8 @@ export async function downloadFile(
772
779
  metadata?: Record<string, string>;
773
780
  contentHash?: string;
774
781
  contentSize?: number;
782
+ /** Non-fatal mode guardrail warnings for caller telemetry. */
783
+ modeWarnings?: DownloadModeWarning[];
775
784
  }> {
776
785
  const io = resolveObjectIO(ctx);
777
786
 
@@ -867,15 +876,25 @@ export async function downloadFile(
867
876
  return { metadata };
868
877
  }
869
878
  const tempPath = downloadTempPath(localPath);
879
+ // A legacy object has no hq-mode. If it is replacing a regular local file,
880
+ // that local mode is the only trustworthy signal we have — retaining it is
881
+ // strictly safer than replacing (for example) a 0755 hook with the receiver
882
+ // umask's usual 0644. Never inspect a symlink or infer intent from content.
883
+ let existingLocalMode: number | undefined;
884
+ try {
885
+ const existing = fs.lstatSync(localPath);
886
+ if (existing.isFile()) existingLocalMode = existing.mode & 0o777;
887
+ } catch {
888
+ // Fresh destination / inaccessible prior path: no local fallback.
889
+ }
870
890
  let tempReady = true;
871
891
  let streamed: { hash: string; size: number };
892
+ const modeWarnings: DownloadModeWarning[] = [];
872
893
  try {
873
894
  streamed = await streamRegularFileToTemp(tempPath, initialChunks, iterator);
874
895
 
875
896
  // Bug #5 — apply source-side mode after the byte write. See
876
- // FILE_MODE_META_KEY for the metadata contract. Parses defensively:
877
- // a malformed value falls through with no chmod so the umask default
878
- // applies, matching the legacy back-compat path. The staged path is a
897
+ // FILE_MODE_META_KEY for the metadata contract. The staged path is a
879
898
  // regular file, then it is atomically renamed over the destination.
880
899
  //
881
900
  // Codex P2 (PR #24 round 3): strict octal-only regex BEFORE parseInt.
@@ -886,17 +905,33 @@ export async function downloadFile(
886
905
  // the upload side stamps (`(mode & 0o777).toString(8)` → at most
887
906
  // three digits, all 0–7) and rejects everything else.
888
907
  const modeOctal = metadata?.[FILE_MODE_META_KEY];
908
+ let modeToApply: number | undefined;
889
909
  if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
890
910
  const parsed = parseInt(modeOctal, 8);
891
911
  if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
892
- try {
893
- fs.chmodSync(tempPath, parsed);
894
- } catch {
895
- // chmod failure (read-only FS, EPERM) is non-fatal — the file
896
- // is materialized, just with the umask default. Surface via
897
- // S3-side metadata being present but the file not matching;
898
- // a future operator-side audit can reconcile.
899
- }
912
+ modeToApply = parsed;
913
+ }
914
+ } else if (modeOctal === undefined) {
915
+ modeToApply = existingLocalMode;
916
+ modeWarnings.push({
917
+ reason: "missing-hq-mode",
918
+ fallback:
919
+ existingLocalMode === undefined
920
+ ? "receiver-default"
921
+ : "preserved-local-mode",
922
+ });
923
+ } else {
924
+ modeWarnings.push({ reason: "invalid-hq-mode", fallback: "receiver-default" });
925
+ }
926
+
927
+ if (modeToApply !== undefined) {
928
+ try {
929
+ fs.chmodSync(tempPath, modeToApply);
930
+ } catch {
931
+ // chmod failure (read-only FS, EPERM) is non-fatal — the file is
932
+ // materialized, but callers must receive a telemetry signal instead
933
+ // of silently believing the guardrail held.
934
+ modeWarnings.push({ reason: "chmod-failed" });
900
935
  }
901
936
  }
902
937
 
@@ -953,7 +988,21 @@ export async function downloadFile(
953
988
  // distinct creation time, so a future receiver upgrade picks it up
954
989
  // automatically without a server-side data migration.
955
990
 
956
- return { metadata, contentHash: streamed.hash, contentSize: streamed.size };
991
+ if (modeWarnings.length > 0) {
992
+ for (const warning of modeWarnings) {
993
+ console.warn(
994
+ `[hq-sync] mode warning for ${key}: ${warning.reason}` +
995
+ (warning.fallback ? ` (${warning.fallback})` : ""),
996
+ );
997
+ }
998
+ }
999
+
1000
+ return {
1001
+ metadata,
1002
+ contentHash: streamed.hash,
1003
+ contentSize: streamed.size,
1004
+ ...(modeWarnings.length > 0 ? { modeWarnings } : {}),
1005
+ };
957
1006
  }
958
1007
 
959
1008
  export interface RemoteFile {
@@ -40,6 +40,7 @@ import type {
40
40
  SyncJournal,
41
41
  } from "./types.js";
42
42
  import { hashFile, tombstoneEntry } from "./journal.js";
43
+ import { localPathForVaultKey } from "./local-path-codec.js";
43
44
  import {
44
45
  isCoveredByAny,
45
46
  type ScopePrefixInput,
@@ -168,7 +169,7 @@ function classifyOrphan(
168
169
  entry: JournalEntry,
169
170
  hqRoot: string,
170
171
  ): OrphanClassification {
171
- const absPath = path.join(hqRoot, relPath);
172
+ const absPath = localPathForVaultKey(hqRoot, relPath);
172
173
  let stat: fs.Stats;
173
174
  try {
174
175
  stat = fs.lstatSync(absPath);
@@ -436,9 +437,9 @@ export function applyScopeShrink(
436
437
  const dirtyKeptPaths: string[] = [];
437
438
 
438
439
  for (const orphan of plan.clean) {
439
- const absPath = path.join(hqRoot, orphan.path);
440
+ const absPath = localPathForVaultKey(hqRoot, orphan.path);
440
441
  if (quarantining) {
441
- const destAbs = path.join(input.quarantineRoot!, orphan.path);
442
+ const destAbs = localPathForVaultKey(input.quarantineRoot!, orphan.path);
442
443
  quarantineOrphan(absPath, destAbs);
443
444
  tombstoneEntry(journal, orphan.path, reason);
444
445
  cleanQuarantined++;