@indigoai-us/hq-cloud 6.14.35 → 6.14.37

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.js CHANGED
@@ -24,6 +24,7 @@ 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";
27
28
  /**
28
29
  * Push-side fresh-collision convergence probe.
29
30
  *
@@ -199,6 +200,25 @@ export function isEphemeralPath(p) {
199
200
  export function isMalformedVaultKey(key) {
200
201
  return key.includes("\\");
201
202
  }
203
+ /**
204
+ * A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
205
+ * PERSONAL vault, where it is handled by the dedicated `personalMode` branch
206
+ * in `computePullPlan` (companies/* content a peer machine pushed into the
207
+ * personal bucket). A COMPANY-scoped vault is already anchored at its company
208
+ * root, so its keys are bucket-relative — a `companies/...` key there is a
209
+ * doubly-scoped corrupt object. The vault-service refuses to presign such a
210
+ * key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
211
+ * never materialize it and the whole company sync wedges at `errored` (runner
212
+ * exit 2) on every run. Verified live 2026-06-16: frogbear's
213
+ * `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
214
+ * was uploaded with a doubled key and broke every sync thereafter. The pull
215
+ * and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
216
+ * with the malformed-(backslash)-key filter above; the bogus objects
217
+ * themselves are cleaned server-side.
218
+ */
219
+ export function isForbiddenCompanyVaultKey(key, personalMode) {
220
+ return !personalMode && key.startsWith("companies/");
221
+ }
202
222
  /**
203
223
  * Test-only export. Kept under a `_testing` namespace so the module's public
204
224
  * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
@@ -212,6 +232,8 @@ export const _testing = {
212
232
  EPHEMERAL_PATH_PATTERN,
213
233
  wrapFilterWithIgnoreVisibility,
214
234
  collectFiles,
235
+ resolveNamedPath,
236
+ isWithinLexicalOrReal,
215
237
  };
216
238
  /**
217
239
  * Pure Stage-1 pass for push: walk the candidate file list, hash each one,
@@ -344,6 +366,35 @@ function computePushPlan(filesToShare, journal, skipUnchanged) {
344
366
  }
345
367
  return { items, filesToUpload, bytesToUpload, filesToSkip };
346
368
  }
369
+ /**
370
+ * Thrown by `share()` when a caller-named path cannot be pushed and
371
+ * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
372
+ * are still being built, so NOTHING has been uploaded, journaled, or deleted
373
+ * when it surfaces — the failed push leaves no partial state behind.
374
+ */
375
+ export class UnreachablePushPathsError extends Error {
376
+ /** Caller's original spellings, verbatim (see the CollectHooks contract). */
377
+ paths;
378
+ /** Per-path reason, keyed by the same original spelling. */
379
+ reasons;
380
+ constructor(unreachable, syncRoot) {
381
+ const paths = [...unreachable.keys()];
382
+ const lines = paths.map((p) => {
383
+ const reason = unreachable.get(p);
384
+ return reason === "outside-company"
385
+ ? ` · ${p} — resolves outside the company folder (${syncRoot})`
386
+ : ` · ${p} — not found under the hq root, the company folder, or the current directory`;
387
+ });
388
+ super(`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
389
+ `nothing was uploaded.\n${lines.join("\n")}\n` +
390
+ `A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
391
+ `(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
392
+ `outside HQ has to sync through whatever owns it, not the vault.`);
393
+ this.name = "UnreachablePushPathsError";
394
+ this.paths = paths;
395
+ this.reasons = Object.fromEntries(unreachable);
396
+ }
397
+ }
347
398
  /**
348
399
  * A conditional-write fence rejection — the SDK's 412 (`name:
349
400
  * "PreconditionFailed"`) or the presigned transport's mirror of it. Means
@@ -490,6 +541,8 @@ async function createPushRunContext(options) {
490
541
  const onScopeExcluded = (rel) => {
491
542
  scopeExcludedSet.add(rel);
492
543
  };
544
+ const unreachablePaths = new Map();
545
+ const linkedSubtreeSet = new Set();
493
546
  const baseFilter = options.personalMode === true
494
547
  ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
495
548
  : recordedIgnoreFilter;
@@ -523,6 +576,8 @@ async function createPushRunContext(options) {
523
576
  scopeExcludedSet,
524
577
  ignoreExcludedSet,
525
578
  ignoreExcludedTotal,
579
+ unreachablePaths,
580
+ linkedSubtreeSet,
526
581
  };
527
582
  }
528
583
  function createShareCounters() {
@@ -537,7 +592,29 @@ function createShareCounters() {
537
592
  };
538
593
  }
539
594
  async function buildSharePlans(run) {
540
- const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync);
595
+ const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync, {
596
+ onUnreachablePath: (namedPath, reason) => {
597
+ // First reason wins: a path is named once, and re-adding would only
598
+ // churn the map ordering the error message and event sample rely on.
599
+ if (!run.unreachablePaths.has(namedPath)) {
600
+ run.unreachablePaths.set(namedPath, reason);
601
+ }
602
+ },
603
+ onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
604
+ });
605
+ // Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
606
+ // exists locally but is unreachable by the resolver". This is the throw that
607
+ // makes it true end-to-end — the CLI's push handler already turns a thrown
608
+ // error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
609
+ // "✓ Pushed 0 file(s)" success can no longer happen for a file that is
610
+ // sitting right there on disk. It fires HERE, before executeUploads, so a
611
+ // failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
612
+ // written, no delete propagated.
613
+ const fatal = collectFatalUnreachablePaths(run);
614
+ if (fatal.size > 0) {
615
+ emitUnreachablePathEvent(run);
616
+ throw new UnreachablePushPathsError(fatal, run.syncRoot);
617
+ }
541
618
  // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
542
619
  // root IS the company folder, so a local entry whose vault key starts with
543
620
  // `companies/` can only come from a stale doubled local tree
@@ -839,7 +916,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
839
916
  run.emit({
840
917
  type: "error",
841
918
  path: relativePath,
842
- message: retryErr instanceof Error ? retryErr.message : String(retryErr),
919
+ message: describeError(retryErr),
843
920
  });
844
921
  }
845
922
  return;
@@ -855,7 +932,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
855
932
  run.emit({
856
933
  type: "error",
857
934
  path: relativePath,
858
- message: err instanceof Error ? err.message : String(err),
935
+ message: describeError(err),
859
936
  });
860
937
  }
861
938
  };
@@ -919,8 +996,7 @@ async function writePushConflictMirror(run, item, remoteHash) {
919
996
  run.emit({
920
997
  type: "error",
921
998
  path: item.relativePath,
922
- message: "conflict mirror write failed: " +
923
- (mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
999
+ message: "conflict mirror write failed: " + describeError(mirrorErr),
924
1000
  });
925
1001
  }
926
1002
  }
@@ -992,7 +1068,7 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
992
1068
  run.emit({
993
1069
  type: "error",
994
1070
  path: relativePath,
995
- message: err instanceof Error ? err.message : String(err),
1071
+ message: describeError(err),
996
1072
  });
997
1073
  pathResults.push({
998
1074
  path: relativePath,
@@ -1003,6 +1079,48 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
1003
1079
  }
1004
1080
  }
1005
1081
  for (const relativePath of deletePlan.toTombstone) {
1082
+ const localPath = localPathForVaultKey(run.syncRoot, relativePath);
1083
+ try {
1084
+ const lstat = fs.lstatSync(localPath);
1085
+ const entry = run.journal.files[relativePath];
1086
+ if (lstat.isFile()) {
1087
+ const localHash = hashFile(localPath);
1088
+ if (entry?.hash && entry.hash !== localHash) {
1089
+ run.emit({
1090
+ type: "error",
1091
+ path: relativePath,
1092
+ message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1093
+ });
1094
+ continue;
1095
+ }
1096
+ fs.unlinkSync(localPath);
1097
+ }
1098
+ else if (lstat.isSymbolicLink()) {
1099
+ const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
1100
+ if (entry?.hash && entry.hash !== localHash) {
1101
+ run.emit({
1102
+ type: "error",
1103
+ path: relativePath,
1104
+ message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1105
+ });
1106
+ continue;
1107
+ }
1108
+ fs.unlinkSync(localPath);
1109
+ }
1110
+ }
1111
+ catch (err) {
1112
+ const code = err && typeof err === "object" && "code" in err
1113
+ ? err.code
1114
+ : undefined;
1115
+ if (code !== "ENOENT") {
1116
+ run.emit({
1117
+ type: "error",
1118
+ path: relativePath,
1119
+ message: `tombstone unlink failed: ${err instanceof Error ? err.message : String(err)}`,
1120
+ });
1121
+ continue;
1122
+ }
1123
+ }
1006
1124
  removeEntry(run.journal, relativePath);
1007
1125
  counters.filesTombstoned++;
1008
1126
  run.emit({
@@ -1085,6 +1203,74 @@ function finalizeShareJournal(run) {
1085
1203
  samplePaths,
1086
1204
  });
1087
1205
  }
1206
+ emitUnreachablePathEvent(run);
1207
+ if (run.linkedSubtreeSet.size > 0) {
1208
+ run.emit({
1209
+ type: "not-shipped",
1210
+ reason: "linked-subtree",
1211
+ count: run.linkedSubtreeSet.size,
1212
+ samplePaths: sampleSet(run.linkedSubtreeSet),
1213
+ });
1214
+ }
1215
+ }
1216
+ /**
1217
+ * Which unreachable named paths are FATAL under the run's policy.
1218
+ *
1219
+ * Under the default `"error"` policy only `"outside-company"` is fatal: the
1220
+ * entry is sitting on disk and the resolver refused to place it under the
1221
+ * company folder, which is precisely the "exists locally but is unreachable by
1222
+ * the resolver" case the report asks to turn into an error, and it cannot
1223
+ * happen for an internal walk root (a company folder is trivially inside
1224
+ * itself).
1225
+ *
1226
+ * `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
1227
+ * and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
1228
+ * one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
1229
+ * folder was never materialized locally; throwing there would turn "you haven't
1230
+ * pulled that company yet" into a hard failure of an unrelated multi-company
1231
+ * push. Same reasoning for a watcher path deleted between the event and the
1232
+ * push. Those callers get the loud report without the regression.
1233
+ *
1234
+ * `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
1235
+ * end to end (the background sync runner).
1236
+ */
1237
+ function collectFatalUnreachablePaths(run) {
1238
+ const fatal = new Map();
1239
+ if (run.options.unreachablePathPolicy === "warn")
1240
+ return fatal;
1241
+ for (const [namedPath, reason] of run.unreachablePaths) {
1242
+ if (reason === "outside-company")
1243
+ fatal.set(namedPath, reason);
1244
+ }
1245
+ return fatal;
1246
+ }
1247
+ /**
1248
+ * Emit the `not-shipped` / `unreachable-path` event for a run, if any named
1249
+ * path went unshipped. Shared by BOTH policies so the operator sees the same
1250
+ * report either way: the fail-fast path emits it immediately before throwing
1251
+ * (the journal finalizer never runs on a throw), and the `"warn"` path emits it
1252
+ * from the finalizer at the end of a successful run. Exactly one of those two
1253
+ * call sites can fire per run, so the event is never duplicated.
1254
+ */
1255
+ function emitUnreachablePathEvent(run) {
1256
+ if (run.unreachablePaths.size === 0)
1257
+ return;
1258
+ run.emit({
1259
+ type: "not-shipped",
1260
+ reason: "unreachable-path",
1261
+ count: run.unreachablePaths.size,
1262
+ samplePaths: sampleSet(run.unreachablePaths.keys()),
1263
+ });
1264
+ }
1265
+ /** First up-to-`limit` members of an iterable, for bounded event payloads. */
1266
+ function sampleSet(set, limit = 10) {
1267
+ const sample = [];
1268
+ for (const value of set) {
1269
+ sample.push(value);
1270
+ if (sample.length >= limit)
1271
+ break;
1272
+ }
1273
+ return sample;
1088
1274
  }
1089
1275
  function throwUploadWorkerErrors(workerErrors) {
1090
1276
  if (workerErrors.length > 0) {
@@ -1112,6 +1298,8 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
1112
1298
  filesExcludedByPolicy: run.excludedSet.size,
1113
1299
  filesExcludedByScope: run.scopeExcludedSet.size,
1114
1300
  filesExcludedByIgnore: run.ignoreExcludedSet.size,
1301
+ unreachablePaths: [...run.unreachablePaths.keys()],
1302
+ linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
1115
1303
  conflictPaths,
1116
1304
  pathResults,
1117
1305
  aborted,
@@ -1189,6 +1377,260 @@ function defaultConsoleLogger(event) {
1189
1377
  console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1190
1378
  }
1191
1379
  }
1380
+ else if (event.type === "not-shipped") {
1381
+ // The other "not silent" surface: content the walk saw but chose not to
1382
+ // ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
1383
+ if (event.reason === "unreachable-path") {
1384
+ 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"}):`);
1385
+ }
1386
+ else {
1387
+ console.warn(` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`);
1388
+ }
1389
+ for (const p of event.samplePaths) {
1390
+ console.warn(` · ${p}`);
1391
+ }
1392
+ if (event.count > event.samplePaths.length) {
1393
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1394
+ }
1395
+ }
1396
+ }
1397
+ /**
1398
+ * Resolve a caller-supplied push path to an absolute path.
1399
+ *
1400
+ * Relative paths were historically resolved against `hqRoot` ONLY, so a
1401
+ * company-relative spelling like `knowledge/agents/x.md` became
1402
+ * `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
1403
+ * caller's cwd or the company being pushed (feedback_258e4a86 /
1404
+ * feedback_a51cb63d — "there is no path spelling that reaches the file").
1405
+ *
1406
+ * PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
1407
+ * (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
1408
+ * ADDITIVE to the legacy behavior: every relative spelling that resolved
1409
+ * pre-fix still resolves to exactly the same file, and the two new bases only
1410
+ * catch spellings that previously resolved to nothing. Probing cwd first would
1411
+ * silently re-point existing callers (a `knowledge/` directory in the shell's
1412
+ * cwd would win over the hq-root one), which is a behavior change no reporter
1413
+ * asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
1414
+ * the unchanged "does not exist" diagnostic. Absolute paths are returned
1415
+ * verbatim.
1416
+ *
1417
+ * `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
1418
+ * rather than an ambient read, so resolution is deterministic and testable
1419
+ * without mutating process state.
1420
+ */
1421
+ function resolveNamedPath(p, hqRoot, syncRoot, cwd = process.cwd()) {
1422
+ if (path.isAbsolute(p))
1423
+ return p;
1424
+ const hqRootCandidate = path.resolve(hqRoot, p);
1425
+ const candidates = [
1426
+ hqRootCandidate,
1427
+ path.resolve(syncRoot, p),
1428
+ path.resolve(cwd, p),
1429
+ ];
1430
+ for (const candidate of candidates) {
1431
+ try {
1432
+ fs.lstatSync(candidate);
1433
+ // An hqRoot hit outside the company folder would be rejected by
1434
+ // collectFiles as outside-company; skip it so a valid company-relative
1435
+ // spelling can win (Codex P2 — common `knowledge/` homonym case).
1436
+ if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
1437
+ continue;
1438
+ }
1439
+ return candidate;
1440
+ }
1441
+ catch {
1442
+ // Base did not resolve to an on-disk entry — try the next one.
1443
+ }
1444
+ }
1445
+ return hqRootCandidate;
1446
+ }
1447
+ /**
1448
+ * Containment check for a regular file or directory that tolerates a symlinked
1449
+ * ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
1450
+ * path reached through a symlinked ancestor (`companies/{co}/knowledge` →
1451
+ * `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
1452
+ * rejected as "outside company folder" — even though its logical path is
1453
+ * in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
1454
+ * company-namespaced key for it. Accept when the LEXICAL path is inside
1455
+ * (honoring the same logical topology the vault key uses) OR the realpath is
1456
+ * inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
1457
+ *
1458
+ * The lexical arm carries TWO bounds, because it is the only place where a
1459
+ * path's bytes and its vault key come from different trees:
1460
+ *
1461
+ * 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
1462
+ * symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
1463
+ * state under a company-namespaced key.
1464
+ * 2. The TENANT — the realpath must not land inside another company's bytes
1465
+ * (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
1466
+ * must never be mistaken for a tenant boundary: hqRoot CONTAINS every
1467
+ * other company, so `companies/acme/knowledge → companies/other/secret`
1468
+ * (or → `repos/private/knowledge-other`, the linked-repo topology) is
1469
+ * lexically inside acme and really inside HQ, and would upload the other
1470
+ * tenant's bytes into acme's bucket under the key `knowledge/…`.
1471
+ *
1472
+ * The motivating topology (`companies/{co}/knowledge` →
1473
+ * `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
1474
+ * that escapes HQ, or one that reaches another tenant, is refused — and under
1475
+ * the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
1476
+ *
1477
+ * Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
1478
+ * externally-linked subtree can only be recognized while that company's folder
1479
+ * is materialized locally and publishes the link. A machine holding
1480
+ * `repos/private/knowledge-other` with no `companies/other` folder has no
1481
+ * on-disk evidence of the claim, so a link into it is indistinguishable from a
1482
+ * link into any other local repo. Ownership metadata (not path shape) is what
1483
+ * would close that gap.
1484
+ */
1485
+ function isWithinLexicalOrReal(parent, child, hqRoot, tenantRootsCache) {
1486
+ // Strict arm first: the realpath is genuinely inside the company folder.
1487
+ // This is the overwhelmingly common case, needs no relaxation, and costs no
1488
+ // directory scan.
1489
+ if (isWithin(parent, child))
1490
+ return true;
1491
+ const resolvedChild = path.resolve(child);
1492
+ if (!isPathWithin(path.resolve(parent), resolvedChild))
1493
+ return false;
1494
+ const childReal = realpathSafe(resolvedChild);
1495
+ if (!isWithin(hqRoot, childReal))
1496
+ return false; // bound 1: escapes HQ
1497
+ // NUL-joined: it is the one byte a path cannot contain, so no pair of
1498
+ // (hqRoot, parent) values can collide on the key.
1499
+ const cacheKey = `${hqRoot}\u0000${parent}`;
1500
+ let foreignRoots = tenantRootsCache?.get(cacheKey);
1501
+ if (foreignRoots === undefined) {
1502
+ foreignRoots = foreignTenantRoots(hqRoot, parent);
1503
+ tenantRootsCache?.set(cacheKey, foreignRoots);
1504
+ }
1505
+ for (const foreign of foreignRoots) {
1506
+ if (isPathWithin(foreign, childReal))
1507
+ return false; // bound 2: other tenant
1508
+ }
1509
+ return true;
1510
+ }
1511
+ /**
1512
+ * Depth (in path segments below a company root) at which we look for the
1513
+ * directory symlinks a company publishes into its own folder. HQ's linked
1514
+ * topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
1515
+ * (`companies/{co}/repos/{name}`); going deeper would turn a containment check
1516
+ * into a full-tree walk for no additional coverage.
1517
+ */
1518
+ const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
1519
+ /**
1520
+ * Canonicalized roots that belong to a tenant OTHER than the one being pushed.
1521
+ * A lexically-contained path whose realpath lands inside any of these is
1522
+ * another company's data wearing this company's key, and must be refused.
1523
+ *
1524
+ * Two kinds of root are collected per foreign company:
1525
+ * - the company folder itself (`companies/{other}`), and
1526
+ * - the targets of the directory symlinks that folder publishes — HQ's
1527
+ * pattern-2 topology puts a company's knowledge in `repos/private/
1528
+ * knowledge-{other}` and links it in, so the bytes live OUTSIDE every
1529
+ * `companies/` root and a companies-only check would miss them entirely.
1530
+ *
1531
+ * Only consulted on the rare lexical arm (a path whose realpath is not inside
1532
+ * the company folder), never on the ordinary in-tree push, so the directory
1533
+ * scan is not on the hot path. Callers may memoize it for the duration of a
1534
+ * single collect pass; nothing memoizes it for longer, because the sync runner
1535
+ * is long-lived and a stale tenant map fails OPEN — the wrong direction for a
1536
+ * boundary whose whole job is to refuse.
1537
+ */
1538
+ function foreignTenantRoots(hqRoot, syncRoot) {
1539
+ const companiesDir = path.join(hqRoot, "companies");
1540
+ let entries;
1541
+ try {
1542
+ entries = fs.readdirSync(companiesDir, { withFileTypes: true });
1543
+ }
1544
+ catch {
1545
+ return []; // no companies/ tree here — nothing to be foreign to
1546
+ }
1547
+ const activeReal = realpathSafe(syncRoot);
1548
+ const roots = [];
1549
+ for (const entry of entries) {
1550
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
1551
+ continue;
1552
+ const companyRoot = path.join(companiesDir, entry.name);
1553
+ const companyReal = realpathSafe(companyRoot);
1554
+ if (companyReal === activeReal)
1555
+ continue; // this is the tenant being pushed
1556
+ roots.push(companyReal);
1557
+ collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
1558
+ }
1559
+ return roots;
1560
+ }
1561
+ /**
1562
+ * Append the resolved targets of the directory symlinks published under
1563
+ * `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
1564
+ * inside the company folder are skipped — they add no reach beyond the root
1565
+ * already recorded. Errors are swallowed per entry on purpose: an unreadable
1566
+ * sibling directory must narrow what we can prove, never abort the push it is
1567
+ * unrelated to.
1568
+ */
1569
+ function collectPublishedLinkTargets(dir, companyReal, depth, out) {
1570
+ if (depth <= 0)
1571
+ return;
1572
+ let entries;
1573
+ try {
1574
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1575
+ }
1576
+ catch {
1577
+ return;
1578
+ }
1579
+ for (const entry of entries) {
1580
+ const child = path.join(dir, entry.name);
1581
+ if (entry.isSymbolicLink()) {
1582
+ let real;
1583
+ try {
1584
+ real = fs.realpathSync.native(child);
1585
+ }
1586
+ catch {
1587
+ continue; // dangling link claims nothing
1588
+ }
1589
+ try {
1590
+ if (!fs.statSync(real).isDirectory())
1591
+ continue;
1592
+ }
1593
+ catch {
1594
+ continue;
1595
+ }
1596
+ if (isPathWithin(companyReal, real))
1597
+ continue; // no reach beyond the root
1598
+ out.push(real);
1599
+ }
1600
+ else if (entry.isDirectory()) {
1601
+ collectPublishedLinkTargets(child, companyReal, depth - 1, out);
1602
+ }
1603
+ }
1604
+ }
1605
+ /**
1606
+ * If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
1607
+ * `onLinkedSubtree` so the caller can report that the link's contents were not
1608
+ * uploaded to the vault. Fires only for links that (a) resolve to a directory
1609
+ * and (b) point outside the company folder — an in-tree link is descended
1610
+ * elsewhere, and a dangling or file link has nothing behind it to report.
1611
+ */
1612
+ function reportLinkedSubtreeIfExternal(linkPath, syncRoot, relativePath, hooks) {
1613
+ if (!hooks.onLinkedSubtree)
1614
+ return;
1615
+ let real;
1616
+ try {
1617
+ real = fs.realpathSync.native(linkPath); // resolves the link to its target
1618
+ }
1619
+ catch {
1620
+ return; // dangling link — nothing behind it to report
1621
+ }
1622
+ let targetStat;
1623
+ try {
1624
+ targetStat = fs.statSync(real);
1625
+ }
1626
+ catch {
1627
+ return;
1628
+ }
1629
+ if (!targetStat.isDirectory())
1630
+ return;
1631
+ if (isWithin(syncRoot, real))
1632
+ return; // in-tree target: descended elsewhere
1633
+ hooks.onLinkedSubtree(relativePath);
1192
1634
  }
