@indigoai-us/hq-cloud 6.14.37 → 6.14.40

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 (44) hide show
  1. package/dist/bin/sync-runner.test.js +44 -0
  2. package/dist/bin/sync-runner.test.js.map +1 -1
  3. package/dist/cli/reindex.d.ts.map +1 -1
  4. package/dist/cli/reindex.js +1 -9
  5. package/dist/cli/reindex.js.map +1 -1
  6. package/dist/cli/share.d.ts +13 -14
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +61 -16
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +141 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +8 -5
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +68 -10
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +154 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/lib/readlink-safe.d.ts +11 -0
  19. package/dist/lib/readlink-safe.d.ts.map +1 -0
  20. package/dist/lib/readlink-safe.js +27 -0
  21. package/dist/lib/readlink-safe.js.map +1 -0
  22. package/dist/lib/readlink-safe.test.d.ts +2 -0
  23. package/dist/lib/readlink-safe.test.d.ts.map +1 -0
  24. package/dist/lib/readlink-safe.test.js +34 -0
  25. package/dist/lib/readlink-safe.test.js.map +1 -0
  26. package/dist/skill-telemetry.d.ts +21 -5
  27. package/dist/skill-telemetry.d.ts.map +1 -1
  28. package/dist/skill-telemetry.js +238 -31
  29. package/dist/skill-telemetry.js.map +1 -1
  30. package/dist/skill-telemetry.test.js +235 -1
  31. package/dist/skill-telemetry.test.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/bin/sync-runner.test.ts +52 -0
  34. package/src/cli/reindex.ts +1 -9
  35. package/src/cli/share.test.ts +180 -0
  36. package/src/cli/share.ts +75 -30
  37. package/src/cli/sync.test.ts +167 -0
  38. package/src/cli/sync.ts +78 -15
  39. package/src/lib/readlink-safe.test.ts +43 -0
  40. package/src/lib/readlink-safe.ts +29 -0
  41. package/src/skill-telemetry.test.ts +286 -0
  42. package/src/skill-telemetry.ts +297 -31
  43. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +124 -0
  44. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/share.ts CHANGED
@@ -72,6 +72,7 @@ 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
74
  import { describeError } from "../lib/describe-error.js";
75
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
75
76
 
76
77
  /**
77
78
  * Push-side fresh-collision convergence probe.
@@ -725,7 +726,7 @@ export interface ShareOptions {
725
726
  }
726
727
 
727
728
  /** Why an explicitly-named push path could not be shipped. */
728
- export type UnreachablePathReason = "missing" | "outside-company";
729
+ export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
729
730
 
730
731
  /**
731
732
  * Thrown by `share()` when a caller-named path cannot be pushed and
@@ -743,9 +744,13 @@ export class UnreachablePushPathsError extends Error {
743
744
  const paths = [...unreachable.keys()];
744
745
  const lines = paths.map((p) => {
745
746
  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`;
747
+ if (reason === "outside-company") {
748
+ return ` · ${p} — resolves outside the company folder (${syncRoot})`;
749
+ }
750
+ if (reason === "unreadable-link") {
751
+ return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
752
+ }
753
+ return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
749
754
  });
750
755
  super(
751
756
  `${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
@@ -1826,7 +1831,17 @@ async function executeDeletes(
1826
1831
  }
1827
1832
  fs.unlinkSync(localPath);
1828
1833
  } else if (lstat.isSymbolicLink()) {
1829
- const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
1834
+ const target = readlinkOrNull(localPath);
1835
+ if (target === null) {
1836
+ run.emit({
1837
+ type: "not-shipped",
1838
+ reason: "unreadable-link",
1839
+ count: 1,
1840
+ samplePaths: [relativePath],
1841
+ });
1842
+ continue;
1843
+ }
1844
+ const localHash = hashSymlinkTarget(target);
1830
1845
  if (entry?.hash && entry.hash !== localHash) {
1831
1846
  run.emit({
1832
1847
  type: "error",
@@ -1992,12 +2007,27 @@ function collectFatalUnreachablePaths(
1992
2007
  */
