@indigoai-us/hq-cloud 6.14.23 → 6.14.25

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 (131) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +11 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +11 -0
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +37 -2
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-planning.test.js +1 -0
  9. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-loop.js +17 -1
  12. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts +6 -0
  14. package/dist/bin/sync-runner.d.ts.map +1 -1
  15. package/dist/bin/sync-runner.js +14 -2
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +155 -1
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/doctor.d.ts +119 -0
  20. package/dist/cli/doctor.d.ts.map +1 -0
  21. package/dist/cli/doctor.js +485 -0
  22. package/dist/cli/doctor.js.map +1 -0
  23. package/dist/cli/doctor.test.d.ts +13 -0
  24. package/dist/cli/doctor.test.d.ts.map +1 -0
  25. package/dist/cli/doctor.test.js +485 -0
  26. package/dist/cli/doctor.test.js.map +1 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.d.ts.map +1 -1
  29. package/dist/cli/index.js +4 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/share.js +6 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/sync.d.ts +29 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +140 -2
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +223 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/cognito-auth.test.js +7 -0
  40. package/dist/cognito-auth.test.js.map +1 -1
  41. package/dist/company-resolver.d.ts +31 -1
  42. package/dist/company-resolver.d.ts.map +1 -1
  43. package/dist/company-resolver.js +329 -21
  44. package/dist/company-resolver.js.map +1 -1
  45. package/dist/company-resolver.test.js +371 -1
  46. package/dist/company-resolver.test.js.map +1 -1
  47. package/dist/index.d.ts +2 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/lib/conflict-file.d.ts.map +1 -1
  52. package/dist/lib/conflict-file.js +5 -1
  53. package/dist/lib/conflict-file.js.map +1 -1
  54. package/dist/lib/conflict-index.d.ts.map +1 -1
  55. package/dist/lib/conflict-index.js +7 -2
  56. package/dist/lib/conflict-index.js.map +1 -1
  57. package/dist/lib/machine-id.test.js +14 -0
  58. package/dist/lib/machine-id.test.js.map +1 -1
  59. package/dist/local-path-codec.d.ts +18 -0
  60. package/dist/local-path-codec.d.ts.map +1 -1
  61. package/dist/local-path-codec.js +63 -0
  62. package/dist/local-path-codec.js.map +1 -1
  63. package/dist/local-path-codec.test.d.ts +14 -0
  64. package/dist/local-path-codec.test.d.ts.map +1 -0
  65. package/dist/local-path-codec.test.js +102 -0
  66. package/dist/local-path-codec.test.js.map +1 -0
  67. package/dist/machine-auth.test.js +7 -0
  68. package/dist/machine-auth.test.js.map +1 -1
  69. package/dist/s3.d.ts +5 -0
  70. package/dist/s3.d.ts.map +1 -1
  71. package/dist/s3.js +129 -3
  72. package/dist/s3.js.map +1 -1
  73. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  74. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  75. package/dist/s3.symlink-materialize.test.js +394 -0
  76. package/dist/s3.symlink-materialize.test.js.map +1 -0
  77. package/dist/s3.test.js +19 -0
  78. package/dist/s3.test.js.map +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +24 -3
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +17 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/telemetry.d.ts +10 -1
  85. package/dist/telemetry.d.ts.map +1 -1
  86. package/dist/telemetry.js +95 -4
  87. package/dist/telemetry.js.map +1 -1
  88. package/dist/telemetry.test.js +177 -0
  89. package/dist/telemetry.test.js.map +1 -1
  90. package/dist/vault-client.d.ts +42 -0
  91. package/dist/vault-client.d.ts.map +1 -1
  92. package/dist/vault-client.js +40 -0
  93. package/dist/vault-client.js.map +1 -1
  94. package/dist/watcher.d.ts.map +1 -1
  95. package/dist/watcher.js +10 -1
  96. package/dist/watcher.js.map +1 -1
  97. package/dist/watcher.test.js +28 -0
  98. package/dist/watcher.test.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/bin/sync-runner-company.ts +10 -0
  101. package/src/bin/sync-runner-planning.test.ts +1 -0
  102. package/src/bin/sync-runner-planning.ts +46 -3
  103. package/src/bin/sync-runner-watch-loop.ts +19 -1
  104. package/src/bin/sync-runner.test.ts +174 -0
  105. package/src/bin/sync-runner.ts +17 -1
  106. package/src/cli/doctor.test.ts +581 -0
  107. package/src/cli/doctor.ts +640 -0
  108. package/src/cli/index.ts +14 -0
  109. package/src/cli/share.ts +6 -1
  110. package/src/cli/sync.test.ts +281 -1
  111. package/src/cli/sync.ts +184 -2
  112. package/src/cognito-auth.test.ts +5 -0
  113. package/src/company-resolver.test.ts +432 -1
  114. package/src/company-resolver.ts +362 -23
  115. package/src/index.ts +10 -0
  116. package/src/lib/conflict-file.ts +6 -1
  117. package/src/lib/conflict-index.ts +7 -2
  118. package/src/lib/machine-id.test.ts +10 -0
  119. package/src/local-path-codec.test.ts +138 -0
  120. package/src/local-path-codec.ts +66 -0
  121. package/src/machine-auth.test.ts +5 -0
  122. package/src/s3.symlink-materialize.test.ts +492 -0
  123. package/src/s3.test.ts +24 -0
  124. package/src/s3.ts +148 -4
  125. package/src/skill-telemetry.test.ts +19 -0
  126. package/src/skill-telemetry.ts +32 -3
  127. package/src/telemetry.test.ts +241 -0
  128. package/src/telemetry.ts +124 -6
  129. package/src/vault-client.ts +68 -0
  130. package/src/watcher.test.ts +33 -0
  131. package/src/watcher.ts +10 -1
