@indigoai-us/hq-cloud 6.14.36 → 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.
Files changed (43) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -3
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +46 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +1 -9
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/share.d.ts +178 -1
  13. package/dist/cli/share.d.ts.map +1 -1
  14. package/dist/cli/share.js +555 -32
  15. package/dist/cli/share.js.map +1 -1
  16. package/dist/cli/share.test.js +780 -2
  17. package/dist/cli/share.test.js.map +1 -1
  18. package/dist/cli/sync.d.ts +27 -0
  19. package/dist/cli/sync.d.ts.map +1 -1
  20. package/dist/cli/sync.js +113 -13
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +188 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/lib/readlink-safe.d.ts +11 -0
  25. package/dist/lib/readlink-safe.d.ts.map +1 -0
  26. package/dist/lib/readlink-safe.js +27 -0
  27. package/dist/lib/readlink-safe.js.map +1 -0
  28. package/dist/lib/readlink-safe.test.d.ts +2 -0
  29. package/dist/lib/readlink-safe.test.d.ts.map +1 -0
  30. package/dist/lib/readlink-safe.test.js +34 -0
  31. package/dist/lib/readlink-safe.test.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/bin/sync-runner-company.ts +19 -3
  34. package/src/bin/sync-runner.test.ts +54 -0
  35. package/src/bin/sync-runner.ts +4 -0
  36. package/src/cli/reindex.ts +1 -9
  37. package/src/cli/share.test.ts +974 -2
  38. package/src/cli/share.ts +675 -32
  39. package/src/cli/sync.test.ts +209 -0
  40. package/src/cli/sync.ts +151 -13
  41. package/src/lib/readlink-safe.test.ts +43 -0
  42. package/src/lib/readlink-safe.ts +29 -0
  43. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
@@ -45,7 +45,8 @@ vi.mock("readline", () => ({
45
45
  }));
46
46
 
47
47
  import * as readline from "readline";
48
- import { share, _testing as shareTesting } from "./share.js";
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
@@ -125,6 +132,220 @@ describe("wrapFilterWithIgnoreVisibility", () => {
125
132
  });
126
133
  });
127
134
 