1993
2008
  function emitUnreachablePathEvent(run: PushRunContext): void {
1994
2009
  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
- });
2010
+ const unreadableLinks: string[] = [];
2011
+ const unreachablePaths: string[] = [];
2012
+ for (const [namedPath, reason] of run.unreachablePaths) {
2013
+ (reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
2014
+ }
2015
+ if (unreachablePaths.length > 0) {
2016
+ run.emit({
2017
+ type: "not-shipped",
2018
+ reason: "unreachable-path",
2019
+ count: unreachablePaths.length,
2020
+ samplePaths: unreachablePaths.slice(0, 10),
2021
+ });
2022
+ }
2023
+ if (unreadableLinks.length > 0) {
2024
+ run.emit({
2025
+ type: "not-shipped",
2026
+ reason: "unreadable-link",
2027
+ count: unreadableLinks.length,
2028
+ samplePaths: unreadableLinks.slice(0, 10),
2029
+ });
2030
+ }
2001
2031
  }
2002
2032
 
2003
2033
  /** First up-to-`limit` members of an iterable, for bounded event payloads. */
@@ -2133,6 +2163,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2133
2163
  console.warn(
2134
2164
  ` ! ${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
2165
  );
2166
+ } else if (event.reason === "unreadable-link") {
2167
+ console.warn(
2168
+ ` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
2169
+ );
2136
2170
  } else {
2137
2171
  console.warn(
2138
2172
  ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
@@ -2166,22 +2200,21 @@ type CollectedEntry =
2166
2200
  * They turn two previously-SILENT outcomes into surfaced signals — without
2167
2201
  * changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
2168
2202
  *
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.
2203
+ * - `onUnreachablePath`: a path that cannot be shipped. It is normally the
2204
+ * caller's explicit spelling (`"outside-company"` or `"missing"`), and is
2205
+ * the company-relative key for an unreadable link discovered during a
2206
+ * directory walk (`"unreadable-link"`). Pre-fix this was a bare
2207
+ * `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
2208
+ * — a false success. The caller can now turn a non-empty set into a real
2209
+ * error / nonzero exit.
2176
2210
  *
2177
- * SPELLING CONTRACT: `namedPath` is ALWAYS the caller's ORIGINAL token,
2211
+ * SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
2178
2212
  * verbatim — never the resolved absolute path, and never normalized. A
2179
2213
  * relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
2180
2214
  * 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.
2215
+ * An unreadable link discovered during a recursive walk instead uses its
2216
+ * company-relative vault key, because it has no separate caller token.
2217
+ * This keeps UI output actionable without exposing host-specific paths.
2185
2218
  * - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
2186
2219
  * descended because its target resolves OUTSIDE the company folder (e.g.
2187
2220
  * `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
@@ -2189,7 +2222,7 @@ type CollectedEntry =
2189
2222
  * created under such a link from vanishing from every push bucket silently.
2190
2223
  */
2191
2224
  interface CollectHooks {
2192
- onUnreachablePath?: (namedPath: string, reason: "missing" | "outside-company") => void;
2225
+ onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
2193
2226
  onLinkedSubtree?: (rel: string) => void;
2194
2227
  }
2195
2228
 
@@ -2517,6 +2550,12 @@ function collectFiles(
2517
2550
  // whole branch). The filter is pure path lookup with no I/O,
2518
2551
  // so two calls are free.
2519
2552
  if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
2553
+ const target = readlinkOrNull(absolutePath);
2554
+ if (target === null) {
2555
+ console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
2556
+ hooks.onUnreachablePath?.(p, "unreadable-link");
2557
+ continue;
2558
+ }
2520
2559
  // A directory symlink whose target lives outside the company folder is
2521
2560
  // recorded here but never descended — its contents ship via their own
2522
2561
  // repo, not the vault. Surface it so files created under such a link
@@ -2526,7 +2565,7 @@ function collectFiles(
2526
2565
  kind: "symlink",
2527
2566
  absolutePath,
2528
2567
  relativePath,
2529
- target: fs.readlinkSync(absolutePath),
2568
+ target,
2530
2569
  });
2531
2570
  continue;
2532
2571
  }
@@ -2602,11 +2641,17 @@ function walkDir(
2602
2641
  // private/knowledge-{co}/), causing per-company knowledge repos
2603
2642
  // to be uploaded into every vault that links them. Recording
2604
2643
  // and not following preserves the link topology while avoiding
2605
- // that duplication. readlinkSync on a Dirent-known link cannot
2606
- // fail under normal conditions; let the throw propagate if it
2607
- // somehow does (race with rm, EPERM) — the operator needs to
2608
- // see it rather than us silently dropping the link again.
2609
2644
  const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
2645
+ // On win32, a Dirent-known link is not sufficient proof that readlink
2646
+ // will succeed (notably for some reparse points). Never fall through to
2647
+ // normal file/directory handling here: doing so would dereference the
2648
+ // link and duplicate its target under this vault key.
2649
+ const target = readlinkOrNull(absolutePath);
2650
+ if (target === null) {
2651
+ console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
2652
+ hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
2653
+ continue;
2654
+ }
2610
2655
  // The link is recorded but its (external) target is not descended, so
2611
2656
  // any files under it are NOT uploaded. Report the subtree so a full
2612
2657
  // `sync now` no longer drops it from every bucket without a trace.
@@ -2615,7 +2660,7 @@ function walkDir(
2615
2660
  kind: "symlink",
2616
2661
  absolutePath,
2617
2662
  relativePath: linkRelative,
2618
- target: fs.readlinkSync(absolutePath),
2663
+ target,
2619
2664
  });
2620
2665
  continue;
2621
2666
  }
