@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/src/cli/share.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  import { appendConflictEntry } from "../lib/conflict-index.js";
72
72
  import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
73
73
  import { VaultAuthError } from "../vault-client.js";
74
+ import { describeError } from "../lib/describe-error.js";
74
75
 
75
76
  /**
76
77
  * Push-side fresh-collision convergence probe.
@@ -258,6 +259,26 @@ export function isMalformedVaultKey(key: string): boolean {
258
259
  return key.includes("\\");
259
260
  }
260
261
 
262
+ /**
263
+ * A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
264
+ * PERSONAL vault, where it is handled by the dedicated `personalMode` branch
265
+ * in `computePullPlan` (companies/* content a peer machine pushed into the
266
+ * personal bucket). A COMPANY-scoped vault is already anchored at its company
267
+ * root, so its keys are bucket-relative — a `companies/...` key there is a
268
+ * doubly-scoped corrupt object. The vault-service refuses to presign such a
269
+ * key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
270
+ * never materialize it and the whole company sync wedges at `errored` (runner
271
+ * exit 2) on every run. Verified live 2026-06-16: frogbear's
272
+ * `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
273
+ * was uploaded with a doubled key and broke every sync thereafter. The pull
274
+ * and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
275
+ * with the malformed-(backslash)-key filter above; the bogus objects
276
+ * themselves are cleaned server-side.
277
+ */
278
+ export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
279
+ return !personalMode && key.startsWith("companies/");
280
+ }
281
+
261
282
  /**
262
283
  * Test-only export. Kept under a `_testing` namespace so the module's public
263
284
  * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
@@ -271,6 +292,8 @@ export const _testing = {
271
292
  EPHEMERAL_PATH_PATTERN,
272
293
  wrapFilterWithIgnoreVisibility,
273
294
  collectFiles,
295
+ resolveNamedPath,
296
+ isWithinLexicalOrReal,
274
297
  };
275
298
 
276
299
  /**
@@ -678,6 +701,63 @@ export interface ShareOptions {
678
701
  * the network. See the consult in the Stage-2 classification pass.
679
702
  */
680
703
  fileTombstones?: Map<string, CompanyTombstone>;
