@indigoai-us/hq-cloud 6.3.4 → 6.3.6

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.
@@ -775,6 +775,91 @@ describe("sync", () => {
775
775
  expect(journal.files["docs/kept.md"]).toBeDefined();
776
776
  });
777
777
 
778
+ it("does NOT tombstone a live file recorded under a Windows backslash journal key whose POSIX twin still exists remotely (ridge data-loss regression — feedback_b8d09d0f)", async () => {
779
+ // Root-cause regression. A pre-5.47.2 Windows client stamped the journal
780
+ // key with path.sep ("\\"): `projects\forecast-development\...\sean-frank.csv`.
781
+ // The forecast file IS still in the vault (remote LIST has the forward-slash
782
+ // key). Pre-fix the planner compared the raw backslash key against the
783
+ // forward-slash remote set, missed the match, classed the LIVE file as
784
+ // remote-deleted, and dropped its journal entry (and on Windows, where
785
+ // path.join collapses "\\", unlinked the real file — ~36 files lost in one
786
+ // pull). The fix: normalize journal keys to POSIX on load + compare in POSIX
787
+ // space, so the live file is recognized as present and never tombstoned.
788
+ const companyRoot = path.join(tmpDir, "companies", "acme");
789
+ const forecastDir = path.join(
790
+ companyRoot,
791
+ "projects",
792
+ "forecast-development",
793
+ "forecasts",
794
+ );
795
+ fs.mkdirSync(forecastDir, { recursive: true });
796
+ const forecastPath = path.join(forecastDir, "sean-frank.csv");
797
+ fs.writeFileSync(forecastPath, "forecast,data\n1,2\n");
798
+
799
+ const posixKey = "projects/forecast-development/forecasts/sean-frank.csv";
800
+ const backslashKey =
801
+ "projects\\forecast-development\\forecasts\\sean-frank.csv";
802
+
803
+ const crypto = await import("node:crypto");
804
+ const baseline = crypto
805
+ .createHash("sha256")
806
+ .update("forecast,data\n1,2\n")
807
+ .digest("hex");
808
+
809
+ // Seed the journal under the MALFORMED backslash key (the landmine state).
810
+ fs.writeFileSync(
811
+ journalPath,
812
+ JSON.stringify({
813
+ version: "2",
814
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
815
+ files: {
816
+ [backslashKey]: {
817
+ hash: baseline,
818
+ size: 18,
819
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
820
+ direction: "down",
821
+ remoteEtag: "e-forecast",
822
+ },
823
+ },
824
+ pulls: [],
825
+ }),
826
+ );
827
+
828
+ // The vault still holds the file under its canonical forward-slash key.
829
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
830
+ {
831
+ key: posixKey,
832
+ size: 18,
833
+ lastModified: new Date(),
834
+ etag: '"e-forecast"',
835
+ },
836
+ ]);
837
+ // If anything still HEAD-probed the backslash key, S3 would say NotFound —
838
+ // model that worst case so the test proves the planner never gets there.
839
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
840
+
841
+ const result = await sync({
842
+ company: "acme",
843
+ vaultConfig: mockConfig,
844
+ hqRoot: tmpDir,
845
+ });
846
+
847
+ // The live forecast file MUST survive the pull.
848
+ expect(fs.existsSync(forecastPath)).toBe(true);
849
+ expect(fs.readFileSync(forecastPath, "utf-8")).toBe("forecast,data\n1,2\n");
850
+ // Nothing was tombstoned — the file is present remotely.
851
+ expect(result.filesTombstoned).toBe(0);
852
+
853
+ // The journal is healed: the malformed key is gone, the canonical POSIX key
854
+ // points at the live file (so a future pull stays converged, not poisoned).
855
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
856
+ expect(journal.files[backslashKey]).toBeUndefined();
857
+ expect(journal.files[posixKey]).toBeDefined();
858
+ expect(
859
+ Object.keys(journal.files).some((k) => k.includes("\\")),
860
+ ).toBe(false);
861
+ });
862
+
778
863
  it("does NOT tombstone keys whose local copy has diverged from the journal (Codex P1 — delete-vs-edit race)", async () => {
779
864
  // Codex review on PR #24 round 3 caught: in a delete-vs-local-edit
780
865
  // race, peer A deletes a file remotely while peer B edits it
package/src/cli/sync.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  listRemoteFiles,
16
16
  headRemoteFile,
17
17
  primeObjectTransport,
18
+ toPosixKey,
18
19
  } from "../s3.js";
19
20
  import type { RemoteFile } from "../s3.js";
20
21
  import {
@@ -1253,6 +1254,17 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1253
1254
  // converged. Failures are reported but non-fatal — the entry stays in
1254
1255
  // the journal and the next run retries.
1255
1256
  for (const key of plan.tombstones) {
1257
+ // Last line of defense against the Windows backslash-key landmine: a
1258
+ // malformed key must NEVER reach fs.unlinkSync. path.join(companyRoot, key)
1259
+ // collapses the backslashes and resolves onto the REAL POSIX file, so
1260
+ // unlinking here destroys live data (ridge incident, feedback_b8d09d0f).
1261
+ // The planner already refuses to enqueue malformed keys; if one still
1262
+ // arrives, drop the poisoned journal entry without touching disk —
1263
+ // normalizeJournalKeys rewrites it to its POSIX form on load.
1264
+ if (isMalformedVaultKey(key)) {
1265
+ removeEntry(journal, key);
1266
+ continue;
1267
+ }
1256
1268
  const localPath = path.join(companyRoot, key);
1257
1269
  let removedSomething = false;
1258
1270
  try {
@@ -1795,7 +1807,19 @@ function computePullPlan(
1795
1807
  for (const rf of remoteFiles) remoteKeySet.add(rf.key);
1796
1808
  const tombstones: string[] = [];
1797
1809
  for (const key of Object.keys(journal.files)) {
1798
- if (remoteKeySet.has(key)) continue;
1810
+ // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
1811
+ // carry backslash separators; the remote LIST is always forward-slash, so a
1812
+ // raw `has(key)` would miss the match and class a live local file as
1813
+ // remote-deleted. normalizeJournalKeys() defuses this on load, but the
1814
+ // POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
1815
+ const posixKey = toPosixKey(key);
1816
+ if (remoteKeySet.has(posixKey)) continue;
1817
+ // Never tombstone-delete via a malformed (backslash) key: the executor's
1818
+ // path.join(companyRoot, key) collapses backslashes back onto the REAL
1819
+ // POSIX file and unlinks live data. Leave it for normalizeJournalKeys to
1820
+ // rewrite to POSIX on the next write; the canonical key is re-evaluated
1821
+ // (and correctly tombstoned if genuinely remote-deleted) on a later pull.
1822
+ if (isMalformedVaultKey(key)) continue;
1799
1823
  // PersonalMode key gating — mirror the download branch.
1800
1824
  if (personalMode && key.startsWith("companies/")) {
1801
1825
  const slug = key.split("/")[1] ?? "";
@@ -17,6 +17,7 @@ import {
17
17
  writeJournal,
18
18
  updateEntry,
19
19
  migrateToV2,
20
+ normalizeJournalKeys,
20
21
  appendPullRecord,
21
22
  lastPullRecord,
22
23
  tombstoneEntry,
@@ -208,6 +209,125 @@ describe("journal", () => {
208
209
  expect(raw.version).toBe("2");
209
210
  expect(raw.pulls).toEqual([]);
210
211
  });
212
+
213
+ it("normalizes backslash keys even on an already-v2 journal", () => {
214
+ // The landmine predates the v2 bump, so a poisoned journal can be v2.
215
+ // migrateToV2's early-return must not skip normalization.
216
+ const j: SyncJournal = {
217
+ version: "2",
218
+ lastSync: "",
219
+ files: {
220
+ "projects\\forecast\\x.csv": {
221
+ hash: "h",
222
+ size: 1,
223
+ syncedAt: "2026-06-10",
224
+ direction: "down",
225
+ },
226
+ },
227
+ pulls: [],
228
+ };
229
+ migrateToV2(j);
230
+ expect(j.files["projects\\forecast\\x.csv"]).toBeUndefined();
231
+ expect(j.files["projects/forecast/x.csv"]).toBeDefined();
232
+ });
233
+ });
234
+
235
+ describe("normalizeJournalKeys (Windows backslash-key landmine — ridge data loss)", () => {
236
+ // Regression for feedback_b8d09d0f: pre-5.47.2 Windows clients stamped
237
+ // journal keys with path.sep ("\\"). Those keys never match the
238
+ // forward-slash remote LIST, so the cross-machine delete planner classed
239
+ // live local files as remote-deleted and path.join collapsed the key back
240
+ // onto the real file — deleting ~36 live files in a single pull. The
241
+ // canonical defense is rewriting every key to POSIX form on load.
242
+ it("rewrites backslash keys to POSIX form and reports the count", () => {
243
+ const j: SyncJournal = {
244
+ version: "2",
245
+ lastSync: "",
246
+ files: {
247
+ "projects\\forecast-development\\forecasts\\sean-frank.csv": {
248
+ hash: "h1",
249
+ size: 10,
250
+ syncedAt: "2026-06-10",
251
+ direction: "down",
252
+ },
253
+ "docs\\notes.md": {
254
+ hash: "h2",
255
+ size: 5,
256
+ syncedAt: "2026-06-10",
257
+ direction: "down",
258
+ },
259
+ },
260
+ pulls: [],
261
+ };
262
+ const rewritten = normalizeJournalKeys(j);
263
+ expect(rewritten).toBe(2);
264
+ expect(
265
+ j.files["projects/forecast-development/forecasts/sean-frank.csv"],
266
+ ).toBeDefined();
267
+ expect(j.files["docs/notes.md"]).toBeDefined();
268
+ // No malformed keys survive.
269
+ expect(
270
+ Object.keys(j.files).some((k) => k.includes("\\")),
271
+ ).toBe(false);
272
+ // Entry payload is preserved verbatim under the new key.
273
+ expect(j.files["docs/notes.md"]?.hash).toBe("h2");
274
+ });
275
+
276
+ it("leaves a clean (all-POSIX) journal untouched and returns 0", () => {
277
+ const j: SyncJournal = {
278
+ version: "2",
279
+ lastSync: "",
280
+ files: {
281
+ "docs/a.md": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
282
+ },
283
+ pulls: [],
284
+ };
285
+ const before = JSON.stringify(j);
286
+ const rewritten = normalizeJournalKeys(j);
287
+ expect(rewritten).toBe(0);
288
+ expect(JSON.stringify(j)).toBe(before);
289
+ });
290
+
291
+ it("prefers an existing POSIX twin and drops the malformed duplicate", () => {
292
+ // When both forms exist, the POSIX entry is authoritative (it round-
293
+ // tripped through an up-to-date client). The malformed dup is discarded.
294
+ const j: SyncJournal = {
295
+ version: "2",
296
+ lastSync: "",
297
+ files: {
298
+ "docs/a.md": {
299
+ hash: "posix-wins",
300
+ size: 1,
301
+ syncedAt: "x",
302
+ direction: "down",
303
+ },
304
+ "docs\\a.md": {
305
+ hash: "backslash-loses",
306
+ size: 2,
307
+ syncedAt: "x",
308
+ direction: "down",
309
+ },
310
+ },
311
+ pulls: [],
312
+ };
313
+ normalizeJournalKeys(j);
314
+ expect(j.files["docs\\a.md"]).toBeUndefined();
315
+ expect(j.files["docs/a.md"]?.hash).toBe("posix-wins");
316
+ });
317
+
318
+ it("is idempotent — a second pass rewrites nothing", () => {
319
+ const j: SyncJournal = {
320
+ version: "2",
321
+ lastSync: "",
322
+ files: {
323
+ "a\\b\\c.txt": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
324
+ },
325
+ pulls: [],
326
+ };
327
+ expect(normalizeJournalKeys(j)).toBe(1);
328
+ expect(normalizeJournalKeys(j)).toBe(0);
329
+ expect(j.files["a/b/c.txt"]).toBeDefined();
330
+ });
211
331
  });
212
332
 
213
333
  describe("generatePullId", () => {
package/src/journal.ts CHANGED
@@ -18,6 +18,7 @@ import * as os from "os";
18
18
  import * as path from "path";
19
19
  import * as crypto from "crypto";
20
20
  import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
21
+ import { toPosixKey } from "./s3.js";
21
22
 
22
23
  /** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
23
24
  export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -134,6 +135,42 @@ export function readJournal(slug: string): SyncJournal {
134
135
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
135
136
  }
136
137
 
138
+ /**
139
+ * Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
140
+ * map. Such clients stamped keys with the OS path separator ("\\"), e.g.
141
+ * `projects\\forecast-development\\x.csv`. A backslash key is a live data-loss
142
+ * hazard for the cross-machine delete planner (Bug #9): it never matches the
143
+ * forward-slash remote LIST, so the planner classifies the still-present local
144
+ * file as remote-deleted, and `path.join(companyRoot, key)` collapses the
145
+ * backslashes back onto the REAL POSIX file — which the executor then unlinks
146
+ * (ridge incident, feedback_b8d09d0f: a single pull deleted ~36 live files,
147
+ * with 587 backslash keys left in the journal as a recurring landmine).
148
+ *
149
+ * Rewriting every key to its canonical POSIX form on load removes the hazard
150
+ * idempotently — a clean (all-POSIX) journal is returned untouched. Merge rule:
151
+ * if a key's POSIX twin already exists, the POSIX entry is authoritative (it
152
+ * round-tripped through an up-to-date client) and the malformed duplicate is
153
+ * dropped; otherwise the entry is moved to its POSIX key. Returns the number of
154
+ * keys rewritten (0 for a clean journal) for telemetry and test assertions.
155
+ */
156
+ export function normalizeJournalKeys(journal: SyncJournal): number {
157
+ if (!journal.files) return 0;
158
+ let rewritten = 0;
159
+ // Snapshot keys up front — we mutate journal.files during the walk.
160
+ for (const key of Object.keys(journal.files)) {
161
+ const posix = toPosixKey(key);
162
+ if (posix === key) continue; // already canonical (no backslash)
163
+ const entry = journal.files[key];
164
+ delete journal.files[key];
165
+ rewritten++;
166
+ // POSIX twin already present → it wins; drop the malformed duplicate.
167
+ if (!(posix in journal.files)) {
168
+ journal.files[posix] = entry;
169
+ }
170
+ }
171
+ return rewritten;
172
+ }
173
+
137
174
  /**
138
175
  * Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
139
176
  * Mutates the input and returns it for chainable use. Call this immediately
@@ -145,6 +182,10 @@ export function readJournal(slug: string): SyncJournal {
145
182
  * "all" in the scope-shrink algorithm).
146
183
  */
147
184
  export function migrateToV2(journal: SyncJournal): SyncJournal {
185
+ // Backslash-key normalization runs UNCONDITIONALLY — a poisoned journal can
186
+ // already carry version "2" (the landmine predates the schema bump), so this
187
+ // must precede the v2 early-return or already-v2 journals stay poisoned.
188
+ normalizeJournalKeys(journal);
148
189
  if (journal.version === "2" && Array.isArray(journal.pulls)) {
149
190
  return journal;
150
191
  }
@@ -57,8 +57,9 @@ import type { TreeChangeBatch } from "../watcher.js";
57
57
  * EXACT full-address matching, case-insensitive — NOT a domain suffix. The
58
58
  * single-account Phase 3 rollout (2026-06-10) targets the operator's own
59
59
  * devices; `xhassaan@getindigo.ai` and `hassaan@getindigo.ai.evil.com` must
60
- * never match. Broadening later is an entry here (or a domain-set like
61
- * `PRESIGN_ROLLOUT_DOMAINS` once GA'd).
60
+ * never match. Broadening later is an entry here, then a domain set, then a
61
+ * GA default-on switch (the path the presigned-URL transport already took in
62
+ * `resolvePresignTransport`).
62
63
  */
63
64
  export const EVENT_SYNC_ROLLOUT_EMAILS: ReadonlySet<string> = new Set([
64
65
  "hassaan@getindigo.ai",