@@ -10,6 +10,13 @@ import { clearContextCache } from "../context.js";
10
10
  import type { VaultServiceConfig } from "../types.js";
11
11
  import { lockPathFor } from "../operation-lock.js";
12
12
 
13
+ // Re-export node:fs as a mutable module so unreadable-link regressions can
14
+ // inject a win32 EINVAL without needing a real Windows reparse point.
15
+ vi.mock("fs", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("fs")>();
17
+ return { ...actual };
18
+ });
19
+
13
20
  // Mock s3 module at the top level
14
21
  vi.mock("../s3.js", async (importOriginal) => {
15
22
  const actual = await importOriginal<typeof import("../s3.js")>();
@@ -67,6 +74,12 @@ const mockConfig: VaultServiceConfig = {
67
74
  region: "us-east-1",
68
75
  };
69
76
 
77
+ function errnoError(code: string): NodeJS.ErrnoException {
78
+ const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
79
+ err.code = code;
80
+ return err;
81
+ }
82
+
70
83
  const mockEntity = {
71
84
  uid: "cmp_01ABCDEF",
72
85
  slug: "acme",
@@ -383,6 +396,52 @@ describe("sync", () => {
383
396
  expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
384
397
  });
385
398
 
399
+ it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
400
+ const linkKey = "policies/downloaded-unreadable-link";
401
+ const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
402
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
403
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
404
+ ]);
405
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
406
+ async (_ctx: unknown, _key: string, localPath: string) => {
407
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
408
+ fs.symlinkSync("missing-target.md", localPath);
409
+ return { metadata: {} };
410
+ },
411
+ );
412
+ const realReadlinkSync = fs.readlinkSync;
413
+ const readlinkSpy = vi
414
+ .spyOn(fs, "readlinkSync")
415
+ .mockImplementation(((candidate: fs.PathLike) => {
416
+ if (candidate === linkPath) throw errnoError("EINVAL");
417
+ return realReadlinkSync(candidate);
418
+ }) as typeof fs.readlinkSync);
419
+ const events: SyncProgressEvent[] = [];
420
+
421
+ try {
422
+ const result = await sync({
423
+ company: "acme",
424
+ vaultConfig: mockConfig,
425
+ hqRoot: tmpDir,
426
+ onEvent: (event) => events.push(event),
427
+ });
428
+
429
+ expect(result.filesDownloaded).toBe(0);
430
+ expect(result.filesSkipped).toBe(1);
431
+ expect(events).toContainEqual({
432
+ type: "not-shipped",
433
+ reason: "unreadable-link",
434
+ count: 1,
435
+ samplePaths: [linkKey],
436
+ });
437
+ expect(events.some((event) => event.type === "error")).toBe(false);
438
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
439
+ expect(journal.files[linkKey]).toBeUndefined();
440
+ } finally {
441
+ readlinkSpy.mockRestore();
442
+ }
443
+ });
444
+
386
445
  it("emits a conflict event with path + resolution on hash mismatch", async () => {
387
446
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
388
447
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -505,6 +564,73 @@ describe("sync", () => {
505
564
  expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
506
565
  });
507
566
 
567
+ it("defers a conflict whose downloaded link target cannot be read", async () => {
568
+ const linkKey = "docs/handoff.md";
569
+ const localPath = path.join(tmpDir, "companies", "acme", linkKey);
570
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
571
+ fs.writeFileSync(localPath, "local version");
572
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
573
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
574
+ ]);
575
+ let mirrorPath = "";
576
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
577
+ async (_ctx: unknown, _key: string, destination: string) => {
578
+ mirrorPath = destination;
579
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
580
+ fs.symlinkSync("missing-target.md", destination);
581
+ return { metadata: {} };
582
+ },
583
+ );
584
+ fs.writeFileSync(
585
+ journalPath,
586
+ JSON.stringify({
587
+ version: "1",
588
+ lastSync: new Date().toISOString(),
589
+ files: {
590
+ [linkKey]: {
591
+ hash: "stale-hash",
592
+ size: 20,
593
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
594
+ direction: "down",
595
+ },
596
+ },
597
+ }),
598
+ );
599
+ const realReadlinkSync = fs.readlinkSync;
600
+ const readlinkSpy = vi
601
+ .spyOn(fs, "readlinkSync")
602
+ .mockImplementation(((candidate: fs.PathLike) => {
603
+ if (candidate === mirrorPath) throw errnoError("EINVAL");
604
+ return realReadlinkSync(candidate);
605
+ }) as typeof fs.readlinkSync);
606
+ const events: SyncProgressEvent[] = [];
607
+
608
+ try {
609
+ const result = await sync({
610
+ company: "acme",
611
+ onConflict: "keep",
612
+ vaultConfig: mockConfig,
613
+ hqRoot: tmpDir,
614
+ onEvent: (event) => events.push(event),
615
+ });
616
+
617
+ expect(result.conflicts).toBe(0);
618
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
619
+ expect(events).toContainEqual({
620
+ type: "not-shipped",
621
+ reason: "unreadable-link",
622
+ count: 1,
623
+ samplePaths: [linkKey],
624
+ });
625
+ expect(events.some((event) => event.type === "error")).toBe(false);
626
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
627
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
628
+ expect(journal.files[linkKey].hash).toBe("stale-hash");
629
+ } finally {
630
+ readlinkSpy.mockRestore();
631
+ }
632
+ });
633
+
508
634
  it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