704
+ /**
705
+ * What to do when a path the caller EXPLICITLY named cannot be shipped —
706
+ * it does not resolve under any base, or it resolves outside the company
707
+ * folder (see `ShareResult.unreachablePaths`).
708
+ *
709
+ * - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
710
+ * company folder throws {@link UnreachablePushPathsError} BEFORE any upload
711
+ * runs, so the push is an atomic no-op and the CLI exits nonzero. This is
712
+ * ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
713
+ * exists locally but is unreachable by the resolver". A user who typed a
714
+ * path and got "✓ Pushed 0 file(s)" had no way to know their content never
715
+ * left the machine. A path that resolves to NOTHING under any base is still
716
+ * only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
717
+ * membership fanout depends on that).
718
+ * - `"warn"`: record it on the result + emit the `not-shipped` event and
719
+ * carry on, never throwing. For callers whose `paths` are INTERNAL walk
720
+ * roots rather than user input — the background sync runner — where a path
721
+ * disappearing mid-run is a benign race (a directory removed between the
722
+ * scan and the push) and must never fail an unattended sync.
723
+ */
724
+ unreachablePathPolicy?: "error" | "warn";
725
+ }
726
+
727
+ /** Why an explicitly-named push path could not be shipped. */
728
+ export type UnreachablePathReason = "missing" | "outside-company";
729
+
730
+ /**
731
+ * Thrown by `share()` when a caller-named path cannot be pushed and
732
+ * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
733
+ * are still being built, so NOTHING has been uploaded, journaled, or deleted
734
+ * when it surfaces — the failed push leaves no partial state behind.
735
+ */
736
+ export class UnreachablePushPathsError extends Error {
737
+ /** Caller's original spellings, verbatim (see the CollectHooks contract). */
738
+ readonly paths: string[];
739
+ /** Per-path reason, keyed by the same original spelling. */
740
+ readonly reasons: Record<string, UnreachablePathReason>;
741
+
742
+ constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
743
+ const paths = [...unreachable.keys()];
744
+ const lines = paths.map((p) => {
745
+ const reason = unreachable.get(p);
746
+ return reason === "outside-company"
747
+ ? ` · ${p} — resolves outside the company folder (${syncRoot})`
748
+ : ` · ${p} — not found under the hq root, the company folder, or the current directory`;
749
+ });
750
+ super(
751
+ `${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
752
+ `nothing was uploaded.\n${lines.join("\n")}\n` +
753
+ `A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
754
+ `(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
755
+ `outside HQ has to sync through whatever owns it, not the vault.`,
756
+ );
757
+ this.name = "UnreachablePushPathsError";
758
+ this.paths = paths;
759
+ this.reasons = Object.fromEntries(unreachable);
760
+ }
681
761
  }
682
762
 
683
763
  export interface ShareResult {
@@ -757,6 +837,34 @@ export interface ShareResult {
757
837
  * once if this is > 0).
758
838
  */
759
839
  filesExcludedByIgnore: number;
840
+ /**
841
+ * Paths the caller EXPLICITLY named for push that exist locally but the
842
+ * resolver could not place under the company folder (or could not find under
843
+ * any base). Empty in the common case (internal walk roots are always the
844
+ * reachable company folder). A non-empty list means the push did NOT ship
845
+ * something the caller asked for.
846
+ *
847
+ * Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
848
+ * `"error"` policy throws {@link UnreachablePushPathsError} instead of
849
+ * returning, so an interactive `hq sync push` fails loudly rather than
850
+ * reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
851
+ * warn-mode surface for unattended callers (sync runner / watcher).
852
+ *
853
+ * Entries are the caller's ORIGINAL spellings, verbatim — a relative token
854
+ * stays relative, an absolute token stays absolute — so the list is directly
855
+ * comparable to the `paths` input. See the CollectHooks spelling contract.
856
+ * Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
857
+ */
858
+ unreachablePaths: string[];
859
+ /**
860
+ * Company-relative keys of directory symlinks that were recorded as links but
861
+ * NOT descended because their target lives outside the company folder — their
862
+ * contents sync via their own repo, not the vault. Surfaced so files created
863
+ * under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
864
+ * longer vanish from every push bucket without a trace. Mirrors the
865
+ * `not-shipped` event with `reason: "linked-subtree"`.
866
+ */
867
+ linkedSubtreesNotShipped: string[];
760
868
  /**
761
869
  * Paths (company-relative) that were detected as push conflicts. Mirrors
762
870
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -935,6 +1043,18 @@ interface PushRunContext {
935
1043
  scopeExcludedSet: Set<string>;
936
1044
  ignoreExcludedSet: Set<string>;
937
1045
  ignoreExcludedTotal: { value: number };
1046
+ /** Explicitly-named push paths that exist locally but the resolver could not
1047
+ * place under the company folder (or find at all), keyed by the CALLER'S
1048
+ * ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
1049
+ * why it could not be shipped. Non-empty ⇒ the push did NOT ship something
1050
+ * the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
1051
+ * false success, and (under the default `unreachablePathPolicy: "error"`)
1052
+ * raised as a hard failure before any upload runs. */
1053
+ unreachablePaths: Map<string, UnreachablePathReason>;
1054
+ /** Company-relative keys of directory symlinks recorded but not descended
1055
+ * because their target lives outside the company folder (contents sync via
1056
+ * their own repo, not the vault). */
1057
+ linkedSubtreeSet: Set<string>;
938
1058
  }
939
1059
 
940
1060
  interface ShareCounters {
@@ -1040,6 +1160,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1040
1160
  const onScopeExcluded = (rel: string) => {
1041
1161
  scopeExcludedSet.add(rel);
1042
1162
  };
1163
+ const unreachablePaths = new Map<string, UnreachablePathReason>();
1164
+ const linkedSubtreeSet = new Set<string>();
1043
1165
  const baseFilter = options.personalMode === true
1044
1166
  ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
1045
1167
  : recordedIgnoreFilter;
@@ -1073,6 +1195,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1073
1195
  scopeExcludedSet,
1074
1196
  ignoreExcludedSet,
1075
1197
  ignoreExcludedTotal,
1198
+ unreachablePaths,
1199
+ linkedSubtreeSet,
1076
1200
  };
1077
1201
  }
1078
1202
 
@@ -1094,7 +1218,30 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1094
1218
  run.hqRoot,
1095
1219
  run.syncRoot,
1096
1220
  run.shouldSync,
1221
+ {
1222
+ onUnreachablePath: (namedPath, reason) => {
1223
+ // First reason wins: a path is named once, and re-adding would only
1224
+ // churn the map ordering the error message and event sample rely on.
1225
+ if (!run.unreachablePaths.has(namedPath)) {
1226
+ run.unreachablePaths.set(namedPath, reason);
1227
+ }
1228
+ },
1229
+ onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
1230
+ },
1097
1231
  );
1232
+ // Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
1233
+ // exists locally but is unreachable by the resolver". This is the throw that
1234
+ // makes it true end-to-end — the CLI's push handler already turns a thrown
1235
+ // error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
1236
+ // "✓ Pushed 0 file(s)" success can no longer happen for a file that is
1237
+ // sitting right there on disk. It fires HERE, before executeUploads, so a
1238
+ // failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
1239
+ // written, no delete propagated.
1240
+ const fatal = collectFatalUnreachablePaths(run);
1241
+ if (fatal.size > 0) {
1242
+ emitUnreachablePathEvent(run);
1243
+ throw new UnreachablePushPathsError(fatal, run.syncRoot);
1244
+ }
1098
1245
  // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1099
1246
  // root IS the company folder, so a local entry whose vault key starts with
1100
1247
  // `companies/` can only come from a stale doubled local tree
@@ -1480,8 +1627,7 @@ async function executeUploads(
1480
1627
  run.emit({
1481
1628
  type: "error",
1482
1629
  path: relativePath,
1483
- message:
1484
- retryErr instanceof Error ? retryErr.message : String(retryErr),
1630
+ message: describeError(retryErr),
1485
1631
  });
1486
1632
  }
1487
1633
  return;
@@ -1501,7 +1647,7 @@ async function executeUploads(
1501
1647
  run.emit({
1502
1648
  type: "error",
1503
1649
  path: relativePath,
1504
- message: err instanceof Error ? err.message : String(err),
1650
+ message: describeError(err),
1505
1651
  });
1506
1652
  }
1507
1653
  };
@@ -1572,9 +1718,7 @@ async function writePushConflictMirror(
1572
1718
  run.emit({
1573
1719
  type: "error",
1574
1720
  path: item.relativePath,
1575
- message:
1576
- "conflict mirror write failed: " +
1577
- (mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
1721
+ message: "conflict mirror write failed: " + describeError(mirrorErr),
1578
1722
  });
1579
1723
  }
1580
1724
  }
@@ -1654,7 +1798,7 @@ async function executeDeletes(
1654
1798
  run.emit({
1655
1799
  type: "error",
1656
1800
  path: relativePath,
1657
- message: err instanceof Error ? err.message : String(err),
1801
+ message: describeError(err),
1658
1802
  });
1659
1803
  pathResults.push({
1660
1804
  path: relativePath,
@@ -1665,6 +1809,51 @@ async function executeDeletes(
1665
1809
  }
1666
1810
  }
1667
1811
  for (const relativePath of deletePlan.toTombstone) {
1812
+ const localPath = localPathForVaultKey(run.syncRoot, relativePath);
1813
+ try {
1814
+ const lstat = fs.lstatSync(localPath);
1815
+ const entry = run.journal.files[relativePath];
1816
+ if (lstat.isFile()) {
1817
+ const localHash = hashFile(localPath);
1818
+ if (entry?.hash && entry.hash !== localHash) {
1819
+ run.emit({
1820
+ type: "error",
1821
+ path: relativePath,
1822
+ message:
1823
+ "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1824
+ });
1825
+ continue;
1826
+ }
1827
+ fs.unlinkSync(localPath);
1828
+ } else if (lstat.isSymbolicLink()) {
1829
+ const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
1830
+ if (entry?.hash && entry.hash !== localHash) {
1831
+ run.emit({
1832
+ type: "error",
1833
+ path: relativePath,
1834
+ message:
1835
+ "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1836
+ });
1837
+ continue;
1838
+ }
1839
+ fs.unlinkSync(localPath);
1840
+ }
1841
+ } catch (err: unknown) {
1842
+ const code =
1843
+ err && typeof err === "object" && "code" in err
1844
+ ? (err as { code?: string }).code
1845
+ : undefined;
1846
+ if (code !== "ENOENT") {
1847
+ run.emit({
1848
+ type: "error",
1849
+ path: relativePath,
1850
+ message: `tombstone unlink failed: ${
1851
+ err instanceof Error ? err.message : String(err)
1852
+ }`,
1853
+ });
1854
+ continue;
1855
+ }
1856
+ }
1668
1857
  removeEntry(run.journal, relativePath);
1669
1858
  counters.filesTombstoned++;
1670
1859
  run.emit({
@@ -1748,6 +1937,77 @@ function finalizeShareJournal(run: PushRunContext): void {
1748
1937
  samplePaths,
1749
1938
  });
1750
1939
  }
1940
+
1941
+ emitUnreachablePathEvent(run);
1942
+
1943
+ if (run.linkedSubtreeSet.size > 0) {
1944
+ run.emit({
1945
+ type: "not-shipped",
1946
+ reason: "linked-subtree",
1947
+ count: run.linkedSubtreeSet.size,
1948
+ samplePaths: sampleSet(run.linkedSubtreeSet),
1949
+ });
1950
+ }
1951
+ }
1952
+
1953
+ /**
1954
+ * Which unreachable named paths are FATAL under the run's policy.
1955
+ *
1956
+ * Under the default `"error"` policy only `"outside-company"` is fatal: the
1957
+ * entry is sitting on disk and the resolver refused to place it under the
1958
+ * company folder, which is precisely the "exists locally but is unreachable by
1959
+ * the resolver" case the report asks to turn into an error, and it cannot
1960
+ * happen for an internal walk root (a company folder is trivially inside
1961
+ * itself).
1962
+ *
1963
+ * `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
1964
+ * and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
1965
+ * one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
1966
+ * folder was never materialized locally; throwing there would turn "you haven't
1967
+ * pulled that company yet" into a hard failure of an unrelated multi-company
1968
+ * push. Same reasoning for a watcher path deleted between the event and the
1969
+ * push. Those callers get the loud report without the regression.
1970
+ *
1971
+ * `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
1972
+ * end to end (the background sync runner).
1973
+ */
1974
+ function collectFatalUnreachablePaths(
1975
+ run: PushRunContext,
1976
+ ): Map<string, UnreachablePathReason> {
1977
+ const fatal = new Map<string, UnreachablePathReason>();
1978
+ if (run.options.unreachablePathPolicy === "warn") return fatal;
1979
+ for (const [namedPath, reason] of run.unreachablePaths) {
1980
+ if (reason === "outside-company") fatal.set(namedPath, reason);
1981
+ }
1982
+ return fatal;
1983
+ }
1984
+
1985
+ /**
1986
+ * Emit the `not-shipped` / `unreachable-path` event for a run, if any named
1987
+ * path went unshipped. Shared by BOTH policies so the operator sees the same
1988
+ * report either way: the fail-fast path emits it immediately before throwing
1989
+ * (the journal finalizer never runs on a throw), and the `"warn"` path emits it
1990
+ * from the finalizer at the end of a successful run. Exactly one of those two
1991
+ * call sites can fire per run, so the event is never duplicated.
1992
+ */
1993
+ function emitUnreachablePathEvent(run: PushRunContext): void {
1994
+ if (run.unreachablePaths.size === 0) return;
1995
+ run.emit({
1996
+ type: "not-shipped",
1997
+ reason: "unreachable-path",
1998
+ count: run.unreachablePaths.size,
1999
+ samplePaths: sampleSet(run.unreachablePaths.keys()),
2000
+ });
2001
+ }
2002
+
2003
+ /** First up-to-`limit` members of an iterable, for bounded event payloads. */
2004
+ function sampleSet(set: Iterable<string>, limit = 10): string[] {
2005
+ const sample: string[] = [];
2006
+ for (const value of set) {
2007
+ sample.push(value);
2008
+ if (sample.length >= limit) break;
2009
+ }
2010
+ return sample;
1751
2011
  }
1752
2012
 
1753
2013
  function throwUploadWorkerErrors(workerErrors: Error[]): void {
@@ -1784,6 +2044,8 @@ function buildShareResult(
1784
2044
  filesExcludedByPolicy: run.excludedSet.size,
1785
2045
  filesExcludedByScope: run.scopeExcludedSet.size,
1786
2046
  filesExcludedByIgnore: run.ignoreExcludedSet.size,
2047
+ unreachablePaths: [...run.unreachablePaths.keys()],
2048
+ linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
1787
2049
  conflictPaths,
1788
2050
  pathResults,
1789
2051
  aborted,
@@ -1864,6 +2126,24 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1864
2126
  if (event.count > event.samplePaths.length) {
1865
2127
  console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1866
2128
  }
2129
+ } else if (event.type === "not-shipped") {
2130
+ // The other "not silent" surface: content the walk saw but chose not to
2131
+ // ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
2132
+ if (event.reason === "unreachable-path") {
2133
+ console.warn(
2134
+ ` ! ${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"}):`,
2135
+ );
2136
+ } else {
2137
+ console.warn(
2138
+ ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
2139
+ );
2140
+ }
2141
+ for (const p of event.samplePaths) {
2142
+ console.warn(` · ${p}`);
2143
+ }
2144
+ if (event.count > event.samplePaths.length) {
2145
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2146
+ }
1867
2147
  }
