@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
@@ -10,6 +10,13 @@ import { clearContextCache } from "../context.js";
10
10
  import type { VaultServiceConfig } from "../types.js";
11
11
  import { lockPathFor } from "../operation-lock.js";
12
12
 
13
+ // Re-export node:fs as a mutable module so unreadable-link regressions can
14
+ // inject a win32 EINVAL without needing a real Windows reparse point.
15
+ vi.mock("fs", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("fs")>();
17
+ return { ...actual };
18
+ });
19
+
13
20
  // Mock s3 module at the top level
14
21
  vi.mock("../s3.js", async (importOriginal) => {
15
22
  const actual = await importOriginal<typeof import("../s3.js")>();
@@ -67,6 +74,12 @@ const mockConfig: VaultServiceConfig = {
67
74
  region: "us-east-1",
68
75
  };
69
76
 
77
+ function errnoError(code: string): NodeJS.ErrnoException {
78
+ const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
79
+ err.code = code;
80
+ return err;
81
+ }
82
+
70
83
  const mockEntity = {
71
84
  uid: "cmp_01ABCDEF",
72
85
  slug: "acme",
@@ -383,6 +396,52 @@ describe("sync", () => {
383
396
  expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
384
397
  });
385
398
 
399
+ it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
400
+ const linkKey = "policies/downloaded-unreadable-link";
401
+ const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
402
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
403
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
404
+ ]);
405
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
406
+ async (_ctx: unknown, _key: string, localPath: string) => {
407
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
408
+ fs.symlinkSync("missing-target.md", localPath);
409
+ return { metadata: {} };
410
+ },
411
+ );
412
+ const realReadlinkSync = fs.readlinkSync;
413
+ const readlinkSpy = vi
414
+ .spyOn(fs, "readlinkSync")
415
+ .mockImplementation(((candidate: fs.PathLike) => {
416
+ if (candidate === linkPath) throw errnoError("EINVAL");
417
+ return realReadlinkSync(candidate);
418
+ }) as typeof fs.readlinkSync);
419
+ const events: SyncProgressEvent[] = [];
420
+
421
+ try {
422
+ const result = await sync({
423
+ company: "acme",
424
+ vaultConfig: mockConfig,
425
+ hqRoot: tmpDir,
426
+ onEvent: (event) => events.push(event),
427
+ });
428
+
429
+ expect(result.filesDownloaded).toBe(0);
430
+ expect(result.filesSkipped).toBe(1);
431
+ expect(events).toContainEqual({
432
+ type: "not-shipped",
433
+ reason: "unreadable-link",
434
+ count: 1,
435
+ samplePaths: [linkKey],
436
+ });
437
+ expect(events.some((event) => event.type === "error")).toBe(false);
438
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
439
+ expect(journal.files[linkKey]).toBeUndefined();
440
+ } finally {
441
+ readlinkSpy.mockRestore();
442
+ }
443
+ });
444
+
386
445
  it("emits a conflict event with path + resolution on hash mismatch", async () => {
387
446
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
388
447
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -505,6 +564,73 @@ describe("sync", () => {
505
564
  expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
506
565
  });
507
566
 
567
+ it("defers a conflict whose downloaded link target cannot be read", async () => {
568
+ const linkKey = "docs/handoff.md";
569
+ const localPath = path.join(tmpDir, "companies", "acme", linkKey);
570
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
571
+ fs.writeFileSync(localPath, "local version");
572
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
573
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
574
+ ]);
575
+ let mirrorPath = "";
576
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
577
+ async (_ctx: unknown, _key: string, destination: string) => {
578
+ mirrorPath = destination;
579
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
580
+ fs.symlinkSync("missing-target.md", destination);
581
+ return { metadata: {} };
582
+ },
583
+ );
584
+ fs.writeFileSync(
585
+ journalPath,
586
+ JSON.stringify({
587
+ version: "1",
588
+ lastSync: new Date().toISOString(),
589
+ files: {
590
+ [linkKey]: {
591
+ hash: "stale-hash",
592
+ size: 20,
593
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
594
+ direction: "down",
595
+ },
596
+ },
597
+ }),
598
+ );
599
+ const realReadlinkSync = fs.readlinkSync;
600
+ const readlinkSpy = vi
601
+ .spyOn(fs, "readlinkSync")
602
+ .mockImplementation(((candidate: fs.PathLike) => {
603
+ if (candidate === mirrorPath) throw errnoError("EINVAL");
604
+ return realReadlinkSync(candidate);
605
+ }) as typeof fs.readlinkSync);
606
+ const events: SyncProgressEvent[] = [];
607
+
608
+ try {
609
+ const result = await sync({
610
+ company: "acme",
611
+ onConflict: "keep",
612
+ vaultConfig: mockConfig,
613
+ hqRoot: tmpDir,
614
+ onEvent: (event) => events.push(event),
615
+ });
616
+
617
+ expect(result.conflicts).toBe(0);
618
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
619
+ expect(events).toContainEqual({
620
+ type: "not-shipped",
621
+ reason: "unreadable-link",
622
+ count: 1,
623
+ samplePaths: [linkKey],
624
+ });
625
+ expect(events.some((event) => event.type === "error")).toBe(false);
626
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
627
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
628
+ expect(journal.files[linkKey].hash).toBe("stale-hash");
629
+ } finally {
630
+ readlinkSpy.mockRestore();
631
+ }
632
+ });
633
+
508
634
  it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
