@indigoai-us/hq-cloud 6.14.37 → 6.14.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Artifact-level regression for the Windows EINVAL reparse-point failure.
3
+ *
4
+ * The Linux test runner cannot create the production reparse-point shape, so
5
+ * the fs seam makes a real symlink's readlink raise EINVAL while the actual
6
+ * runner → company fanout → share walk executes over a temp HQ tree.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import {
12
+ runRunner,
13
+ type RunnerDeps,
14
+ type RunnerEvent,
15
+ type VaultClientSurface,
16
+ } from "../../../src/bin/sync-runner.js";
17
+ import { share } from "../../../src/cli/share.js";
18
+ import type { EntityContext, EntityInfo } from "../../../src/types.js";
19
+ import { uploadFile, uploadSymlink } from "../../../src/s3.js";
20
+
21
+ vi.mock("fs", async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import("fs")>();
23
+ return { ...actual };
24
+ });
25
+
26
+ vi.mock("../../../src/s3.js", async (importOriginal) => {
27
+ const actual = await importOriginal<typeof import("../../../src/s3.js")>();
28
+ return {
29
+ ...actual,
30
+ uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
31
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"symlink-etag"' }),
32
+ headRemoteFile: vi.fn().mockResolvedValue(null),
33
+ primeObjectTransport: vi.fn().mockResolvedValue(undefined),
34
+ primeUploads: vi.fn().mockResolvedValue(undefined),
35
+ };
36
+ });
37
+
38
+ function errnoError(code: string): NodeJS.ErrnoException {
39
+ const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
40
+ err.code = code;
41
+ return err;
42
+ }
43
+
44
+ function makeWriter(): {
45
+ write: (chunk: string) => boolean;
46
+ events: () => RunnerEvent[];
47
+ } {
48
+ let output = "";
49
+ return {
50
+ write: (chunk) => {
51
+ output += chunk;
52
+ return true;
53
+ },
54
+ events: () =>
55
+ output
56
+ .split("\n")
57
+ .filter(Boolean)
58
+ .map((line) => JSON.parse(line) as RunnerEvent),
59
+ };
60
+ }
61
+
62
+ let hqRoot: string;
63
+ let stateDir: string;
64
+ let unreadableLink: string;
65
+
66
+ beforeEach(() => {
67
+ // Keep fixture roots inside the checkout so operator runs never create a
68
+ // scratch HQ tree outside the task worktree.
69
+ hqRoot = fs.mkdtempSync(path.join(process.cwd(), ".hqcloud-einval-e2e-"));
70
+ stateDir = fs.mkdtempSync(path.join(process.cwd(), ".hqcloud-einval-state-"));
71
+ process.env.HQ_STATE_DIR = stateDir;
72
+ const companyRoot = path.join(hqRoot, "companies", "acme");
73
+ const policiesDir = path.join(companyRoot, "policies");
74
+ const targetDir = path.join(hqRoot, "outside", "target");
75
+ fs.mkdirSync(policiesDir, { recursive: true });
76
+ fs.mkdirSync(targetDir, { recursive: true });
77
+ fs.writeFileSync(path.join(policiesDir, "safe.md"), "safe bytes");
78
+ fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "target bytes");
79
+ unreadableLink = path.join(policiesDir, "unreadable-link");
80
+ fs.symlinkSync(targetDir, unreadableLink, "dir");
81
+ });
82
+
83
+ afterEach(() => {
84
+ vi.restoreAllMocks();
85
+ fs.rmSync(hqRoot, { recursive: true, force: true });
86
+ fs.rmSync(stateDir, { recursive: true, force: true });
87
+ delete process.env.HQ_STATE_DIR;
88
+ });
89
+
90
+ describe("sync runner unreadable Windows link", () => {
91
+ it("keeps the leg complete and emits the offending relative path", async () => {
92
+ const stdout = makeWriter();
93
+ const stderr = makeWriter();
94
+ const entityContext: EntityContext = {
95
+ uid: "cmp_acme",
96
+ slug: "acme",
97
+ bucketName: "hq-vault-acme-test",
98
+ region: "us-east-1",
99
+ credentials: {
100
+ accessKeyId: "test-key",
101
+ secretAccessKey: "test-secret",
102
+ sessionToken: "test-session",
103
+ },
104
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
105
+ };
106
+ const client = {
107
+ listMyMemberships: async () => [{ companyUid: "cmp_acme" }],
108
+ listMyPendingInvitesByEmail: async () => [],
109
+ claimPendingInvitesByEmail: async () => undefined,
110
+ ensureMyPersonEntity: async () => ({}) as EntityInfo,
111
+ entity: {
112
+ get: async (uid: string) => ({
113
+ uid,
114
+ type: "company",
115
+ slug: "acme",
116
+ bucketName: "hq-vault-acme-test",
117
+ status: "active",
118
+ }) as EntityInfo,
119
+ listByType: async () => [],
120
+ },
121
+ } as unknown as VaultClientSurface;
122
+ const realReadlinkSync = fs.readlinkSync;
123
+ const readlinkSpy = vi
124
+ .spyOn(fs, "readlinkSync")
125
+ .mockImplementation(((candidate: fs.PathLike) => {
126
+ if (candidate === unreadableLink) throw errnoError("EINVAL");
127
+ return realReadlinkSync(candidate);
128
+ }) as typeof fs.readlinkSync);
129
+ const deps: RunnerDeps = {
130
+ stdout,
131
+ stderr,
132
+ getAccessToken: async () => "test-access-token",
133
+ getIdTokenClaims: () => null,
134
+ createVaultClient: () => client,
135
+ share: async (options) => {
136
+ const { vaultConfig: _vaultConfig, ...shareOptions } = options;
137
+ return share({ ...shareOptions, entityContext });
138
+ },
139
+ reindex: () => ({ status: 0 }) as ReturnType<NonNullable<RunnerDeps["reindex"]>>,
140
+ qmdReindex: () => ({
141
+ qmdAvailable: true,
142
+ collectionsAdded: [],
143
+ pathDriftDetected: [],
144
+ collectionsRepaired: [],
145
+ updated: false,
146
+ embedded: false,
147
+ pendingDirty: false,
148
+ lockBusy: false,
149
+ timedOut: false,
150
+ corruptionQuarantined: false,
151
+ corruptionQuarantineFailed: false,
152
+ indexDir: null,
153
+ }) as ReturnType<NonNullable<RunnerDeps["qmdReindex"]>>,
154
+ reconcileManifest: async () => ({
155
+ written: false,
156
+ added: [],
157
+ updated: [],
158
+ skipped: [],
159
+ }) as Awaited<ReturnType<NonNullable<RunnerDeps["reconcileManifest"]>>>,
160
+ collectTelemetry: async () => undefined,
161
+ };
162
+
163
+ try {
164
+ const code = await runRunner(
165
+ ["--companies", "--direction", "push", "--hq-root", hqRoot],
166
+ deps,
167
+ );
168
+
169
+ expect(code).toBe(0);
170
+ expect(stdout.events()).toContainEqual({
171
+ type: "not-shipped",
172
+ company: "acme",
173
+ reason: "unreadable-link",
174
+ count: 1,
175
+ samplePaths: ["policies/unreadable-link"],
176
+ });
177
+ expect(stdout.events()).toContainEqual(
178
+ expect.objectContaining({
179
+ type: "all-complete",
180
+ errors: [],
181
+ partial: false,
182
+ }),
183
+ );
184
+ expect(stderr.events()).toEqual([]);
185
+ expect(uploadFile).toHaveBeenCalledTimes(1);
186
+ expect(uploadSymlink).not.toHaveBeenCalled();
187
+ } finally {
188
+ readlinkSpy.mockRestore();
189
+ }
190
+ });
191
+ });