1868
2148
  }
1869
2149
 
@@ -1881,6 +2161,285 @@ type CollectedEntry =
1881
2161
  | { kind: "file"; absolutePath: string; relativePath: string }
1882
2162
  | { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
1883
2163
 
2164
+ /**
2165
+ * Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
2166
+ * They turn two previously-SILENT outcomes into surfaced signals — without
2167
+ * changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
2168
+ *
2169
+ * - `onUnreachablePath`: a path the caller EXPLICITLY named (never an internal
2170
+ * walk root — those are always the reachable company folder) that exists on
2171
+ * disk but the resolver could not place under the company folder
2172
+ * (`"outside-company"`), or could not resolve to an existing file under any
2173
+ * base (`"missing"`). Pre-fix this was a bare `console.error` warn-skip that
2174
+ * still let the push report "Pushed 0 file(s)" — a false success. The caller
2175
+ * can now turn a non-empty set into a real error / nonzero exit.
2176
+ *
2177
+ * SPELLING CONTRACT: `namedPath` is ALWAYS the caller's ORIGINAL token,
2178
+ * verbatim — never the resolved absolute path, and never normalized. A
2179
+ * relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
2180
+ * an absolute path is reported absolute because that is what was passed.
2181
+ * Echoing the caller's own spelling is what makes the error actionable
2182
+ * ("the thing you typed did not ship"), and it keeps `unreachablePaths`
2183
+ * comparable to the input array without a resolution step. Both call sites
2184
+ * below pass the loop variable `p` for exactly this reason.
2185
+ * - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
2186
+ * descended because its target resolves OUTSIDE the company folder (e.g.
2187
+ * `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
2188
+ * contents ship via their own repo, not the vault; reporting it stops files
2189
+ * created under such a link from vanishing from every push bucket silently.
2190
+ */
2191
+ interface CollectHooks {
2192
+ onUnreachablePath?: (namedPath: string, reason: "missing" | "outside-company") => void;
2193
+ onLinkedSubtree?: (rel: string) => void;
2194
+ }
2195
+
2196
+ /**
2197
+ * Resolve a caller-supplied push path to an absolute path.
2198
+ *
2199
+ * Relative paths were historically resolved against `hqRoot` ONLY, so a
2200
+ * company-relative spelling like `knowledge/agents/x.md` became
2201
+ * `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
2202
+ * caller's cwd or the company being pushed (feedback_258e4a86 /
2203
+ * feedback_a51cb63d — "there is no path spelling that reaches the file").
2204
+ *
2205
+ * PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
2206
+ * (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
2207
+ * ADDITIVE to the legacy behavior: every relative spelling that resolved
2208
+ * pre-fix still resolves to exactly the same file, and the two new bases only
2209
+ * catch spellings that previously resolved to nothing. Probing cwd first would
2210
+ * silently re-point existing callers (a `knowledge/` directory in the shell's
2211
+ * cwd would win over the hq-root one), which is a behavior change no reporter
2212
+ * asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
2213
+ * the unchanged "does not exist" diagnostic. Absolute paths are returned
2214
+ * verbatim.
2215
+ *
2216
+ * `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
2217
+ * rather than an ambient read, so resolution is deterministic and testable
2218
+ * without mutating process state.
2219
+ */
2220
+ function resolveNamedPath(
2221
+ p: string,
2222
+ hqRoot: string,
2223
+ syncRoot: string,
2224
+ cwd: string = process.cwd(),
2225
+ ): string {
2226
+ if (path.isAbsolute(p)) return p;
2227
+ const hqRootCandidate = path.resolve(hqRoot, p);
2228
+ const candidates = [
2229
+ hqRootCandidate,
2230
+ path.resolve(syncRoot, p),
2231
+ path.resolve(cwd, p),
2232
+ ];
2233
+ for (const candidate of candidates) {
2234
+ try {
2235
+ fs.lstatSync(candidate);
2236
+ // An hqRoot hit outside the company folder would be rejected by
2237
+ // collectFiles as outside-company; skip it so a valid company-relative
2238
+ // spelling can win (Codex P2 — common `knowledge/` homonym case).
2239
+ if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
2240
+ continue;
2241
+ }
2242
+ return candidate;
2243
+ } catch {
2244
+ // Base did not resolve to an on-disk entry — try the next one.
2245
+ }
2246
+ }
2247
+ return hqRootCandidate;
2248
+ }
2249
+
2250
+ /**
2251
+ * Containment check for a regular file or directory that tolerates a symlinked
2252
+ * ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
2253
+ * path reached through a symlinked ancestor (`companies/{co}/knowledge` →
2254
+ * `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
2255
+ * rejected as "outside company folder" — even though its logical path is
2256
+ * in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
2257
+ * company-namespaced key for it. Accept when the LEXICAL path is inside
2258
+ * (honoring the same logical topology the vault key uses) OR the realpath is
2259
+ * inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
2260
+ *
2261
+ * The lexical arm carries TWO bounds, because it is the only place where a
2262
+ * path's bytes and its vault key come from different trees:
2263
+ *
2264
+ * 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
2265
+ * symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
2266
+ * state under a company-namespaced key.
2267
+ * 2. The TENANT — the realpath must not land inside another company's bytes
2268
+ * (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
2269
+ * must never be mistaken for a tenant boundary: hqRoot CONTAINS every
2270
+ * other company, so `companies/acme/knowledge → companies/other/secret`
2271
+ * (or → `repos/private/knowledge-other`, the linked-repo topology) is
2272
+ * lexically inside acme and really inside HQ, and would upload the other
2273
+ * tenant's bytes into acme's bucket under the key `knowledge/…`.
2274
+ *
2275
+ * The motivating topology (`companies/{co}/knowledge` →
2276
+ * `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
2277
+ * that escapes HQ, or one that reaches another tenant, is refused — and under
2278
+ * the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
2279
+ *
2280
+ * Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
2281
+ * externally-linked subtree can only be recognized while that company's folder
2282
+ * is materialized locally and publishes the link. A machine holding
2283
+ * `repos/private/knowledge-other` with no `companies/other` folder has no
2284
+ * on-disk evidence of the claim, so a link into it is indistinguishable from a
2285
+ * link into any other local repo. Ownership metadata (not path shape) is what
2286
+ * would close that gap.
2287
+ */
2288
+ function isWithinLexicalOrReal(
2289
+ parent: string,
2290
+ child: string,
2291
+ hqRoot: string,
2292
+ tenantRootsCache?: Map<string, string[]>,
2293
+ ): boolean {
2294
+ // Strict arm first: the realpath is genuinely inside the company folder.
2295
+ // This is the overwhelmingly common case, needs no relaxation, and costs no
2296
+ // directory scan.
2297
+ if (isWithin(parent, child)) return true;
2298
+
2299
+ const resolvedChild = path.resolve(child);
2300
+ if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
2301
+
2302
+ const childReal = realpathSafe(resolvedChild);
2303
+ if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
2304
+
2305
+ // NUL-joined: it is the one byte a path cannot contain, so no pair of
2306
+ // (hqRoot, parent) values can collide on the key.
2307
+ const cacheKey = `${hqRoot}\u0000${parent}`;
2308
+ let foreignRoots = tenantRootsCache?.get(cacheKey);
2309
+ if (foreignRoots === undefined) {
2310
+ foreignRoots = foreignTenantRoots(hqRoot, parent);
2311
+ tenantRootsCache?.set(cacheKey, foreignRoots);
2312
+ }
2313
+ for (const foreign of foreignRoots) {
2314
+ if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
2315
+ }
2316
+ return true;
2317
+ }
2318
+
2319
+ /**
2320
+ * Depth (in path segments below a company root) at which we look for the
2321
+ * directory symlinks a company publishes into its own folder. HQ's linked
2322
+ * topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
2323
+ * (`companies/{co}/repos/{name}`); going deeper would turn a containment check
2324
+ * into a full-tree walk for no additional coverage.
2325
+ */
2326
+ const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
2327
+
2328
+ /**
2329
+ * Canonicalized roots that belong to a tenant OTHER than the one being pushed.
2330
+ * A lexically-contained path whose realpath lands inside any of these is
2331
+ * another company's data wearing this company's key, and must be refused.
2332
+ *
2333
+ * Two kinds of root are collected per foreign company:
2334
+ * - the company folder itself (`companies/{other}`), and
2335
+ * - the targets of the directory symlinks that folder publishes — HQ's
2336
+ * pattern-2 topology puts a company's knowledge in `repos/private/
2337
+ * knowledge-{other}` and links it in, so the bytes live OUTSIDE every
2338
+ * `companies/` root and a companies-only check would miss them entirely.
2339
+ *
2340
+ * Only consulted on the rare lexical arm (a path whose realpath is not inside
2341
+ * the company folder), never on the ordinary in-tree push, so the directory
2342
+ * scan is not on the hot path. Callers may memoize it for the duration of a
2343
+ * single collect pass; nothing memoizes it for longer, because the sync runner
2344
+ * is long-lived and a stale tenant map fails OPEN — the wrong direction for a
2345
+ * boundary whose whole job is to refuse.
2346
+ */
2347
+ function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
2348
+ const companiesDir = path.join(hqRoot, "companies");
2349
+ let entries: fs.Dirent[];
2350
+ try {
2351
+ entries = fs.readdirSync(companiesDir, { withFileTypes: true });
2352
+ } catch {
2353
+ return []; // no companies/ tree here — nothing to be foreign to
2354
+ }
2355
+
2356
+ const activeReal = realpathSafe(syncRoot);
2357
+ const roots: string[] = [];
2358
+ for (const entry of entries) {
2359
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
2360
+ const companyRoot = path.join(companiesDir, entry.name);
2361
+ const companyReal = realpathSafe(companyRoot);
2362
+ if (companyReal === activeReal) continue; // this is the tenant being pushed
2363
+ roots.push(companyReal);
2364
+ collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
2365
+ }
2366
+ return roots;
2367
+ }
2368
+
2369
+ /**
2370
+ * Append the resolved targets of the directory symlinks published under
2371
+ * `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
2372
+ * inside the company folder are skipped — they add no reach beyond the root
2373
+ * already recorded. Errors are swallowed per entry on purpose: an unreadable
2374
+ * sibling directory must narrow what we can prove, never abort the push it is
2375
+ * unrelated to.
2376
+ */
2377
+ function collectPublishedLinkTargets(
2378
+ dir: string,
2379
+ companyReal: string,
2380
+ depth: number,
2381
+ out: string[],
2382
+ ): void {
2383
+ if (depth <= 0) return;
2384
+ let entries: fs.Dirent[];
2385
+ try {
2386
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2387
+ } catch {
2388
+ return;
2389
+ }
2390
+ for (const entry of entries) {
2391
+ const child = path.join(dir, entry.name);
2392
+ if (entry.isSymbolicLink()) {
2393
+ let real: string;
2394
+ try {
2395
+ real = fs.realpathSync.native(child);
2396
+ } catch {
2397
+ continue; // dangling link claims nothing
2398
+ }
2399
+ try {
2400
+ if (!fs.statSync(real).isDirectory()) continue;
2401
+ } catch {
2402
+ continue;
2403
+ }
2404
+ if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
2405
+ out.push(real);
2406
+ } else if (entry.isDirectory()) {
2407
+ collectPublishedLinkTargets(child, companyReal, depth - 1, out);
2408
+ }
2409
+ }
2410
+ }
2411
+
2412
+ /**
2413
+ * If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
2414
+ * `onLinkedSubtree` so the caller can report that the link's contents were not
2415
+ * uploaded to the vault. Fires only for links that (a) resolve to a directory
2416
+ * and (b) point outside the company folder — an in-tree link is descended
2417
+ * elsewhere, and a dangling or file link has nothing behind it to report.
2418
+ */
2419
+ function reportLinkedSubtreeIfExternal(
2420
+ linkPath: string,
2421
+ syncRoot: string,
2422
+ relativePath: string,
2423
+ hooks: CollectHooks,
2424
+ ): void {
2425
+ if (!hooks.onLinkedSubtree) return;
2426
+ let real: string;
2427
+ try {
2428
+ real = fs.realpathSync.native(linkPath); // resolves the link to its target
2429
+ } catch {
2430
+ return; // dangling link — nothing behind it to report
2431
+ }
2432
+ let targetStat: fs.Stats;
2433
+ try {
2434
+ targetStat = fs.statSync(real);
2435
+ } catch {
2436
+ return;
2437
+ }
2438
+ if (!targetStat.isDirectory()) return;
2439
+ if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
2440
+ hooks.onLinkedSubtree(relativePath);
2441
+ }
2442
+
1884
2443
  /**
1885
2444
  * Collect files from paths (expanding directories recursively).
1886
2445
  *
@@ -1898,11 +2457,17 @@ function collectFiles(
1898
2457
  hqRoot: string,
1899
2458
  syncRoot: string,
1900
2459
  filter: (p: string, isDir?: boolean) => boolean,
2460
+ hooks: CollectHooks = {},
1901
2461
  ): CollectedEntry[] {
1902
2462
  const results: CollectedEntry[] = [];
2463
+ // Scoped to THIS collect pass and discarded with it: a watcher batch can
2464
+ // name hundreds of paths, and rescanning the companies/ tree for each one
2465
+ // is wasted I/O. A cache that outlived the pass could fail open on a tenant
2466
+ // boundary, so it deliberately does not.
2467
+ const tenantRoots = new Map<string, string[]>();
1903
2468
 
1904
2469
  for (const p of paths) {
1905
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
2470
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
1906
2471
 
1907
2472
  // Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
1908
2473
  // Caller may pass one explicitly; we still refuse to upload it. Basename
@@ -1918,6 +2483,7 @@ function collectFiles(
1918
2483
  lstat = fs.lstatSync(absolutePath);
1919
2484
  } catch {
1920
2485
  console.error(` Warning: ${p} does not exist, skipping.`);
2486
+ hooks.onUnreachablePath?.(p, "missing");
1921
2487
  continue;
1922
2488
  }
1923
2489
 
@@ -1936,6 +2502,7 @@ function collectFiles(
1936
2502
  if (lstat.isSymbolicLink()) {
1937
2503
  if (!isWithinForLink(syncRoot, absolutePath)) {
1938
2504
  console.error(` Warning: ${p} is outside company folder, skipping.`);
2505
+ hooks.onUnreachablePath?.(p, "outside-company");
1939
2506
  continue;
1940
2507
  }
1941
2508
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
@@ -1950,6 +2517,11 @@ function collectFiles(
1950
2517
  // whole branch). The filter is pure path lookup with no I/O,
1951
2518
  // so two calls are free.
1952
2519
  if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
2520
+ // A directory symlink whose target lives outside the company folder is
2521
+ // recorded here but never descended — its contents ship via their own
2522
+ // repo, not the vault. Surface it so files created under such a link
2523
+ // don't vanish from every push bucket silently.
2524
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
1953
2525
  results.push({
1954
2526
  kind: "symlink",
1955
2527
  absolutePath,
@@ -1959,14 +2531,15 @@ function collectFiles(
1959
2531
  continue;
1960
2532
  }
1961
2533
 
1962
- if (!isWithin(syncRoot, absolutePath)) {
2534
+ if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
1963
2535
  console.error(` Warning: ${p} is outside company folder, skipping.`);
2536
+ hooks.onUnreachablePath?.(p, "outside-company");
1964
2537
  continue;
1965
2538
  }
1966
2539
 
1967
2540
  if (lstat.isDirectory()) {
1968
2541
  if (!filter(absolutePath, true)) continue;
1969
- walkDir(absolutePath, syncRoot, filter, results);
2542
+ walkDir(absolutePath, syncRoot, filter, results, hooks);
1970
2543
  } else if (lstat.isFile()) {
1971
2544
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1972
2545
  if (filter(absolutePath)) {
@@ -1983,6 +2556,7 @@ function walkDir(
1983
2556
  syncRoot: string,
1984
2557
  filter: (p: string, isDir?: boolean) => boolean,
1985
2558
  results: CollectedEntry[],
2559
+ hooks: CollectHooks = {},
1986
2560
  ): void {
1987
2561
  // A frame per open directory preserves the recursive walk's depth-first
1988
2562
  // ordering without turning a completed subtree into one giant call argument
@@ -2032,10 +2606,15 @@ function walkDir(
2032
2606
  // fail under normal conditions; let the throw propagate if it
2033
2607
  // somehow does (race with rm, EPERM) — the operator needs to
2034
2608
  // see it rather than us silently dropping the link again.
2609
+ const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
2610
+ // The link is recorded but its (external) target is not descended, so
2611
+ // any files under it are NOT uploaded. Report the subtree so a full
2612
+ // `sync now` no longer drops it from every bucket without a trace.
2613
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
2035
2614
  results.push({
2036
2615
  kind: "symlink",
2037
2616
  absolutePath,
2038
- relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
2617
+ relativePath: linkRelative,
2039
2618
  target: fs.readlinkSync(absolutePath),
2040
2619
  });
2041
2620
  continue;
@@ -2155,6 +2734,23 @@ function isWithinForLink(parent: string, linkPath: string): boolean {
2155
2734
  *
2156
2735
  * Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
2157
2736
  * itself; this is the bidirectional-runner case.
2737
+ *
2738
+ * Path spellings are resolved with `resolveNamedPath`, the same resolver the
2739
+ * upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
2740
+ * like its absolute equivalent instead of silently resolving nowhere and
2741
+ * scoping nothing.
2742
+ *
2743
+ * Containment, however, deliberately stays on strict realpath `isWithin` and
2744
+ * does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
2745
+ * legs are asymmetric on purpose: the upload leg ships the files it was handed,
2746
+ * while a delete scope is a PREFIX that authorizes removing every remote object
2747
+ * beneath it. A linked subtree's contents are never walked (`walkDir` does not
2748
+ * descend external directory symlinks), so anchoring a delete scope on one
2749
+ * would compare an empty local walk against a populated remote prefix and sweep
2750
+ * the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
2751
+ * accepted cost is that deletes inside a linked subtree are not propagated,
2752
+ * which matches the snapshot semantics `hq sync push` already documents for
2753
+ * those paths.
2158
2754
  */