509
635
  // Guard the other side of the convergence branch: when the probe bytes
510
636
  // differ from local, it remains a real conflict — counted, kept, and the
@@ -2782,6 +2908,48 @@ describe("sync", () => {
2782
2908
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2783
2909
  });
2784
2910
 
2911
+ it("skips remote keys under companies/<slug>/ in a company vault (doubly-scoped corrupt object — frogbear exit-2 regression)", async () => {
2912
+ // A company vault is already anchored at its company root, so its keys are
2913
+ // bucket-relative. A remote key literally beginning with `companies/...` is
2914
+ // a doubly-scoped corrupt object: the vault-service refuses to presign it
2915
+ // (INVALID_KEY_COMPANIES_SCOPED), so the per-file GET fails and the whole
2916
+ // company wedges at "errored" (runner exit 2) on every run. Verified live
2917
+ // 2026-06-16 against frogbear. The pull planner must refuse it at planning
2918
+ // time — same policy bucket as the malformed-key / ephemeral filters.
2919
+ const corruptKey = "companies/acme/drafts/reports/signals-2026-06-15.html";
2920
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
2921
+ // Doubly-scoped corrupt key — must be filtered, never downloaded.
2922
+ { key: corruptKey, size: 2152, lastModified: new Date(), etag: '"corrupt"' },
2923
+ // A legitimate bucket-relative key — must still download.
2924
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"ok"' },
2925
+ ]);
2926
+
2927
+ const result = await sync({
2928
+ company: "acme",
2929
+ vaultConfig: mockConfig,
2930
+ hqRoot: tmpDir,
2931
+ });
2932
+
2933
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2934
+ // The corrupt key MUST NOT be materialized at its doubled local path.
2935
+ expect(
2936
+ fs.existsSync(
2937
+ path.join(companyRoot, "companies", "acme", "drafts", "reports", "signals-2026-06-15.html"),
2938
+ ),
2939
+ ).toBe(false);
2940
+ // The vault-service GET must never be attempted for the corrupt key — the
2941
+ // failing presign is the exact symptom this guard removes.
2942
+ for (const call of vi.mocked(s3Module.downloadFile).mock.calls) {
2943
+ expect(call[1]).not.toBe(corruptKey);
2944
+ }
2945
+ // The legitimate bucket-relative key MUST still download.
2946
+ expect(fs.existsSync(path.join(companyRoot, "docs", "handoff.md"))).toBe(true);
2947
+
2948
+ expect(result.filesDownloaded).toBe(1);
2949
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2950
+ expect(result.aborted).toBe(false);
2951
+ });
2952
+
2785
2953
  it("F02: rejects traversal remote keys before they can escape the company root", async () => {
2786
2954
  const escapeName = `${path.basename(tmpDir)}-escaped.md`;
2787
2955
  const traversalKey = `../../../${escapeName}`;
@@ -4089,6 +4257,47 @@ describe("sync", () => {
4089
4257
  expect(result.conflicts).toBe(0);
4090
4258
  });
4091
4259
 
4260
+ it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
4261
+ const linkKey = "policies/unreadable-link";
4262
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4263
+ const linkPath = path.join(companyRoot, linkKey);
4264
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
4265
+ fs.symlinkSync("target.md", linkPath);
4266
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4267
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
4268
+ ]);
4269
+
4270
+ const realReadlinkSync = fs.readlinkSync;
4271
+ const readlinkSpy = vi
4272
+ .spyOn(fs, "readlinkSync")
4273
+ .mockImplementation(((candidate: fs.PathLike) => {
4274
+ if (candidate === linkPath) throw errnoError("EINVAL");
4275
+ return realReadlinkSync(candidate);
4276
+ }) as typeof fs.readlinkSync);
4277
+
4278
+ try {
4279
+ const events: SyncProgressEvent[] = [];
4280
+ const result = await sync({
4281
+ company: "acme",
4282
+ vaultConfig: mockConfig,
4283
+ hqRoot: tmpDir,
4284
+ onEvent: (event) => events.push(event),
4285
+ });
4286
+
4287
+ expect(result.filesDownloaded).toBe(0);
4288
+ expect(result.filesSkipped).toBe(1);
4289
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
4290
+ expect(events).toContainEqual({
4291
+ type: "not-shipped",
4292
+ reason: "unreadable-link",
4293
+ count: 1,
4294
+ samplePaths: [linkKey],
4295
+ });
4296
+ } finally {
4297
+ readlinkSpy.mockRestore();
4298
+ }
4299
+ });
4300
+
4092
4301
  it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
