@indigoai-us/hq-cloud 6.14.6 → 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.
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++;