509
635
  // Guard the other side of the convergence branch: when the probe bytes
510
636
  // differ from local, it remains a real conflict — counted, kept, and the
@@ -4131,6 +4257,47 @@ describe("sync", () => {
4131
4257
  expect(result.conflicts).toBe(0);
4132
4258
  });
4133
4259
 
4260
+ it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
4261
+ const linkKey = "policies/unreadable-link";
4262
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4263
+ const linkPath = path.join(companyRoot, linkKey);
4264
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
4265
+ fs.symlinkSync("target.md", linkPath);
4266
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4267
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
4268
+ ]);
4269
+
4270
+ const realReadlinkSync = fs.readlinkSync;
4271
+ const readlinkSpy = vi
4272
+ .spyOn(fs, "readlinkSync")
4273
+ .mockImplementation(((candidate: fs.PathLike) => {
4274
+ if (candidate === linkPath) throw errnoError("EINVAL");
4275
+ return realReadlinkSync(candidate);
4276
+ }) as typeof fs.readlinkSync);
4277
+
4278
+ try {
4279
+ const events: SyncProgressEvent[] = [];
4280
+ const result = await sync({
4281
+ company: "acme",
4282
+ vaultConfig: mockConfig,
4283
+ hqRoot: tmpDir,
4284
+ onEvent: (event) => events.push(event),
4285
+ });
4286
+
4287
+ expect(result.filesDownloaded).toBe(0);
4288
+ expect(result.filesSkipped).toBe(1);
4289
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
4290
+ expect(events).toContainEqual({
4291
+ type: "not-shipped",
4292
+ reason: "unreadable-link",
4293
+ count: 1,
4294
+ samplePaths: [linkKey],
4295
+ });
4296
+ } finally {
4297
+ readlinkSpy.mockRestore();
4298
+ }
4299
+ });
4300
+
4134
4301
  it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
4135
4302
  // Codex round-11 P2 follow-up: pre-fix, the conflict executor
4136
4303
  // built the resolveConflict prompt with `fs.statSync(localPath).
package/src/cli/sync.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  SyncJournal,
14
14
  } from "../types.js";
15
15
  import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
16
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
16
17
  import {
17
18
  emitCloudTelemetry,
18
19
  type TelemetryClaims,
@@ -436,11 +437,11 @@ export type SyncProgressEvent =
436
437
  }