2159
2755
  function resolveDeleteScopeRoots(
2160
2756
  paths: string[],
@@ -2181,7 +2777,7 @@ function resolveDeleteScopeRoots(
2181
2777
  prefixes.add(normalized);
2182
2778
  }
2183
2779
  for (const p of paths) {
2184
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
2780
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
2185
2781
  if (!fs.existsSync(absolutePath)) continue;
2186
2782
  if (!isWithin(syncRoot, absolutePath)) continue;
2187
2783
  const stat = fs.statSync(absolutePath);
@@ -2480,6 +3076,22 @@ async function computeDeletePlan(
2480
3076
  if (!inScope) continue;
2481
3077
  inScopeJournalEntries++;
2482
3078
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
3079
+
3080
+ // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
3081
+ // context, a journal entry at a literal `companies/…` key records a
3082
+ // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
3083
+ // presign transport is rejected by the server validator
3084
+ // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
3085
+ // straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
3086
+ // remote call). Personal-vault pushes (personalMode) carry legitimate
3087
+ // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
3088
+ // Checked before the presentLocally gate: the poison file lives at the
3089
+ // doubled path and must drain even while still on disk.
3090
+ if (companyScoped && relativeKey.startsWith("companies/")) {
3091
+ plan.toTombstone.push(relativeKey);
3092
+ continue;
3093
+ }
3094
+
2483
3095
  let presentLocally = true;
2484
3096
  try {
2485
3097
  fs.lstatSync(localPath);
@@ -2516,20 +3128,6 @@ async function computeDeletePlan(
2516
3128
  continue;
2517
3129
  }
2518
3130
 
2519
- // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
2520
- // context, a journal entry at a literal `companies/…` key records a
2521
- // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
2522
- // presign transport is rejected by the server validator
2523
- // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
2524
- // straight to `toTombstone` (journal drop, no remote call) — the local
2525
- // journal entry drains; server-side cleanup of any poisoned object is an
2526
- // operator action. Personal-vault pushes (personalMode) carry legitimate
2527
- // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
2528
- if (companyScoped && relativeKey.startsWith("companies/")) {
2529
- plan.toTombstone.push(relativeKey);
2530
- continue;
2531
- }
2532
-
2533
3131
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
2534
3132
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
2535
3133
  // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op