@indigoai-us/hq-cloud 5.16.0 → 5.18.1

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.
@@ -11,9 +11,12 @@ import type { VaultServiceConfig } from "../types.js";
11
11
 
12
12
  // Mock s3 module at the top level. uploadFile resolves to a synthetic ETag
13
13
  // so share() can record it on the journal entry — the real PutObject
14
- // response shape is `{ ETag: '"<hex>"' }`.
14
+ // response shape is `{ ETag: '"<hex>"' }`. uploadSymlink is the symlink-
15
+ // preserving sibling that puts a zero-byte object with target metadata
16
+ // instead of dereferencing the link.
15
17
  vi.mock("../s3.js", () => ({
16
18
  uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
19
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
17
20
  downloadFile: vi.fn().mockResolvedValue(undefined),
18
21
  listRemoteFiles: vi.fn().mockResolvedValue([]),
19
22
  deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
@@ -21,7 +24,7 @@ vi.mock("../s3.js", () => ({
21
24
  }));
22
25
 
23
26
  import { share } from "./share.js";
24
- import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
27
+ import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
25
28
  import type { EntityContext } from "../types.js";
26
29
 
27
30
  const mockConfig: VaultServiceConfig = {
@@ -106,6 +109,7 @@ describe("share", () => {
106
109
  // clearAllMocks wipes the default ETag impl set in vi.mock(), so
107
110
  // re-prime it for the next test.
108
111
  vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
112
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
109
113
  vi.mocked(headRemoteFile).mockResolvedValue(null);
110
114
  fs.rmSync(tmpDir, { recursive: true, force: true });
111
115
  fs.rmSync(stateDir, { recursive: true, force: true });
@@ -855,6 +859,110 @@ describe("share", () => {
855
859
  // versioning enabled, so DeleteObject is soft (a delete-marker becomes the
856
860
  // current version; prior object versions remain recoverable).
857
861
 
862
+ it("propagateDeletes: deletes a journal entry whose key matches only a dir-only allowlist (dual-hint shouldSync)", async () => {
863
+ // Codex round-8 P2 follow-up: third instance of the dual-hint
864
+ // pattern. By the time computeDeletePlan considers an entry for
865
+ // remote deletion, the local file is already gone — we don't know
866
+ // whether it was a regular file or a symlink record. Pre-fix, the
867
+ // single isDir=false probe of shouldSync rejected paths whose
868
+ // only matching .hqinclude pattern was dir-only (e.g.
869
+ // `companies/*/knowledge/`), so a deleted symlink at such a path
870
+ // would leave the remote record orphaned forever. Mirrors the
871
+ // walkDir/collectFiles (push) and computePullPlan (pull) fixes.
872
+ const companyRoot = path.join(tmpDir, "companies", "acme");
873
+ fs.mkdirSync(companyRoot, { recursive: true });
874
+ // Allowlist with ONLY a dir-only pattern. Without the dual-hint
875
+ // probe, the slashless probe wouldn't match and the entry would
876
+ // not be queued for delete.
877
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
878
+
879
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
880
+ fs.writeFileSync(
881
+ journalPath,
882
+ JSON.stringify({
883
+ version: "1",
884
+ lastSync: new Date().toISOString(),
885
+ files: {
886
+ // Locally absent — was a symlink record. Should
887
+ // delete-propagate now that the path is no longer ignored
888
+ // under the dual-hint probe.
889
+ knowledge: {
890
+ hash: "irrelevant-not-checked",
891
+ size: 0,
892
+ syncedAt: new Date().toISOString(),
893
+ direction: "up",
894
+ remoteEtag: "knowledge-etag",
895
+ },
896
+ },
897
+ }),
898
+ );
899
+
900
+ const result = await share({
901
+ paths: [companyRoot],
902
+ company: "acme",
903
+ vaultConfig: mockConfig,
904
+ hqRoot: tmpDir,
905
+ skipUnchanged: true,
906
+ propagateDeletes: true,
907
+ });
908
+
909
+ expect(result.filesDeleted).toBe(1);
910
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
911
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
912
+ expect(journal.files["knowledge"]).toBeUndefined();
913
+ });
914
+
915
+ it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
916
+ // Codex round-3 P2 follow-up: pre-fix, computeDeletePlan used
917
+ // fs.existsSync which follows symlinks → returns false for a
918
+ // dangling link → the link's journal entry was queued for remote
919
+ // DeleteObject in the same sync cycle that just uploaded it via
920
+ // uploadSymlink. The link round-tripped as "upload, then delete"
921
+ // in one pass. Switching to lstat means the link file itself
922
+ // counts as locally present even when its target is missing.
923
+ const companyRoot = path.join(tmpDir, "companies", "acme");
924
+ fs.mkdirSync(companyRoot, { recursive: true });
925
+ // Dangling symlink: target file deliberately not created.
926
+ const danglingLink = path.join(companyRoot, "dangling-link.md");
927
+ fs.symlinkSync("./missing-target.md", danglingLink);
928
+
929
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
930
+ fs.writeFileSync(
931
+ journalPath,
932
+ JSON.stringify({
933
+ version: "1",
934
+ lastSync: new Date().toISOString(),
935
+ files: {
936
+ "dangling-link.md": {
937
+ // sha256("./missing-target.md") so the planner's
938
+ // skipUnchanged gate would also see this as unchanged on
939
+ // a normal upload pass.
940
+ hash: "irrelevant-not-checked-here",
941
+ size: 0,
942
+ syncedAt: new Date().toISOString(),
943
+ direction: "up",
944
+ remoteEtag: "dangling-etag",
945
+ },
946
+ },
947
+ }),
948
+ );
949
+
950
+ const result = await share({
951
+ paths: [companyRoot],
952
+ company: "acme",
953
+ vaultConfig: mockConfig,
954
+ hqRoot: tmpDir,
955
+ skipUnchanged: true,
956
+ propagateDeletes: true,
957
+ });
958
+
959
+ expect(result.filesDeleted).toBe(0);
960
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
961
+ // Journal entry must survive the run.
962
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
963
+ expect(journal.files["dangling-link.md"]).toBeDefined();
964
+ });
965
+
858
966
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
859
967
  const companyRoot = path.join(tmpDir, "companies", "acme");
860
968
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -1083,6 +1191,151 @@ describe("share", () => {
1083
1191
  expect(journal.files["flaky.md"]).toBeDefined();
1084
1192
  });
1085
1193
 
1194
+ // ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
1195
+ //
1196
+ // The pre-fix planner converted any journal-tracked path whose local file
1197
+ // was missing into a `DeleteObject`. Two real-world failure modes followed:
1198
+ //
1199
+ // B. A behind machine's first `sync now` ran push-before-pull. Its
1200
+ // journal had `direction: "down"` entries for files it had pulled
1201
+ // historically (or seeded from another machine); the moment a peer
1202
+ // uploaded a new file, the behind machine's next push concluded
1203
+ // "local missing → delete" and erased the peer's upload from the
1204
+ // vault before the pull leg ever ran.
1205
+ //
1206
+ // C. The pull's ignore filter and the push's local walk used the same
1207
+ // `createIgnoreFilter(hqRoot)` symbol, but `hqRoot` differed across
1208
+ // machines (older HQ layout had `knowledge/public/` and
1209
+ // `workers/public/` at top level; the post-v14 layout collapses
1210
+ // them under `core/`). When a new-layout machine pulled, the
1211
+ // old-layout paths were filtered locally; the journal still
1212
+ // recorded them as "down"; the next push concluded "local missing
1213
+ // → delete" and erased other machines' content from the vault.
1214
+ //
1215
+ // Both fixes live in `computeDeletePlan` and are belt-and-suspenders:
1216
+ // (i) `direction === "up"` requirement under the default policy.
1217
+ // (ii) `shouldSync` must accept the key — same filter the pull uses.
1218
+
1219
+ it("propagateDeletes: under owned-only (default), skips direction:'down' entries", async () => {
1220
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1221
+ fs.mkdirSync(companyRoot, { recursive: true });
1222
+ // No local files. One journal entry pulled from elsewhere (direction:'down')
1223
+ // and one this machine uploaded (direction:'up'). Only the 'up' one should
1224
+ // be eligible for delete-propagation.
1225
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1226
+ fs.writeFileSync(
1227
+ journalPath,
1228
+ JSON.stringify({
1229
+ version: "1",
1230
+ lastSync: new Date().toISOString(),
1231
+ files: {
1232
+ "pulled-from-peer.md": {
1233
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1234
+ direction: "down",
1235
+ },
1236
+ "i-uploaded.md": {
1237
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1238
+ direction: "up",
1239
+ },
1240
+ },
1241
+ }),
1242
+ );
1243
+
1244
+ const result = await share({
1245
+ paths: [companyRoot],
1246
+ company: "acme",
1247
+ vaultConfig: mockConfig,
1248
+ hqRoot: tmpDir,
1249
+ skipUnchanged: true,
1250
+ propagateDeletes: true,
1251
+ // propagateDeletePolicy omitted ⇒ defaults to "owned-only".
1252
+ });
1253
+
1254
+ // Only the 'up' entry is deleted; the 'down' entry is left alone so a
1255
+ // behind-machine first-sync can't erase peer uploads.
1256
+ expect(result.filesDeleted).toBe(1);
1257
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
1258
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "i-uploaded.md");
1259
+ });
1260
+
1261
+ it("propagateDeletes: policy:'all' opts back into legacy any-direction deletes", async () => {
1262
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1263
+ fs.mkdirSync(companyRoot, { recursive: true });
1264
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1265
+ fs.writeFileSync(
1266
+ journalPath,
1267
+ JSON.stringify({
1268
+ version: "1",
1269
+ lastSync: new Date().toISOString(),
1270
+ files: {
1271
+ "pulled.md": {
1272
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1273
+ direction: "down",
1274
+ },
1275
+ },
1276
+ }),
1277
+ );
1278
+
1279
+ const result = await share({
1280
+ paths: [companyRoot],
1281
+ company: "acme",
1282
+ vaultConfig: mockConfig,
1283
+ hqRoot: tmpDir,
1284
+ skipUnchanged: true,
1285
+ propagateDeletes: true,
1286
+ propagateDeletePolicy: "all",
1287
+ });
1288
+
1289
+ expect(result.filesDeleted).toBe(1);
1290
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "pulled.md");
1291
+ });
1292
+
1293
+ it("propagateDeletes: skips entries the ignore filter would reject (filter symmetry)", async () => {
1294
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1295
+ fs.mkdirSync(companyRoot, { recursive: true });
1296
+ // Author a .hqignore at hqRoot that filters out a path the journal still
1297
+ // tracks. The push must NOT delete that path from the vault — the local
1298
+ // absence is a filter outcome, not a user-intended delete.
1299
+ fs.writeFileSync(
1300
+ path.join(tmpDir, ".hqignore"),
1301
+ "companies/acme/legacy/**\n",
1302
+ );
1303
+
1304
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1305
+ fs.writeFileSync(
1306
+ journalPath,
1307
+ JSON.stringify({
1308
+ version: "1",
1309
+ lastSync: new Date().toISOString(),
1310
+ files: {
1311
+ "legacy/old-layout.md": {
1312
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1313
+ direction: "up",
1314
+ },
1315
+ "active/current.md": {
1316
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1317
+ direction: "up",
1318
+ },
1319
+ },
1320
+ }),
1321
+ );
1322
+
1323
+ const result = await share({
1324
+ paths: [companyRoot],
1325
+ company: "acme",
1326
+ vaultConfig: mockConfig,
1327
+ hqRoot: tmpDir,
1328
+ skipUnchanged: true,
1329
+ propagateDeletes: true,
1330
+ });
1331
+
1332
+ // legacy/old-layout.md is filter-skipped; only active/current.md is
1333
+ // eligible for delete.
1334
+ expect(result.filesDeleted).toBe(1);
1335
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "active/current.md");
1336
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "legacy/old-layout.md");
1337
+ });
1338
+
1086
1339
  // ── personalMode ───────────────────────────────────────────────────────────
1087
1340
  //
1088
1341
  // The personal vault (slug "personal" in the runner's fanout plan) shares
@@ -1228,4 +1481,259 @@ describe("share", () => {
1228
1481
  expect(uploadFile).not.toHaveBeenCalled();
1229
1482
  });
1230
1483
  });