4093
4302
  // Codex round-11 P2 follow-up: pre-fix, the conflict executor
4094
4303
  // built the resolveConflict prompt with `fs.statSync(localPath).
package/src/cli/sync.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  SyncJournal,
14
14
  } from "../types.js";
15
15
  import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
16
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
16
17
  import {
17
18
  emitCloudTelemetry,
18
19
  type TelemetryClaims,
@@ -78,7 +79,7 @@ import {
78
79
  resolveActiveCompany,
79
80
  resolveTransferConcurrency,
80
81
  } from "../sync-core.js";
81
- import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
82
+ import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
82
83
  import { resolveConflict } from "./conflict.js";
83
84
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
84
85
  import {
@@ -433,6 +434,34 @@ export type SyncProgressEvent =
433
434
  path: string;
434
435
  /** The already-journaled spelling that owns this logical path. */
435
436
  journaledKey: string;
437
+ }
438
+ | {
439
+ /**
440
+ * Emitted when local content is deliberately not transferred and would
441
+ * otherwise vanish from sync output without a trace (feedback_258e4a86 /
442
+ * feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
443
+ * named nothing that was skipped). Push batches by reason; pull emits an
444
+ * unreadable link by its remote key. `reason` distinguishes the cause:
445
+ *
446
+ * - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
447
+ * disk but the resolver could not place it under the company folder,
448
+ * or could not find it under any base. This is actionable signal — the
449
+ * named target was NOT shipped — so the CLI may treat it as an error.
450
+ * - `"linked-subtree"`: a directory symlink whose target lives OUTSIDE
451
+ * the company folder was recorded as a link but its contents were not
452
+ * descended/uploaded. They sync via their own repo, not the vault;
453
+ * this is informational, not an error.
454
+ * - `"unreadable-link"`: the OS identified a local symbolic link but
455
+ * did not expose a readable target. The link is skipped rather than
456
+ * dereferenced, and the company leg remains complete.
457
+ *
458
+ * `count` is the number of distinct paths for that reason; `samplePaths`
459
+ * carries up to 10 for display. Not emitted when `count === 0`.
460
+ */
461
+ type: "not-shipped";
462
+ reason: "unreachable-path" | "unreadable-link" | "linked-subtree";
463
+ count: number;
464
+ samplePaths: string[];
436
465
  };
437
466
 
438
467
  export interface SyncOptions {
@@ -1236,6 +1265,19 @@ async function executeConflictExecutor(
1236
1265
  counters.filesSkipped++;
1237
1266
  continue;
1238
1267
  }
1268
+ if (item.action === "skip-unreadable-link") {
1269
+ // An unreadable local link must stay non-fatal: the runner treats a
1270
+ // generic error event as a failed company leg. Surface the exact remote
1271
+ // key through the recoverable not-shipped channel instead.
1272
+ counters.filesSkipped++;
1273
+ run.emit({
1274
+ type: "not-shipped",
1275
+ reason: "unreadable-link",
1276
+ count: 1,
1277
+ samplePaths: [item.remoteFile.key],
1278
+ });
1279
+ continue;
1280
+ }
1239
1281
  if (item.action === "skip-excluded-policy") {
1240
1282
  continue;
1241
1283
  }
@@ -1383,10 +1425,23 @@ async function executeConflictItem(
1383
1425
  try {
1384
1426
  const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
1385
1427
  remoteFetched = true;
1386
- const remoteHash = fs.lstatSync(conflictAbs).isSymbolicLink()
1387
- ? hashSymlinkTarget(fs.readlinkSync(conflictAbs))
1388
- : (downloaded.contentHash ?? hashFile(conflictAbs));
1389
- converged = remoteHash === item.localHash;
1428
+ if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
1429
+ const target = readlinkOrNull(conflictAbs);
1430
+ if (target === null) {
1431
+ counters.filesSkipped++;
1432
+ run.emit({
1433
+ type: "not-shipped",
1434
+ reason: "unreadable-link",
1435
+ count: 1,
1436
+ samplePaths: [remoteFile.key],
1437
+ });
1438
+ return null;
1439
+ } else {
1440
+ converged = hashSymlinkTarget(target) === item.localHash;
1441
+ }
1442
+ } else {
1443
+ converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
1444
+ }
1390
1445
  } catch (probeErr) {
1391
1446
  if (probeErr instanceof VaultAuthError) throw probeErr;
1392
1447
  run.emit({
@@ -1610,9 +1665,23 @@ async function downloadOne(
1610
1665
 
1611
1666
  const localLstat = fs.lstatSync(localPath);
1612
1667
  const isLocalSymlink = localLstat.isSymbolicLink();
1613
- const hash = isLocalSymlink
1614
- ? hashSymlinkTarget(fs.readlinkSync(localPath))
1615
- : (contentHash ?? hashFile(localPath));
1668
+ let hash: string;
1669
+ if (isLocalSymlink) {
1670
+ const target = readlinkOrNull(localPath);
1671
+ if (target === null) {
1672
+ counters.filesSkipped++;
1673
+ run.emit({
1674
+ type: "not-shipped",
1675
+ reason: "unreadable-link",
1676
+ count: 1,
1677
+ samplePaths: [remoteFile.key],
1678
+ });
1679
+ return;
1680
+ }
1681
+ hash = hashSymlinkTarget(target);
1682
+ } else {
1683
+ hash = contentHash ?? hashFile(localPath);
1684
+ }
1616
1685
  const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
1617
1686
 
1618
1687
  updateEntry(
@@ -2141,6 +2210,11 @@ type PullPlanItem =
2141
2210
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
2142
2211
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
2143
2212
  | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
2213
+ // A local symlink was classified as a link but Windows (or a filesystem
2214
+ // race) would not expose its target. This stays distinct from generic
2215
+ // skip-local-only so the executor can report the exact path without making
2216
+ // the whole company leg fail.
2217
+ | { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string }
2144
2218
  // A stale personal-overlay symlink marker in the vault sitting at a key that
2145
2219
  // is ALSO a real, release-shipped core directory (e.g. an old
2146
2220
  // `core/knowledge/public/agent-browser` overlay marker after a release
@@ -2432,6 +2506,19 @@ function computePullPlan(
2432
2506
  continue;
2433
2507
  }
2434
2508
 
2509
+ // Company-vault double-scoped-key filter. A company vault is already
2510
+ // anchored at its company root, so a remote key under `companies/...` is a
2511
+ // corrupt doubly-scoped object the vault-service refuses to presign
2512
+ // (INVALID_KEY_COMPANIES_SCOPED). Without this, the puller enqueues it, the
2513
+ // per-file GET fails, and the whole company wedges at "errored" (runner
2514
+ // exit 2) on every run (frogbear, 2026-06-16). Refuse at plan time,
2515
+ // symmetric with the malformed/ephemeral filters above. Personal vaults
2516
+ // handle `companies/...` keys in the dedicated branch below.
2517
+ if (isForbiddenCompanyVaultKey(remoteFile.key, personalMode)) {
2518
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2519
+ continue;
2520
+ }
2521
+
2435
2522
  // Personal-vault policy must be symmetric across both transfer legs.
2436
2523
  // The push walker refuses derived/machine-local/sensitive paths, but old
2437
2524
  // objects can remain in the bucket from clients that predate an exclusion.
@@ -2762,9 +2849,19 @@ function computePullPlan(
2762
2849
  ) {
2763
2850
  localHash = journalEntry.hash;
2764
2851
  } else {
2765
- localHash = isLocalSymlink
2766
- ? hashSymlinkTarget(fs.readlinkSync(localPath))
2767
- : hashFile(localPath);
2852
+ if (isLocalSymlink) {
2853
+ const target = readlinkOrNull(localPath);
2854
+ if (target === null) {
2855
+ // We cannot prove whether the local link diverged, so never
2856
+ // overwrite or tombstone it from this plan. A later pass can retry
2857
+ // once the OS exposes a readable target.
2858
+ items.push({ action: "skip-unreadable-link", remoteFile, localPath });
2859
+ continue;
2860
+ }
2861
+ localHash = hashSymlinkTarget(target);
2862
+ } else {
2863
+ localHash = hashFile(localPath);
2864
+ }
2768
2865
  }
2769
2866
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
2770
2867
  plannedLocalSnapshot = {
@@ -3085,6 +3182,13 @@ function computePullPlan(
3085
3182
  }
3086
3183
  const localPath = resolveContainedVaultPath(companyRoot, key);
3087
3184
  if (localPath === null) continue;
3185
+ // Scope-invalid journal keys (company mode, `companies/…` prefix): the
3186
+ // download branch skips them (presign rejects), but a journaled entry whose
3187
+ // key is absent from the remote LIST is doubled-tree poison that must
3188
+ // drain — local copy under companies/{slug}/companies/{slug}/… removed and
3189
+ // journal dropped. HEAD verify is skipped in verifyPlannedJournalTombstones
3190
+ // (presign would reject); see the auto-verified branch there.
3191
+ const scopeInvalidJournalKey = isForbiddenCompanyVaultKey(key, personalMode);
3088
3192
  // PersonalMode key gating — mirror the download branch. Local (non-cloud)
3089
3193
  // company keys are tombstone-eligible (a peer's delete should propagate),
3090
3194
  // but team-synced orphans are left alone (the team-bucket pull owns those
@@ -3101,8 +3205,15 @@ function computePullPlan(
3101
3205
  if (isEphemeralPath(key)) continue;
3102
3206
  // Honor the current ignore filter — if a path was previously synced
3103
3207
  // but is now ignored (operator edited .hqignore), do NOT delete
3104
- // the local copy. They're keeping it deliberately.
3105
- if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
3208
+ // the local copy. They're keeping it deliberately. Scope-invalid poison
3209
+ // bypasses this: the doubled-tree copy must not survive silently.
3210
+ if (
3211
+ !scopeInvalidJournalKey &&
3212
+ !shouldSync(localPath, false) &&
3213
+ !shouldSync(localPath, true)
3214
+ ) {
3215
+ continue;
3216
+ }
3106
3217
  // Codex P1 (PR #24 round 3): detect local edits before tombstoning.
3107
3218
  // Delete-vs-local-edit race: peer deleted the file remotely while
3108
3219
  // this machine edited it locally before the next sync. Without
@@ -3272,5 +3383,32 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
3272
3383
  console.warn(
3273
3384
  ` ! ${event.path} skipped — junk key spelling; this logical path is already synced as ${event.journaledKey} (mixed-version peer artifact; the vault doctor collapses the family)`,
3274
3385
  );
3386
+ } else if (event.type === "not-shipped") {
3387
+ if (event.reason === "unreachable-path") {
3388
+ console.warn(
3389
+ ` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
3390
+ );
3391
+ } else if (event.reason === "unreadable-link") {
3392
+ console.warn(
3393
+ ` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
3394
+ );
3395
+ } else {
3396
+ console.warn(
3397
+ ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
3398
+ );
3399
+ }
3400
+ for (const p of event.samplePaths) {
3401
+ console.warn(` · ${p}`);
3402
+ }
3403
+ if (event.count > event.samplePaths.length) {
3404
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
3405
+ }
3406
+ if (event.reason === "linked-subtree") {
3407
+ console.warn(
3408
+ ` To put a file from a linked subtree in the vault, push it by name:\n` +
3409
+ ` hq sync push <path> --company <slug>\n` +
3410
+ ` That uploads a point-in-time copy; re-run it after each edit (regular sync will not track or update it).`,
3411
+ );
3412
+ }
3275
3413
  }
3276
3414
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readlinkOrNull } from "./readlink-safe.js";
3
+
4
+ function errnoError(code: string): NodeJS.ErrnoException {
5
+ const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
6
+ err.code = code;
7
+ return err;
8
+ }
9
+
10
+ describe("readlinkOrNull", () => {
11
+ it("uses the filesystem reader by default", () => {
12
+ expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
13
+ });
14
+
15
+ it("returns the target string verbatim when readlink succeeds", () => {
16
+ expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
17
+ "../target with spaces",
18
+ );
19
+ });
20
+
21
+ it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
22
+ expect(readlinkOrNull("/test/link", () => {
23
+ throw errnoError(code);
24
+ })).toBeNull();
25
+ });
26
+
27
+ it("rethrows non-filesystem failures", () => {
28
+ const defect = new Error("unexpected test defect");
29
+
30
+ expect(() => readlinkOrNull("/test/link", () => {
31
+ throw defect;
32
+ })).toThrow(defect);
33
+ });
34
+
35
+ it("rethrows code-bearing programming errors", () => {
36
+ const defect = new TypeError("path must be a string") as TypeError & { code: string };
37
+ defect.code = "ERR_INVALID_ARG_TYPE";
38
+
39
+ expect(() => readlinkOrNull("/test/link", () => {
40
+ throw defect;
41
+ })).toThrow(defect);
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ import * as fs from "fs";
2
+
3
+ /**
4
+ * Read a link target without turning an expected filesystem race or an
5
+ * unsupported link shape into a caller-wide failure.
6
+ *
7
+ * A `null` result is deliberately distinct from an empty target string: it
8
+ * means the caller must preserve the link boundary and choose its conservative
9
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
10
+ * errors cannot be mistaken for an unreadable link.
11
+ */
12
+ export function readlinkOrNull(
13
+ linkPath: string,
14
+ readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
15
+ ): string | null {
16
+ try {
17
+ return readlink(linkPath);
18
+ } catch (err: unknown) {
19
+ const code =
20
+ err && typeof err === "object" && "code" in err
21
+ ? (err as { code?: unknown }).code
22
+ : undefined;
23
+ // Node system errors use POSIX-style errno names such as EINVAL and
24
+ // ENOENT. Runtime/programming errors use ERR_* codes; those must remain
25
+ // loud rather than being misclassified as an unreadable link.
26
+ if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
27
+ throw err;
28
+ }
29
+ }