@indigoai-us/hq-cloud 6.14.36 → 6.14.39
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/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +20 -3
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +46 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +178 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +555 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +780 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +113 -13
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +188 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner.test.ts +54 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +974 -2
- package/src/cli/share.ts +675 -32
- package/src/cli/sync.test.ts +209 -0
- package/src/cli/sync.ts +151 -13
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/dist/cli/share.js
CHANGED
|
@@ -24,6 +24,8 @@ import { buildConflictId, buildConflictPath, readShortMachineId, } from "../lib/
|
|
|
24
24
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
25
25
|
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
26
26
|
import { VaultAuthError } from "../vault-client.js";
|
|
27
|
+
import { describeError } from "../lib/describe-error.js";
|
|
28
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
27
29
|
/**
|
|
28
30
|
* Push-side fresh-collision convergence probe.
|
|
29
31
|
*
|
|
@@ -199,6 +201,25 @@ export function isEphemeralPath(p) {
|
|
|
199
201
|
export function isMalformedVaultKey(key) {
|
|
200
202
|
return key.includes("\\");
|
|
201
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
|
|
206
|
+
* PERSONAL vault, where it is handled by the dedicated `personalMode` branch
|
|
207
|
+
* in `computePullPlan` (companies/* content a peer machine pushed into the
|
|
208
|
+
* personal bucket). A COMPANY-scoped vault is already anchored at its company
|
|
209
|
+
* root, so its keys are bucket-relative — a `companies/...` key there is a
|
|
210
|
+
* doubly-scoped corrupt object. The vault-service refuses to presign such a
|
|
211
|
+
* key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
|
|
212
|
+
* never materialize it and the whole company sync wedges at `errored` (runner
|
|
213
|
+
* exit 2) on every run. Verified live 2026-06-16: frogbear's
|
|
214
|
+
* `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
|
|
215
|
+
* was uploaded with a doubled key and broke every sync thereafter. The pull
|
|
216
|
+
* and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
|
|
217
|
+
* with the malformed-(backslash)-key filter above; the bogus objects
|
|
218
|
+
* themselves are cleaned server-side.
|
|
219
|
+
*/
|
|
220
|
+
export function isForbiddenCompanyVaultKey(key, personalMode) {
|
|
221
|
+
return !personalMode && key.startsWith("companies/");
|
|
222
|
+
}
|
|
202
223
|
/**
|
|
203
224
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
204
225
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -212,6 +233,8 @@ export const _testing = {
|
|
|
212
233
|
EPHEMERAL_PATH_PATTERN,
|
|
213
234
|
wrapFilterWithIgnoreVisibility,
|
|
214
235
|
collectFiles,
|
|
236
|
+
resolveNamedPath,
|
|
237
|
+
isWithinLexicalOrReal,
|
|
215
238
|
};
|
|
216
239
|
/**
|
|
217
240
|
* Pure Stage-1 pass for push: walk the candidate file list, hash each one,
|
|
@@ -344,6 +367,39 @@ function computePushPlan(filesToShare, journal, skipUnchanged) {
|
|
|
344
367
|
}
|
|
345
368
|
return { items, filesToUpload, bytesToUpload, filesToSkip };
|
|
346
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
372
|
+
* `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
|
|
373
|
+
* are still being built, so NOTHING has been uploaded, journaled, or deleted
|
|
374
|
+
* when it surfaces — the failed push leaves no partial state behind.
|
|
375
|
+
*/
|
|
376
|
+
export class UnreachablePushPathsError extends Error {
|
|
377
|
+
/** Caller's original spellings, verbatim (see the CollectHooks contract). */
|
|
378
|
+
paths;
|
|
379
|
+
/** Per-path reason, keyed by the same original spelling. */
|
|
380
|
+
reasons;
|
|
381
|
+
constructor(unreachable, syncRoot) {
|
|
382
|
+
const paths = [...unreachable.keys()];
|
|
383
|
+
const lines = paths.map((p) => {
|
|
384
|
+
const reason = unreachable.get(p);
|
|
385
|
+
if (reason === "outside-company") {
|
|
386
|
+
return ` · ${p} — resolves outside the company folder (${syncRoot})`;
|
|
387
|
+
}
|
|
388
|
+
if (reason === "unreadable-link") {
|
|
389
|
+
return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
|
|
390
|
+
}
|
|
391
|
+
return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
392
|
+
});
|
|
393
|
+
super(`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
394
|
+
`nothing was uploaded.\n${lines.join("\n")}\n` +
|
|
395
|
+
`A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
|
|
396
|
+
`(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
|
|
397
|
+
`outside HQ has to sync through whatever owns it, not the vault.`);
|
|
398
|
+
this.name = "UnreachablePushPathsError";
|
|
399
|
+
this.paths = paths;
|
|
400
|
+
this.reasons = Object.fromEntries(unreachable);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
347
403
|
/**
|
|
348
404
|
* A conditional-write fence rejection — the SDK's 412 (`name:
|
|
349
405
|
* "PreconditionFailed"`) or the presigned transport's mirror of it. Means
|
|
@@ -490,6 +546,8 @@ async function createPushRunContext(options) {
|
|
|
490
546
|
const onScopeExcluded = (rel) => {
|
|
491
547
|
scopeExcludedSet.add(rel);
|
|
492
548
|
};
|
|
549
|
+
const unreachablePaths = new Map();
|
|
550
|
+
const linkedSubtreeSet = new Set();
|
|
493
551
|
const baseFilter = options.personalMode === true
|
|
494
552
|
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
495
553
|
: recordedIgnoreFilter;
|
|
@@ -523,6 +581,8 @@ async function createPushRunContext(options) {
|
|
|
523
581
|
scopeExcludedSet,
|
|
524
582
|
ignoreExcludedSet,
|
|
525
583
|
ignoreExcludedTotal,
|
|
584
|
+
unreachablePaths,
|
|
585
|
+
linkedSubtreeSet,
|
|
526
586
|
};
|
|
527
587
|
}
|
|
528
588
|
function createShareCounters() {
|
|
@@ -537,7 +597,29 @@ function createShareCounters() {
|
|
|
537
597
|
};
|
|
538
598
|
}
|
|
539
599
|
async function buildSharePlans(run) {
|
|
540
|
-
const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync
|
|
600
|
+
const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync, {
|
|
601
|
+
onUnreachablePath: (namedPath, reason) => {
|
|
602
|
+
// First reason wins: a path is named once, and re-adding would only
|
|
603
|
+
// churn the map ordering the error message and event sample rely on.
|
|
604
|
+
if (!run.unreachablePaths.has(namedPath)) {
|
|
605
|
+
run.unreachablePaths.set(namedPath, reason);
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
|
|
609
|
+
});
|
|
610
|
+
// Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
|
|
611
|
+
// exists locally but is unreachable by the resolver". This is the throw that
|
|
612
|
+
// makes it true end-to-end — the CLI's push handler already turns a thrown
|
|
613
|
+
// error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
|
|
614
|
+
// "✓ Pushed 0 file(s)" success can no longer happen for a file that is
|
|
615
|
+
// sitting right there on disk. It fires HERE, before executeUploads, so a
|
|
616
|
+
// failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
|
|
617
|
+
// written, no delete propagated.
|
|
618
|
+
const fatal = collectFatalUnreachablePaths(run);
|
|
619
|
+
if (fatal.size > 0) {
|
|
620
|
+
emitUnreachablePathEvent(run);
|
|
621
|
+
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
622
|
+
}
|
|
541
623
|
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
542
624
|
// root IS the company folder, so a local entry whose vault key starts with
|
|
543
625
|
// `companies/` can only come from a stale doubled local tree
|
|
@@ -839,7 +921,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
839
921
|
run.emit({
|
|
840
922
|
type: "error",
|
|
841
923
|
path: relativePath,
|
|
842
|
-
message:
|
|
924
|
+
message: describeError(retryErr),
|
|
843
925
|
});
|
|
844
926
|
}
|
|
845
927
|
return;
|
|
@@ -855,7 +937,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
855
937
|
run.emit({
|
|
856
938
|
type: "error",
|
|
857
939
|
path: relativePath,
|
|
858
|
-
message:
|
|
940
|
+
message: describeError(err),
|
|
859
941
|
});
|
|
860
942
|
}
|
|
861
943
|
};
|
|
@@ -919,8 +1001,7 @@ async function writePushConflictMirror(run, item, remoteHash) {
|
|
|
919
1001
|
run.emit({
|
|
920
1002
|
type: "error",
|
|
921
1003
|
path: item.relativePath,
|
|
922
|
-
message: "conflict mirror write failed: " +
|
|
923
|
-
(mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
|
|
1004
|
+
message: "conflict mirror write failed: " + describeError(mirrorErr),
|
|
924
1005
|
});
|
|
925
1006
|
}
|
|
926
1007
|
}
|
|
@@ -992,7 +1073,7 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
992
1073
|
run.emit({
|
|
993
1074
|
type: "error",
|
|
994
1075
|
path: relativePath,
|
|
995
|
-
message:
|
|
1076
|
+
message: describeError(err),
|
|
996
1077
|
});
|
|
997
1078
|
pathResults.push({
|
|
998
1079
|
path: relativePath,
|
|
@@ -1003,6 +1084,58 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1003
1084
|
}
|
|
1004
1085
|
}
|
|
1005
1086
|
for (const relativePath of deletePlan.toTombstone) {
|
|
1087
|
+
const localPath = localPathForVaultKey(run.syncRoot, relativePath);
|
|
1088
|
+
try {
|
|
1089
|
+
const lstat = fs.lstatSync(localPath);
|
|
1090
|
+
const entry = run.journal.files[relativePath];
|
|
1091
|
+
if (lstat.isFile()) {
|
|
1092
|
+
const localHash = hashFile(localPath);
|
|
1093
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1094
|
+
run.emit({
|
|
1095
|
+
type: "error",
|
|
1096
|
+
path: relativePath,
|
|
1097
|
+
message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1098
|
+
});
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
fs.unlinkSync(localPath);
|
|
1102
|
+
}
|
|
1103
|
+
else if (lstat.isSymbolicLink()) {
|
|
1104
|
+
const target = readlinkOrNull(localPath);
|
|
1105
|
+
if (target === null) {
|
|
1106
|
+
run.emit({
|
|
1107
|
+
type: "not-shipped",
|
|
1108
|
+
reason: "unreadable-link",
|
|
1109
|
+
count: 1,
|
|
1110
|
+
samplePaths: [relativePath],
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const localHash = hashSymlinkTarget(target);
|
|
1115
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1116
|
+
run.emit({
|
|
1117
|
+
type: "error",
|
|
1118
|
+
path: relativePath,
|
|
1119
|
+
message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1120
|
+
});
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
fs.unlinkSync(localPath);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
catch (err) {
|
|
1127
|
+
const code = err && typeof err === "object" && "code" in err
|
|
1128
|
+
? err.code
|
|
1129
|
+
: undefined;
|
|
1130
|
+
if (code !== "ENOENT") {
|
|
1131
|
+
run.emit({
|
|
1132
|
+
type: "error",
|
|
1133
|
+
path: relativePath,
|
|
1134
|
+
message: `tombstone unlink failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1135
|
+
});
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1006
1139
|
removeEntry(run.journal, relativePath);
|
|
1007
1140
|
counters.filesTombstoned++;
|
|
1008
1141
|
run.emit({
|
|
@@ -1085,6 +1218,89 @@ function finalizeShareJournal(run) {
|
|
|
1085
1218
|
samplePaths,
|
|
1086
1219
|
});
|
|
1087
1220
|
}
|
|
1221
|
+
emitUnreachablePathEvent(run);
|
|
1222
|
+
if (run.linkedSubtreeSet.size > 0) {
|
|
1223
|
+
run.emit({
|
|
1224
|
+
type: "not-shipped",
|
|
1225
|
+
reason: "linked-subtree",
|
|
1226
|
+
count: run.linkedSubtreeSet.size,
|
|
1227
|
+
samplePaths: sampleSet(run.linkedSubtreeSet),
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Which unreachable named paths are FATAL under the run's policy.
|
|
1233
|
+
*
|
|
1234
|
+
* Under the default `"error"` policy only `"outside-company"` is fatal: the
|
|
1235
|
+
* entry is sitting on disk and the resolver refused to place it under the
|
|
1236
|
+
* company folder, which is precisely the "exists locally but is unreachable by
|
|
1237
|
+
* the resolver" case the report asks to turn into an error, and it cannot
|
|
1238
|
+
* happen for an internal walk root (a company folder is trivially inside
|
|
1239
|
+
* itself).
|
|
1240
|
+
*
|
|
1241
|
+
* `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
|
|
1242
|
+
* and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
|
|
1243
|
+
* one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
|
|
1244
|
+
* folder was never materialized locally; throwing there would turn "you haven't
|
|
1245
|
+
* pulled that company yet" into a hard failure of an unrelated multi-company
|
|
1246
|
+
* push. Same reasoning for a watcher path deleted between the event and the
|
|
1247
|
+
* push. Those callers get the loud report without the regression.
|
|
1248
|
+
*
|
|
1249
|
+
* `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
|
|
1250
|
+
* end to end (the background sync runner).
|
|
1251
|
+
*/
|
|
1252
|
+
function collectFatalUnreachablePaths(run) {
|
|
1253
|
+
const fatal = new Map();
|
|
1254
|
+
if (run.options.unreachablePathPolicy === "warn")
|
|
1255
|
+
return fatal;
|
|
1256
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
1257
|
+
if (reason === "outside-company")
|
|
1258
|
+
fatal.set(namedPath, reason);
|
|
1259
|
+
}
|
|
1260
|
+
return fatal;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Emit the `not-shipped` / `unreachable-path` event for a run, if any named
|
|
1264
|
+
* path went unshipped. Shared by BOTH policies so the operator sees the same
|
|
1265
|
+
* report either way: the fail-fast path emits it immediately before throwing
|
|
1266
|
+
* (the journal finalizer never runs on a throw), and the `"warn"` path emits it
|
|
1267
|
+
* from the finalizer at the end of a successful run. Exactly one of those two
|
|
1268
|
+
* call sites can fire per run, so the event is never duplicated.
|
|
1269
|
+
*/
|
|
1270
|
+
function emitUnreachablePathEvent(run) {
|
|
1271
|
+
if (run.unreachablePaths.size === 0)
|
|
1272
|
+
return;
|
|
1273
|
+
const unreadableLinks = [];
|
|
1274
|
+
const unreachablePaths = [];
|
|
1275
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
1276
|
+
(reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
|
|
1277
|
+
}
|
|
1278
|
+
if (unreachablePaths.length > 0) {
|
|
1279
|
+
run.emit({
|
|
1280
|
+
type: "not-shipped",
|
|
1281
|
+
reason: "unreachable-path",
|
|
1282
|
+
count: unreachablePaths.length,
|
|
1283
|
+
samplePaths: unreachablePaths.slice(0, 10),
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
if (unreadableLinks.length > 0) {
|
|
1287
|
+
run.emit({
|
|
1288
|
+
type: "not-shipped",
|
|
1289
|
+
reason: "unreadable-link",
|
|
1290
|
+
count: unreadableLinks.length,
|
|
1291
|
+
samplePaths: unreadableLinks.slice(0, 10),
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
1296
|
+
function sampleSet(set, limit = 10) {
|
|
1297
|
+
const sample = [];
|
|
1298
|
+
for (const value of set) {
|
|
1299
|
+
sample.push(value);
|
|
1300
|
+
if (sample.length >= limit)
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
return sample;
|
|
1088
1304
|
}
|
|
1089
1305
|
function throwUploadWorkerErrors(workerErrors) {
|
|
1090
1306
|
if (workerErrors.length > 0) {
|
|
@@ -1112,6 +1328,8 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
|
|
|
1112
1328
|
filesExcludedByPolicy: run.excludedSet.size,
|
|
1113
1329
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1114
1330
|
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
1331
|
+
unreachablePaths: [...run.unreachablePaths.keys()],
|
|
1332
|
+
linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
|
|
1115
1333
|
conflictPaths,
|
|
1116
1334
|
pathResults,
|
|
1117
1335
|
aborted,
|
|
@@ -1189,6 +1407,263 @@ function defaultConsoleLogger(event) {
|
|
|
1189
1407
|
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1190
1408
|
}
|
|
1191
1409
|
}
|
|
1410
|
+
else if (event.type === "not-shipped") {
|
|
1411
|
+
// The other "not silent" surface: content the walk saw but chose not to
|
|
1412
|
+
// ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
|
|
1413
|
+
if (event.reason === "unreachable-path") {
|
|
1414
|
+
console.warn(` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`);
|
|
1415
|
+
}
|
|
1416
|
+
else if (event.reason === "unreadable-link") {
|
|
1417
|
+
console.warn(` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`);
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
console.warn(` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`);
|
|
1421
|
+
}
|
|
1422
|
+
for (const p of event.samplePaths) {
|
|
1423
|
+
console.warn(` · ${p}`);
|
|
1424
|
+
}
|
|
1425
|
+
if (event.count > event.samplePaths.length) {
|
|
1426
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Resolve a caller-supplied push path to an absolute path.
|
|
1432
|
+
*
|
|
1433
|
+
* Relative paths were historically resolved against `hqRoot` ONLY, so a
|
|
1434
|
+
* company-relative spelling like `knowledge/agents/x.md` became
|
|
1435
|
+
* `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
|
|
1436
|
+
* caller's cwd or the company being pushed (feedback_258e4a86 /
|
|
1437
|
+
* feedback_a51cb63d — "there is no path spelling that reaches the file").
|
|
1438
|
+
*
|
|
1439
|
+
* PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
|
|
1440
|
+
* (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
|
|
1441
|
+
* ADDITIVE to the legacy behavior: every relative spelling that resolved
|
|
1442
|
+
* pre-fix still resolves to exactly the same file, and the two new bases only
|
|
1443
|
+
* catch spellings that previously resolved to nothing. Probing cwd first would
|
|
1444
|
+
* silently re-point existing callers (a `knowledge/` directory in the shell's
|
|
1445
|
+
* cwd would win over the hq-root one), which is a behavior change no reporter
|
|
1446
|
+
* asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
|
|
1447
|
+
* the unchanged "does not exist" diagnostic. Absolute paths are returned
|
|
1448
|
+
* verbatim.
|
|
1449
|
+
*
|
|
1450
|
+
* `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
|
|
1451
|
+
* rather than an ambient read, so resolution is deterministic and testable
|
|
1452
|
+
* without mutating process state.
|
|
1453
|
+
*/
|
|
1454
|
+
function resolveNamedPath(p, hqRoot, syncRoot, cwd = process.cwd()) {
|
|
1455
|
+
if (path.isAbsolute(p))
|
|
1456
|
+
return p;
|
|
1457
|
+
const hqRootCandidate = path.resolve(hqRoot, p);
|
|
1458
|
+
const candidates = [
|
|
1459
|
+
hqRootCandidate,
|
|
1460
|
+
path.resolve(syncRoot, p),
|
|
1461
|
+
path.resolve(cwd, p),
|
|
1462
|
+
];
|
|
1463
|
+
for (const candidate of candidates) {
|
|
1464
|
+
try {
|
|
1465
|
+
fs.lstatSync(candidate);
|
|
1466
|
+
// An hqRoot hit outside the company folder would be rejected by
|
|
1467
|
+
// collectFiles as outside-company; skip it so a valid company-relative
|
|
1468
|
+
// spelling can win (Codex P2 — common `knowledge/` homonym case).
|
|
1469
|
+
if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
return candidate;
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
// Base did not resolve to an on-disk entry — try the next one.
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return hqRootCandidate;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Containment check for a regular file or directory that tolerates a symlinked
|
|
1482
|
+
* ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
|
|
1483
|
+
* path reached through a symlinked ancestor (`companies/{co}/knowledge` →
|
|
1484
|
+
* `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
|
|
1485
|
+
* rejected as "outside company folder" — even though its logical path is
|
|
1486
|
+
* in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
|
|
1487
|
+
* company-namespaced key for it. Accept when the LEXICAL path is inside
|
|
1488
|
+
* (honoring the same logical topology the vault key uses) OR the realpath is
|
|
1489
|
+
* inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
|
|
1490
|
+
*
|
|
1491
|
+
* The lexical arm carries TWO bounds, because it is the only place where a
|
|
1492
|
+
* path's bytes and its vault key come from different trees:
|
|
1493
|
+
*
|
|
1494
|
+
* 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
|
|
1495
|
+
* symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
|
|
1496
|
+
* state under a company-namespaced key.
|
|
1497
|
+
* 2. The TENANT — the realpath must not land inside another company's bytes
|
|
1498
|
+
* (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
|
|
1499
|
+
* must never be mistaken for a tenant boundary: hqRoot CONTAINS every
|
|
1500
|
+
* other company, so `companies/acme/knowledge → companies/other/secret`
|
|
1501
|
+
* (or → `repos/private/knowledge-other`, the linked-repo topology) is
|
|
1502
|
+
* lexically inside acme and really inside HQ, and would upload the other
|
|
1503
|
+
* tenant's bytes into acme's bucket under the key `knowledge/…`.
|
|
1504
|
+
*
|
|
1505
|
+
* The motivating topology (`companies/{co}/knowledge` →
|
|
1506
|
+
* `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
|
|
1507
|
+
* that escapes HQ, or one that reaches another tenant, is refused — and under
|
|
1508
|
+
* the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
|
|
1509
|
+
*
|
|
1510
|
+
* Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
|
|
1511
|
+
* externally-linked subtree can only be recognized while that company's folder
|
|
1512
|
+
* is materialized locally and publishes the link. A machine holding
|
|
1513
|
+
* `repos/private/knowledge-other` with no `companies/other` folder has no
|
|
1514
|
+
* on-disk evidence of the claim, so a link into it is indistinguishable from a
|
|
1515
|
+
* link into any other local repo. Ownership metadata (not path shape) is what
|
|
1516
|
+
* would close that gap.
|
|
1517
|
+
*/
|
|
1518
|
+
function isWithinLexicalOrReal(parent, child, hqRoot, tenantRootsCache) {
|
|
1519
|
+
// Strict arm first: the realpath is genuinely inside the company folder.
|
|
1520
|
+
// This is the overwhelmingly common case, needs no relaxation, and costs no
|
|
1521
|
+
// directory scan.
|
|
1522
|
+
if (isWithin(parent, child))
|
|
1523
|
+
return true;
|
|
1524
|
+
const resolvedChild = path.resolve(child);
|
|
1525
|
+
if (!isPathWithin(path.resolve(parent), resolvedChild))
|
|
1526
|
+
return false;
|
|
1527
|
+
const childReal = realpathSafe(resolvedChild);
|
|
1528
|
+
if (!isWithin(hqRoot, childReal))
|
|
1529
|
+
return false; // bound 1: escapes HQ
|
|
1530
|
+
// NUL-joined: it is the one byte a path cannot contain, so no pair of
|
|
1531
|
+
// (hqRoot, parent) values can collide on the key.
|
|
1532
|
+
const cacheKey = `${hqRoot}\u0000${parent}`;
|
|
1533
|
+
let foreignRoots = tenantRootsCache?.get(cacheKey);
|
|
1534
|
+
if (foreignRoots === undefined) {
|
|
1535
|
+
foreignRoots = foreignTenantRoots(hqRoot, parent);
|
|
1536
|
+
tenantRootsCache?.set(cacheKey, foreignRoots);
|
|
1537
|
+
}
|
|
1538
|
+
for (const foreign of foreignRoots) {
|
|
1539
|
+
if (isPathWithin(foreign, childReal))
|
|
1540
|
+
return false; // bound 2: other tenant
|
|
1541
|
+
}
|
|
1542
|
+
return true;
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Depth (in path segments below a company root) at which we look for the
|
|
1546
|
+
* directory symlinks a company publishes into its own folder. HQ's linked
|
|
1547
|
+
* topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
|
|
1548
|
+
* (`companies/{co}/repos/{name}`); going deeper would turn a containment check
|
|
1549
|
+
* into a full-tree walk for no additional coverage.
|
|
1550
|
+
*/
|
|
1551
|
+
const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
|
|
1552
|
+
/**
|
|
1553
|
+
* Canonicalized roots that belong to a tenant OTHER than the one being pushed.
|
|
1554
|
+
* A lexically-contained path whose realpath lands inside any of these is
|
|
1555
|
+
* another company's data wearing this company's key, and must be refused.
|
|
1556
|
+
*
|
|
1557
|
+
* Two kinds of root are collected per foreign company:
|
|
1558
|
+
* - the company folder itself (`companies/{other}`), and
|
|
1559
|
+
* - the targets of the directory symlinks that folder publishes — HQ's
|
|
1560
|
+
* pattern-2 topology puts a company's knowledge in `repos/private/
|
|
1561
|
+
* knowledge-{other}` and links it in, so the bytes live OUTSIDE every
|
|
1562
|
+
* `companies/` root and a companies-only check would miss them entirely.
|
|
1563
|
+
*
|
|
1564
|
+
* Only consulted on the rare lexical arm (a path whose realpath is not inside
|
|
1565
|
+
* the company folder), never on the ordinary in-tree push, so the directory
|
|
1566
|
+
* scan is not on the hot path. Callers may memoize it for the duration of a
|
|
1567
|
+
* single collect pass; nothing memoizes it for longer, because the sync runner
|
|
1568
|
+
* is long-lived and a stale tenant map fails OPEN — the wrong direction for a
|
|
1569
|
+
* boundary whose whole job is to refuse.
|
|
1570
|
+
*/
|
|
1571
|
+
function foreignTenantRoots(hqRoot, syncRoot) {
|
|
1572
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
1573
|
+
let entries;
|
|
1574
|
+
try {
|
|
1575
|
+
entries = fs.readdirSync(companiesDir, { withFileTypes: true });
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
return []; // no companies/ tree here — nothing to be foreign to
|
|
1579
|
+
}
|
|
1580
|
+
const activeReal = realpathSafe(syncRoot);
|
|
1581
|
+
const roots = [];
|
|
1582
|
+
for (const entry of entries) {
|
|
1583
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
1584
|
+
continue;
|
|
1585
|
+
const companyRoot = path.join(companiesDir, entry.name);
|
|
1586
|
+
const companyReal = realpathSafe(companyRoot);
|
|
1587
|
+
if (companyReal === activeReal)
|
|
1588
|
+
continue; // this is the tenant being pushed
|
|
1589
|
+
roots.push(companyReal);
|
|
1590
|
+
collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
|
|
1591
|
+
}
|
|
1592
|
+
return roots;
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Append the resolved targets of the directory symlinks published under
|
|
1596
|
+
* `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
|
|
1597
|
+
* inside the company folder are skipped — they add no reach beyond the root
|
|
1598
|
+
* already recorded. Errors are swallowed per entry on purpose: an unreadable
|
|
1599
|
+
* sibling directory must narrow what we can prove, never abort the push it is
|
|
1600
|
+
* unrelated to.
|
|
1601
|
+
*/
|
|
1602
|
+
function collectPublishedLinkTargets(dir, companyReal, depth, out) {
|
|
1603
|
+
if (depth <= 0)
|
|
1604
|
+
return;
|
|
1605
|
+
let entries;
|
|
1606
|
+
try {
|
|
1607
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
for (const entry of entries) {
|
|
1613
|
+
const child = path.join(dir, entry.name);
|
|
1614
|
+
if (entry.isSymbolicLink()) {
|
|
1615
|
+
let real;
|
|
1616
|
+
try {
|
|
1617
|
+
real = fs.realpathSync.native(child);
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
continue; // dangling link claims nothing
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
if (!fs.statSync(real).isDirectory())
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (isPathWithin(companyReal, real))
|
|
1630
|
+
continue; // no reach beyond the root
|
|
1631
|
+
out.push(real);
|
|
1632
|
+
}
|
|
1633
|
+
else if (entry.isDirectory()) {
|
|
1634
|
+
collectPublishedLinkTargets(child, companyReal, depth - 1, out);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
|
|
1640
|
+
* `onLinkedSubtree` so the caller can report that the link's contents were not
|
|
1641
|
+
* uploaded to the vault. Fires only for links that (a) resolve to a directory
|
|
1642
|
+
* and (b) point outside the company folder — an in-tree link is descended
|
|
1643
|
+
* elsewhere, and a dangling or file link has nothing behind it to report.
|
|
1644
|
+
*/
|
|
1645
|
+
function reportLinkedSubtreeIfExternal(linkPath, syncRoot, relativePath, hooks) {
|
|
1646
|
+
if (!hooks.onLinkedSubtree)
|
|
1647
|
+
return;
|
|
1648
|
+
let real;
|
|
1649
|
+
try {
|
|
1650
|
+
real = fs.realpathSync.native(linkPath); // resolves the link to its target
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
return; // dangling link — nothing behind it to report
|
|
1654
|
+
}
|
|
1655
|
+
let targetStat;
|
|
1656
|
+
try {
|
|
1657
|
+
targetStat = fs.statSync(real);
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (!targetStat.isDirectory())
|
|
1663
|
+
return;
|
|
1664
|
+
if (isWithin(syncRoot, real))
|
|
1665
|
+
return; // in-tree target: descended elsewhere
|
|
1666
|
+
hooks.onLinkedSubtree(relativePath);
|
|
1192
1667
|
}
|
|
1193
1668
|
/**
|
|
1194
1669
|
* Collect files from paths (expanding directories recursively).
|
|
@@ -1202,10 +1677,15 @@ function defaultConsoleLogger(event) {
|
|
|
1202
1677
|
* Pre-fix, statSync followed the link and the target's bytes were uploaded
|
|
1203
1678
|
* under the link's key — silently flattening the link topology.
|
|
1204
1679
|
*/
|
|
1205
|
-
function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
1680
|
+
function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
|
|
1206
1681
|
const results = [];
|
|
1682
|
+
// Scoped to THIS collect pass and discarded with it: a watcher batch can
|
|
1683
|
+
// name hundreds of paths, and rescanning the companies/ tree for each one
|
|
1684
|
+
// is wasted I/O. A cache that outlived the pass could fail open on a tenant
|
|
1685
|
+
// boundary, so it deliberately does not.
|
|
1686
|
+
const tenantRoots = new Map();
|
|
1207
1687
|
for (const p of paths) {
|
|
1208
|
-
const absolutePath =
|
|
1688
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1209
1689
|
// Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
|
|
1210
1690
|
// Caller may pass one explicitly; we still refuse to upload it. Basename
|
|
1211
1691
|
// check matches the walkDir gate so behavior is identical whether the
|
|
@@ -1221,6 +1701,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1221
1701
|
}
|
|
1222
1702
|
catch {
|
|
1223
1703
|
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
1704
|
+
hooks.onUnreachablePath?.(p, "missing");
|
|
1224
1705
|
continue;
|
|
1225
1706
|
}
|
|
1226
1707
|
// Containment check is split by entry kind: regular files and
|
|
@@ -1238,6 +1719,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1238
1719
|
if (lstat.isSymbolicLink()) {
|
|
1239
1720
|
if (!isWithinForLink(syncRoot, absolutePath)) {
|
|
1240
1721
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1722
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1241
1723
|
continue;
|
|
1242
1724
|
}
|
|
1243
1725
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1253,22 +1735,34 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1253
1735
|
// so two calls are free.
|
|
1254
1736
|
if (!filter(absolutePath, false) && !filter(absolutePath, true))
|
|
1255
1737
|
continue;
|
|
1738
|
+
const target = readlinkOrNull(absolutePath);
|
|
1739
|
+
if (target === null) {
|
|
1740
|
+
console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
|
|
1741
|
+
hooks.onUnreachablePath?.(p, "unreadable-link");
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
// A directory symlink whose target lives outside the company folder is
|
|
1745
|
+
// recorded here but never descended — its contents ship via their own
|
|
1746
|
+
// repo, not the vault. Surface it so files created under such a link
|
|
1747
|
+
// don't vanish from every push bucket silently.
|
|
1748
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
|
|
1256
1749
|
results.push({
|
|
1257
1750
|
kind: "symlink",
|
|
1258
1751
|
absolutePath,
|
|
1259
1752
|
relativePath,
|
|
1260
|
-
target
|
|
1753
|
+
target,
|
|
1261
1754
|
});
|
|
1262
1755
|
continue;
|
|
1263
1756
|
}
|
|
1264
|
-
if (!
|
|
1757
|
+
if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
|
|
1265
1758
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1759
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1266
1760
|
continue;
|
|
1267
1761
|
}
|
|
1268
1762
|
if (lstat.isDirectory()) {
|
|
1269
1763
|
if (!filter(absolutePath, true))
|
|
1270
1764
|
continue;
|
|
1271
|
-
walkDir(absolutePath, syncRoot, filter, results);
|
|
1765
|
+
walkDir(absolutePath, syncRoot, filter, results, hooks);
|
|
1272
1766
|
}
|
|
1273
1767
|
else if (lstat.isFile()) {
|
|
1274
1768
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1279,7 +1773,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1279
1773
|
}
|
|
1280
1774
|
return results;
|
|
1281
1775
|
}
|
|
1282
|
-
function walkDir(dir, syncRoot, filter, results) {
|
|
1776
|
+
function walkDir(dir, syncRoot, filter, results, hooks = {}) {
|
|
1283
1777
|
// A frame per open directory preserves the recursive walk's depth-first
|
|
1284
1778
|
// ordering without turning a completed subtree into one giant call argument
|
|
1285
1779
|
// list. This matters for both operator-visible plan ordering and large trees.
|
|
@@ -1325,15 +1819,26 @@ function walkDir(dir, syncRoot, filter, results) {
|
|
|
1325
1819
|
// private/knowledge-{co}/), causing per-company knowledge repos
|
|
1326
1820
|
// to be uploaded into every vault that links them. Recording
|
|
1327
1821
|
// and not following preserves the link topology while avoiding
|
|
1328
|
-
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1822
|
+
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1823
|
+
// On win32, a Dirent-known link is not sufficient proof that readlink
|
|
1824
|
+
// will succeed (notably for some reparse points). Never fall through to
|
|
1825
|
+
// normal file/directory handling here: doing so would dereference the
|
|
1826
|
+
// link and duplicate its target under this vault key.
|
|
1827
|
+
const target = readlinkOrNull(absolutePath);
|
|
1828
|
+
if (target === null) {
|
|
1829
|
+
console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
|
|
1830
|
+
hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
// The link is recorded but its (external) target is not descended, so
|
|
1834
|
+
// any files under it are NOT uploaded. Report the subtree so a full
|
|
1835
|
+
// `sync now` no longer drops it from every bucket without a trace.
|
|
1836
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
|
|
1332
1837
|
results.push({
|
|
1333
1838
|
kind: "symlink",
|
|
1334
1839
|
absolutePath,
|
|
1335
|
-
relativePath:
|
|
1336
|
-
target
|
|
1840
|
+
relativePath: linkRelative,
|
|
1841
|
+
target,
|
|
1337
1842
|
});
|
|
1338
1843
|
continue;
|
|
1339
1844
|
}
|
|
@@ -1449,6 +1954,23 @@ function isWithinForLink(parent, linkPath) {
|
|
|
1449
1954
|
*
|
|
1450
1955
|
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
1451
1956
|
* itself; this is the bidirectional-runner case.
|
|
1957
|
+
*
|
|
1958
|
+
* Path spellings are resolved with `resolveNamedPath`, the same resolver the
|
|
1959
|
+
* upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
|
|
1960
|
+
* like its absolute equivalent instead of silently resolving nowhere and
|
|
1961
|
+
* scoping nothing.
|
|
1962
|
+
*
|
|
1963
|
+
* Containment, however, deliberately stays on strict realpath `isWithin` and
|
|
1964
|
+
* does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
|
|
1965
|
+
* legs are asymmetric on purpose: the upload leg ships the files it was handed,
|
|
1966
|
+
* while a delete scope is a PREFIX that authorizes removing every remote object
|
|
1967
|
+
* beneath it. A linked subtree's contents are never walked (`walkDir` does not
|
|
1968
|
+
* descend external directory symlinks), so anchoring a delete scope on one
|
|
1969
|
+
* would compare an empty local walk against a populated remote prefix and sweep
|
|
1970
|
+
* the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
|
|
1971
|
+
* accepted cost is that deletes inside a linked subtree are not propagated,
|
|
1972
|
+
* which matches the snapshot semantics `hq sync push` already documents for
|
|
1973
|
+
* those paths.
|
|
1452
1974
|
*/
|
|
1453
1975
|
function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
|
|
1454
1976
|
const prefixes = new Set();
|
|
@@ -1468,7 +1990,7 @@ function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
|
|
|
1468
1990
|
prefixes.add(normalized);
|
|
1469
1991
|
}
|
|
1470
1992
|
for (const p of paths) {
|
|
1471
|
-
const absolutePath =
|
|
1993
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1472
1994
|
if (!fs.existsSync(absolutePath))
|
|
1473
1995
|
continue;
|
|
1474
1996
|
if (!isWithin(syncRoot, absolutePath))
|
|
@@ -1689,6 +2211,20 @@ companyScoped = true) {
|
|
|
1689
2211
|
continue;
|
|
1690
2212
|
inScopeJournalEntries++;
|
|
1691
2213
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2214
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
2215
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
2216
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
2217
|
+
// presign transport is rejected by the server validator
|
|
2218
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
2219
|
+
// straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
|
|
2220
|
+
// remote call). Personal-vault pushes (personalMode) carry legitimate
|
|
2221
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
2222
|
+
// Checked before the presentLocally gate: the poison file lives at the
|
|
2223
|
+
// doubled path and must drain even while still on disk.
|
|
2224
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
2225
|
+
plan.toTombstone.push(relativeKey);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
1692
2228
|
let presentLocally = true;
|
|
1693
2229
|
try {
|
|
1694
2230
|
fs.lstatSync(localPath);
|
|
@@ -1723,19 +2259,6 @@ companyScoped = true) {
|
|
|
1723
2259
|
litterToDelete.push(relativeKey);
|
|
1724
2260
|
continue;
|
|
1725
2261
|
}
|
|
1726
|
-
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
1727
|
-
// context, a journal entry at a literal `companies/…` key records a
|
|
1728
|
-
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
1729
|
-
// presign transport is rejected by the server validator
|
|
1730
|
-
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
1731
|
-
// straight to `toTombstone` (journal drop, no remote call) — the local
|
|
1732
|
-
// journal entry drains; server-side cleanup of any poisoned object is an
|
|
1733
|
-
// operator action. Personal-vault pushes (personalMode) carry legitimate
|
|
1734
|
-
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
1735
|
-
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
1736
|
-
plan.toTombstone.push(relativeKey);
|
|
1737
|
-
continue;
|
|
1738
|
-
}
|
|
1739
2262
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true))
|
|
1740
2263
|
continue;
|
|
1741
2264
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|