@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.
Files changed (43) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -3
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +46 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +1 -9
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/share.d.ts +178 -1
  13. package/dist/cli/share.d.ts.map +1 -1
  14. package/dist/cli/share.js +555 -32
  15. package/dist/cli/share.js.map +1 -1
  16. package/dist/cli/share.test.js +780 -2
  17. package/dist/cli/share.test.js.map +1 -1
  18. package/dist/cli/sync.d.ts +27 -0
  19. package/dist/cli/sync.d.ts.map +1 -1
  20. package/dist/cli/sync.js +113 -13
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +188 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/lib/readlink-safe.d.ts +11 -0
  25. package/dist/lib/readlink-safe.d.ts.map +1 -0
  26. package/dist/lib/readlink-safe.js +27 -0
  27. package/dist/lib/readlink-safe.js.map +1 -0
  28. package/dist/lib/readlink-safe.test.d.ts +2 -0
  29. package/dist/lib/readlink-safe.test.d.ts.map +1 -0
  30. package/dist/lib/readlink-safe.test.js +34 -0
  31. package/dist/lib/readlink-safe.test.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/bin/sync-runner-company.ts +19 -3
  34. package/src/bin/sync-runner.test.ts +54 -0
  35. package/src/bin/sync-runner.ts +4 -0
  36. package/src/cli/reindex.ts +1 -9
  37. package/src/cli/share.test.ts +974 -2
  38. package/src/cli/share.ts +675 -32
  39. package/src/cli/sync.test.ts +209 -0
  40. package/src/cli/sync.ts +151 -13
  41. package/src/lib/readlink-safe.test.ts +43 -0
  42. package/src/lib/readlink-safe.ts +29 -0
  43. 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: retryErr instanceof Error ? retryErr.message : String(retryErr),
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: err instanceof Error ? err.message : String(err),
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: err instanceof Error ? err.message : String(err),
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 = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
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: fs.readlinkSync(absolutePath),
1753
+ target,
1261
1754
  });
1262
1755
  continue;
1263
1756
  }
1264
- if (!isWithin(syncRoot, absolutePath)) {
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
- // that duplication. readlinkSync on a Dirent-known link cannot
1329
- // fail under normal conditions; let the throw propagate if it
1330
- // somehow does (race with rm, EPERM) the operator needs to
1331
- // see it rather than us silently dropping the link again.
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: vaultKeyForLocalPath(syncRoot, absolutePath),
1336
- target: fs.readlinkSync(absolutePath),
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 = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
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