437
438
  | {
438
439
  /**
439
- * Emitted at most ONCE per `reason` per `share()` push leg when local
440
- * content the walk saw was deliberately NOT uploaded and would otherwise
441
- * vanish from every push bucket without a trace (feedback_258e4a86 /
440
+ * Emitted when local content is deliberately not transferred and would
441
+ * otherwise vanish from sync output without a trace (feedback_258e4a86 /
442
442
  * feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
443
- * named nothing that was skipped). `reason` distinguishes the cause:
443
+ * named nothing that was skipped). Push batches by reason; pull emits an
444
+ * unreadable link by its remote key. `reason` distinguishes the cause:
444
445
  *
445
446
  * - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
446
447
  * disk but the resolver could not place it under the company folder,
@@ -450,12 +451,15 @@ export type SyncProgressEvent =
450
451
  * the company folder was recorded as a link but its contents were not
451
452
  * descended/uploaded. They sync via their own repo, not the vault;
452
453
  * this is informational, not an error.
454
+ * - `"unreadable-link"`: the OS identified a local symbolic link but
455
+ * did not expose a readable target. The link is skipped rather than
456
+ * dereferenced, and the company leg remains complete.
453
457
  *
454
458
  * `count` is the number of distinct paths for that reason; `samplePaths`
455
459
  * carries up to 10 for display. Not emitted when `count === 0`.
456
460
  */
457
461
  type: "not-shipped";
458
- reason: "unreachable-path" | "linked-subtree";
462
+ reason: "unreachable-path" | "unreadable-link" | "linked-subtree";
459
463
  count: number;
460
464
  samplePaths: string[];
461
465
  };
@@ -1261,6 +1265,19 @@ async function executeConflictExecutor(
1261
1265
  counters.filesSkipped++;
1262
1266
  continue;
1263
1267
  }
1268
+ if (item.action === "skip-unreadable-link") {
1269
+ // An unreadable local link must stay non-fatal: the runner treats a
1270
+ // generic error event as a failed company leg. Surface the exact remote
1271
+ // key through the recoverable not-shipped channel instead.
1272
+ counters.filesSkipped++;
1273
+ run.emit({
1274
+ type: "not-shipped",
1275
+ reason: "unreadable-link",
1276
+ count: 1,
1277
+ samplePaths: [item.remoteFile.key],
1278
+ });
1279
+ continue;
1280
+ }
1264
1281
  if (item.action === "skip-excluded-policy") {
1265
1282
  continue;
1266
1283
  }
