@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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Read a link target without turning an expected filesystem race or an
3
+ * unsupported link shape into a caller-wide failure.
4
+ *
5
+ * A `null` result is deliberately distinct from an empty target string: it
6
+ * means the caller must preserve the link boundary and choose its conservative
7
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
8
+ * errors cannot be mistaken for an unreadable link.
9
+ */
10
+ export declare function readlinkOrNull(linkPath: string, readlink?: (path: string) => string): string | null;
11
+ //# sourceMappingURL=readlink-safe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.d.ts","sourceRoot":"","sources":["../../src/lib/readlink-safe.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAgD,GAC3E,MAAM,GAAG,IAAI,CAcf"}
@@ -0,0 +1,27 @@
1
+ import * as fs from "fs";
2
+ /**
3
+ * Read a link target without turning an expected filesystem race or an
4
+ * unsupported link shape into a caller-wide failure.
5
+ *
6
+ * A `null` result is deliberately distinct from an empty target string: it
7
+ * means the caller must preserve the link boundary and choose its conservative
8
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
9
+ * errors cannot be mistaken for an unreadable link.
10
+ */
11
+ export function readlinkOrNull(linkPath, readlink = (path) => fs.readlinkSync(path, "utf8")) {
12
+ try {
13
+ return readlink(linkPath);
14
+ }
15
+ catch (err) {
16
+ const code = err && typeof err === "object" && "code" in err
17
+ ? err.code
18
+ : undefined;
19
+ // Node system errors use POSIX-style errno names such as EINVAL and
20
+ // ENOENT. Runtime/programming errors use ERR_* codes; those must remain
21
+ // loud rather than being misclassified as an unreadable link.
22
+ if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code))
23
+ return null;
24
+ throw err;
25
+ }
26
+ }
27
+ //# sourceMappingURL=readlink-safe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.js","sourceRoot":"","sources":["../../src/lib/readlink-safe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAEzB;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,EAChB,WAAqC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC;IAE5E,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC5B,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACtB,MAAM,IAAI,GACR,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,MAAM,IAAI,GAAG;YAC7C,CAAC,CAAE,GAA0B,CAAC,IAAI;YAClC,CAAC,CAAC,SAAS,CAAC;QAChB,oEAAoE;QACpE,wEAAwE;QACxE,8DAA8D;QAC9D,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACvE,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=readlink-safe.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.test.d.ts","sourceRoot":"","sources":["../../src/lib/readlink-safe.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readlinkOrNull } from "./readlink-safe.js";
3
+ function errnoError(code) {
4
+ const err = new Error(`${code}: scripted readlink failure`);
5
+ err.code = code;
6
+ return err;
7
+ }
8
+ describe("readlinkOrNull", () => {
9
+ it("uses the filesystem reader by default", () => {
10
+ expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
11
+ });
12
+ it("returns the target string verbatim when readlink succeeds", () => {
13
+ expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe("../target with spaces");
14
+ });
15
+ it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
16
+ expect(readlinkOrNull("/test/link", () => {
17
+ throw errnoError(code);
18
+ })).toBeNull();
19
+ });
20
+ it("rethrows non-filesystem failures", () => {
21
+ const defect = new Error("unexpected test defect");
22
+ expect(() => readlinkOrNull("/test/link", () => {
23
+ throw defect;
24
+ })).toThrow(defect);
25
+ });
26
+ it("rethrows code-bearing programming errors", () => {
27
+ const defect = new TypeError("path must be a string");
28
+ defect.code = "ERR_INVALID_ARG_TYPE";
29
+ expect(() => readlinkOrNull("/test/link", () => {
30
+ throw defect;
31
+ })).toThrow(defect);
32
+ });
33
+ });
34
+ //# sourceMappingURL=readlink-safe.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.test.js","sourceRoot":"","sources":["../../src/lib/readlink-safe.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEpD,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,GAAG,IAAI,6BAA6B,CAA0B,CAAC;IACrF,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,GAAG,CAAC;AACb,CAAC;AAED,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC9B,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,CAAC,cAAc,CAAC,0BAA0B,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC7E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAC,IAAI,CACtE,uBAAuB,CACxB,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,qBAAqB,EAAE,CAAC,IAAI,EAAE,EAAE;QACrE,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YACvC,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IACjB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C,MAAM,MAAM,GAAG,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;QAEnD,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YAC7C,MAAM,MAAM,CAAC;QACf,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,uBAAuB,CAAiC,CAAC;QACtF,MAAM,CAAC,IAAI,GAAG,sBAAsB,CAAC;QAErC,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YAC7C,MAAM,MAAM,CAAC;QACf,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.37",
3
+ "version": "6.14.39",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -2027,6 +2027,58 @@ describe("per-company fanout", () => {
2027
2027
  // ---------------------------------------------------------------------------
2028
2028
 
2029
2029
  describe("all-complete aggregate", () => {
2030
+ it("reports every unreadable-link fallback by path without turning the company leg into PARTIAL_SYNC_EXIT", async () => {
2031
+ const unreadablePaths = [
2032
+ "policies/scope-invalid-tombstone",
2033
+ "docs/conflict-convergence-probe",
2034
+ "skills/downloaded-link-hash",
2035
+ ];
2036
+ const deps = makeDeps({
2037
+ createVaultClient: () =>
2038
+ makeVaultStub({
2039
+ memberships: [{ companyUid: "cmp_a" }],
2040
+ entityGet: (uid: string) =>
2041
+ Promise.resolve({
2042
+ uid,
2043
+ type: "company",
2044
+ slug: "acme",
2045
+ bucketName: "hq-vault-acme-test",
2046
+ status: "active",
2047
+ } as unknown as EntityInfo),
2048
+ }),
2049
+ share: vi.fn(async (options: ShareOptions) => {
2050
+ for (const path of unreadablePaths) {
2051
+ options.onEvent?.({
2052
+ type: "not-shipped",
2053
+ reason: "unreadable-link",
2054
+ count: 1,
2055
+ samplePaths: [path],
2056
+ });
2057
+ }
2058
+ return defaultShareResult();
2059
+ }),
2060
+ });
2061
+
2062
+ const code = await runRunner(["--companies", "--direction", "push"], deps);
2063
+
2064
+ for (const path of unreadablePaths) {
2065
+ expect(deps.stdout.events()).toContainEqual({
2066
+ type: "not-shipped",
2067
+ company: "acme",
2068
+ reason: "unreadable-link",
2069
+ count: 1,
2070
+ samplePaths: [path],
2071
+ });
2072
+ }
2073
+ const all = deps.stdout.events().find(
2074
+ (event): event is Extract<RunnerEvent, { type: "all-complete" }> =>
2075
+ event.type === "all-complete",
2076
+ );
2077
+ expect(all?.errors).toEqual([]);
2078
+ expect(all?.partial).toBe(false);
2079
+ expect(code).toBe(0);
2080
+ });
2081
+
2030
2082
  it("emits all-complete when telemetry never resolves", async () => {
2031
2083
  const deps = makeDeps({
2032
2084
  createVaultClient: () =>
@@ -22,6 +22,7 @@ import { spawnSync } from "child_process";
22
22
  import * as fs from "fs";
23
23
  import * as os from "os";
24
24
  import * as path from "path";
25
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
25
26
  import {
26
27
  acquireOperationLock,
27
28
  OperationLockedError,
@@ -126,15 +127,6 @@ function lstatOrNull(p: string): fs.Stats | null {
126
127
  }
127
128
  }
128
129
 
129
- /** readlink without throwing (null when not a symlink / missing). */
130
- function readlinkOrNull(p: string): string | null {
131
- try {
132
- return fs.readlinkSync(p);
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
130
  /**
139
131
  * Entries of `dir` matching a bash `dir/*` glob: dotfiles excluded (no
140
132
  * dotglob), sorted lexicographically (bash glob order). Missing dir → [].
@@ -46,6 +46,7 @@ vi.mock("readline", () => ({
46
46
 
47
47
  import * as readline from "readline";
48
48
  import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, _testing as shareTesting } from "./share.js";
49
+ import type { SyncProgressEvent } from "./sync.js";
49
50
  import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
50
51
  import type { EntityContext } from "../types.js";
51
52
  import { VaultAuthError } from "../vault-client.js";
@@ -56,6 +57,12 @@ const mockConfig: VaultServiceConfig = {
56
57
  region: "us-east-1",
57
58
  };
58
59
 
60
+ function errnoError(code: string): NodeJS.ErrnoException {
61
+ const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
62
+ err.code = code;
63
+ return err;
64
+ }
65
+
59
66
  /**
60
67
  * Build a pre-vended EntityContext as if a caller (e.g. AppBar) had already
61
68
  * called `/sts/vend-child` and is passing the result into share() via the
@@ -4364,6 +4371,113 @@ describe("share", () => {
4364
4371
  );
4365
4372
  });
4366
4373
 
4374
+ it("skips and reports a top-level link whose win32 readlink raises EINVAL", () => {
4375
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4376
+ const targetDir = path.join(tmpDir, "outside", "target");
4377
+ fs.mkdirSync(targetDir, { recursive: true });
4378
+ fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
4379
+ const unreadableLink = path.join(companyRoot, "knowledge");
4380
+ fs.mkdirSync(companyRoot, { recursive: true });
4381
+ fs.symlinkSync(targetDir, unreadableLink, "dir");
4382
+
4383
+ const realReadlinkSync = fs.readlinkSync;
4384
+ const readlinkSpy = vi
4385
+ .spyOn(fs, "readlinkSync")
4386
+ .mockImplementation(((linkPath: fs.PathLike) => {
4387
+ if (linkPath === unreadableLink) throw errnoError("EINVAL");
4388
+ return realReadlinkSync(linkPath);
4389
+ }) as typeof fs.readlinkSync);
4390
+ const warnings = vi.spyOn(console, "error").mockImplementation(() => {});
4391
+ const unreachable: Array<[string, string]> = [];
4392
+
4393
+ try {
4394
+ const collected = shareTesting.collectFiles(
4395
+ [unreadableLink],
4396
+ tmpDir,
4397
+ companyRoot,
4398
+ () => true,
4399
+ {
4400
+ onUnreachablePath: (namedPath, reason) => unreachable.push([namedPath, reason]),
4401
+ },
4402
+ );
4403
+
4404
+ expect(collected).toEqual([]);
4405
+ expect(unreachable).toEqual([[unreadableLink, "unreadable-link"]]);
4406
+ } finally {
4407
+ warnings.mockRestore();
4408
+ readlinkSpy.mockRestore();
4409
+ }
4410
+ });
4411
+
4412
+ it("skips and names a nested unreadable link without descending into its target", async () => {
4413
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4414
+ const policiesDir = path.join(companyRoot, "policies");
4415
+ const targetDir = path.join(tmpDir, "outside", "target");
4416
+ fs.mkdirSync(policiesDir, { recursive: true });
4417
+ fs.mkdirSync(targetDir, { recursive: true });
4418
+ fs.writeFileSync(path.join(policiesDir, "safe.md"), "safe bytes");
4419
+ fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
4420
+ const unreadableLink = path.join(policiesDir, "unreadable-link");
4421
+ fs.symlinkSync(targetDir, unreadableLink, "dir");
4422
+
4423
+ const realReadlinkSync = fs.readlinkSync;
4424
+ const readlinkSpy = vi
4425
+ .spyOn(fs, "readlinkSync")
4426
+ .mockImplementation(((linkPath: fs.PathLike) => {
4427
+ if (linkPath === unreadableLink) throw errnoError("EINVAL");
4428
+ return realReadlinkSync(linkPath);
4429
+ }) as typeof fs.readlinkSync);
4430
+ const warnings = vi.spyOn(console, "error").mockImplementation(() => {});
4431
+ const events: Array<{
4432
+ type?: string;
4433
+ reason?: string;
4434
+ samplePaths?: string[];
4435
+ }> = [];
4436
+
4437
+ try {
4438
+ const result = await share({
4439
+ paths: [companyRoot],
4440
+ company: "acme",
4441
+ vaultConfig: mockConfig,
4442
+ hqRoot: tmpDir,
4443
+ onEvent: (event) => events.push(event),
4444
+ });
4445
+
4446
+ expect(result.filesUploaded).toBe(1);
4447
+ expect(result.unreachablePaths).toEqual(["policies/unreadable-link"]);
4448
+ expect(uploadFile).toHaveBeenCalledWith(
4449
+ expect.anything(),
4450
+ path.join(policiesDir, "safe.md"),
4451
+ "policies/safe.md",
4452
+ undefined,
4453
+ expect.anything(),
4454
+ );
4455
+ expect(uploadFile).not.toHaveBeenCalledWith(
4456
+ expect.anything(),
4457
+ path.join(unreadableLink, "must-not-upload.md"),
4458
+ "policies/unreadable-link/must-not-upload.md",
4459
+ undefined,
4460
+ expect.anything(),
4461
+ );
4462
+ expect(uploadSymlink).not.toHaveBeenCalledWith(
4463
+ expect.anything(),
4464
+ expect.anything(),
4465
+ "policies/unreadable-link",
4466
+ undefined,
4467
+ expect.anything(),
4468
+ );
4469
+ expect(events).toContainEqual({
4470
+ type: "not-shipped",
4471
+ reason: "unreadable-link",
4472
+ count: 1,
4473
+ samplePaths: ["policies/unreadable-link"],
4474
+ });
4475
+ } finally {
4476
+ warnings.mockRestore();
4477
+ readlinkSpy.mockRestore();
4478
+ }
4479
+ });
4480
+
4367
4481
  it("accepts a symlink inside the company folder even when its target lives outside", async () => {
4368
4482
  // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
4369
4483
  // via realpathSync, so a directory symlink whose target lived
@@ -6528,6 +6642,72 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
6528
6642
  expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
6529
6643
  });
6530
6644
 
6645
+ it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
6646
+ const companyRoot = path.join(tmpDir, "companies", "acme");
6647
+ const linkKey = "companies/acme/knowledge/unreadable-link";
6648
+ const linkPath = path.join(companyRoot, ...linkKey.split("/"));
6649
+ const targetDir = path.join(tmpDir, "outside", "target");
6650
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
6651
+ fs.mkdirSync(targetDir, { recursive: true });
6652
+ fs.symlinkSync(targetDir, linkPath, "dir");
6653
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
6654
+ fs.writeFileSync(
6655
+ journalPath,
6656
+ JSON.stringify({
6657
+ version: "1",
6658
+ lastSync: new Date().toISOString(),
6659
+ files: {
6660
+ [linkKey]: {
6661
+ hash: "recorded-link-hash",
6662
+ size: 0,
6663
+ syncedAt: new Date().toISOString(),
6664
+ direction: "up",
6665
+ remoteEtag: "poison-etag",
6666
+ },
6667
+ },
6668
+ }),
6669
+ );
6670
+ const realReadlinkSync = fs.readlinkSync;
6671
+ let linkReadCount = 0;
6672
+ const readlinkSpy = vi
6673
+ .spyOn(fs, "readlinkSync")
6674
+ .mockImplementation(((candidate: fs.PathLike) => {
6675
+ if (candidate === linkPath && ++linkReadCount === 2) {
6676
+ throw errnoError("EINVAL");
6677
+ }
6678
+ return realReadlinkSync(candidate);
6679
+ }) as typeof fs.readlinkSync);
6680
+ const events: SyncProgressEvent[] = [];
6681
+
6682
+ try {
6683
+ const result = await share({
6684
+ paths: [companyRoot],
6685
+ company: "acme",
6686
+ vaultConfig: mockConfig,
6687
+ hqRoot: tmpDir,
6688
+ skipUnchanged: true,
6689
+ propagateDeletes: true,
6690
+ propagateDeletePolicy: "currency-gated",
6691
+ onEvent: (event) => events.push(event),
6692
+ });
6693
+
6694
+ expect(linkReadCount).toBe(2);
6695
+ expect(result.filesTombstoned).toBe(0);
6696
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
6697
+ expect(events).toContainEqual({
6698
+ type: "not-shipped",
6699
+ reason: "unreadable-link",
6700
+ count: 1,
6701
+ samplePaths: [linkKey],
6702
+ });
6703
+ expect(events.some((event) => event.type === "error")).toBe(false);
6704
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
6705
+ expect(journal.files[linkKey]).toBeDefined();
6706
+ } finally {
6707
+ readlinkSpy.mockRestore();
6708
+ }
6709
+ });
6710
+
6531
6711
  it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
6532
6712
  const localCo = path.join(tmpDir, "companies", "localco");
6533
6713
  fs.mkdirSync(localCo, { recursive: true });
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
  }