@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
@@ -39,7 +39,7 @@ vi.mock("readline", () => ({
39
39
  })),
40
40
  }));
41
41
  import * as readline from "readline";
42
- import { share, _testing as shareTesting } from "./share.js";
42
+ import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, _testing as shareTesting } from "./share.js";
43
43
  import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
44
44
  import { VaultAuthError } from "../vault-client.js";
45
45
  const mockConfig = {
@@ -47,6 +47,11 @@ const mockConfig = {
47
47
  authToken: "test-jwt-token",
48
48
  region: "us-east-1",
49
49
  };
50
+ function errnoError(code) {
51
+ const err = new Error(`${code}: invalid argument, readlink`);
52
+ err.code = code;
53
+ return err;
54
+ }
50
55
  /**
51
56
  * Build a pre-vended EntityContext as if a caller (e.g. AppBar) had already
52
57
  * called `/sts/vend-child` and is passing the result into share() via the
@@ -100,6 +105,175 @@ describe("wrapFilterWithIgnoreVisibility", () => {
100
105
  expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo.md"]);
101
106
  });
102
107
  });
108
+ describe("resolveNamedPath (feedback_258e4a86 / feedback_a51cb63d)", () => {
109
+ let tmpDir;
110
+ beforeEach(() => {
111
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolve-test-"));
112
+ });
113
+ afterEach(() => {
114
+ fs.rmSync(tmpDir, { recursive: true, force: true });
115
+ });
116
+ it("returns absolute paths verbatim", () => {
117
+ const abs = path.join(tmpDir, "x.md");
118
+ expect(shareTesting.resolveNamedPath(abs, tmpDir, path.join(tmpDir, "companies", "acme"), tmpDir)).toBe(abs);
119
+ });
120
+ it("resolves a company-relative path against the company folder when it does not exist under hqRoot", () => {
121
+ const syncRoot = path.join(tmpDir, "companies", "acme");
122
+ fs.mkdirSync(path.join(syncRoot, "knowledge", "agents"), { recursive: true });
123
+ const brief = path.join(syncRoot, "knowledge", "agents", "brief.md");
124
+ fs.writeFileSync(brief, "x");
125
+ // hqRoot base would be <tmpDir>/knowledge/agents/brief.md, which is absent;
126
+ // the syncRoot base resolves. This is the reporter's "company-relative
127
+ // spelling reaches nothing" case.
128
+ expect(shareTesting.resolveNamedPath("knowledge/agents/brief.md", tmpDir, syncRoot, tmpDir))
129
+ .toBe(brief);
130
+ });
131
+ it("resolves against the injected cwd when neither hqRoot nor the company folder has it", () => {
132
+ const syncRoot = path.join(tmpDir, "companies", "acme");
133
+ const workDir = path.join(tmpDir, "work");
134
+ fs.mkdirSync(workDir, { recursive: true });
135
+ fs.writeFileSync(path.join(workDir, "local.md"), "x");
136
+ // cwd is INJECTED, never read ambiently — no process.chdir in this suite.
137
+ expect(shareTesting.resolveNamedPath("local.md", tmpDir, syncRoot, workDir)).toBe(path.join(workDir, "local.md"));
138
+ });
139
+ it("prefers the company folder over an hqRoot homonym outside the company tree", () => {
140
+ const syncRoot = path.join(tmpDir, "companies", "acme");
141
+ const workDir = path.join(tmpDir, "work");
142
+ for (const base of [tmpDir, syncRoot, workDir]) {
143
+ fs.mkdirSync(path.join(base, "knowledge"), { recursive: true });
144
+ fs.writeFileSync(path.join(base, "knowledge", "brief.md"), base);
145
+ }
146
+ // hqRoot/companies/acme is outside syncRoot's parent chain for tmpDir/knowledge,
147
+ // so the unrelated hq-root homonym is skipped and syncRoot wins.
148
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(path.join(syncRoot, "knowledge", "brief.md"));
149
+ // With the company copy gone, cwd is the last resort (hqRoot homonym still skipped).
150
+ fs.rmSync(path.join(syncRoot, "knowledge"), { recursive: true, force: true });
151
+ expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(path.join(workDir, "knowledge", "brief.md"));
152
+ });
153
+ it("honors hqRoot-first precedence when the hqRoot hit is inside the company tree", () => {
154
+ const syncRoot = path.join(tmpDir, "companies", "acme");
155
+ const nested = path.join(syncRoot, "nested");
156
+ fs.mkdirSync(path.join(nested, "knowledge"), { recursive: true });
157
+ fs.writeFileSync(path.join(nested, "knowledge", "brief.md"), "nested");
158
+ fs.mkdirSync(path.join(syncRoot, "knowledge"), { recursive: true });
159
+ fs.writeFileSync(path.join(syncRoot, "knowledge", "brief.md"), "company");
160
+ expect(shareTesting.resolveNamedPath("nested/knowledge/brief.md", syncRoot, syncRoot, syncRoot)).toBe(path.join(nested, "knowledge", "brief.md"));
161
+ });
162
+ it("falls back to the hqRoot base (preserving the 'does not exist' diagnostic) when nothing resolves", () => {
163
+ const syncRoot = path.join(tmpDir, "companies", "acme");
164
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot, tmpDir)).toBe(path.resolve(tmpDir, "nope/missing.md"));
165
+ });
166
+ it("defaults cwd to process.cwd() when the parameter is omitted", () => {
167
+ const syncRoot = path.join(tmpDir, "companies", "acme");
168
+ // Nothing exists under any base, so the fallback proves the signature is
169
+ // callable without the injected cwd (production call sites rely on it).
170
+ expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot)).toBe(path.resolve(tmpDir, "nope/missing.md"));
171
+ });
172
+ });
173
+ describe("isWithinLexicalOrReal (feedback_258e4a86 / feedback_a51cb63d)", () => {
174
+ let tmpDir;
175
+ let hqRoot;
176
+ beforeEach(() => {
177
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-within-test-"));
178
+ hqRoot = path.join(tmpDir, "hq");
179
+ fs.mkdirSync(hqRoot, { recursive: true });
180
+ });
181
+ afterEach(() => {
182
+ fs.rmSync(tmpDir, { recursive: true, force: true });
183
+ });
184
+ it("accepts an in-tree path reached through a symlinked ancestor (which realpath would reject)", () => {
185
+ const syncRoot = path.join(hqRoot, "companies", "acme");
186
+ fs.mkdirSync(syncRoot, { recursive: true });
187
+ const external = path.join(hqRoot, "repos", "knowledge-acme", "agents");
188
+ fs.mkdirSync(external, { recursive: true });
189
+ fs.writeFileSync(path.join(external, "brief.md"), "x");
190
+ fs.symlinkSync(path.join(hqRoot, "repos", "knowledge-acme"), path.join(syncRoot, "knowledge"));
191
+ const throughLink = path.join(syncRoot, "knowledge", "agents", "brief.md");
192
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, throughLink, hqRoot)).toBe(true);
193
+ });
194
+ it("still rejects a path that is genuinely outside the company folder", () => {
195
+ const syncRoot = path.join(hqRoot, "companies", "acme");
196
+ fs.mkdirSync(syncRoot, { recursive: true });
197
+ const outside = path.join(hqRoot, "outside", "stray.md");
198
+ fs.mkdirSync(path.dirname(outside), { recursive: true });
199
+ fs.writeFileSync(outside, "x");
200
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, outside, hqRoot)).toBe(false);
201
+ });
202
+ it("rejects a lexically-inside path whose realpath escapes the HQ tree entirely", () => {
203
+ // The lexical arm is what lets a symlinked ancestor through; without the
204
+ // hqRoot bound it would ALSO let `companies/acme/elsewhere/secret` upload
205
+ // bytes from anywhere on the machine under a company-namespaced key.
206
+ const syncRoot = path.join(hqRoot, "companies", "acme");
207
+ fs.mkdirSync(syncRoot, { recursive: true });
208
+ const foreign = path.join(tmpDir, "not-hq", "secrets");
209
+ fs.mkdirSync(foreign, { recursive: true });
210
+ fs.writeFileSync(path.join(foreign, "creds.txt"), "x");
211
+ fs.symlinkSync(foreign, path.join(syncRoot, "elsewhere"));
212
+ const escaping = path.join(syncRoot, "elsewhere", "creds.txt");
213
+ // Lexically inside the company folder…
214
+ const lexicalRel = path.relative(syncRoot, escaping);
215
+ expect(lexicalRel.startsWith("..")).toBe(false);
216
+ // …but the realpath leaves HQ, so it is refused.
217
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, escaping, hqRoot)).toBe(false);
218
+ });
219
+ // ── Tenant bound on the lexical arm ──────────────────────────────────────
220
+ //
221
+ // Bounding the lexical arm by hqRoot alone is NOT enough: hqRoot contains
222
+ // every other company, so a symlinked ancestor under companies/acme that
223
+ // points at another tenant's bytes would pass "lexically inside acme AND
224
+ // really inside HQ" and upload those bytes into acme's vault under an
225
+ // acme-namespaced key. The bound has to be the TENANT, not the tree.
226
+ it("rejects a lexically-inside path whose realpath lands in another company's folder", () => {
227
+ const syncRoot = path.join(hqRoot, "companies", "acme");
228
+ fs.mkdirSync(syncRoot, { recursive: true });
229
+ const otherSecrets = path.join(hqRoot, "companies", "other", "secret");
230
+ fs.mkdirSync(otherSecrets, { recursive: true });
231
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
232
+ // A mislinked (or hostile) ancestor pointing straight at a sibling tenant.
233
+ fs.symlinkSync(otherSecrets, path.join(syncRoot, "knowledge"));
234
+ const crossTenant = path.join(syncRoot, "knowledge", "creds.md");
235
+ // Lexically inside acme, and its realpath is inside HQ — so only the
236
+ // tenant bound can refuse it.
237
+ expect(path.relative(syncRoot, crossTenant).startsWith("..")).toBe(false);
238
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
239
+ });
240
+ it("rejects a lexically-inside path whose realpath lands in another company's linked knowledge repo", () => {
241
+ // HQ topology pattern 2: a company's knowledge lives in its own repo under
242
+ // repos/private/ and is symlinked into the company folder. That target is
243
+ // outside every companies/<slug> root, so a companies-only bound would
244
+ // miss it — the sibling company's OWN link is what marks it as claimed.
245
+ const syncRoot = path.join(hqRoot, "companies", "acme");
246
+ fs.mkdirSync(syncRoot, { recursive: true });
247
+ const otherRoot = path.join(hqRoot, "companies", "other");
248
+ fs.mkdirSync(otherRoot, { recursive: true });
249
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
250
+ fs.mkdirSync(otherRepo, { recursive: true });
251
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
252
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
253
+ // acme's ancestor points at the sibling's repo (a typo away from its own).
254
+ fs.symlinkSync(otherRepo, path.join(syncRoot, "knowledge"));
255
+ const crossTenant = path.join(syncRoot, "knowledge", "roadmap.md");
256
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
257
+ });
258
+ it("still accepts the company's OWN linked knowledge repo when sibling tenants exist", () => {
259
+ // The tenant bound must not collapse the motivating topology: a sibling
260
+ // company being present (and publishing its own link) is not a reason to
261
+ // refuse acme's own linked subtree.
262
+ const syncRoot = path.join(hqRoot, "companies", "acme");
263
+ fs.mkdirSync(syncRoot, { recursive: true });
264
+ const otherRoot = path.join(hqRoot, "companies", "other");
265
+ fs.mkdirSync(otherRoot, { recursive: true });
266
+ const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
267
+ fs.mkdirSync(otherRepo, { recursive: true });
268
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
269
+ const acmeRepo = path.join(hqRoot, "repos", "private", "knowledge-acme", "agents");
270
+ fs.mkdirSync(acmeRepo, { recursive: true });
271
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "x");
272
+ fs.symlinkSync(path.join(hqRoot, "repos", "private", "knowledge-acme"), path.join(syncRoot, "knowledge"));
273
+ const own = path.join(syncRoot, "knowledge", "agents", "brief.md");
274
+ expect(shareTesting.isWithinLexicalOrReal(syncRoot, own, hqRoot)).toBe(true);
275
+ });
276
+ });
103
277
  const mockEntity = {
104
278
  uid: "cmp_01ABCDEF",
105
279
  slug: "acme",
@@ -274,16 +448,34 @@ describe("share", () => {
274
448
  // Key is "knowledge/crawl.json", not "companies/acme/knowledge/crawl.json"
275
449
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json", undefined, expect.anything());
276
450
  });
277
- it("skips files outside the company folder with a warning", async () => {
451
+ it("never uploads a file outside the company folder, and fails the push by default", async () => {
278
452
  const warnSpy = vi.spyOn(console, "error").mockImplementation(() => { });
279
453
  // File at hqRoot, outside companies/acme/
280
454
  const outsideFile = path.join(tmpDir, "stray.md");
281
455
  fs.writeFileSync(outsideFile, "stray");
456
+ // The per-path warning is unchanged; what changed (feedback_a51cb63d ask
457
+ // #2) is that an explicitly named, locally-present, unreachable path is no
458
+ // longer swallowed into a green "Pushed 0 file(s)".
459
+ await expect(share({
460
+ paths: [outsideFile],
461
+ company: "acme",
462
+ vaultConfig: mockConfig,
463
+ hqRoot: tmpDir,
464
+ })).rejects.toBeInstanceOf(UnreachablePushPathsError);
465
+ expect(uploadFile).not.toHaveBeenCalled();
466
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/outside company folder/i));
467
+ warnSpy.mockRestore();
468
+ });
469
+ it("skips files outside the company folder with a warning under unreachablePathPolicy: 'warn'", async () => {
470
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => { });
471
+ const outsideFile = path.join(tmpDir, "stray.md");
472
+ fs.writeFileSync(outsideFile, "stray");
282
473
  const result = await share({
283
474
  paths: [outsideFile],
284
475
  company: "acme",
285
476
  vaultConfig: mockConfig,
286
477
  hqRoot: tmpDir,
478
+ unreachablePathPolicy: "warn",
287
479
  });
288
480
  expect(result.filesUploaded).toBe(0);
289
481
  expect(uploadFile).not.toHaveBeenCalled();
@@ -1434,6 +1626,7 @@ describe("share", () => {
1434
1626
  e.type === "scope-excluded" ||
1435
1627
  e.type === "scope-materialization-gap" ||
1436
1628
  e.type === "ignore-excluded" ||
1629
+ e.type === "not-shipped" ||
1437
1630
  e.type === "delete-refused-bulk-asymmetry")
1438
1631
  return;
1439
1632
  events.push({
@@ -2079,6 +2272,101 @@ describe("share", () => {
2079
2272
  // not act on files outside the named scope.
2080
2273
  expect(journal.files["other/also-gone.md"]).toBeDefined();
2081
2274
  });
2275
+ it("propagateDeletes: a company-relative directory spelling scopes deletes exactly like its absolute equivalent", async () => {
2276
+ // Resolver symmetry (cluster ask #2). Delete scoping resolved named paths
2277
+ // against hqRoot only, so `in-scope` — the same spelling `collectFiles`
2278
+ // now accepts for upload — resolved to <hqRoot>/in-scope, did not exist,
2279
+ // and was silently dropped from the scope. The result was a push whose
2280
+ // upload leg honored the path and whose delete leg did not.
2281
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2282
+ fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
2283
+ fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
2284
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2285
+ fs.writeFileSync(journalPath, JSON.stringify({
2286
+ version: "1",
2287
+ lastSync: new Date().toISOString(),
2288
+ files: {
2289
+ "in-scope/gone.md": {
2290
+ hash: "h",
2291
+ size: 1,
2292
+ syncedAt: new Date().toISOString(),
2293
+ direction: "up",
2294
+ remoteEtag: "in-scope-etag",
2295
+ kind: "file",
2296
+ localDeleteIntent: deleteIntent("in-scope-etag", "h"),
2297
+ },
2298
+ "other/also-gone.md": {
2299
+ hash: "h",
2300
+ size: 1,
2301
+ syncedAt: new Date().toISOString(),
2302
+ direction: "up",
2303
+ remoteEtag: "other-etag",
2304
+ kind: "file",
2305
+ localDeleteIntent: deleteIntent("other-etag", "h"),
2306
+ },
2307
+ },
2308
+ }));
2309
+ const result = await share({
2310
+ paths: ["in-scope"], // company-relative, NOT hq-root-relative
2311
+ company: "acme",
2312
+ vaultConfig: mockConfig,
2313
+ hqRoot: tmpDir,
2314
+ skipUnchanged: true,
2315
+ propagateDeletes: true,
2316
+ propagateDeletePolicy: "owned-only",
2317
+ });
2318
+ expect(result.filesDeleted).toBe(1);
2319
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
2320
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2321
+ expect(journal.files["in-scope/gone.md"]).toBeUndefined();
2322
+ // Scope stays exactly as narrow as the absolute spelling's — no wider.
2323
+ expect(journal.files["other/also-gone.md"]).toBeDefined();
2324
+ });
2325
+ it("propagateDeletes: a directory reached through a symlinked ancestor anchors NO delete scope", async () => {
2326
+ // Deliberate asymmetry with the upload leg, and the reason delete scoping
2327
+ // keeps realpath containment: a linked subtree's contents are never walked
2328
+ // (they ship via their own repo), so treating it as a delete scope root
2329
+ // would make every vault object under that prefix look locally absent and
2330
+ // sweep it away. Narrower is the only safe direction here.
2331
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2332
+ fs.mkdirSync(companyRoot, { recursive: true });
2333
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme");
2334
+ fs.mkdirSync(acmeRepo, { recursive: true });
2335
+ fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
2336
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2337
+ fs.writeFileSync(journalPath, JSON.stringify({
2338
+ version: "1",
2339
+ lastSync: new Date().toISOString(),
2340
+ files: {
2341
+ // Fully delete-eligible: current intent, matching etag, locally
2342
+ // absent. The ONLY thing standing between it and a DeleteObject is
2343
+ // that the linked directory anchors no scope — so a regression that
2344
+ // widened delete containment would fail this test loudly.
2345
+ "knowledge/agents/brief.md": {
2346
+ hash: "h",
2347
+ size: 1,
2348
+ syncedAt: new Date().toISOString(),
2349
+ direction: "up",
2350
+ remoteEtag: "brief-etag",
2351
+ kind: "file",
2352
+ localDeleteIntent: deleteIntent("brief-etag", "h"),
2353
+ },
2354
+ },
2355
+ }));
2356
+ const result = await share({
2357
+ paths: [path.join(companyRoot, "knowledge")],
2358
+ company: "acme",
2359
+ vaultConfig: mockConfig,
2360
+ hqRoot: tmpDir,
2361
+ skipUnchanged: true,
2362
+ propagateDeletes: true,
2363
+ propagateDeletePolicy: "owned-only",
2364
+ });
2365
+ expect(result.filesDeleted).toBe(0);
2366
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2367
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2368
+ expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
2369
+ });
2082
2370
  it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
2083
2371
  const companyRoot = path.join(tmpDir, "companies", "acme");
2084
2372
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -3280,6 +3568,81 @@ describe("share", () => {
3280
3568
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), realPolicy, "policies/real.md", undefined, expect.anything());
3281
3569
  expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), "real.md", "policies/link.md", undefined, expect.anything());
3282
3570
  });
3571
+ it("skips and reports a top-level link whose win32 readlink raises EINVAL", () => {
3572
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3573
+ const targetDir = path.join(tmpDir, "outside", "target");
3574
+ fs.mkdirSync(targetDir, { recursive: true });
3575
+ fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
3576
+ const unreadableLink = path.join(companyRoot, "knowledge");
3577
+ fs.mkdirSync(companyRoot, { recursive: true });
3578
+ fs.symlinkSync(targetDir, unreadableLink, "dir");
3579
+ const realReadlinkSync = fs.readlinkSync;
3580
+ const readlinkSpy = vi
3581
+ .spyOn(fs, "readlinkSync")
3582
+ .mockImplementation(((linkPath) => {
3583
+ if (linkPath === unreadableLink)
3584
+ throw errnoError("EINVAL");
3585
+ return realReadlinkSync(linkPath);
3586
+ }));
3587
+ const warnings = vi.spyOn(console, "error").mockImplementation(() => { });
3588
+ const unreachable = [];
3589
+ try {
3590
+ const collected = shareTesting.collectFiles([unreadableLink], tmpDir, companyRoot, () => true, {
3591
+ onUnreachablePath: (namedPath, reason) => unreachable.push([namedPath, reason]),
3592
+ });
3593
+ expect(collected).toEqual([]);
3594
+ expect(unreachable).toEqual([[unreadableLink, "unreadable-link"]]);
3595
+ }
3596
+ finally {
3597
+ warnings.mockRestore();
3598
+ readlinkSpy.mockRestore();
3599
+ }
3600
+ });
3601
+ it("skips and names a nested unreadable link without descending into its target", async () => {
3602
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3603
+ const policiesDir = path.join(companyRoot, "policies");
3604
+ const targetDir = path.join(tmpDir, "outside", "target");
3605
+ fs.mkdirSync(policiesDir, { recursive: true });
3606
+ fs.mkdirSync(targetDir, { recursive: true });
3607
+ fs.writeFileSync(path.join(policiesDir, "safe.md"), "safe bytes");
3608
+ fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
3609
+ const unreadableLink = path.join(policiesDir, "unreadable-link");
3610
+ fs.symlinkSync(targetDir, unreadableLink, "dir");
3611
+ const realReadlinkSync = fs.readlinkSync;
3612
+ const readlinkSpy = vi
3613
+ .spyOn(fs, "readlinkSync")
3614
+ .mockImplementation(((linkPath) => {
3615
+ if (linkPath === unreadableLink)
3616
+ throw errnoError("EINVAL");
3617
+ return realReadlinkSync(linkPath);
3618
+ }));
3619
+ const warnings = vi.spyOn(console, "error").mockImplementation(() => { });
3620
+ const events = [];
3621
+ try {
3622
+ const result = await share({
3623
+ paths: [companyRoot],
3624
+ company: "acme",
3625
+ vaultConfig: mockConfig,
3626
+ hqRoot: tmpDir,
3627
+ onEvent: (event) => events.push(event),
3628
+ });
3629
+ expect(result.filesUploaded).toBe(1);
3630
+ expect(result.unreachablePaths).toEqual(["policies/unreadable-link"]);
3631
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), path.join(policiesDir, "safe.md"), "policies/safe.md", undefined, expect.anything());
3632
+ expect(uploadFile).not.toHaveBeenCalledWith(expect.anything(), path.join(unreadableLink, "must-not-upload.md"), "policies/unreadable-link/must-not-upload.md", undefined, expect.anything());
3633
+ expect(uploadSymlink).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), "policies/unreadable-link", undefined, expect.anything());
3634
+ expect(events).toContainEqual({
3635
+ type: "not-shipped",
3636
+ reason: "unreadable-link",
3637
+ count: 1,
3638
+ samplePaths: ["policies/unreadable-link"],
3639
+ });
3640
+ }
3641
+ finally {
3642
+ warnings.mockRestore();
3643
+ readlinkSpy.mockRestore();
3644
+ }
3645
+ });
3283
3646
  it("accepts a symlink inside the company folder even when its target lives outside", async () => {
3284
3647
  // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
3285
3648
  // via realpathSync, so a directory symlink whose target lived
@@ -3419,6 +3782,337 @@ describe("share", () => {
3419
3782
  expect(calls.find((c) => c[2].includes("secret.md"))).toBeUndefined();
3420
3783
  });
3421
3784
  });
3785
+ // ── Push scope + visibility (feedback_258e4a86 / feedback_a51cb63d) ─────
3786
+ //
3787
+ // New local files under a synced subdir were invisible to every push path:
3788
+ // a company-relative path resolved only against hqRoot ("does not exist"),
3789
+ // an in-tree path reached through a symlinked ancestor was rejected as
3790
+ // "outside company folder", and a directory symlink to an external repo
3791
+ // swallowed its contents from every bucket — all while push still reported
3792
+ // "Pushed 0 file(s)". These pin the resolver + visibility fixes.
3793
+ describe("push scope + visibility (feedback_258e4a86 / feedback_a51cb63d)", () => {
3794
+ it("resolves a company-relative push path against the company folder, not just hqRoot", async () => {
3795
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3796
+ fs.mkdirSync(path.join(companyRoot, "knowledge", "agents"), { recursive: true });
3797
+ const brief = path.join(companyRoot, "knowledge", "agents", "brief.md");
3798
+ fs.writeFileSync(brief, "mission brief");
3799
+ // Pre-fix: path.resolve(hqRoot, "knowledge/agents/brief.md") →
3800
+ // <hqRoot>/knowledge/agents/brief.md, which does not exist → the file
3801
+ // was reported "does not exist, skipping" and 0 uploaded no matter where
3802
+ // the operator stood. The company-folder base now resolves it.
3803
+ const result = await share({
3804
+ paths: ["knowledge/agents/brief.md"],
3805
+ company: "acme",
3806
+ vaultConfig: mockConfig,
3807
+ hqRoot: tmpDir,
3808
+ });
3809
+ expect(result.filesUploaded).toBe(1);
3810
+ expect(result.unreachablePaths).toEqual([]);
3811
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), brief, "knowledge/agents/brief.md", undefined, expect.anything());
3812
+ });
3813
+ it("pushes an in-tree file reached through a symlinked ancestor (not 'outside company folder')", async () => {
3814
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3815
+ fs.mkdirSync(companyRoot, { recursive: true });
3816
+ const externalKnowledge = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
3817
+ fs.mkdirSync(externalKnowledge, { recursive: true });
3818
+ fs.writeFileSync(path.join(externalKnowledge, "brief.md"), "mission brief");
3819
+ // companies/acme/knowledge → repos/private/knowledge-acme (HQ pattern 2).
3820
+ fs.symlinkSync(path.join(tmpDir, "repos", "private", "knowledge-acme"), path.join(companyRoot, "knowledge"));
3821
+ // Explicitly name the in-tree file THROUGH the symlinked ancestor.
3822
+ // Pre-fix, isWithin's realpath resolved it to repos/... → "outside
3823
+ // company folder, skipping" and 0 uploaded, even though
3824
+ // vaultKeyForLocalPath (lexical) would key it fine. It now uploads under
3825
+ // its logical company-namespaced key.
3826
+ const result = await share({
3827
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
3828
+ company: "acme",
3829
+ vaultConfig: mockConfig,
3830
+ hqRoot: tmpDir,
3831
+ });
3832
+ expect(result.filesUploaded).toBe(1);
3833
+ expect(result.unreachablePaths).toEqual([]);
3834
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), expect.anything(), "knowledge/agents/brief.md", undefined, expect.anything());
3835
+ });
3836
+ it("refuses a named push whose symlinked ancestor points at ANOTHER company's folder", async () => {
3837
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3838
+ fs.mkdirSync(companyRoot, { recursive: true });
3839
+ const otherSecrets = path.join(tmpDir, "companies", "other", "secret");
3840
+ fs.mkdirSync(otherSecrets, { recursive: true });
3841
+ fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
3842
+ // Mislinked (or hostile) ancestor inside acme pointing at a sibling tenant.
3843
+ fs.symlinkSync(otherSecrets, path.join(companyRoot, "knowledge"));
3844
+ // Bounding the lexical containment arm by hqRoot alone would accept this
3845
+ // — the path is lexically inside acme and its realpath is inside HQ —
3846
+ // and upload company "other"'s bytes into acme's bucket under the
3847
+ // acme-namespaced key "knowledge/creds.md". The bound is the tenant.
3848
+ await expect(share({
3849
+ paths: [path.join(companyRoot, "knowledge", "creds.md")],
3850
+ company: "acme",
3851
+ vaultConfig: mockConfig,
3852
+ hqRoot: tmpDir,
3853
+ })).rejects.toBeInstanceOf(UnreachablePushPathsError);
3854
+ expect(uploadFile).not.toHaveBeenCalled();
3855
+ });
3856
+ it("refuses a named push whose symlinked ancestor points at another company's linked knowledge repo", async () => {
3857
+ // The realistic HQ topology: each company's knowledge is its own repo
3858
+ // under repos/private/, symlinked into the company folder. That target
3859
+ // sits outside every companies/<slug> root, so a companies-only bound
3860
+ // would let a typo'd link ship the sibling's repo into acme's vault.
3861
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3862
+ fs.mkdirSync(companyRoot, { recursive: true });
3863
+ const otherRoot = path.join(tmpDir, "companies", "other");
3864
+ fs.mkdirSync(otherRoot, { recursive: true });
3865
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
3866
+ fs.mkdirSync(otherRepo, { recursive: true });
3867
+ fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
3868
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
3869
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "knowledge"));
3870
+ await expect(share({
3871
+ paths: [path.join(companyRoot, "knowledge", "roadmap.md")],
3872
+ company: "acme",
3873
+ vaultConfig: mockConfig,
3874
+ hqRoot: tmpDir,
3875
+ })).rejects.toBeInstanceOf(UnreachablePushPathsError);
3876
+ expect(uploadFile).not.toHaveBeenCalled();
3877
+ });
3878
+ it("still ships the company's OWN linked knowledge repo when a sibling tenant is present", async () => {
3879
+ // Guard against over-correction: the tenant bound must not refuse the
3880
+ // motivating topology just because another company exists locally.
3881
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3882
+ fs.mkdirSync(companyRoot, { recursive: true });
3883
+ const otherRoot = path.join(tmpDir, "companies", "other");
3884
+ fs.mkdirSync(otherRoot, { recursive: true });
3885
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
3886
+ fs.mkdirSync(otherRepo, { recursive: true });
3887
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
3888
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
3889
+ fs.mkdirSync(acmeRepo, { recursive: true });
3890
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
3891
+ fs.symlinkSync(path.join(tmpDir, "repos", "private", "knowledge-acme"), path.join(companyRoot, "knowledge"));
3892
+ const result = await share({
3893
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
3894
+ company: "acme",
3895
+ vaultConfig: mockConfig,
3896
+ hqRoot: tmpDir,
3897
+ });
3898
+ expect(result.filesUploaded).toBe(1);
3899
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), expect.anything(), "knowledge/agents/brief.md", undefined, expect.anything());
3900
+ });
3901
+ it("separates own-link from foreign-link within a SINGLE push pass", async () => {
3902
+ // The tenant-root lookup is memoized for the duration of one collect
3903
+ // pass. This pins that the memo keys on the company being pushed rather
3904
+ // than leaking one path's verdict onto the next: in one invocation the
3905
+ // own linked repo ships and the sibling's linked repo does not.
3906
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3907
+ fs.mkdirSync(companyRoot, { recursive: true });
3908
+ const otherRoot = path.join(tmpDir, "companies", "other");
3909
+ fs.mkdirSync(otherRoot, { recursive: true });
3910
+ const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
3911
+ fs.mkdirSync(otherRepo, { recursive: true });
3912
+ fs.writeFileSync(path.join(otherRepo, "secret.md"), "other tenant bytes");
3913
+ fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
3914
+ // acme ALSO links the sibling's repo in, under its own name.
3915
+ fs.symlinkSync(otherRepo, path.join(companyRoot, "borrowed"));
3916
+ const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
3917
+ fs.mkdirSync(acmeRepo, { recursive: true });
3918
+ fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
3919
+ fs.symlinkSync(path.join(tmpDir, "repos", "private", "knowledge-acme"), path.join(companyRoot, "knowledge"));
3920
+ const result = await share({
3921
+ paths: [
3922
+ path.join(companyRoot, "knowledge", "agents", "brief.md"),
3923
+ path.join(companyRoot, "borrowed", "secret.md"),
3924
+ ],
3925
+ company: "acme",
3926
+ vaultConfig: mockConfig,
3927
+ hqRoot: tmpDir,
3928
+ // warn so the pass completes and both verdicts are observable at once;
3929
+ // the default 'fail' path is covered by the tests above.
3930
+ unreachablePathPolicy: "warn",
3931
+ });
3932
+ expect(result.filesUploaded).toBe(1);
3933
+ expect(uploadFile).toHaveBeenCalledTimes(1);
3934
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), expect.anything(), "knowledge/agents/brief.md", undefined, expect.anything());
3935
+ expect(result.unreachablePaths).toEqual([
3936
+ path.join(companyRoot, "borrowed", "secret.md"),
3937
+ ]);
3938
+ });
3939
+ it("fails the push when an explicitly named path exists but is outside the company folder (not a silent 0-file success)", async () => {
3940
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3941
+ fs.mkdirSync(companyRoot, { recursive: true });
3942
+ fs.writeFileSync(path.join(companyRoot, "in-scope.md"), "shippable");
3943
+ // A real file that lives OUTSIDE the company folder entirely.
3944
+ const stray = path.join(tmpDir, "outside", "stray.md");
3945
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
3946
+ fs.writeFileSync(stray, "outside content");
3947
+ // Ask #2 of the report: "error, not warn-skip, when the named file
3948
+ // exists locally but is unreachable by the resolver". Pre-fix this
3949
+ // printed "✓ Pushed 0 file(s)" and exited 0.
3950
+ const events = [];
3951
+ await expect(share({
3952
+ paths: [stray, path.join(companyRoot, "in-scope.md")],
3953
+ company: "acme",
3954
+ vaultConfig: mockConfig,
3955
+ hqRoot: tmpDir,
3956
+ onEvent: (e) => events.push(e),
3957
+ })).rejects.toBeInstanceOf(UnreachablePushPathsError);
3958
+ // The failure is an ATOMIC no-op: it fires before any upload, so the
3959
+ // reachable sibling in the same invocation is not half-shipped either.
3960
+ expect(uploadFile).not.toHaveBeenCalled();
3961
+ // Still visible on the event stream before the throw.
3962
+ const notShipped = events.find((e) => e.type === "not-shipped" && e.reason === "unreachable-path");
3963
+ expect(notShipped).toBeDefined();
3964
+ expect(notShipped?.count).toBe(1);
3965
+ });
3966
+ it("names the offending path and its reason on the thrown error", async () => {
3967
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3968
+ fs.mkdirSync(companyRoot, { recursive: true });
3969
+ const stray = path.join(tmpDir, "outside", "stray.md");
3970
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
3971
+ fs.writeFileSync(stray, "outside content");
3972
+ let err;
3973
+ try {
3974
+ await share({
3975
+ paths: [stray],
3976
+ company: "acme",
3977
+ vaultConfig: mockConfig,
3978
+ hqRoot: tmpDir,
3979
+ });
3980
+ }
3981
+ catch (e) {
3982
+ err = e;
3983
+ }
3984
+ expect(err).toBeInstanceOf(UnreachablePushPathsError);
3985
+ const unreachable = err;
3986
+ expect(unreachable.paths).toEqual([stray]);
3987
+ expect(unreachable.reasons[stray]).toBe("outside-company");
3988
+ // The operator gets the actionable topology hint, not just a code.
3989
+ expect(unreachable.message).toContain("outside the company folder");
3990
+ });
3991
+ it("records rather than throws for an unreachable path under unreachablePathPolicy: 'warn'", async () => {
3992
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3993
+ fs.mkdirSync(companyRoot, { recursive: true });
3994
+ const stray = path.join(tmpDir, "outside", "stray.md");
3995
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
3996
+ fs.writeFileSync(stray, "outside content");
3997
+ // The sync runner walks roots it computed itself, not operator input, so
3998
+ // it opts into the legacy warn behavior instead of failing a whole
3999
+ // multi-company sync on one odd root.
4000
+ const result = await share({
4001
+ paths: [stray],
4002
+ company: "acme",
4003
+ vaultConfig: mockConfig,
4004
+ hqRoot: tmpDir,
4005
+ unreachablePathPolicy: "warn",
4006
+ });
4007
+ expect(result.filesUploaded).toBe(0);
4008
+ expect(result.unreachablePaths).toEqual([stray]);
4009
+ });
4010
+ it("surfaces a named path that does not exist under any base as unreachable without failing the run", async () => {
4011
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4012
+ fs.mkdirSync(companyRoot, { recursive: true });
4013
+ // "missing" is deliberately NOT fatal even under the default policy: a
4014
+ // multi-company push plans one leg per membership, including companies
4015
+ // whose folder was never materialized locally. Only "outside-company" —
4016
+ // the report's "exists locally but unreachable" case — fails the run.
4017
+ const result = await share({
4018
+ paths: ["knowledge/agents/nope.md"],
4019
+ company: "acme",
4020
+ vaultConfig: mockConfig,
4021
+ hqRoot: tmpDir,
4022
+ });
4023
+ expect(result.filesUploaded).toBe(0);
4024
+ expect(result.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4025
+ });
4026
+ it("reports unreachable paths using the caller's original spelling verbatim", async () => {
4027
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4028
+ fs.mkdirSync(companyRoot, { recursive: true });
4029
+ // Spelling contract: a relative token is echoed relative (never resolved
4030
+ // to one of the probe bases), so an operator can match the reported path
4031
+ // against the one they typed.
4032
+ const relative = await share({
4033
+ paths: ["knowledge/agents/nope.md"],
4034
+ company: "acme",
4035
+ vaultConfig: mockConfig,
4036
+ hqRoot: tmpDir,
4037
+ });
4038
+ expect(relative.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
4039
+ // …and an absolute token is echoed absolute.
4040
+ const stray = path.join(tmpDir, "outside", "stray.md");
4041
+ fs.mkdirSync(path.dirname(stray), { recursive: true });
4042
+ fs.writeFileSync(stray, "outside content");
4043
+ const absolute = await share({
4044
+ paths: [stray],
4045
+ company: "acme",
4046
+ vaultConfig: mockConfig,
4047
+ hqRoot: tmpDir,
4048
+ unreachablePathPolicy: "warn",
4049
+ });
4050
+ expect(absolute.unreachablePaths).toEqual([stray]);
4051
+ });
4052
+ it("reports a directory symlink to an external repo as a linked subtree not shipped to the vault", async () => {
4053
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4054
+ fs.mkdirSync(companyRoot, { recursive: true });
4055
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
4056
+ fs.mkdirSync(externalTarget, { recursive: true });
4057
+ fs.writeFileSync(path.join(externalTarget, "brief.md"), "lives in the repo");
4058
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
4059
+ const events = [];
4060
+ const result = await share({
4061
+ paths: [companyRoot],
4062
+ company: "acme",
4063
+ vaultConfig: mockConfig,
4064
+ hqRoot: tmpDir,
4065
+ onEvent: (e) => events.push(e),
4066
+ });
4067
+ // Topology contract preserved: only the link record ships; its external
4068
+ // contents do NOT (no regular-file upload for brief.md).
4069
+ expect(result.filesUploaded).toBe(1);
4070
+ expect(uploadFile).not.toHaveBeenCalled();
4071
+ // But the subtree is now VISIBLE instead of vanishing from every bucket.
4072
+ expect(result.linkedSubtreesNotShipped).toEqual(["knowledge"]);
4073
+ const notShipped = events.find((e) => e.type === "not-shipped" && e.reason === "linked-subtree");
4074
+ expect(notShipped).toBeDefined();
4075
+ expect(notShipped?.samplePaths).toEqual(["knowledge"]);
4076
+ });
4077
+ it("keeps a file explicitly pushed from under a linked subtree alive across a later full-walk push", async () => {
4078
+ // The documented workaround for a linked subtree is `hq sync push
4079
+ // <path>` — a point-in-time copy. That copy is only useful if the very
4080
+ // next full-company sync does not immediately delete it again: the walk
4081
+ // never descends the link, so the key has no walk-visible counterpart.
4082
+ // Delete candidacy is lstat-based (the file IS on disk through the
4083
+ // link), so the copy survives even the gate-free "all" policy.
4084
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4085
+ fs.mkdirSync(companyRoot, { recursive: true });
4086
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
4087
+ fs.mkdirSync(path.join(externalTarget, "agents"), { recursive: true });
4088
+ fs.writeFileSync(path.join(externalTarget, "agents", "brief.md"), "mission brief");
4089
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
4090
+ const pushed = await share({
4091
+ paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
4092
+ company: "acme",
4093
+ vaultConfig: mockConfig,
4094
+ hqRoot: tmpDir,
4095
+ });
4096
+ expect(pushed.filesUploaded).toBe(1);
4097
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4098
+ const afterPush = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4099
+ expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
4100
+ vi.mocked(deleteRemoteFile).mockClear();
4101
+ const walked = await share({
4102
+ paths: [companyRoot],
4103
+ company: "acme",
4104
+ vaultConfig: mockConfig,
4105
+ hqRoot: tmpDir,
4106
+ propagateDeletes: true,
4107
+ propagateDeletePolicy: "all",
4108
+ });
4109
+ expect(walked.filesDeleted).toBe(0);
4110
+ const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
4111
+ expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
4112
+ const afterWalk = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4113
+ expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
4114
+ });
4115
+ });
3422
4116
  // ── Bulk-asymmetry circuit-breaker ─────────────────────────────────────
3423
4117
  //
3424
4118
  // Defends against the "local mirror lost, journal still full" failure
@@ -4775,6 +5469,67 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
4775
5469
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4776
5470
  expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
4777
5471
  });
5472
+ it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
5473
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5474
+ const linkKey = "companies/acme/knowledge/unreadable-link";
5475
+ const linkPath = path.join(companyRoot, ...linkKey.split("/"));
5476
+ const targetDir = path.join(tmpDir, "outside", "target");
5477
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
5478
+ fs.mkdirSync(targetDir, { recursive: true });
5479
+ fs.symlinkSync(targetDir, linkPath, "dir");
5480
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
5481
+ fs.writeFileSync(journalPath, JSON.stringify({
5482
+ version: "1",
5483
+ lastSync: new Date().toISOString(),
5484
+ files: {
5485
+ [linkKey]: {
5486
+ hash: "recorded-link-hash",
5487
+ size: 0,
5488
+ syncedAt: new Date().toISOString(),
5489
+ direction: "up",
5490
+ remoteEtag: "poison-etag",
5491
+ },
5492
+ },
5493
+ }));
5494
+ const realReadlinkSync = fs.readlinkSync;
5495
+ let linkReadCount = 0;
5496
+ const readlinkSpy = vi
5497
+ .spyOn(fs, "readlinkSync")
5498
+ .mockImplementation(((candidate) => {
5499
+ if (candidate === linkPath && ++linkReadCount === 2) {
5500
+ throw errnoError("EINVAL");
5501
+ }
5502
+ return realReadlinkSync(candidate);
5503
+ }));
5504
+ const events = [];
5505
+ try {
5506
+ const result = await share({
5507
+ paths: [companyRoot],
5508
+ company: "acme",
5509
+ vaultConfig: mockConfig,
5510
+ hqRoot: tmpDir,
5511
+ skipUnchanged: true,
5512
+ propagateDeletes: true,
5513
+ propagateDeletePolicy: "currency-gated",
5514
+ onEvent: (event) => events.push(event),
5515
+ });
5516
+ expect(linkReadCount).toBe(2);
5517
+ expect(result.filesTombstoned).toBe(0);
5518
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
5519
+ expect(events).toContainEqual({
5520
+ type: "not-shipped",
5521
+ reason: "unreadable-link",
5522
+ count: 1,
5523
+ samplePaths: [linkKey],
5524
+ });
5525
+ expect(events.some((event) => event.type === "error")).toBe(false);
5526
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5527
+ expect(journal.files[linkKey]).toBeDefined();
5528
+ }
5529
+ finally {
5530
+ readlinkSpy.mockRestore();
5531
+ }
5532
+ });
4778
5533
  it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
4779
5534
  const localCo = path.join(tmpDir, "companies", "localco");
4780
5535
  fs.mkdirSync(localCo, { recursive: true });
@@ -4795,4 +5550,27 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
4795
5550
  expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
4796
5551
  });
4797
5552
  });
5553
+ // ── Pure-function unit coverage: isForbiddenCompanyVaultKey ───────────────────
5554
+ //
5555
+ // Contract: in a COMPANY vault (personalMode=false) any key under `companies/`
5556
+ // is a doubly-scoped corrupt object and must be refused; in a PERSONAL vault
5557
+ // (personalMode=true) `companies/` keys are legitimate and handled by the
5558
+ // dedicated personalMode branch, so this guard must NOT fire there.
5559
+ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", () => {
5560
+ it.each([
5561
+ // Company vault: companies/* keys are corrupt → forbidden.
5562
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", false, true],
5563
+ ["companies/acme/knowledge/readme.md", false, true],
5564
+ ["companies/manifest.yaml", false, true],
5565
+ // Company vault: bucket-relative keys are legitimate → allowed.
5566
+ ["docs/handoff.md", false, false],
5567
+ ["knowledge/readme.md", false, false],
5568
+ // Personal vault: companies/* keys are NOT forbidden here (handled by the
5569
+ // personalMode branch, which carries its own nuanced gating).
5570
+ ["companies/frogbear/drafts/reports/signals-2026-06-15.html", true, false],
5571
+ ["docs/handoff.md", true, false],
5572
+ ])("key=%s personalMode=%s → forbidden=%s", (key, personalMode, expected) => {
5573
+ expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
5574
+ });
5575
+ });
4798
5576
  //# sourceMappingURL=share.test.js.map