@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/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +5 -3
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +6 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +55 -6
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue.test.js +24 -0
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -11
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +28 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/local-path-codec.d.ts +19 -0
- package/dist/local-path-codec.d.ts.map +1 -0
- package/dist/local-path-codec.js +69 -0
- package/dist/local-path-codec.js.map +1 -0
- package/dist/s3.d.ts +8 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +52 -22
- package/dist/s3.js.map +1 -1
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +4 -3
- package/dist/scope-shrink.js.map +1 -1
- package/package.json +1 -1
- package/scripts/presign-transport-e2e.mjs +57 -10
- package/scripts/vault-rebaseline.sh +49 -1
- package/scripts/vault-rescue.sh +42 -1
- package/src/cli/reindex.ts +5 -3
- package/src/cli/rescue-core.ts +57 -10
- package/src/cli/rescue.test.ts +34 -0
- package/src/cli/share.ts +12 -11
- package/src/cli/sync.ts +33 -7
- package/src/local-path-codec.ts +95 -0
- package/src/s3.ts +61 -12
- package/src/scope-shrink.ts +4 -3
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(
|
|
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 =
|
|
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 =
|
|
1240
|
-
const conflictKey =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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 {
|
package/src/scope-shrink.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
440
|
+
const absPath = localPathForVaultKey(hqRoot, orphan.path);
|
|
440
441
|
if (quarantining) {
|
|
441
|
-
const destAbs =
|
|
442
|
+
const destAbs = localPathForVaultKey(input.quarantineRoot!, orphan.path);
|
|
442
443
|
quarantineOrphan(absPath, destAbs);
|
|
443
444
|
tombstoneEntry(journal, orphan.path, reason);
|
|
444
445
|
cleanQuarantined++;
|