@@ -1408,10 +1425,23 @@ async function executeConflictItem(
1408
1425
  try {
1409
1426
  const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
1410
1427
  remoteFetched = true;
1411
- const remoteHash = fs.lstatSync(conflictAbs).isSymbolicLink()
1412
- ? hashSymlinkTarget(fs.readlinkSync(conflictAbs))
1413
- : (downloaded.contentHash ?? hashFile(conflictAbs));
1414
- converged = remoteHash === item.localHash;
1428
+ if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
1429
+ const target = readlinkOrNull(conflictAbs);
1430
+ if (target === null) {
1431
+ counters.filesSkipped++;
1432
+ run.emit({
1433
+ type: "not-shipped",
1434
+ reason: "unreadable-link",
1435
+ count: 1,
1436
+ samplePaths: [remoteFile.key],
1437
+ });
1438
+ return null;
1439
+ } else {
1440
+ converged = hashSymlinkTarget(target) === item.localHash;
1441
+ }
1442
+ } else {
1443
+ converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
1444
+ }
1415
1445
  } catch (probeErr) {
1416
1446
  if (probeErr instanceof VaultAuthError) throw probeErr;
1417
1447
  run.emit({
@@ -1635,9 +1665,23 @@ async function downloadOne(
1635
1665
 
1636
1666
  const localLstat = fs.lstatSync(localPath);
1637
1667
  const isLocalSymlink = localLstat.isSymbolicLink();
1638
- const hash = isLocalSymlink
1639
- ? hashSymlinkTarget(fs.readlinkSync(localPath))
1640
- : (contentHash ?? hashFile(localPath));
1668
+ let hash: string;
1669
+ if (isLocalSymlink) {
1670
+ const target = readlinkOrNull(localPath);
1671
+ if (target === null) {
1672
+ counters.filesSkipped++;
1673
+ run.emit({
1674
+ type: "not-shipped",
1675
+ reason: "unreadable-link",
1676
+ count: 1,
1677
+ samplePaths: [remoteFile.key],
1678
+ });
1679
+ return;
1680
+ }
1681
+ hash = hashSymlinkTarget(target);
1682
+ } else {
1683
+ hash = contentHash ?? hashFile(localPath);
1684
+ }
1641
1685
  const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
1642
1686
 
1643
1687
  updateEntry(
@@ -2166,6 +2210,11 @@ type PullPlanItem =
2166
2210
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
2167
2211
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
2168
2212
  | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
2213
+ // A local symlink was classified as a link but Windows (or a filesystem
2214
+ // race) would not expose its target. This stays distinct from generic
2215
+ // skip-local-only so the executor can report the exact path without making
2216
+ // the whole company leg fail.
2217
+ | { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string }
2169
2218
  // A stale personal-overlay symlink marker in the vault sitting at a key that
2170
2219
  // is ALSO a real, release-shipped core directory (e.g. an old
2171
2220
  // `core/knowledge/public/agent-browser` overlay marker after a release
@@ -2800,9 +2849,19 @@ function computePullPlan(
2800
2849
  ) {
2801
2850
  localHash = journalEntry.hash;
2802
2851
  } else {
2803
- localHash = isLocalSymlink
2804
- ? hashSymlinkTarget(fs.readlinkSync(localPath))
2805
- : hashFile(localPath);
2852
+ if (isLocalSymlink) {
2853
+ const target = readlinkOrNull(localPath);
2854
+ if (target === null) {
2855
+ // We cannot prove whether the local link diverged, so never
2856
+ // overwrite or tombstone it from this plan. A later pass can retry
2857
+ // once the OS exposes a readable target.
2858
+ items.push({ action: "skip-unreadable-link", remoteFile, localPath });
2859
+ continue;
2860
+ }
2861
+ localHash = hashSymlinkTarget(target);
2862
+ } else {
2863
+ localHash = hashFile(localPath);
2864
+ }
2806
2865
  }
2807
2866
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
2808
2867
  plannedLocalSnapshot = {
@@ -3329,6 +3388,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
3329
3388
  console.warn(
3330
3389
  ` ! ${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"}):`,
3331
3390
  );
3391
+ } else if (event.reason === "unreadable-link") {
3392
+ console.warn(
3393
+ ` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
3394
+ );
3332
3395
  } else {
3333
3396
  console.warn(
3334
3397
  ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readlinkOrNull } from "./readlink-safe.js";
3
+
4
+ function errnoError(code: string): NodeJS.ErrnoException {
5
+ const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
6
+ err.code = code;
7
+ return err;
8
+ }
9
+
10
+ describe("readlinkOrNull", () => {
11
+ it("uses the filesystem reader by default", () => {
12
+ expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
13
+ });
14
+
15
+ it("returns the target string verbatim when readlink succeeds", () => {
16
+ expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
17
+ "../target with spaces",
18
+ );
19
+ });
20
+
21
+ it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
22
+ expect(readlinkOrNull("/test/link", () => {
23
+ throw errnoError(code);
24
+ })).toBeNull();
25
+ });
26
+
27
+ it("rethrows non-filesystem failures", () => {
28
+ const defect = new Error("unexpected test defect");
29
+
30
+ expect(() => readlinkOrNull("/test/link", () => {
31
+ throw defect;
32
+ })).toThrow(defect);
33
+ });
34
+
35
+ it("rethrows code-bearing programming errors", () => {
36
+ const defect = new TypeError("path must be a string") as TypeError & { code: string };
37
+ defect.code = "ERR_INVALID_ARG_TYPE";
38
+
39
+ expect(() => readlinkOrNull("/test/link", () => {
40
+ throw defect;
41
+ })).toThrow(defect);
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ import * as fs from "fs";
2
+
3
+ /**
4
+ * Read a link target without turning an expected filesystem race or an
5
+ * unsupported link shape into a caller-wide failure.
6
+ *
7
+ * A `null` result is deliberately distinct from an empty target string: it
8
+ * means the caller must preserve the link boundary and choose its conservative
9
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
10
+ * errors cannot be mistaken for an unreadable link.
11
+ */
12
+ export function readlinkOrNull(
13
+ linkPath: string,
14
+ readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
15
+ ): string | null {
16
+ try {
17
+ return readlink(linkPath);
18
+ } catch (err: unknown) {
19
+ const code =
20
+ err && typeof err === "object" && "code" in err
21
+ ? (err as { code?: unknown }).code
22
+ : undefined;
23
+ // Node system errors use POSIX-style errno names such as EINVAL and
24
+ // ENOENT. Runtime/programming errors use ERR_* codes; those must remain
25
+ // loud rather than being misclassified as an unreadable link.
26
+ if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
27
+ throw err;
28
+ }
29
+ }