1193
1635
  /**
1194
1636
  * Collect files from paths (expanding directories recursively).
@@ -1202,10 +1644,15 @@ function defaultConsoleLogger(event) {
1202
1644
  * Pre-fix, statSync followed the link and the target's bytes were uploaded
1203
1645
  * under the link's key — silently flattening the link topology.
1204
1646
  */
1205
- function collectFiles(paths, hqRoot, syncRoot, filter) {
1647
+ function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
1206
1648
  const results = [];
1649
+ // Scoped to THIS collect pass and discarded with it: a watcher batch can
1650
+ // name hundreds of paths, and rescanning the companies/ tree for each one
1651
+ // is wasted I/O. A cache that outlived the pass could fail open on a tenant
1652
+ // boundary, so it deliberately does not.
1653
+ const tenantRoots = new Map();
1207
1654
  for (const p of paths) {
1208
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
1655
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
1209
1656
  // Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
1210
1657
  // Caller may pass one explicitly; we still refuse to upload it. Basename
1211
1658
  // check matches the walkDir gate so behavior is identical whether the
@@ -1221,6 +1668,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
1221
1668
  }
1222
1669
  catch {
1223
1670
  console.error(` Warning: ${p} does not exist, skipping.`);
1671
+ hooks.onUnreachablePath?.(p, "missing");
1224
1672
  continue;
1225
1673
  }
1226
1674
  // Containment check is split by entry kind: regular files and
@@ -1238,6 +1686,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
1238
1686
  if (lstat.isSymbolicLink()) {
1239
1687
  if (!isWithinForLink(syncRoot, absolutePath)) {
1240
1688
  console.error(` Warning: ${p} is outside company folder, skipping.`);
1689
+ hooks.onUnreachablePath?.(p, "outside-company");
1241
1690
  continue;
1242
1691
  }
1243
1692
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
@@ -1253,6 +1702,11 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
1253
1702
  // so two calls are free.
1254
1703
  if (!filter(absolutePath, false) && !filter(absolutePath, true))
1255
1704
  continue;
1705
+ // A directory symlink whose target lives outside the company folder is
1706
+ // recorded here but never descended — its contents ship via their own
1707
+ // repo, not the vault. Surface it so files created under such a link
1708
+ // don't vanish from every push bucket silently.
1709
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
1256
1710
  results.push({
1257
1711
  kind: "symlink",
1258
1712
  absolutePath,
@@ -1261,14 +1715,15 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
1261
1715
  });
1262
1716
  continue;
1263
1717
  }
1264
- if (!isWithin(syncRoot, absolutePath)) {
1718
+ if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
1265
1719
  console.error(` Warning: ${p} is outside company folder, skipping.`);
1720
+ hooks.onUnreachablePath?.(p, "outside-company");
1266
1721
  continue;
1267
1722
  }
1268
1723
  if (lstat.isDirectory()) {
1269
1724
  if (!filter(absolutePath, true))
1270
1725
  continue;
1271
- walkDir(absolutePath, syncRoot, filter, results);
1726
+ walkDir(absolutePath, syncRoot, filter, results, hooks);
1272
1727
  }
1273
1728
  else if (lstat.isFile()) {
1274
1729
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
@@ -1279,7 +1734,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
1279
1734
  }
1280
1735
  return results;
1281
1736
  }