135
+ describe("resolveNamedPath (feedback_258e4a86 / feedback_a51cb63d)", () => {
136
+ let tmpDir: string;
137
+
138
+ beforeEach(() => {
139
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolve-test-"));
140
+ });
141
+
142
+ afterEach(() => {
143
+ fs.rmSync(tmpDir, { recursive: true, force: true });
144
+ });
145
+
146
+ it("returns absolute paths verbatim", () => {
147
+ const abs = path.join(tmpDir, "x.md");
148
+ expect(
149
+ shareTesting.resolveNamedPath(abs, tmpDir, path.join(tmpDir, "companies", "acme"), tmpDir),
150
+ ).toBe(abs);
151
+ });
152
+
153
+ it("resolves a company-relative path against the company folder when it does not exist under hqRoot", () => {
154
+ const syncRoot = path.join(tmpDir, "companies", "acme");
155
+ fs.mkdirSync(path.join(syncRoot, "knowledge", "agents"), { recursive: true });
156
+ const brief = path.join(syncRoot, "knowledge", "agents", "brief.md");
157
+ fs.writeFileSync(brief, "x");
158
+ // hqRoot base would be <tmpDir>/knowledge/agents/brief.md, which is absent;
159
+ // the syncRoot base resolves. This is the reporter's "company-relative
160
+ // spelling reaches nothing" case.
161
+ expect(shareTesting.resolveNamedPath("knowledge/agents/brief.md", tmpDir, syncRoot, tmpDir))
162
+ .toBe(brief);
163
+ });
164
+
165
+ it("resolves against the injected cwd when neither hqRoot nor the company folder has it", () => {
166
+ const syncRoot = path.join(tmpDir, "companies", "acme");
167
+ const workDir = path.join(tmpDir, "work");
168
+ fs.mkdirSync(workDir, { recursive: true });
169
+ fs.writeFileSync(path.join(workDir, "local.md"), "x");
170
+ // cwd is INJECTED, never read ambiently — no process.chdir in this suite.
171
+ expect(shareTesting.resolveNamedPath("local.md", tmpDir, syncRoot, workDir)).toBe(
172
+ path.join(workDir, "local.md"),
173
+ );
174
+ });
175
+
176
+ it("prefers the company folder over an hqRoot homonym outside the company tree", () => {
177
+ const syncRoot = path.join(tmpDir, "companies", "acme");
178
+ const workDir = path.join(tmpDir, "work");
179
+ for (const base of [tmpDir, syncRoot, workDir]) {
180
+ fs.mkdirSync(path.join(base, "knowledge"), { recursive: true });
181
+ fs.writeFileSync(path.join(base, "knowledge", "brief.md"), base);
182
+ }
183
+
184
+ // hqRoot/companies/acme is outside syncRoot's parent chain for tmpDir/knowledge,
185
+ // so the unrelated hq-root homonym is skipped and syncRoot wins.
186
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
187
+ path.join(syncRoot, "knowledge", "brief.md"),
188
+ );
189
+
190
+ // With the company copy gone, cwd is the last resort (hqRoot homonym still skipped).
191
+ fs.rmSync(path.join(syncRoot, "knowledge"), { recursive: true, force: true });
192
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
193
+ path.join(workDir, "knowledge", "brief.md"),
194
+ );
195
+ });
196
+
197
+ it("honors hqRoot-first precedence when the hqRoot hit is inside the company tree", () => {
198
+ const syncRoot = path.join(tmpDir, "companies", "acme");
199
+ const nested = path.join(syncRoot, "nested");
200
+ fs.mkdirSync(path.join(nested, "knowledge"), { recursive: true });
201
+ fs.writeFileSync(path.join(nested, "knowledge", "brief.md"), "nested");
202
+ fs.mkdirSync(path.join(syncRoot, "knowledge"), { recursive: true });
203
+ fs.writeFileSync(path.join(syncRoot, "knowledge", "brief.md"), "company");
204
+
205
+ expect(
206
+ shareTesting.resolveNamedPath("nested/knowledge/brief.md", syncRoot, syncRoot, syncRoot),
207
+ ).toBe(path.join(nested, "knowledge", "brief.md"));
208
+ });
209
+
210
+ it("falls back to the hqRoot base (preserving the 'does not exist' diagnostic) when nothing resolves", () => {
211
+ const syncRoot = path.join(tmpDir, "companies", "acme");
212
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot, tmpDir)).toBe(
213
+ path.resolve(tmpDir, "nope/missing.md"),
214
+ );
215
+ });
216
+
217
+ it("defaults cwd to process.cwd() when the parameter is omitted", () => {
218
+ const syncRoot = path.join(tmpDir, "companies", "acme");
219
+ // Nothing exists under any base, so the fallback proves the signature is
220
+ // callable without the injected cwd (production call sites rely on it).
221
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot)).toBe(
222
+ path.resolve(tmpDir, "nope/missing.md"),
223
+ );
224
+ });
225
+ });
226
+
227
+ describe("isWithinLexicalOrReal (feedback_258e4a86 / feedback_a51cb63d)", () => {
228
+ let tmpDir: string;
229
+ let hqRoot: string;
230
+
231
+ beforeEach(() => {
232
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-within-test-"));
233
+ hqRoot = path.join(tmpDir, "hq");
234
+ fs.mkdirSync(hqRoot, { recursive: true });
235
+ });
236
+
237
+ afterEach(() => {
238
+ fs.rmSync(tmpDir, { recursive: true, force: true });
239
+ });
240
+
241
+ it("accepts an in-tree path reached through a symlinked ancestor (which realpath would reject)", () => {
242
+ const syncRoot = path.join(hqRoot, "companies", "acme");
243
+ fs.mkdirSync(syncRoot, { recursive: true });
244
+ const external = path.join(hqRoot, "repos", "knowledge-acme", "agents");
245
+ fs.mkdirSync(external, { recursive: true });
246
+ fs.writeFileSync(path.join(external, "brief.md"), "x");
247
+ fs.symlinkSync(path.join(hqRoot, "repos", "knowledge-acme"), path.join(syncRoot, "knowledge"));
248
+
249
+ const throughLink = path.join(syncRoot, "knowledge", "agents", "brief.md");
250
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, throughLink, hqRoot)).toBe(true);
251
+ });
252
+
253
+ it("still rejects a path that is genuinely outside the company folder", () => {
254
+ const syncRoot = path.join(hqRoot, "companies", "acme");
255
+ fs.mkdirSync(syncRoot, { recursive: true });
256
+ const outside = path.join(hqRoot, "outside", "stray.md");
257
+ fs.mkdirSync(path.dirname(outside), { recursive: true });
258
+ fs.writeFileSync(outside, "x");
259
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, outside, hqRoot)).toBe(false);
260
+ });
261
+
262
+ it("rejects a lexically-inside path whose realpath escapes the HQ tree entirely", () => {
263
+ // The lexical arm is what lets a symlinked ancestor through; without the
264
+ // hqRoot bound it would ALSO let `companies/acme/elsewhere/secret` upload
265
+ // bytes from anywhere on the machine under a company-namespaced key.
266
+ const syncRoot = path.join(hqRoot, "companies", "acme");
267
+ fs.mkdirSync(syncRoot, { recursive: true });
268
+ const foreign = path.join(tmpDir, "not-hq", "secrets");
269
+ fs.mkdirSync(foreign, { recursive: true });
270
+ fs.writeFileSync(path.join(foreign, "creds.txt"), "x");
271
+ fs.symlinkSync(foreign, path.join(syncRoot, "elsewhere"));
272
+
273
+ const escaping = path.join(syncRoot, "elsewhere", "creds.txt");
274
+ // Lexically inside the company folder…
275
+ const lexicalRel = path.relative(syncRoot, escaping);
276
+ expect(lexicalRel.startsWith("..")).toBe(false);
277
+ // …but the realpath leaves HQ, so it is refused.
278
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, escaping, hqRoot)).toBe(false);
279
+ });
280
+
281
+ // ── Tenant bound on the lexical arm ──────────────────────────────────────
282
+ //
283
+ // Bounding the lexical arm by hqRoot alone is NOT enough: hqRoot contains
284
+ // every other company, so a symlinked ancestor under companies/acme that
285
+ // points at another tenant's bytes would pass "lexically inside acme AND
286
+ // really inside HQ" and upload those bytes into acme's vault under an
287
+ // acme-namespaced key. The bound has to be the TENANT, not the tree.
288
+ it("rejects a lexically-inside path whose realpath lands in another company's folder", () => {
289
+ const syncRoot = path.join(hqRoot, "companies", "acme");
290
+ fs.mkdirSync(syncRoot, { recursive: true });
291
+ const otherSecrets = path.join(hqRoot, "companies", "other", "secret");
292
+ fs.mkdirSync(otherSecrets, { recursive: true });
293
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
294
+ // A mislinked (or hostile) ancestor pointing straight at a sibling tenant.
295
+ fs.symlinkSync(otherSecrets, path.join(syncRoot, "knowledge"));
296
+
297
+ const crossTenant = path.join(syncRoot, "knowledge", "creds.md");
298
+ // Lexically inside acme, and its realpath is inside HQ — so only the
299
+ // tenant bound can refuse it.
300
+ expect(path.relative(syncRoot, crossTenant).startsWith("..")).toBe(false);
301
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
302
+ });
303
+
304
+ it("rejects a lexically-inside path whose realpath lands in another company's linked knowledge repo", () => {
305
+ // HQ topology pattern 2: a company's knowledge lives in its own repo under
306
+ // repos/private/ and is symlinked into the company folder. That target is
307
+ // outside every companies/<slug> root, so a companies-only bound would
308
+ // miss it — the sibling company's OWN link is what marks it as claimed.
309
+ const syncRoot = path.join(hqRoot, "companies", "acme");
310
+ fs.mkdirSync(syncRoot, { recursive: true });
311
+ const otherRoot = path.join(hqRoot, "companies", "other");
312
+ fs.mkdirSync(otherRoot, { recursive: true });
313
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
314
+ fs.mkdirSync(otherRepo, { recursive: true });
315
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
316
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
317
+ // acme's ancestor points at the sibling's repo (a typo away from its own).
318
+ fs.symlinkSync(otherRepo, path.join(syncRoot, "knowledge"));
319
+
320
+ const crossTenant = path.join(syncRoot, "knowledge", "roadmap.md");
321
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
322
+ });
323
+
324
+ it("still accepts the company's OWN linked knowledge repo when sibling tenants exist", () => {
325
+ // The tenant bound must not collapse the motivating topology: a sibling
326
+ // company being present (and publishing its own link) is not a reason to
327
+ // refuse acme's own linked subtree.
328
+ const syncRoot = path.join(hqRoot, "companies", "acme");
329
+ fs.mkdirSync(syncRoot, { recursive: true });
330
+ const otherRoot = path.join(hqRoot, "companies", "other");
331
+ fs.mkdirSync(otherRoot, { recursive: true });
332
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
333
+ fs.mkdirSync(otherRepo, { recursive: true });
334
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
335
+
336
+ const acmeRepo = path.join(hqRoot, "repos", "private", "knowledge-acme", "agents");
337
+ fs.mkdirSync(acmeRepo, { recursive: true });
338
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "x");
339
+ fs.symlinkSync(
340
+ path.join(hqRoot, "repos", "private", "knowledge-acme"),
341
+ path.join(syncRoot, "knowledge"),
342
+ );
343
+
344
+ const own = path.join(syncRoot, "knowledge", "agents", "brief.md");
345
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, own, hqRoot)).toBe(true);
346
+ });
347
+ });
348
+
128
349
  const mockEntity = {
129
350
  uid: "cmp_01ABCDEF",
130
351
  slug: "acme",
@@ -336,17 +557,42 @@ describe("share", () => {
336
557
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json", undefined, expect.anything());
337
558
  });
338
559
 
339
- it("skips files outside the company folder with a warning", async () => {
560
+ it("never uploads a file outside the company folder, and fails the push by default", async () => {
340
561
  const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
341
562
  // File at hqRoot, outside companies/acme/
342
563
  const outsideFile = path.join(tmpDir, "stray.md");
343
564
  fs.writeFileSync(outsideFile, "stray");
344
565
 
566
+ // The per-path warning is unchanged; what changed (feedback_a51cb63d ask
567
+ // #2) is that an explicitly named, locally-present, unreachable path is no
568
+ // longer swallowed into a green "Pushed 0 file(s)".
569
+ await expect(
570
+ share({
571
+ paths: [outsideFile],
572
+ company: "acme",
573
+ vaultConfig: mockConfig,
574
+ hqRoot: tmpDir,
575
+ }),
576
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
577
+
578
+ expect(uploadFile).not.toHaveBeenCalled();
579
+ expect(warnSpy).toHaveBeenCalledWith(
580
+ expect.stringMatching(/outside company folder/i),
581
+ );
582
+ warnSpy.mockRestore();
583
+ });
584
+
585
+ it("skips files outside the company folder with a warning under unreachablePathPolicy: 'warn'", async () => {
586
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
587
+ const outsideFile = path.join(tmpDir, "stray.md");
588
+ fs.writeFileSync(outsideFile, "stray");
589
+
345
590
  const result = await share({
346
591
  paths: [outsideFile],
347
592
  company: "acme",
348
593
  vaultConfig: mockConfig,
349
594
  hqRoot: tmpDir,
595
+ unreachablePathPolicy: "warn",
350
596
  });
351
597
 
352
598
  expect(result.filesUploaded).toBe(0);
@@ -1735,6 +1981,7 @@ describe("share", () => {
1735
1981
  e.type === "scope-excluded" ||
1736
1982
  e.type === "scope-materialization-gap" ||
1737
1983
  e.type === "ignore-excluded" ||
1984
+ e.type === "not-shipped" ||
1738
1985
  e.type === "delete-refused-bulk-asymmetry"
1739
1986
  ) return;
1740
1987
  events.push({
@@ -2556,6 +2803,115 @@ describe("share", () => {
2556
2803
  expect(journal.files["other/also-gone.md"]).toBeDefined();
2557
2804
  });
2558
2805
 
2806
+ it("propagateDeletes: a company-relative directory spelling scopes deletes exactly like its absolute equivalent", async () => {
2807
+ // Resolver symmetry (cluster ask #2). Delete scoping resolved named paths
2808
+ // against hqRoot only, so `in-scope` — the same spelling `collectFiles`
2809
+ // now accepts for upload — resolved to <hqRoot>/in-scope, did not exist,
2810
+ // and was silently dropped from the scope. The result was a push whose
2811
+ // upload leg honored the path and whose delete leg did not.
2812
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2813
+ fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
2814
+ fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
2815
+
2816
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2817
+ fs.writeFileSync(
2818
+ journalPath,
2819
+ JSON.stringify({
2820
+ version: "1",
2821
+ lastSync: new Date().toISOString(),
2822
+ files: {
2823
+ "in-scope/gone.md": {
2824
+ hash: "h",
2825
+ size: 1,
2826
+ syncedAt: new Date().toISOString(),
2827
+ direction: "up",
2828
+ remoteEtag: "in-scope-etag",
2829
+ kind: "file",
2830
+ localDeleteIntent: deleteIntent("in-scope-etag", "h"),
2831
+ },
2832
+ "other/also-gone.md": {
2833
+ hash: "h",
2834
+ size: 1,
2835
+ syncedAt: new Date().toISOString(),
2836
+ direction: "up",
2837
+ remoteEtag: "other-etag",
2838
+ kind: "file",
2839
+ localDeleteIntent: deleteIntent("other-etag", "h"),
2840
+ },
2841
+ },
2842
+ }),
2843
+ );
2844
+
2845
+ const result = await share({
2846
+ paths: ["in-scope"], // company-relative, NOT hq-root-relative
2847
+ company: "acme",
2848
+ vaultConfig: mockConfig,
2849
+ hqRoot: tmpDir,
2850
+ skipUnchanged: true,
2851
+ propagateDeletes: true,
2852
+ propagateDeletePolicy: "owned-only",
2853
+ });
2854
+
2855
+ expect(result.filesDeleted).toBe(1);
2856
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
2857
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2858
+ expect(journal.files["in-scope/gone.md"]).toBeUndefined();
2859
+ // Scope stays exactly as narrow as the absolute spelling's — no wider.
2860
+ expect(journal.files["other/also-gone.md"]).toBeDefined();
2861
+ });
2862
+
2863
+ it("propagateDeletes: a directory reached through a symlinked ancestor anchors NO delete scope", async () => {
2864
+ // Deliberate asymmetry with the upload leg, and the reason delete scoping
2865
+ // keeps realpath containment: a linked subtree's contents are never walked
2866
+ // (they ship via their own repo), so treating it as a delete scope root
2867
+ // would make every vault object under that prefix look locally absent and
2868
+ // sweep it away. Narrower is the only safe direction here.
2869
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2870
+ fs.mkdirSync(companyRoot, { recursive: true });
2871
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme");
2872
+ fs.mkdirSync(acmeRepo, { recursive: true });
2873
+ fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
2874
+
2875
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2876
+ fs.writeFileSync(
2877
+ journalPath,
2878
+ JSON.stringify({
2879
+ version: "1",
2880
+ lastSync: new Date().toISOString(),
2881
+ files: {
2882
+ // Fully delete-eligible: current intent, matching etag, locally
2883
+ // absent. The ONLY thing standing between it and a DeleteObject is
2884
+ // that the linked directory anchors no scope — so a regression that
2885
+ // widened delete containment would fail this test loudly.
2886
+ "knowledge/agents/brief.md": {
2887
+ hash: "h",
2888
+ size: 1,
2889
+ syncedAt: new Date().toISOString(),
2890
+ direction: "up",
2891
+ remoteEtag: "brief-etag",
2892
+ kind: "file",
2893
+ localDeleteIntent: deleteIntent("brief-etag", "h"),
2894
+ },
2895
+ },
2896
+ }),
2897
+ );
2898
+
2899
+ const result = await share({
2900
+ paths: [path.join(companyRoot, "knowledge")],
2901
+ company: "acme",
2902
+ vaultConfig: mockConfig,
2903
+ hqRoot: tmpDir,
2904
+ skipUnchanged: true,
2905
+ propagateDeletes: true,
2906
+ propagateDeletePolicy: "owned-only",
2907
+ });
2908
+
2909
+ expect(result.filesDeleted).toBe(0);
2910
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2911
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2912
+ expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
2913
+ });
2914
+
2559
2915
  it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
2560
2916
  const companyRoot = path.join(tmpDir, "companies", "acme");
2561
2917
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -4015,6 +4371,113 @@ describe("share", () => {
4015
4371
  );
4016
4372
  });
4017
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
+
4018
4481
  it("accepts a symlink inside the company folder even when its target lives outside", async () => {
4019
4482
  // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
4020
4483
  // via realpathSync, so a directory symlink whose target lived
@@ -4200,6 +4663,424 @@ describe("share", () => {
4200
4663
  });
4201
4664
  });
4202
4665
 
4666
+ // ── Push scope + visibility (feedback_258e4a86 / feedback_a51cb63d) ─────
4667
+ //
4668
+ // New local files under a synced subdir were invisible to every push path:
4669
+ // a company-relative path resolved only against hqRoot ("does not exist"),
4670
+ // an in-tree path reached through a symlinked ancestor was rejected as
4671
+ // "outside company folder", and a directory symlink to an external repo
4672
+ // swallowed its contents from every bucket — all while push still reported
4673
+ // "Pushed 0 file(s)". These pin the resolver + visibility fixes.
4674
+ describe("push scope + visibility (feedback_258e4a86 / feedback_a51cb63d)", () => {
4675
+ it("resolves a company-relative push path against the company folder, not just hqRoot", async () => {
4676
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4677
+ fs.mkdirSync(path.join(companyRoot, "knowledge", "agents"), { recursive: true });
4678
+ const brief = path.join(companyRoot, "knowledge", "agents", "brief.md");
4679
+ fs.writeFileSync(brief, "mission brief");
4680
+
4681
+ // Pre-fix: path.resolve(hqRoot, "knowledge/agents/brief.md") →
4682
+ // <hqRoot>/knowledge/agents/brief.md, which does not exist → the file
4683
+ // was reported "does not exist, skipping" and 0 uploaded no matter where
4684
+ // the operator stood. The company-folder base now resolves it.
4685
+ const result = await share({
4686
+ paths: ["knowledge/agents/brief.md"],
4687
+ company: "acme",
4688
+ vaultConfig: mockConfig,
4689
+ hqRoot: tmpDir,
4690
+ });
4691
+
4692
+ expect(result.filesUploaded).toBe(1);
4693
+ expect(result.unreachablePaths).toEqual([]);
4694
+ expect(uploadFile).toHaveBeenCalledWith(
4695
+ expect.anything(),
4696
+ brief,
4697
+ "knowledge/agents/brief.md",
4698
+ undefined,
4699
+ expect.anything(),
4700
+ );
4701
+ });
4702
+
4703
+ it("pushes an in-tree file reached through a symlinked ancestor (not 'outside company folder')", async () => {
4704
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4705
+ fs.mkdirSync(companyRoot, { recursive: true });
4706
+ const externalKnowledge = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4707
+ fs.mkdirSync(externalKnowledge, { recursive: true });
4708
+ fs.writeFileSync(path.join(externalKnowledge, "brief.md"), "mission brief");
4709
+ // companies/acme/knowledge → repos/private/knowledge-acme (HQ pattern 2).
4710
+ fs.symlinkSync(
4711
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4712
+ path.join(companyRoot, "knowledge"),
4713
+ );
4714
+
4715
+ // Explicitly name the in-tree file THROUGH the symlinked ancestor.
4716
+ // Pre-fix, isWithin's realpath resolved it to repos/... → "outside
4717
+ // company folder, skipping" and 0 uploaded, even though
4718
+ // vaultKeyForLocalPath (lexical) would key it fine. It now uploads under
4719
+ // its logical company-namespaced key.
4720
+ const result = await share({
4721
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4722
+ company: "acme",
4723
+ vaultConfig: mockConfig,
4724
+ hqRoot: tmpDir,
4725
+ });
4726
+
4727
+ expect(result.filesUploaded).toBe(1);
4728
+ expect(result.unreachablePaths).toEqual([]);
4729
+ expect(uploadFile).toHaveBeenCalledWith(
4730
+ expect.anything(),
4731
+ expect.anything(),
4732
+ "knowledge/agents/brief.md",
4733
+ undefined,
4734
+ expect.anything(),
4735
+ );
4736
+ });
4737
+
4738
+ it("refuses a named push whose symlinked ancestor points at ANOTHER company's folder", async () => {
4739
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4740
+ fs.mkdirSync(companyRoot, { recursive: true });
4741
+ const otherSecrets = path.join(tmpDir, "companies", "other", "secret");
4742
+ fs.mkdirSync(otherSecrets, { recursive: true });
4743
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
4744
+ // Mislinked (or hostile) ancestor inside acme pointing at a sibling tenant.
4745
+ fs.symlinkSync(otherSecrets, path.join(companyRoot, "knowledge"));
4746
+
4747
+ // Bounding the lexical containment arm by hqRoot alone would accept this
4748
+ // — the path is lexically inside acme and its realpath is inside HQ —
4749
+ // and upload company "other"'s bytes into acme's bucket under the
4750
+ // acme-namespaced key "knowledge/creds.md". The bound is the tenant.
4751
+ await expect(
4752
+ share({
4753
+ paths: [path.join(companyRoot, "knowledge", "creds.md")],
4754
+ company: "acme",
4755
+ vaultConfig: mockConfig,
4756
+ hqRoot: tmpDir,
4757
+ }),
4758
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4759
+
4760
+ expect(uploadFile).not.toHaveBeenCalled();
4761
+ });
4762
+
4763
+ it("refuses a named push whose symlinked ancestor points at another company's linked knowledge repo", async () => {
4764
+ // The realistic HQ topology: each company's knowledge is its own repo
4765
+ // under repos/private/, symlinked into the company folder. That target
4766
+ // sits outside every companies/<slug> root, so a companies-only bound
4767
+ // would let a typo'd link ship the sibling's repo into acme's vault.
4768
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4769
+ fs.mkdirSync(companyRoot, { recursive: true });
4770
+ const otherRoot = path.join(tmpDir, "companies", "other");
4771
+ fs.mkdirSync(otherRoot, { recursive: true });
4772
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4773
+ fs.mkdirSync(otherRepo, { recursive: true });
4774
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
4775
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4776
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "knowledge"));
4777
+
4778
+ await expect(
4779
+ share({
4780
+ paths: [path.join(companyRoot, "knowledge", "roadmap.md")],
4781
+ company: "acme",
4782
+ vaultConfig: mockConfig,
4783
+ hqRoot: tmpDir,
4784
+ }),
4785
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4786
+
4787
+ expect(uploadFile).not.toHaveBeenCalled();
4788
+ });
4789
+
4790
+ it("still ships the company's OWN linked knowledge repo when a sibling tenant is present", async () => {
4791
+ // Guard against over-correction: the tenant bound must not refuse the
4792
+ // motivating topology just because another company exists locally.
4793
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4794
+ fs.mkdirSync(companyRoot, { recursive: true });
4795
+ const otherRoot = path.join(tmpDir, "companies", "other");
4796
+ fs.mkdirSync(otherRoot, { recursive: true });
4797
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4798
+ fs.mkdirSync(otherRepo, { recursive: true });
4799
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4800
+
4801
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4802
+ fs.mkdirSync(acmeRepo, { recursive: true });
4803
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
4804
+ fs.symlinkSync(
4805
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4806
+ path.join(companyRoot, "knowledge"),
4807
+ );
4808
+
4809
+ const result = await share({
4810
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4811
+ company: "acme",
4812
+ vaultConfig: mockConfig,
4813
+ hqRoot: tmpDir,
4814
+ });
4815
+
4816
+ expect(result.filesUploaded).toBe(1);
4817
+ expect(uploadFile).toHaveBeenCalledWith(
4818
+ expect.anything(),
4819
+ expect.anything(),
4820
+ "knowledge/agents/brief.md",
4821
+ undefined,
4822
+ expect.anything(),
4823
+ );
4824
+ });
4825
+
4826
+ it("separates own-link from foreign-link within a SINGLE push pass", async () => {
4827
+ // The tenant-root lookup is memoized for the duration of one collect
4828
+ // pass. This pins that the memo keys on the company being pushed rather
4829
+ // than leaking one path's verdict onto the next: in one invocation the
4830
+ // own linked repo ships and the sibling's linked repo does not.
4831
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4832
+ fs.mkdirSync(companyRoot, { recursive: true });
4833
+ const otherRoot = path.join(tmpDir, "companies", "other");
4834
+ fs.mkdirSync(otherRoot, { recursive: true });
4835
+
4836
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
4837
+ fs.mkdirSync(otherRepo, { recursive: true });
4838
+ fs.writeFileSync(path.join(otherRepo, "secret.md"), "other tenant bytes");
4839
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
4840
+ // acme ALSO links the sibling's repo in, under its own name.
4841
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "borrowed"));
4842
+
4843
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
4844
+ fs.mkdirSync(acmeRepo, { recursive: true });
4845
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
4846
+ fs.symlinkSync(
4847
+ path.join(tmpDir, "repos", "private", "knowledge-acme"),
4848
+ path.join(companyRoot, "knowledge"),
4849
+ );
4850
+
4851
+ const result = await share({
4852
+ paths: [
4853
+ path.join(companyRoot, "knowledge", "agents", "brief.md"),
4854
+ path.join(companyRoot, "borrowed", "secret.md"),
4855
+ ],
4856
+ company: "acme",
4857
+ vaultConfig: mockConfig,
4858
+ hqRoot: tmpDir,
4859
+ // warn so the pass completes and both verdicts are observable at once;
4860
+ // the default 'fail' path is covered by the tests above.
4861
+ unreachablePathPolicy: "warn",
4862
+ });
4863
+
4864
+ expect(result.filesUploaded).toBe(1);
4865
+ expect(uploadFile).toHaveBeenCalledTimes(1);
4866
+ expect(uploadFile).toHaveBeenCalledWith(
4867
+ expect.anything(),
4868
+ expect.anything(),
4869
+ "knowledge/agents/brief.md",
4870
+ undefined,
4871
+ expect.anything(),
4872
+ );
4873
+ expect(result.unreachablePaths).toEqual([
4874
+ path.join(companyRoot, "borrowed", "secret.md"),
4875
+ ]);
4876
+ });
4877
+
4878
+ it("fails the push when an explicitly named path exists but is outside the company folder (not a silent 0-file success)", async () => {
4879
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4880
+ fs.mkdirSync(companyRoot, { recursive: true });
4881
+ fs.writeFileSync(path.join(companyRoot, "in-scope.md"), "shippable");
4882
+ // A real file that lives OUTSIDE the company folder entirely.
4883
+ const stray = path.join(tmpDir, "outside", "stray.md");
4884
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4885
+ fs.writeFileSync(stray, "outside content");
4886
+
4887
+ // Ask #2 of the report: "error, not warn-skip, when the named file
4888
+ // exists locally but is unreachable by the resolver". Pre-fix this
4889
+ // printed "✓ Pushed 0 file(s)" and exited 0.
4890
+ const events: Array<{ type: string; reason?: string; count?: number }> = [];
4891
+ await expect(
4892
+ share({
4893
+ paths: [stray, path.join(companyRoot, "in-scope.md")],
4894
+ company: "acme",
4895
+ vaultConfig: mockConfig,
4896
+ hqRoot: tmpDir,
4897
+ onEvent: (e) => events.push(e as { type: string; reason?: string; count?: number }),
4898
+ }),
4899
+ ).rejects.toBeInstanceOf(UnreachablePushPathsError);
4900
+
4901
+ // The failure is an ATOMIC no-op: it fires before any upload, so the
4902
+ // reachable sibling in the same invocation is not half-shipped either.
4903
+ expect(uploadFile).not.toHaveBeenCalled();
4904
+ // Still visible on the event stream before the throw.
4905
+ const notShipped = events.find(
4906
+ (e) => e.type === "not-shipped" && e.reason === "unreachable-path",
4907
+ );
4908
+ expect(notShipped).toBeDefined();
4909
+ expect(notShipped?.count).toBe(1);
4910
+ });
4911
+
4912
+ it("names the offending path and its reason on the thrown error", async () => {
4913
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4914
+ fs.mkdirSync(companyRoot, { recursive: true });
4915
+ const stray = path.join(tmpDir, "outside", "stray.md");
4916
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4917
+ fs.writeFileSync(stray, "outside content");
4918
+
4919
+ let err: unknown;
4920
+ try {
4921
+ await share({
4922
+ paths: [stray],
4923
+ company: "acme",
4924
+ vaultConfig: mockConfig,
4925
+ hqRoot: tmpDir,
4926
+ });
4927
+ } catch (e) {
4928
+ err = e;
4929
+ }
4930
+
4931
+ expect(err).toBeInstanceOf(UnreachablePushPathsError);
4932
+ const unreachable = err as UnreachablePushPathsError;
4933
+ expect(unreachable.paths).toEqual([stray]);
4934
+ expect(unreachable.reasons[stray]).toBe("outside-company");
4935
+ // The operator gets the actionable topology hint, not just a code.
4936
+ expect(unreachable.message).toContain("outside the company folder");
4937
+ });
4938
+
4939
+ it("records rather than throws for an unreachable path under unreachablePathPolicy: 'warn'", async () => {
4940
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4941
+ fs.mkdirSync(companyRoot, { recursive: true });
4942
+ const stray = path.join(tmpDir, "outside", "stray.md");
4943
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4944
+ fs.writeFileSync(stray, "outside content");
4945
+
4946
+ // The sync runner walks roots it computed itself, not operator input, so
4947
+ // it opts into the legacy warn behavior instead of failing a whole
4948
+ // multi-company sync on one odd root.
4949
+ const result = await share({
4950
+ paths: [stray],
4951
+ company: "acme",
4952
+ vaultConfig: mockConfig,
4953
+ hqRoot: tmpDir,
4954
+ unreachablePathPolicy: "warn",
4955
+ });
4956
+
4957
+ expect(result.filesUploaded).toBe(0);
4958
+ expect(result.unreachablePaths).toEqual([stray]);
4959
+ });
4960
+
4961
+ it("surfaces a named path that does not exist under any base as unreachable without failing the run", async () => {
4962
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4963
+ fs.mkdirSync(companyRoot, { recursive: true });
4964
+
4965
+ // "missing" is deliberately NOT fatal even under the default policy: a
4966
+ // multi-company push plans one leg per membership, including companies
4967
+ // whose folder was never materialized locally. Only "outside-company" —
4968
+ // the report's "exists locally but unreachable" case — fails the run.
4969
+ const result = await share({
4970
+ paths: ["knowledge/agents/nope.md"],
4971
+ company: "acme",
4972
+ vaultConfig: mockConfig,
4973
+ hqRoot: tmpDir,
4974
+ });
4975
+
4976
+ expect(result.filesUploaded).toBe(0);
4977
+ expect(result.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4978
+ });
4979
+
4980
+ it("reports unreachable paths using the caller's original spelling verbatim", async () => {
4981
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4982
+ fs.mkdirSync(companyRoot, { recursive: true });
4983
+
4984
+ // Spelling contract: a relative token is echoed relative (never resolved
4985
+ // to one of the probe bases), so an operator can match the reported path
4986
+ // against the one they typed.
4987
+ const relative = await share({
4988
+ paths: ["knowledge/agents/nope.md"],
4989
+ company: "acme",
4990
+ vaultConfig: mockConfig,
4991
+ hqRoot: tmpDir,
4992
+ });
4993
+ expect(relative.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4994
+
4995
+ // …and an absolute token is echoed absolute.
4996
+ const stray = path.join(tmpDir, "outside", "stray.md");
4997
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4998
+ fs.writeFileSync(stray, "outside content");
4999
+ const absolute = await share({
5000
+ paths: [stray],
5001
+ company: "acme",
5002
+ vaultConfig: mockConfig,
5003
+ hqRoot: tmpDir,
5004
+ unreachablePathPolicy: "warn",
5005
+ });
5006
+ expect(absolute.unreachablePaths).toEqual([stray]);
5007
+ });
5008
+
5009
+ it("reports a directory symlink to an external repo as a linked subtree not shipped to the vault", async () => {
5010
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5011
+ fs.mkdirSync(companyRoot, { recursive: true });
5012
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
5013
+ fs.mkdirSync(externalTarget, { recursive: true });
5014
+ fs.writeFileSync(path.join(externalTarget, "brief.md"), "lives in the repo");
5015
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
5016
+
5017
+ const events: Array<{ type: string; reason?: string; samplePaths?: string[] }> = [];
5018
+ const result = await share({
5019
+ paths: [companyRoot],
5020
+ company: "acme",
5021
+ vaultConfig: mockConfig,
5022
+ hqRoot: tmpDir,
5023
+ onEvent: (e) =>
5024
+ events.push(e as { type: string; reason?: string; samplePaths?: string[] }),
5025
+ });
5026
+
5027
+ // Topology contract preserved: only the link record ships; its external
5028
+ // contents do NOT (no regular-file upload for brief.md).
5029
+ expect(result.filesUploaded).toBe(1);
5030
+ expect(uploadFile).not.toHaveBeenCalled();
5031
+ // But the subtree is now VISIBLE instead of vanishing from every bucket.
5032
+ expect(result.linkedSubtreesNotShipped).toEqual(["knowledge"]);
5033
+ const notShipped = events.find(
5034
+ (e) => e.type === "not-shipped" && e.reason === "linked-subtree",
5035
+ );
5036
+ expect(notShipped).toBeDefined();
5037
+ expect(notShipped?.samplePaths).toEqual(["knowledge"]);
5038
+ });
5039
+
5040
+ it("keeps a file explicitly pushed from under a linked subtree alive across a later full-walk push", async () => {
5041
+ // The documented workaround for a linked subtree is `hq sync push
5042
+ // <path>` — a point-in-time copy. That copy is only useful if the very
5043
+ // next full-company sync does not immediately delete it again: the walk
5044
+ // never descends the link, so the key has no walk-visible counterpart.
5045
+ // Delete candidacy is lstat-based (the file IS on disk through the
5046
+ // link), so the copy survives even the gate-free "all" policy.
5047
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5048
+ fs.mkdirSync(companyRoot, { recursive: true });
5049
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
5050
+ fs.mkdirSync(path.join(externalTarget, "agents"), { recursive: true });
5051
+ fs.writeFileSync(path.join(externalTarget, "agents", "brief.md"), "mission brief");
5052
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
5053
+
5054
+ const pushed = await share({
5055
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
5056
+ company: "acme",
5057
+ vaultConfig: mockConfig,
5058
+ hqRoot: tmpDir,
5059
+ });
5060
+ expect(pushed.filesUploaded).toBe(1);
5061
+
5062
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
5063
+ const afterPush = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5064
+ expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
5065
+
5066
+ vi.mocked(deleteRemoteFile).mockClear();
5067
+ const walked = await share({
5068
+ paths: [companyRoot],
5069
+ company: "acme",
5070
+ vaultConfig: mockConfig,
5071
+ hqRoot: tmpDir,
5072
+ propagateDeletes: true,
5073
+ propagateDeletePolicy: "all",
5074
+ });
5075
+
5076
+ expect(walked.filesDeleted).toBe(0);
5077
+ const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1] as string);
5078
+ expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
5079
+ const afterWalk = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5080
+ expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
5081
+ });
5082
+ });
5083
+
4203
5084
  // ── Bulk-asymmetry circuit-breaker ─────────────────────────────────────
4204
5085
  //
4205
5086
  // Defends against the "local mirror lost, journal still full" failure
@@ -5646,6 +6527,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
5646
6527
  });
5647
6528
  });
5648
6529
 
6530
+
5649
6531
  describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
5650
6532
  let tmpDir: string;
5651
6533
  let stateDir: string;
@@ -5760,6 +6642,72 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
5760
6642
  expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
5761
6643
  });