1484
+
1485
+ describe("symlinks", () => {
1486
+ // Pre-fix bug: collectFiles called fs.statSync (which dereferences) on
1487
+ // top-level paths and walkDir relied on Dirent.isFile()/isDirectory()
1488
+ // (both false for symlinks), so a top-level symlink got uploaded as the
1489
+ // target's bytes under the link's key while a nested symlink was
1490
+ // silently dropped from every push. The link topology never survived a
1491
+ // round trip — fresh-machine pulls landed in a state where overlay
1492
+ // symlinks just didn't exist until master-sync.sh recreated them
1493
+ // locally. The fix detects symlinks via lstat / Dirent.isSymbolicLink
1494
+ // and routes them to a new uploadSymlink primitive that PUTs a
1495
+ // zero-byte object with x-amz-meta-hq-symlink-target carrying the
1496
+ // readlink string verbatim.
1497
+
1498
+ it("uploads a top-level symlink as a symlink record (not the target's bytes)", async () => {
1499
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1500
+ fs.mkdirSync(companyRoot, { recursive: true });
1501
+ const target = path.join(companyRoot, "target.md");
1502
+ fs.writeFileSync(target, "I am the target");
1503
+ const link = path.join(companyRoot, "link.md");
1504
+ fs.symlinkSync("target.md", link);
1505
+
1506
+ const result = await share({
1507
+ paths: [link],
1508
+ company: "acme",
1509
+ vaultConfig: mockConfig,
1510
+ hqRoot: tmpDir,
1511
+ });
1512
+
1513
+ expect(result.filesUploaded).toBe(1);
1514
+ expect(uploadSymlink).toHaveBeenCalledWith(
1515
+ expect.anything(),
1516
+ "target.md",
1517
+ "link.md",
1518
+ );
1519
+ // The link itself must NOT be uploaded as a regular file. Pre-fix,
1520
+ // fs.statSync(link) followed the link and uploadFile got called with
1521
+ // the link's path → cloud stored a copy of target.md's bytes under
1522
+ // the key "link.md", silently flattening the link.
1523
+ const fileCalls = vi.mocked(uploadFile).mock.calls.filter(
1524
+ (c) => c[2] === "link.md",
1525
+ );
1526
+ expect(fileCalls).toHaveLength(0);
1527
+ });
1528
+
1529
+ it("uploads a nested symlink discovered during walkDir as a symlink record", async () => {
1530
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1531
+ const policiesDir = path.join(companyRoot, "policies");
1532
+ fs.mkdirSync(policiesDir, { recursive: true });
1533
+ const realPolicy = path.join(policiesDir, "real.md");
1534
+ fs.writeFileSync(realPolicy, "real content");
1535
+ const linkPolicy = path.join(policiesDir, "link.md");
1536
+ // Mirrors the master-sync.sh overlay shape: relative target pointing
1537
+ // to a sibling in the same dir.
1538
+ fs.symlinkSync("real.md", linkPolicy);
1539
+
1540
+ const result = await share({
1541
+ paths: [companyRoot],
1542
+ company: "acme",
1543
+ vaultConfig: mockConfig,
1544
+ hqRoot: tmpDir,
1545
+ });
1546
+
1547
+ // Two uploads: one regular file (real.md), one symlink record (link.md).
1548
+ // Pre-fix, walkDir's Dirent.isFile() returned false for the symlink and
1549
+ // it was silently dropped — only the real file was uploaded.
1550
+ expect(result.filesUploaded).toBe(2);
1551
+ expect(uploadFile).toHaveBeenCalledWith(
1552
+ expect.anything(),
1553
+ realPolicy,
1554
+ "policies/real.md",
1555
+ );
1556
+ expect(uploadSymlink).toHaveBeenCalledWith(
1557
+ expect.anything(),
1558
+ "real.md",
1559
+ "policies/link.md",
1560
+ );
1561
+ });
1562
+
1563
+ it("accepts a symlink inside the company folder even when its target lives outside", async () => {
1564
+ // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
1565
+ // via realpathSync, so a directory symlink whose target lived
1566
+ // outside the company folder (the canonical motivating shape:
1567
+ // companies/{co}/knowledge → repos/private/knowledge-{co}/) was
1568
+ // rejected with "outside company folder" and the link was
1569
+ // dropped entirely. The link's own pathname is inside the
1570
+ // company folder; that's what containment must compare against.
1571
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1572
+ fs.mkdirSync(companyRoot, { recursive: true });
1573
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1574
+ fs.mkdirSync(externalTarget, { recursive: true });
1575
+ const linkPath = path.join(companyRoot, "knowledge");
1576
+ fs.symlinkSync(externalTarget, linkPath);
1577
+
1578
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1579
+ const result = await share({
1580
+ paths: [linkPath],
1581
+ company: "acme",
1582
+ vaultConfig: mockConfig,
1583
+ hqRoot: tmpDir,
1584
+ });
1585
+ warnSpy.mockRestore();
1586
+
1587
+ expect(result.filesUploaded).toBe(1);
1588
+ expect(uploadSymlink).toHaveBeenCalledWith(
1589
+ expect.anything(),
1590
+ externalTarget,
1591
+ "knowledge",
1592
+ );
1593
+ // No "outside company folder" warning for this case.
1594
+ expect(warnSpy).not.toHaveBeenCalledWith(
1595
+ expect.stringMatching(/outside company folder/i),
1596
+ );
1597
+ });
1598
+
1599
+ it("uploads a symlink even when its target string equals the prior regular file's contents (skipUnchanged distinguishability)", async () => {
1600
+ // Codex round-5 P1 follow-up: pre-fix, the journal hash for a
1601
+ // symlink was sha256(target). For a key whose journal entry was
1602
+ // a regular file containing the bytes "real.md", a local
1603
+ // replacement with a symlink → "real.md" produced the same
1604
+ // hash, so skipUnchanged short-circuited the upload and the
1605
+ // representation drift never propagated. The pull side also
1606
+ // saw no etag change (because we never uploaded), so the remote
1607
+ // never repaired. Fix: hash symlinks as sha256("hq-symlink:" +
1608
+ // target), making the two representations structurally
1609
+ // inequal in journal-hash space.
1610
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1611
+ fs.mkdirSync(companyRoot, { recursive: true });
1612
+ const target = "real.md";
1613
+ // Pre-stamp the journal as if the prior sync had uploaded a
1614
+ // regular file whose contents were exactly the target string.
1615
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1616
+ const crypto = await import("crypto");
1617
+ const regularFileHash = crypto
1618
+ .createHash("sha256")
1619
+ .update(target)
1620
+ .digest("hex");
1621
+ fs.writeFileSync(
1622
+ journalPath,
1623
+ JSON.stringify({
1624
+ version: "1",
1625
+ lastSync: new Date().toISOString(),
1626
+ files: {
1627
+ "case-key.md": {
1628
+ hash: regularFileHash,
1629
+ size: target.length,
1630
+ syncedAt: new Date().toISOString(),
1631
+ direction: "up",
1632
+ remoteEtag: "regular-file-etag",
1633
+ },
1634
+ },
1635
+ }),
1636
+ );
1637
+ // Now locally, the user has replaced that key with a symlink to
1638
+ // the same target string.
1639
+ fs.symlinkSync(target, path.join(companyRoot, "case-key.md"));
1640
+
1641
+ const result = await share({
1642
+ paths: [companyRoot],
1643
+ company: "acme",
1644
+ vaultConfig: mockConfig,
1645
+ hqRoot: tmpDir,
1646
+ skipUnchanged: true, // the gate the bug lives behind
1647
+ });
1648
+
1649
+ // Upload MUST happen — the hash-namespace prefix means the
1650
+ // symlink's journal hash differs from the regular-file hash.
1651
+ expect(result.filesUploaded).toBe(1);
1652
+ expect(result.filesSkipped).toBe(0);
1653
+ expect(uploadSymlink).toHaveBeenCalledWith(
1654
+ expect.anything(),
1655
+ target,
1656
+ "case-key.md",
1657
+ );
1658
+ });
1659
+
1660
+ it("includes a directory symlink whose only matching allowlist pattern is dir-only", async () => {
1661
+ // Codex round-6 P1 follow-up: pre-fix, walkDir called the filter
1662
+ // with isDir = entry.isDirectory(), which returns false for any
1663
+ // symlink — including directory symlinks. With an .hqinclude
1664
+ // dir-only allowlist pattern like `companies/*/knowledge/`, the
1665
+ // filter's `ignore.ignores('foo')` vs `ignore.ignores('foo/')`
1666
+ // distinction means the slashless probe doesn't match and the
1667
+ // symlink is dropped before reaching the record-only branch.
1668
+ // Same story for collectFiles' top-level path classification.
1669
+ // Fix: probe the filter with both isDir hints for symlinks; the
1670
+ // filter is pure path lookup, so two calls cost nothing.
1671
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1672
+ fs.mkdirSync(companyRoot, { recursive: true });
1673
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1674
+ fs.mkdirSync(externalTarget, { recursive: true });
1675
+ // Create the directory symlink AND a sibling regular file at the
1676
+ // same depth. The .hqinclude pattern matches only the dir-only
1677
+ // form; the regular file should NOT make it past the filter,
1678
+ // proving the include rule actually applies (otherwise this
1679
+ // test would pass for the wrong reason).
1680
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
1681
+ fs.writeFileSync(path.join(companyRoot, "stray.md"), "not in allowlist");
1682
+ // Allowlist mode: only `companies/*/knowledge/` is in scope.
1683
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
1684
+
1685
+ const result = await share({
1686
+ paths: [companyRoot],
1687
+ company: "acme",
1688
+ vaultConfig: mockConfig,
1689
+ hqRoot: tmpDir,
1690
+ });
1691
+
1692
+ // The symlink record uploaded; the sibling regular file did NOT.
1693
+ expect(result.filesUploaded).toBe(1);
1694
+ expect(uploadSymlink).toHaveBeenCalledWith(
1695
+ expect.anything(),
1696
+ externalTarget,
1697
+ "knowledge",
1698
+ );
1699
+ expect(uploadFile).not.toHaveBeenCalledWith(
1700
+ expect.anything(),
1701
+ expect.stringContaining("stray.md"),
1702
+ expect.anything(),
1703
+ );
1704
+ });
1705
+
1706
+ it("does not recurse into directory symlinks (record-only)", async () => {
1707
+ // Following directory symlinks during walk would duplicate content
1708
+ // into the wrong vault path — exactly what the legacy "silently
1709
+ // skipped" topology accidentally prevented. The fix preserves that
1710
+ // topology while now actually recording the link.
1711
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1712
+ fs.mkdirSync(companyRoot, { recursive: true });
1713
+ const realDir = path.join(tmpDir, "outside");
1714
+ fs.mkdirSync(realDir, { recursive: true });
1715
+ fs.writeFileSync(path.join(realDir, "secret.md"), "do not upload me");
1716
+
1717
+ const linkDir = path.join(companyRoot, "linked-dir");
1718
+ fs.symlinkSync(realDir, linkDir);
1719
+
1720
+ const result = await share({
1721
+ paths: [companyRoot],
1722
+ company: "acme",
1723
+ vaultConfig: mockConfig,
1724
+ hqRoot: tmpDir,
1725
+ });
1726
+
1727
+ // The link record itself uploads; the target's contents do NOT.
1728
+ expect(result.filesUploaded).toBe(1);
1729
+ expect(uploadSymlink).toHaveBeenCalledWith(
1730
+ expect.anything(),
1731
+ realDir,
1732
+ "linked-dir",
1733
+ );
1734
+ // Crucially, no upload of the dir's contents.
1735
+ const calls = vi.mocked(uploadFile).mock.calls;
1736
+ expect(calls.find((c) => c[2].includes("secret.md"))).toBeUndefined();
1737
+ });
1738
+ });
1231
1739
  });