1282
- function walkDir(dir, syncRoot, filter, results) {
1737
+ function walkDir(dir, syncRoot, filter, results, hooks = {}) {
1283
1738
  // A frame per open directory preserves the recursive walk's depth-first
1284
1739
  // ordering without turning a completed subtree into one giant call argument
1285
1740
  // list. This matters for both operator-visible plan ordering and large trees.
@@ -1329,10 +1784,15 @@ function walkDir(dir, syncRoot, filter, results) {
1329
1784
  // fail under normal conditions; let the throw propagate if it
1330
1785
  // somehow does (race with rm, EPERM) — the operator needs to
1331
1786
  // see it rather than us silently dropping the link again.
1787
+ const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
1788
+ // The link is recorded but its (external) target is not descended, so
1789
+ // any files under it are NOT uploaded. Report the subtree so a full
1790
+ // `sync now` no longer drops it from every bucket without a trace.
1791
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
1332
1792
  results.push({
1333
1793
  kind: "symlink",
1334
1794
  absolutePath,
1335
- relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
1795
+ relativePath: linkRelative,
1336
1796
  target: fs.readlinkSync(absolutePath),
1337
1797
  });
1338
1798
  continue;
@@ -1449,6 +1909,23 @@ function isWithinForLink(parent, linkPath) {
1449
1909
  *
1450
1910
  * Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
1451
1911
  * itself; this is the bidirectional-runner case.
1912
+ *
1913
+ * Path spellings are resolved with `resolveNamedPath`, the same resolver the
1914
+ * upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
1915
+ * like its absolute equivalent instead of silently resolving nowhere and
1916
+ * scoping nothing.
1917
+ *
1918
+ * Containment, however, deliberately stays on strict realpath `isWithin` and
1919
+ * does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
1920
+ * legs are asymmetric on purpose: the upload leg ships the files it was handed,
1921
+ * while a delete scope is a PREFIX that authorizes removing every remote object
1922
+ * beneath it. A linked subtree's contents are never walked (`walkDir` does not
1923
+ * descend external directory symlinks), so anchoring a delete scope on one
1924
+ * would compare an empty local walk against a populated remote prefix and sweep
1925
+ * the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
1926
+ * accepted cost is that deletes inside a linked subtree are not propagated,
1927
+ * which matches the snapshot semantics `hq sync push` already documents for
1928
+ * those paths.
1452
1929
  */
1453
1930
  function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
1454
1931
  const prefixes = new Set();
@@ -1468,7 +1945,7 @@ function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
1468
1945
  prefixes.add(normalized);
1469
1946
  }
1470
1947
  for (const p of paths) {
1471
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
1948
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
1472
1949
  if (!fs.existsSync(absolutePath))
1473
1950
  continue;
1474
1951
  if (!isWithin(syncRoot, absolutePath))
@@ -1689,6 +2166,20 @@ companyScoped = true) {
1689
2166
  continue;
1690
2167
  inScopeJournalEntries++;
1691
2168
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
2169
+ // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
2170
+ // context, a journal entry at a literal `companies/…` key records a
2171
+ // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
2172
+ // presign transport is rejected by the server validator
2173
+ // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
2174
+ // straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
2175
+ // remote call). Personal-vault pushes (personalMode) carry legitimate
2176
+ // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
2177
+ // Checked before the presentLocally gate: the poison file lives at the
2178
+ // doubled path and must drain even while still on disk.
2179
+ if (companyScoped && relativeKey.startsWith("companies/")) {
2180
+ plan.toTombstone.push(relativeKey);
2181
+ continue;
2182
+ }
1692
2183
  let presentLocally = true;
1693
2184
  try {
1694
2185
  fs.lstatSync(localPath);
@@ -1723,19 +2214,6 @@ companyScoped = true) {
1723
2214
  litterToDelete.push(relativeKey);
1724
2215
  continue;
1725
2216
  }
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
2217
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true))
1740
2218
  continue;
1741
2219
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the