5762
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
+
5763
6711
  it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
5764
6712
  const localCo = path.join(tmpDir, "companies", "localco");
5765
6713
  fs.mkdirSync(localCo, { recursive: true });
@@ -5782,3 +6730,27 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
5782
6730
  expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
5783
6731
  });
5784
6732
  });
6733
+
6734
+ // ── Pure-function unit coverage: isForbiddenCompanyVaultKey ───────────────────
6735
+ //
6736
+ // Contract: in a COMPANY vault (personalMode=false) any key under `companies/`
6737
+ // is a doubly-scoped corrupt object and must be refused; in a PERSONAL vault
6738
+ // (personalMode=true) `companies/` keys are legitimate and handled by the
6739
+ // dedicated personalMode branch, so this guard must NOT fire there.
6740
+ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", () => {
6741
+ it.each([
6742
+ // Company vault: companies/* keys are corrupt → forbidden.
6743
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", false, true],
6744
+ ["companies/acme/knowledge/readme.md", false, true],
6745
+ ["companies/manifest.yaml", false, true],
6746
+ // Company vault: bucket-relative keys are legitimate → allowed.
6747
+ ["docs/handoff.md", false, false],
6748
+ ["knowledge/readme.md", false, false],
6749
+ // Personal vault: companies/* keys are NOT forbidden here (handled by the
6750
+ // personalMode branch, which carries its own nuanced gating).
6751
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", true, false],
6752
+ ["docs/handoff.md", true, false],
6753
+ ])("key=%s personalMode=%s → forbidden=%s", (key, personalMode, expected) => {
6754
+ expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
6755
+ });
6756
+ });