package/src/s3.ts CHANGED
@@ -317,7 +317,13 @@ function removeTempPath(tempPath: string): void {
317
317
  try {
318
318
  fs.unlinkSync(tempPath);
319
319
  } catch {
320
- // Best-effort cleanup; do not mask the transfer/materialization error.
320
+ // A win32 directory-type symlink is a directory reparse point and
321
+ // refuses unlink (EPERM); rmdir is the correct removal call for it.
322
+ try {
323
+ fs.rmdirSync(tempPath);
324
+ } catch {
325
+ // Best-effort cleanup; do not mask the transfer/materialization error.
326
+ }
321
327
  }
322
328
  }
323
329
 
@@ -368,6 +374,69 @@ export function createStagedSymlink(
368
374
  ops.symlink(absTarget, linkPath, "junction");
369
375
  }
370
376
 
377
+ /**
378
+ * downloadFile's `options.win32` seam is the authority for symlink-flavor
379
+ * decisions (it is what makes Windows semantics testable on POSIX CI), so the
380
+ * staged-link helper must see the SAME platform the idempotence fast path
381
+ * reasoned about rather than the raw `process.platform`. createStagedSymlink
382
+ * only ever asks "is this win32?", so "linux" is a faithful stand-in for
383
+ * "not Windows" on the false branch.
384
+ */
385
+ function stagedSymlinkOpsFor(win32: boolean): CreateStagedSymlinkOps {
386
+ return {
387
+ ...DEFAULT_CREATE_STAGED_SYMLINK_OPS,
388
+ platform: win32 ? "win32" : "linux",
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Would createStagedSymlink() materialize `target` as a DIRECTORY-flavored
394
+ * link (a junction) rather than a file symlink? Mirrors that helper's rule
395
+ * exactly — only a target confirmed to be a non-directory earns a file
396
+ * symlink; directories and unresolvable targets become junctions, which need
397
+ * no Windows privilege. Keeping the two in lockstep is what lets the
398
+ * idempotence fast path below decide whether an existing link is already the
399
+ * link we would mint today.
400
+ */
401
+ function win32StagedLinkIsDirectoryFlavored(
402
+ target: string,
403
+ linkPath: string,
404
+ ops: CreateStagedSymlinkOps = DEFAULT_CREATE_STAGED_SYMLINK_OPS,
405
+ ): boolean {
406
+ const absTarget = path.isAbsolute(target)
407
+ ? target
408
+ : path.resolve(path.dirname(linkPath), target);
409
+ return ops.statIsDirectory(absTarget) !== false;
410
+ }
411
+
412
+ /**
413
+ * Compare an on-disk readlink() result against an incoming wire target.
414
+ *
415
+ * POSIX compares exactly: we write the wire target verbatim, so readlink()
416
+ * returns it byte-for-byte, and a backslash is an ordinary filename character
417
+ * there. On win32 neither property holds — createStagedSymlink() materializes
418
+ * directory overlays as JUNCTIONS, which require an ABSOLUTE substitute path,
419
+ * and Node rewrites '/' → '\\' at symlink() time. A raw string compare would
420
+ * therefore never match on Windows and the fast path would never fire for the
421
+ * .claude/skills link farm (the exact perpetual-churn bug this guards). So
422
+ * win32 equality resolves BOTH sides against the link's parent directory and
423
+ * compares separator-insensitively, which normalizes the relative wire target
424
+ * and the absolute junction target onto the same footing.
425
+ */
426
+ function symlinkTargetsEqual(
427
+ existing: string,
428
+ incoming: string,
429
+ linkPath: string,
430
+ win32: boolean,
431
+ ): boolean {
432
+ if (!win32) return existing === incoming;
433
+ const normalize = (value: string): string =>
434
+ path
435
+ .resolve(path.dirname(linkPath), value.replace(/\\/g, "/"))
436
+ .replace(/\\/g, "/");
437
+ return normalize(existing) === normalize(incoming);
438
+ }
439
+
371
440
  export interface ReplaceStagedPathOps {
372
441
  lstat(path: string): fs.Stats;
373
442
  rename(from: string, to: string): void;
@@ -377,7 +446,19 @@ export interface ReplaceStagedPathOps {
377
446
  const DEFAULT_REPLACE_STAGED_PATH_OPS: ReplaceStagedPathOps = {
378
447
  lstat: (p) => fs.lstatSync(p),
379
448
  rename: (from, to) => fs.renameSync(from, to),
380
- remove: (p) => fs.unlinkSync(p),
449
+ remove: (p) => {
450
+ try {
451
+ fs.unlinkSync(p);
452
+ } catch (err) {
453
+ // A win32 directory-type symlink or junction is a directory reparse
454
+ // point; some runtimes refuse unlink (EPERM/EISDIR) and need rmdir —
455
+ // same fallback as removeTempPath. Anything else stays loud so
456
+ // replaceStagedPath can roll back.
457
+ const code = (err as NodeJS.ErrnoException).code;
458
+ if (code !== "EPERM" && code !== "EISDIR") throw err;
459
+ fs.rmdirSync(p);
460
+ }
461
+ },
381
462
  };
382
463
 
383
464
  function replacementBackupPath(localPath: string): string {
@@ -1060,7 +1141,14 @@ export async function downloadFile(
1060
1141
  ctx: EntityContext,
1061
1142
  key: string,
1062
1143
  localPath: string,
1063
- options: { beforeReplace?: () => void } = {},
1144
+ options: {
1145
+ beforeReplace?: () => void;
1146
+ /**
1147
+ * Platform override so win32 symlink semantics are directly testable
1148
+ * on any host (same convention as local-path-codec.ts).
1149
+ */
1150
+ win32?: boolean;
1151
+ } = {},
1064
1152
  ): Promise<{
1065
1153
  metadata?: Record<string, string>;
1066
1154
  contentHash?: string;
@@ -1151,9 +1239,65 @@ export async function downloadFile(
1151
1239
  );
1152
1240
  }
1153
1241
 
1242
+ // Idempotence fast path: the .claude/skills link farm makes symlink
1243
+ // records the most re-pulled object class, and a pull that changes
1244
+ // nothing must not touch disk at all (pre-fix, an already-correct
1245
+ // Windows dir link still went through temp+rename, EPERM'd every
1246
+ // pass, never journaled, and re-downloaded forever). On POSIX a
1247
+ // target-string match is sufficient (lstat + readlink — never stat:
1248
+ // dangling links are legitimate). On win32 a matching target string is
1249
+ // NOT sufficient — links are flavored, and a file-type link at a
1250
+ // directory target (minted by an older client, or while the target was
1251
+ // absent) is broken despite the matching string. There the fast path
1252
+ // additionally requires stat-follow to confirm the link FUNCTIONS as
1253
+ // the type win32SymlinkType() would mint now; anything unconfirmable
1254
+ // (including a dangling link, whose flavor cannot be probed) falls
1255
+ // through to replacement, which is cheap and journals correctly.
1256
+ const win32 = options.win32 ?? process.platform === "win32";
1257
+ let existingLink: fs.Stats | null = null;
1258
+ try {
1259
+ existingLink = fs.lstatSync(localPath);
1260
+ } catch {
1261
+ // Absent (or unreadable) destination: no fast path; the normal
1262
+ // materialization below surfaces real filesystem faults loudly.
1263
+ }
1264
+ if (existingLink?.isSymbolicLink()) {
1265
+ let existingTarget: string | null = null;
1266
+ try {
1267
+ existingTarget = fs.readlinkSync(localPath);
1268
+ } catch {
1269
+ // Link raced away between lstat and readlink; replace it below.
1270
+ }
1271
+ if (
1272
+ existingTarget !== null &&
1273
+ symlinkTargetsEqual(existingTarget, symlinkTarget, localPath, win32)
1274
+ ) {
1275
+ if (!win32) return { metadata };
1276
+ const desiredIsDirectory = win32StagedLinkIsDirectoryFlavored(
1277
+ symlinkTarget,
1278
+ localPath,
1279
+ );
1280
+ let followed: fs.Stats | null = null;
1281
+ try {
1282
+ followed = fs.statSync(localPath);
1283
+ } catch {
1284
+ // Unresolvable via this link (dangling, or a wrong-flavor link
1285
+ // that Windows refuses to traverse): recreate below.
1286
+ }
1287
+ if (followed !== null && followed.isDirectory() === desiredIsDirectory) {
1288
+ return { metadata };
1289
+ }
1290
+ }
1291
+ }
1292
+
1154
1293
  const tempPath = downloadTempPath(localPath);
1155
1294
  try {
1156
- createStagedSymlink(symlinkTarget, tempPath);
1295
+ // Creation goes through createStagedSymlink (#219): on win32 a directory
1296
+ // overlay becomes an unprivileged JUNCTION rather than a 'dir' NTFS
1297
+ // symlink, which is what stops the EPERM that pushed the one-shot runner
1298
+ // to PARTIAL_SYNC_EXIT. The staged link lands beside `localPath`, so its
1299
+ // flavor decision resolves the target identically to the fast path above.
1300
+ createStagedSymlink(symlinkTarget, tempPath, stagedSymlinkOpsFor(win32));
1157
1301
  options.beforeReplace?.();
1158
1302
  replaceStagedPath(tempPath, localPath);
1159
1303
  } catch (err) {
@@ -13,6 +13,7 @@ import {
13
13
  computeSkillVersion,
14
14
  readFileRegion,
15
15
  } from "./skill-telemetry.js";
16
+ import { encodeLocalVaultSegment } from "./local-path-codec.js";
16
17
  import type { SkillInvocationBatch } from "./vault-client.js";
17
18
 
18
19
  describe("extractSkillEvents", () => {
@@ -1364,6 +1365,24 @@ describe("computeSkillVersion (US-015)", () => {
1364
1365
  await fs.rm(tmp, { recursive: true, force: true });
1365
1366
  });
1366
1367
 
1368
+ it("resolves an ALREADY-ENCODED local segment without double-encoding (win32 Codex path capture)", async () => {
1369
+ // Codex telemetry extraction captures the on-disk wrapper name from a
1370
+ // win32 path (`indigo%3Ahello-world`); encoding that again would look
1371
+ // under `indigo%253A...` and miss. Normalization decodes to canonical
1372
+ // first, then re-encodes for THIS host.
1373
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1374
+ const body = "encoded-name skill";
1375
+ const hostDirName = encodeLocalVaultSegment("indigo:hello-world");
1376
+ const dir = path.join(tmp, ".claude", "skills", hostDirName);
1377
+ await fs.mkdir(dir, { recursive: true });
1378
+ await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1379
+
1380
+ expect(await computeSkillVersion(tmp, "indigo%3Ahello-world")).toBe(sha256(body));
1381
+ // A decoded separator can never smuggle past the traversal guard.
1382
+ expect(await computeSkillVersion(tmp, "a%2Fb")).toBeUndefined();
1383
+ await fs.rm(tmp, { recursive: true, force: true });
1384
+ });
1385
+
1367
1386
  it("falls back to .agents/skills/ (Codex bridge layout)", async () => {
1368
1387
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1369
1388
  const body = "codex-bridged skill\n";
@@ -66,6 +66,10 @@ import {
66
66
  resolveCompanyForSkill,
67
67
  type RepoCompanyMap,
68
68
  } from "./company-resolver.js";
69
+ import {
70
+ canonicalVaultSegmentSpelling,
71
+ encodeLocalVaultSegment,
72
+ } from "./local-path-codec.js";
69
73
  import type {
70
74
  SkillInvocationBatch,
71
75
  SkillInvocationIngestResult,
@@ -621,10 +625,35 @@ export async function computeSkillVersion(
621
625
  if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
622
626
  return undefined;
623
627
  }
628
+ // Codec boundary: callers hand us EITHER the canonical invocation name
629
+ // (`indigo:hello-world`) or an already-encoded local dir segment captured
630
+ // from a win32 path (`indigo%3Ahello-world`, Codex telemetry extraction).
631
+ // Normalize to canonical first — encoding an already-encoded name would
632
+ // look under `indigo%253A...` and miss the real wrapper — then encode for
633
+ // THIS host. Re-run the traversal guard on the decoded form: a crafted
634
+ // `%2F`/`%5C` escape must not smuggle a separator past the raw check.
635
+ const canonicalSkill = canonicalVaultSegmentSpelling(skill);
636
+ if (
637
+ canonicalSkill.includes("/") ||
638
+ canonicalSkill.includes("\\") ||
639
+ canonicalSkill.includes("..")
640
+ ) {
641
+ return undefined;
642
+ }
643
+ const localSkillDirName = encodeLocalVaultSegment(canonicalSkill);
624
644
  const candidates = [
625
- path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
626
- path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
645
+ path.join(hqRoot, ".claude", "skills", localSkillDirName, "SKILL.md"),
646
+ path.join(hqRoot, ".agents", "skills", localSkillDirName, "SKILL.md"),
627
647
  ];
648
+ // Colon-free names do not normalize (amplifier families are namespaced),
649
+ // so a win32 capture of a space-named skill's dir (`foo%20bar`) would
650
+ // re-encode to `foo%2520bar` and miss — also try the raw segment as-is.
651
+ if (skill !== localSkillDirName) {
652
+ candidates.push(
653
+ path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
654
+ path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
655
+ );
656
+ }
628
657
  for (const candidate of candidates) {
629
658
  try {
630
659
  const bytes = await fs.readFile(candidate);
@@ -854,7 +883,7 @@ export async function collectAndSendSkillTelemetry(
854
883
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
855
884
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
856
885
  ? await buildRepoCompanyMap(opts.hqRoot)
857
- : { entries: [], bySlug: new Map() };
886
+ : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
858
887
 
859
888
  // skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
860
889
  // ONCE per run (skills repeat across a session) and stamp it onto every event.
@@ -33,24 +33,42 @@ interface StubClient extends TelemetryClientSurface {
33
33
  posts: UsageBatch[];
34
34
  /** Number of times `getTelemetryOptIn` was called. */
35
35
  optInCalls: number;
36
+ /** Every value handed to `setTelemetryOptIn`, in arrival order. */
37
+ optInSets: boolean[];
38
+ /** The options object passed alongside each `setTelemetryOptIn` call. */
39
+ optInSetOpts: Array<{ onlyIfUnset?: boolean } | undefined>;
36
40
  }
37
41
 
38
42
  function makeClient(opts: {
39
43
  optInResponse?: TelemetryOptInResponse | Error;
40
44
  postResponse?: UsageIngestResult | Error;
45
+ /** When set, `setTelemetryOptIn` rejects with this error. */
46
+ setOptInError?: Error;
47
+ /** `applied` value the conditional write reports back (default true). */
48
+ setOptInApplied?: boolean;
41
49
  } = {}): StubClient {
42
50
  const optInResponse = opts.optInResponse ?? { enabled: true, updatedAt: null };
43
51
  const postResponse = opts.postResponse ?? { ok: true, written: 0, skipped: [] };
44
52
 
45
53
  const posts: UsageBatch[] = [];
54
+ const optInSets: boolean[] = [];
55
+ const optInSetOpts: Array<{ onlyIfUnset?: boolean } | undefined> = [];
46
56
  const stub: StubClient = {
47
57
  posts,
48
58
  optInCalls: 0,
59
+ optInSets,
60
+ optInSetOpts,
49
61
  async getTelemetryOptIn() {
50
62
  this.optInCalls++;
51
63
  if (optInResponse instanceof Error) throw optInResponse;
52
64
  return optInResponse;
53
65
  },
66
+ async setTelemetryOptIn(enabled: boolean, setOpts?: { onlyIfUnset?: boolean }) {
67
+ optInSets.push(enabled);
68
+ optInSetOpts.push(setOpts);
69
+ if (opts.setOptInError) throw opts.setOptInError;
70
+ return { applied: opts.setOptInApplied ?? true };
71
+ },
54
72
  async postUsage(batch: UsageBatch) {
55
73
  posts.push(batch);
56
74
  if (postResponse instanceof Error) throw postResponse;
@@ -722,3 +740,226 @@ describe("collectAndSendTelemetry — companyUid attribution", () => {
722
740
  for (const ev of events) expect(ev.companyUid).toBe(COMPANY_UID);
723
741
  });
724
742
  });
743
+
744
+ const CALLER = "prs_01CALLER";
745
+
746
+ // ── Consent self-heal ────────────────────────────────────────────────────────
747
+ //
748
+ // Regression for the opt-in race: the installer writes the user's answer to
749
+ // `~/.hq/menubar.json` (always succeeds) AND posts it to `/v1/usage/opt-in` —
750
+ // but that post fires before the person entity exists, so it 404s and the
751
+ // server attribute is never written. Absence read as `false`, the emitter went
752
+ // silent, and the person showed as "not opted in" forever. Measured in prod:
753
+ // 22 of 33 active Indigo members had NO attribute at all, against 2 genuine
754
+ // opt-outs.
755
+ //
756
+ // The heal replays the answer the user already gave. It must fire ONLY on the
757
+ // server's `unset` state, and must never invent or overwrite consent.
758
+
759
+ describe("collectAndSendTelemetry — consent self-heal", () => {
760
+ let env: TestEnv;
761
+ beforeEach(() => {
762
+ env = setupEnv();
763
+ });
764
+ afterEach(() => teardownEnv(env));
765
+
766
+ // The cached answer must name the account it belongs to; an unbound record is
767
+ // deliberately not replayable (see the cross-account test below).
768
+ function writeMenubar(value: unknown, personUid: string | undefined = CALLER): void {
769
+ fs.writeFileSync(
770
+ env.menubarPath,
771
+ JSON.stringify({
772
+ telemetryEnabled: value,
773
+ ...(personUid ? { telemetryOptInPersonUid: personUid } : {}),
774
+ }),
775
+ );
776
+ }
777
+
778
+ it("re-asserts a local opt-IN when the server has no recorded consent", async () => {
779
+ writeMenubar(true);
780
+ const client = makeClient({
781
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
782
+ });
783
+
784
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
785
+
786
+ expect(client.optInSets).toEqual([true]);
787
+ expect(result.enabled).toBe(true);
788
+ expect(result.optInSource).toBe("menubar-reasserted");
789
+ });
790
+
791
+ it("re-asserts a local opt-OUT so the server records the real answer", async () => {
792
+ writeMenubar(false);
793
+ const client = makeClient({
794
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
795
+ });
796
+
797
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
798
+
799
+ expect(client.optInSets).toEqual([false]);
800
+ expect(result.enabled).toBe(false);
801
+ });
802
+
803
+ it("NEVER overwrites an explicit server opt-out", async () => {
804
+ // The user opted in on this machine at some point, then explicitly opted
805
+ // out account-wide. The account-wide answer must win.
806
+ writeMenubar(true);
807
+ const client = makeClient({
808
+ optInResponse: { enabled: false, updatedAt: "2026-06-19T16:11:09.939Z", unset: false },
809
+ });
810
+
811
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
812
+
813
+ expect(client.optInSets).toEqual([]);
814
+ expect(result.enabled).toBe(false);
815
+ expect(result.optInSource).toBe("server");
816
+ });
817
+
818
+ it("does not invent consent when no local answer exists", async () => {
819
+ // No menubar.json at all — we hold no answer, so we assert nothing.
820
+ const client = makeClient({
821
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
822
+ });
823
+
824
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
825
+
826
+ expect(client.optInSets).toEqual([]);
827
+ expect(result.enabled).toBe(false);
828
+ });
829
+
830
+ it("does not invent consent when the local value is not a boolean", async () => {
831
+ writeMenubar("yes");
832
+ const client = makeClient({
833
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
834
+ });
835
+
836
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
837
+
838
+ expect(client.optInSets).toEqual([]);
839
+ expect(result.enabled).toBe(false);
840
+ });
841
+
842
+ it("falls back to the server answer when the re-assert POST fails", async () => {
843
+ writeMenubar(true);
844
+ const client = makeClient({
845
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
846
+ setOptInError: new Error("network down"),
847
+ });
848
+
849
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
850
+
851
+ expect(client.optInSets).toEqual([true]);
852
+ // Non-fatal: the run completes on the server's answer and retries next time.
853
+ expect(result.enabled).toBe(false);
854
+ expect(result.optInSource).toBe("server");
855
+ });
856
+
857
+ it("skips the heal entirely against an older server that omits `unset`", async () => {
858
+ writeMenubar(true);
859
+ const client = makeClient({ optInResponse: { enabled: false, updatedAt: null } });
860
+
861
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
862
+
863
+ expect(client.optInSets).toEqual([]);
864
+ expect(result.optInSource).toBe("server");
865
+ });
866
+ });
867
+
868
+ // The self-heal must be ATOMIC. Reading `unset` and replaying the local answer
869
+ // are two requests; without a server-side condition another device could record
870
+ // a real opt-out in between and the replay would clobber it.
871
+ describe("collectAndSendTelemetry — self-heal is conditional", () => {
872
+ let env: TestEnv;
873
+ beforeEach(() => {
874
+ env = setupEnv();
875
+ });
876
+ afterEach(() => teardownEnv(env));
877
+
878
+ it("asks the server to write ONLY if the consent is still unset", async () => {
879
+ fs.writeFileSync(
880
+ env.menubarPath,
881
+ JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
882
+ );
883
+ const client = makeClient({
884
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
885
+ });
886
+
887
+ await collectAndSendTelemetry(makeOpts(env, client));
888
+
889
+ expect(client.optInSetOpts[0]).toMatchObject({ onlyIfUnset: true });
890
+ });
891
+
892
+ it("defers to the server when the conditional write loses the race", async () => {
893
+ // A real answer was recorded elsewhere between our GET and our POST.
894
+ fs.writeFileSync(
895
+ env.menubarPath,
896
+ JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
897
+ );
898
+ const client = makeClient({
899
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
900
+ setOptInApplied: false,
901
+ });
902
+
903
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
904
+
905
+ // The stale local `true` must NOT win over the answer that landed first.
906
+ expect(result.enabled).toBe(false);
907
+ expect(result.optInSource).toBe("server");
908
+ });
909
+ });
910
+
911
+ // The cached consent lives in a per-MACHINE file. If two people sign in under
912
+ // the same OS user it holds whoever answered LAST, so replaying it for a
913
+ // different account would opt in someone who never consented.
914
+ describe("collectAndSendTelemetry — consent replay is account-scoped", () => {
915
+ let env: TestEnv;
916
+ beforeEach(() => {
917
+ env = setupEnv();
918
+ });
919
+ afterEach(() => teardownEnv(env));
920
+
921
+ it("does NOT replay a cached answer belonging to a different account", async () => {
922
+ fs.writeFileSync(
923
+ env.menubarPath,
924
+ JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: "prs_01SOMEONE_ELSE" }),
925
+ );
926
+ const client = makeClient({
927
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
928
+ });
929
+
930
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
931
+
932
+ expect(client.optInSets).toEqual([]);
933
+ expect(result.enabled).toBe(false);
934
+ });
935
+
936
+ it("does NOT replay a legacy record with no account binding", async () => {
937
+ // Written before consent was bound to an account — we cannot prove whose
938
+ // answer it is, so the installer's post-sign-in upload is the recovery path.
939
+ fs.writeFileSync(env.menubarPath, JSON.stringify({ telemetryEnabled: true }));
940
+ const client = makeClient({
941
+ optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
942
+ });
943
+
944
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
945
+
946
+ expect(client.optInSets).toEqual([]);
947
+ expect(result.enabled).toBe(false);
948
+ });
949
+
950
+ it("does NOT replay when the server cannot say who the caller is", async () => {
951
+ fs.writeFileSync(
952
+ env.menubarPath,
953
+ JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
954
+ );
955
+ // Older server: no `personUid`, so the match cannot be proven.
956
+ const client = makeClient({
957
+ optInResponse: { enabled: false, updatedAt: null, unset: true },
958
+ });
959
+
960
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
961
+
962
+ expect(client.optInSets).toEqual([]);
963
+ expect(result.enabled).toBe(false);
964
+ });
965
+ });