@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/dist/cli/share.d.ts +11 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +214 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +595 -28
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -5
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +38 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +55 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +50 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +56 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +49 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +37 -2
- package/dist/journal.test.js.map +1 -1
- package/dist/s3.test.js +6 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +670 -28
- package/src/cli/share.ts +234 -34
- package/src/cli/sync.ts +29 -15
- package/src/ignore.test.ts +55 -0
- package/src/ignore.ts +57 -0
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
- package/src/s3.test.ts +6 -0
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 {
|
|
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
|
|
645
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1873
|
+
reason: refusalReason,
|
|
1801
1874
|
});
|
|
1802
1875
|
pathResults.push({
|
|
1803
1876
|
path: relativePath,
|
|
1804
1877
|
status: "refused",
|
|
1805
1878
|
operation: "delete",
|
|
1806
|
-
reason:
|
|
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
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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);
|
package/src/ignore.test.ts
CHANGED
|
@@ -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
|