@indigoai-us/hq-cloud 6.14.46 → 6.14.48

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/share.ts CHANGED
@@ -32,11 +32,17 @@ import {
32
32
  hashSymlinkTarget,
33
33
  updateEntry,
34
34
  removeEntry,
35
+ isTombstone,
35
36
  normalizeEtag,
36
37
  PERSONAL_VAULT_JOURNAL_SLUG,
37
38
  migratePersonalVaultJournal,
38
39
  } from "../journal.js";
39
- import { createIgnoreFilter, isExpectedIgnore, isWithinSizeLimit } from "../ignore.js";
40
+ import {
41
+ createIgnoreFilter,
42
+ hasControlCharacters,
43
+ isExpectedIgnore,
44
+ isWithinSizeLimit,
45
+ } from "../ignore.js";
40
46
  import {
41
47
  wrapFilterWithPersonalVaultDefaults,
42
48
  type PersonalVaultExclusion,
@@ -295,6 +301,7 @@ export const _testing = {
295
301
  collectFiles,
296
302
  resolveNamedPath,
297
303
  isWithinLexicalOrReal,
304
+ defaultConsoleLogger,
298
305
  };
299
306
 
300
307
  /**
@@ -641,8 +648,10 @@ export interface ShareOptions {
641
648
  * belong in THIS bucket regardless of local state" and uses the same
642
649
  * DeleteObject + journal-removal path as `propagateDeletes`.
643
650
  *
644
- * Honors `propagateDeletePolicy` `"owned-only"` (default) only
645
- * decommissions journal entries with `direction === "up"`, so a
651
+ * Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
652
+ * own fallback when the caller passes nothing; every live caller passes
653
+ * `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
654
+ * journal entries with `direction === "up"` are decommissioned, so a
646
655
  * misconfigured caller never erases content pulled from elsewhere.
647
656
  *
648
657
  * Independent of `propagateDeletes`: callers can opt into decommission
@@ -888,6 +897,7 @@ export type ShareDeleteRefusalReason =
888
897
  | "divergent-local"
889
898
  | "missing-delete-intent"
890
899
  | "intent-changed"
900
+ | "recreated-locally"
891
901
  | "transfer-error";
892
902
 
893
903
  export interface SharePathResult {
@@ -1088,7 +1098,7 @@ const REFUSED_STALE_PATH_CAP = 50;
1088
1098
 
1089
1099
  async function createPushRunContext(options: ShareOptions): Promise<PushRunContext> {
1090
1100
  const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
1091
- let propagateDeletePolicy: DeletePolicy =
1101
+ const propagateDeletePolicy: DeletePolicy =
1092
1102
  options.propagateDeletePolicy ?? "owned-only";
1093
1103
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
1094
1104
 
@@ -1118,9 +1128,30 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1118
1128
  ? entityContext
1119
1129
  : await resolveEntityContext(companyRef, vaultConfig!);
1120
1130
 
1121
- if (ctx.uid.startsWith("prs_") && propagateDeletePolicy === "owned-only") {
1122
- propagateDeletePolicy = "currency-gated";
1123
- }
1131
+ // NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
1132
+ // `owned-only` -> `currency-gated` here (366721b). It was correct for the
1133
+ // problem it was written against and is now actively harmful, for three
1134
+ // reasons that only became true later:
1135
+ //
1136
+ // 1. Its ONLY reachable effect in production was to override an EXPLICIT
1137
+ // `owned-only`. Every live caller passes a policy
1138
+ // (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
1139
+ // resolver already defaults to `currency-gated`. So the coercion never
1140
+ // fired on a default run — it fired exactly when an operator had set
1141
+ // `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
1142
+ // 2. Once delete authorization moved to ETag currency (no watcher-minted
1143
+ // intent required), that override stopped being cosmetic: an operator
1144
+ // reaching for the rollback because deletes were misbehaving would keep
1145
+ // getting intent-less etag-only deletes on their personal vault. A
1146
+ // rollback knob that silently exempts a vault is not a rollback knob.
1147
+ // 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
1148
+ // is fixed twice over by mechanisms that postdate it: the vault-litter
1149
+ // drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
1150
+ // and etag-gated deletes propagate an intent-less `direction:"down"`
1151
+ // removal under the default policy without needing any coercion.
1152
+ //
1153
+ // So `owned-only` now means owned-only on every vault kind. It is the strict
1154
+ // rollback path and must stay strictly stricter than the default everywhere.
1124
1155
 
1125
1156
  // Mirror push progress into the shared cross-process snapshot (see sync.ts).
1126
1157
  // Record the friendly company slug, or null for the personal vault so the
@@ -1247,6 +1278,35 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1247
1278
  emitUnreachablePathEvent(run);
1248
1279
  throw new UnreachablePushPathsError(fatal, run.syncRoot);
1249
1280
  }
1281
+ // Control-character key filter (macOS Finder Icon\r incident). A key
1282
+ // containing a control character can never be stored — validateVaultUploadKey
1283
+ // rejects it with INVALID_KEY_CONTROL_CHARS — so planning an upload for one
1284
+ // only buys a guaranteed throw inside executeUploads, which lands in the
1285
+ // generic catch and emits `type: "error"`. That is the failure this filter
1286
+ // exists to stop: a non-empty errors[] makes the runner return
1287
+ // PARTIAL_SYNC_EXIT, so ONE such file on disk marks EVERY sync pass failed
1288
+ // forever. A macOS tree seeded with Finder `Icon\r` files produced 100+ per
1289
+ // run, wedging sync indefinitely.
1290
+ //
1291
+ // Structurally identical to the size-limit skip in executeUploads: a
1292
+ // permanently-invalid path is a benign skip, never an error. Symmetric with
1293
+ // the pull leg, which has classified such keys since the 2026-07-11 incident
1294
+ // (see computePullPlan's classifyVaultKey call). Applied before the mode
1295
+ // branch below so it covers personal and company vaults alike.
1296
+ //
1297
+ // Finder icon files are normally already gone by here — createIgnoreFilter
1298
+ // drops them during the walk. This stays as the general guard for any other
1299
+ // control-char path, and as defense for callers that bypass the walker.
1300
+ const controlCharFiltered = collected.filter((entry) => {
1301
+ if (!hasControlCharacters(entry.relativePath)) return true;
1302
+ run.emit({
1303
+ type: "skip-invalid-scoped-key",
1304
+ path: entry.relativePath,
1305
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
1306
+ });
1307
+ return false;
1308
+ });
1309
+
1250
1310
  // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1251
1311
  // root IS the company folder, so a local entry whose vault key starts with
1252
1312
  // `companies/` can only come from a stale doubled local tree
@@ -1260,7 +1320,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1260
1320
  // `companies/{slug}/…` keys, so this only applies in company mode.
1261
1321
  const filesToShare =
1262
1322
  run.options.personalMode === true
1263
- ? collected.filter((entry) => {
1323
+ ? controlCharFiltered.filter((entry) => {
1264
1324
  // `core/<type>` symlinks are generated projections of canonical
1265
1325
  // personal/package content. Reindex or package wiring owns their
1266
1326
  // lifecycle, so uploading them creates remote objects that a later
@@ -1281,7 +1341,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1281
1341
  }
1282
1342
  return false;
1283
1343
  })
1284
- : collected.filter((entry) => {
1344
+ : controlCharFiltered.filter((entry) => {
1285
1345
  if (!entry.relativePath.startsWith("companies/")) return true;
1286
1346
  run.emit({
1287
1347
  type: "skip-invalid-scoped-key",
@@ -1519,10 +1579,12 @@ async function executeUploads(
1519
1579
  localHash,
1520
1580
  lstat.size,
1521
1581
  "up",
1522
- remoteMeta.etag,
1523
- lstat.mtimeMs,
1524
- undefined,
1525
- item.kind,
1582
+ absolutePath,
1583
+ {
1584
+ remoteEtag: remoteMeta.etag,
1585
+ mtimeMs: lstat.mtimeMs,
1586
+ kind: item.kind,
1587
+ },
1526
1588
  );
1527
1589
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1528
1590
  counters.filesSkipped++;
@@ -1604,10 +1666,8 @@ async function executeUploads(
1604
1666
  localHash,
1605
1667
  size,
1606
1668
  "up",
1607
- etag,
1608
- mtimeMs,
1609
- undefined,
1610
- item.kind,
1669
+ absolutePath,
1670
+ { remoteEtag: etag, mtimeMs, kind: item.kind },
1611
1671
  );
1612
1672
  if (run.message) {
1613
1673
  run.journal.files[relativePath] = {
@@ -1773,21 +1833,34 @@ async function executeDeletes(
1773
1833
  }
1774
1834
  try {
1775
1835
  const entry = run.journal.files[relativePath];
1776
- if (
1836
+ // Last-second absence re-check. Applies to EVERY propagated delete,
1837
+ // intent-backed or etag-only: a pull leg or the user can recreate the
1838
+ // file between planning and applying, and deleting it remotely then
1839
+ // destroys a file that exists locally right now. This used to sit inside
1840
+ // the intent-version check, which meant it was skipped for any item
1841
+ // carrying `intentVersion: null` — harmless while that only covered
1842
+ // litter and decommission, but not once etag-only candidates travel that
1843
+ // way too.
1844
+ //
1845
+ // Codec boundary: journal keys are canonical; the probe must use the
1846
+ // ENCODED local path or a win32 colon key always looks absent (a raw `:`
1847
+ // name can never exist on disk) and the remote delete proceeds while the
1848
+ // encoded local file is still present.
1849
+ const recreatedLocally =
1850
+ !item.decommission &&
1851
+ !isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
1852
+ const intentChanged =
1777
1853
  !item.decommission &&
1778
1854
  item.intentVersion !== null &&
1779
1855
  (!hasCurrentLocalDeleteIntent(entry) ||
1780
- entry!.localDeleteIntent!.version !== item.intentVersion ||
1781
- // Codec boundary: journal keys are canonical; the last-second
1782
- // absence re-check must probe the ENCODED local path or a win32
1783
- // colon key always looks absent (a raw `:` name can never exist
1784
- // on disk) and the remote delete proceeds while the encoded local
1785
- // file is still present.
1786
- !isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath)))
1787
- ) {
1856
+ entry!.localDeleteIntent!.version !== item.intentVersion);
1857
+ if (recreatedLocally || intentChanged) {
1788
1858
  if (entry?.localDeleteIntent) {
1789
1859
  delete entry.localDeleteIntent;
1790
1860
  }
1861
+ const refusalReason = recreatedLocally
1862
+ ? ("recreated-locally" as const)
1863
+ : ("intent-changed" as const);
1791
1864
  counters.filesRefusedStale++;
1792
1865
  if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1793
1866
  filesRefusedStalePaths.push(relativePath);
@@ -1797,13 +1870,13 @@ async function executeDeletes(
1797
1870
  path: relativePath,
1798
1871
  journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
1799
1872
  remoteEtag: "<not-checked>",
1800
- reason: "intent-changed",
1873
+ reason: refusalReason,
1801
1874
  });
1802
1875
  pathResults.push({
1803
1876
  path: relativePath,
1804
1877
  status: "refused",
1805
1878
  operation: "delete",
1806
- reason: "intent-changed",
1879
+ reason: refusalReason,
1807
1880
  });
1808
1881
  continue;
1809
1882
  }
@@ -2149,6 +2222,32 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2149
2222
  // Per-key lines stay compact so a 100-candidate refusal doesn't bury
2150
2223
  // the summary banner.
2151
2224
  console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
2225
+ } else if (event.reason === "missing-delete-intent") {
2226
+ // NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
2227
+ // is the `<not-checked>` sentinel, and `journalEtag` is the entry's
2228
+ // real recorded etag. Printing them under the `stale-etag` wording
2229
+ // (which this branch used to fall through to) reads as an etag
2230
+ // conflict and has repeatedly sent operators chasing a currency bug
2231
+ // that does not exist — the actual cause is that no watcher observed
2232
+ // the removal, so no delete authorization was ever recorded.
2233
+ console.error(
2234
+ ` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
2235
+ `(the sync watcher did not observe this removal, so the delete was never authorized)`,
2236
+ );
2237
+ } else if (event.reason === "recreated-locally") {
2238
+ console.error(
2239
+ ` ⚠ recreated-locally, kept on remote: ${event.path} ` +
2240
+ `(the file exists locally again, so the delete was abandoned)`,
2241
+ );
2242
+ } else if (event.reason === "divergent-local") {
2243
+ // Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
2244
+ // recorded etag was stamped only to silence a re-fired conflict; the
2245
+ // local copy never matched it, so propagating the delete would destroy
2246
+ // divergent remote work.
2247
+ console.error(
2248
+ ` ⚠ divergent-local, kept on remote: ${event.path} ` +
2249
+ `(the local copy never matched the recorded remote version; pull to reconcile first)`,
2250
+ );
2152
2251
  } else {
2153
2252
  console.error(
2154
2253
  ` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`,
@@ -2877,7 +2976,8 @@ type RefusedStaleReason =
2877
2976
  | "bulk-asymmetry"
2878
2977
  | "divergent-local"
2879
2978
  | "missing-delete-intent"
2880
- | "intent-changed";
2979
+ | "intent-changed"
2980
+ | "recreated-locally";
2881
2981
 
2882
2982
  /**
2883
2983
  * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
@@ -3100,7 +3200,7 @@ async function computeDeletePlan(
3100
3200
  // all), or queue it for HEAD (currency-gated). Keeping this synchronous
3101
3201
  // means the HEAD pass below sees a single, deduplicated candidate list
3102
3202
  // and the journal-mutation buckets are already settled before any I/O.
3103
- type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
3203
+ type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 | null };
3104
3204
  const headCandidates: HeadCandidate[] = [];
3105
3205
  // Litter drain bucket — kept separate from `plan.toDelete` so the
3106
3206
  // bulk-asymmetry breaker (which moves toDelete + headCandidates into
@@ -3143,6 +3243,41 @@ async function computeDeletePlan(
3143
3243
  relativeKey.startsWith(`${root}/`),
3144
3244
  );
3145
3245
  if (!inScope) continue;
3246
+
3247
+ // A tombstoned entry has ALREADY had its disposition decided — by a scope
3248
+ // shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
3249
+ // intentional-delete record. It must never be re-evaluated as a fresh
3250
+ // delete candidate.
3251
+ //
3252
+ // This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
3253
+ // KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
3254
+ // what was there), and the shrink removes the file locally. Every one of
3255
+ // those entries therefore presents as locally-absent, intent-less and
3256
+ // etag-current — which, now that authorization is etag-only, is an
3257
+ // instruction to delete another member's files out of the shared company
3258
+ // vault, for the crime of having narrowed one's own sync scope.
3259
+ //
3260
+ // Until now the ONLY thing standing in the way was `shouldSync` happening
3261
+ // to carry a `prefixSet` (see `createPushRunContext`); any run that
3262
+ // resolved no prefix set — a degraded scope lookup, a caller that does not
3263
+ // set one — would have walked straight through. That is far too load-
3264
+ // bearing for an implicit dependency, so the invariant is stated here
3265
+ // directly.
3266
+ //
3267
+ // `local-delete` is the ONE reason that must pass: the pull leg stamps it
3268
+ // as a deliberate producer hand-off ("the user removed this; push leg,
3269
+ // finish the job"), and skipping it would strand exactly the deletes this
3270
+ // change exists to enable. Every other reason — scope_shrink,
3271
+ // narrow_apply, manual, or an absent/unrecognised one — means "stop
3272
+ // tracking, do NOT touch the remote", so the test is written fail-closed:
3273
+ // only the explicit hand-off is let through.
3274
+ //
3275
+ // Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
3276
+ // numerator nor the denominator of the bulk-asymmetry ratio. Excluding
3277
+ // them from the denominator can only make the breaker MORE likely to trip,
3278
+ // which is the safe direction.
3279
+ if (isTombstone(entry) && entry.removedReason !== "local-delete") continue;
3280
+
3146
3281
  inScopeJournalEntries++;
3147
3282
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
3148
3283
 
@@ -3222,13 +3357,78 @@ async function computeDeletePlan(
3222
3357
  }
3223
3358
 
3224
3359
  if (!hasCurrentLocalDeleteIntent(entry)) {
3225
- if (
3226
- policy === "currency-gated" ||
3227
- (policy === "owned-only" && entry.direction === "up")
3228
- ) {
3360
+ // A delete intent is minted ONLY by a live watcher, at the instant it
3361
+ // observes the unlink. Three of the four ways HQ syncs never construct a
3362
+ // watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
3363
+ // now` is one-shot, and the desktop app drops `--event-push` (and with it
3364
+ // the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
3365
+ // an intent can never exist, so requiring one made deletes structurally
3366
+ // impossible rather than merely gated. Files deleted while no watcher was
3367
+ // running also stranded permanently: the file is already gone, so no
3368
+ // watcher can ever retroactively witness it.
3369
+ //
3370
+ // `currency-gated` therefore authorizes on ETag currency alone. The
3371
+ // per-file question it answers is unchanged — "is the remote object still
3372
+ // exactly what this journal recorded, i.e. has no peer touched it since"
3373
+ // — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
3374
+ // never destroyed.
3375
+ //
3376
+ // The whole-set question ("does my local mirror look catastrophically
3377
+ // empty") is NOT answered by the ETag, and must not be conflated with it:
3378
+ // in the steady state nobody has overwritten anything, so a moved hqRoot,
3379
+ // unmounted volume, fresh clone or partial restore presents mass-ENOENT
3380
+ // with perfectly matching ETags. That is exactly how the 2026-05-25
3381
+ // indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
3382
+ // breaker below is the guard for that class and is deliberately left
3383
+ // intact — these candidates are counted into `bulkCandidatePicks` on the
3384
+ // SAME line as intent-backed ones, so relaxing the per-file gate cannot
3385
+ // weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
3386
+ // (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
3387
+ //
3388
+ // `owned-only` keeps requiring an intent: it is the documented rollback
3389
+ // knob, and an operator who selects it is asking for the stricter path.
3390
+ if (policy === "owned-only") {
3391
+ // Still counted into the breaker exactly as before: an owned-only run
3392
+ // whose mirror has gone missing must trip the whole-set guard even
3393
+ // though each individual key is refused for want of an intent.
3394
+ if (entry.direction === "up") {
3395
+ bulkCandidatePicks++;
3396
+ intentlessKeys.add(relativeKey);
3397
+ }
3398
+ plan.refusedStale.push({
3399
+ key: relativeKey,
3400
+ journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
3401
+ remoteEtag: "<not-checked>",
3402
+ reason: "missing-delete-intent",
3403
+ });
3404
+ continue;
3405
+ }
3406
+ if (policy === "currency-gated") {
3229
3407
  bulkCandidatePicks++;
3230
3408
  intentlessKeys.add(relativeKey);
3409
+ // No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
3410
+ // for the same reason an intent-backed legacy entry is (below).
3411
+ if (!entry.remoteEtag) {
3412
+ plan.refusedStale.push({
3413
+ key: relativeKey,
3414
+ journalEtag: "<legacy-no-etag>",
3415
+ remoteEtag: "<unknown>",
3416
+ reason: "legacy-no-etag",
3417
+ });
3418
+ continue;
3419
+ }
3420
+ headCandidates.push({
3421
+ key: relativeKey,
3422
+ journalEtag: entry.remoteEtag,
3423
+ intentVersion: null,
3424
+ });
3425
+ continue;
3231
3426
  }
3427
+ // policy === "all" is deliberately NOT relaxed here. It already skips the
3428
+ // bulk-asymmetry breaker, so letting it skip the intent gate too would
3429
+ // make it a wholly unguarded mass delete — a strictly larger blast radius
3430
+ // than this change is scoped to. It keeps refusing intent-less entries
3431
+ // exactly as before.
3232
3432
  plan.refusedStale.push({
3233
3433
  key: relativeKey,
3234
3434
  journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
package/src/cli/sync.ts CHANGED
@@ -238,7 +238,8 @@ export type SyncProgressEvent =
238
238
  | "bulk-asymmetry"
239
239
  | "divergent-local"
240
240
  | "missing-delete-intent"
241
- | "intent-changed";
241
+ | "intent-changed"
242
+ | "recreated-locally";
242
243
  }
243
244
  | {
244
245
  /**
@@ -398,10 +399,14 @@ export type SyncProgressEvent =
398
399
  }
399
400
  | {
400
401
  /**
401
- * Emitted by the PULL leg once per remote key skipped because the server
402
- * will never presign it (for example, a `companies/…`-prefixed key in a
403
- * company-scoped vault or a key with control characters). Such keys are
404
- * bucket poisoning from an outdated direct-S3 client. Like
402
+ * Emitted once per key skipped because it is permanently unstorable.
403
+ *
404
+ * PULL leg: a remote key the server will never presign (for example, a
405
+ * `companies/…`-prefixed key in a company-scoped vault or a key with
406
+ * control characters) — bucket poisoning from an outdated direct-S3
407
+ * client. PUSH leg: a local key the upload validator would reject, so
408
+ * the upload can only ever throw — a doubled-tree `companies/…` key, or
409
+ * a control-character path such as a macOS Finder `Icon\r` file. Like
405
410
  * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
406
411
  * object must never error a whole company sync (erroring gated
407
412
  * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
@@ -1467,9 +1472,12 @@ async function executeConflictItem(
1467
1472
  item.localHash,
1468
1473
  item.localSize,
1469
1474
  "down",
1470
- remoteFile.etag,
1471
- item.localMtime.getTime(),
1472
- item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1475
+ localPath,
1476
+ {
1477
+ remoteEtag: remoteFile.etag,
1478
+ mtimeMs: item.localMtime.getTime(),
1479
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1480
+ },
1473
1481
  );
1474
1482
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1475
1483
  counters.filesSkipped++;
@@ -1557,9 +1565,12 @@ async function executeConflictItem(
1557
1565
  item.localHash,
1558
1566
  item.localSize,
1559
1567
  "down",
1560
- remoteFile.etag,
1561
- item.localMtime.getTime(),
1562
- item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1568
+ localPath,
1569
+ {
1570
+ remoteEtag: remoteFile.etag,
1571
+ mtimeMs: item.localMtime.getTime(),
1572
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1573
+ },
1563
1574
  );
1564
1575
  // Journal-honesty: we recorded the remote etag so this conflict can't
1565
1576
  // re-fire (#137), but the KEPT local copy diverges from that remote — it
@@ -1690,10 +1701,13 @@ async function downloadOne(
1690
1701
  hash,
1691
1702
  size,
1692
1703
  "down",
1693
- remoteFile.etag,
1694
- localLstat.mtimeMs,
1695
- createdBySub,
1696
- isLocalSymlink ? "symlink" : "file",
1704
+ localPath,
1705
+ {
1706
+ remoteEtag: remoteFile.etag,
1707
+ mtimeMs: localLstat.mtimeMs,
1708
+ ...(createdBySub !== undefined ? { createdBySub } : {}),
1709
+ kind: isLocalSymlink ? "symlink" : "file",
1710
+ },
1697
1711
  );
1698
1712
 
1699
1713
  const priorEntry = getEntry(run.journal, remoteFile.key);
@@ -33,6 +33,24 @@ describe("isExpectedIgnore", () => {
33
33
  expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
34
34
  });
35
35
 
36
+ it("classifies macOS Finder Icon\\r files as expected noise", () => {
37
+ // Finder scatters one into every directory it renders a custom icon for,
38
+ // and a folder-level cloud-sync agent propagates them tree-wide. Treated
39
+ // exactly like .DS_Store so a seeded tree stays silent rather than firing
40
+ // the "EXCLUDED from sync — review in case this is unintended" warning
41
+ // once per file.
42
+ expect(isExpectedIgnore("Icon\r")).toBe(true);
43
+ expect(isExpectedIgnore("companies/indigo/Icon\r")).toBe(true);
44
+ });
45
+
46
+ it("keeps a real file named Icons noteworthy (no `Icon?` over-match)", () => {
47
+ // The `Icon?` gitignore workaround would swallow these. Dropping real
48
+ // user content is a worse failure than the cruft being excluded.
49
+ expect(isExpectedIgnore("companies/indigo/Icons")).toBe(false);
50
+ expect(isExpectedIgnore("companies/indigo/Icon")).toBe(false);
51
+ expect(isExpectedIgnore("companies/indigo/Icons.md")).toBe(false);
52
+ });
53
+
36
54
  it("keeps nested repos/workspace and ordinary content noteworthy", () => {
37
55
  expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
38
56
  expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
@@ -85,6 +103,43 @@ describe("createIgnoreFilter", () => {
85
103
  expect(shouldSync(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
86
104
  });
87
105
 
106
+ it("permissive mode: macOS Finder Icon\\r files never enter the walk", () => {
107
+ // A CR is a control character, so the vault key validator rejects the key
108
+ // (INVALID_KEY_CONTROL_CHARS) and the upload can only ever throw. Dropping
109
+ // them during the walk is what keeps them off the upload path entirely.
110
+ const shouldSync = createIgnoreFilter(hqRoot);
111
+ expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
112
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon\r"))).toBe(false);
113
+ expect(shouldSync(path.join(hqRoot, ".agents/Icon\r"))).toBe(false);
114
+ // Caught at any depth, like the other OS-cruft exclusions.
115
+ expect(shouldSync(path.join(hqRoot, "a/b/c/d/Icon\r"))).toBe(false);
116
+ });
117
+
118
+ it("permissive mode: a real file named Icons still syncs", () => {
119
+ // Regression guard for the `Icon?` gitignore workaround, whose `?`
120
+ // single-char wildcard also matches Icons / Icon1 / IconX. This exclusion
121
+ // matches the exact Finder byte sequence instead, so real content survives.
122
+ const shouldSync = createIgnoreFilter(hqRoot);
123
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons"))).toBe(true);
124
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon"))).toBe(true);
125
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon1"))).toBe(true);
126
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons.md"))).toBe(true);
127
+ });
128
+
129
+ it("a literal Icon+CR line in .gitignore does NOT reach the ignore lib", () => {
130
+ // This is why the exclusion is a predicate and not a DEFAULT_IGNORES
131
+ // entry. ignore@5 strips a trailing CR from a pattern line, so the
132
+ // spelling git itself requires silently never matches. Pinning the
133
+ // upstream behavior here means a future ignore-lib bump that changes it
134
+ // surfaces as a failing test rather than a silent double-exclusion.
135
+ fs.writeFileSync(path.join(hqRoot, ".gitignore"), "Icon\r\n");
136
+ const shouldSync = createIgnoreFilter(hqRoot);
137
+ // Still excluded — by the predicate, not by the pattern.
138
+ expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
139
+ // Proof the pattern itself is inert: it did not take `Icons` with it.
140
+ expect(shouldSync(path.join(hqRoot, "Icons"))).toBe(true);
141
+ });
142
+
88
143
  it("repos/ exclusion is root-anchored to hqRoot (DEV-1791)", () => {
89
144
  const shouldSync = createIgnoreFilter(hqRoot);
90
145
  expect(shouldSync(path.join(hqRoot, "repos/private/hq-cloud/src/x.ts"))).toBe(false);
package/src/ignore.ts CHANGED
@@ -244,6 +244,52 @@ function segmentToRegex(seg: string): RegExp {
244
244
  return new RegExp(`^${body}$`);
245
245
  }
246
246
 
247
+ /**
248
+ * Match a path whose basename is the macOS Finder custom-icon file: the
249
+ * literal `Icon` followed by a carriage return (U+000D).
250
+ *
251
+ * This CANNOT be expressed as a `DEFAULT_IGNORES` entry, which is why it is a
252
+ * predicate instead of a pattern string. Two independent reasons:
253
+ *
254
+ * 1. A pattern line ending in a literal CR is inert. The `ignore` package
255
+ * strips the trailing CR, so the line degrades to `Icon` and never
256
+ * matches — verified against ignore@5.3.2:
257
+ * `ignore().add("Icon\r\n").ignores("Icon\r") === false`. Git does the
258
+ * same thing (it reads the CR as part of the line ending), which is why
259
+ * the GitHub macOS template has to end that line with TWO CRs.
260
+ * 2. The usual workaround pattern `Icon?` DOES match, but `?` is a
261
+ * single-character wildcard in gitignore syntax, so it also swallows any
262
+ * real file named `Icons`, `Icon1`, `IconX`. Silently dropping user
263
+ * content is worse than the cruft we are excluding.
264
+ *
265
+ * Matching the exact byte sequence keeps the exclusion precise. Finder writes
266
+ * these into every directory it renders a custom icon for, and a folder-level
267
+ * cloud-sync agent (iCloud Desktop, backup tooling) propagates them across an
268
+ * entire tree — including into `.git/`. They carry no user content.
269
+ *
270
+ * They are also unsyncable by construction: a CR is a control character, so
271
+ * the vault key validator rejects the key outright
272
+ * (INVALID_KEY_CONTROL_CHARS). Excluding them at the walk is what keeps them
273
+ * from reaching the upload path at all.
274
+ */
275
+ export function isMacFinderIconFile(relPath: string): boolean {
276
+ const basename = relPath.replace(/\/$/, "").split("/").pop() ?? "";
277
+ return basename === "Icon\r";
278
+ }
279
+
280
+ /**
281
+ * True when any segment of `relPath` contains a character the vault key
282
+ * validator rejects (see `KEY_CONTROL_CHARS` in s3.ts — C0 controls plus DEL).
283
+ *
284
+ * Kept in lock-step with that validator on purpose: a key this returns `true`
285
+ * for can never be stored, so planning an upload for it only produces a
286
+ * guaranteed per-file failure.
287
+ */
288
+ export function hasControlCharacters(relPath: string): boolean {
289
+ // eslint-disable-next-line no-control-regex
290
+ return /[\x00-\x1F\x7F]/.test(relPath);
291
+ }
292
+
247
293
  export const EXPECTED_IGNORE_SEGMENTS = new Set([
248
294
  ".git",
249
295
  "node_modules",
@@ -307,6 +353,11 @@ export function isExpectedIgnore(relPath: string): boolean {
307
353
  return (
308
354
  basename === ".DS_Store" ||
309
355
  basename === "Thumbs.db" ||
356
+ // Same class as .DS_Store: OS-generated, zero user content. Classified
357
+ // expected so a tree full of them stays silent instead of firing the
358
+ // "EXCLUDED from sync — review in case this is unintended" warning once
359
+ // per file (Finder scatters one into every directory it touches).
360
+ basename === "Icon\r" ||
310
361
  basename === "company.yaml" ||
311
362
  basename === "INDEX.md" ||
312
363
  basename === "modules.lock" ||
@@ -373,6 +424,12 @@ export function createIgnoreFilter(
373
424
  const relative = path.relative(hqRoot, filePath);
374
425
  if (!relative || relative.startsWith("..")) return true; // outside HQ root
375
426
 
427
+ // macOS Finder custom-icon files. Excluded here rather than as a
428
+ // DEFAULT_IGNORES pattern because neither available spelling works —
429
+ // see isMacFinderIconFile for the ignore@5 CR-stripping proof and the
430
+ // `Icon?` over-match hazard.
431
+ if (isMacFinderIconFile(relative)) return false;
432
+
376
433
  // Gitignore dir-only patterns (`foo/`) only match candidate paths that
377
434
  // end with `/`. The `ignore` lib has no stat awareness, so when the
378
435
  // caller knows the entry is a directory we hand the matcher the