@indigoai-us/hq-cloud 6.14.36 → 6.14.37

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.
@@ -2782,6 +2782,48 @@ describe("sync", () => {
2782
2782
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2783
2783
  });
2784
2784
 
2785
+ it("skips remote keys under companies/<slug>/ in a company vault (doubly-scoped corrupt object — frogbear exit-2 regression)", async () => {
2786
+ // A company vault is already anchored at its company root, so its keys are
2787
+ // bucket-relative. A remote key literally beginning with `companies/...` is
2788
+ // a doubly-scoped corrupt object: the vault-service refuses to presign it
2789
+ // (INVALID_KEY_COMPANIES_SCOPED), so the per-file GET fails and the whole
2790
+ // company wedges at "errored" (runner exit 2) on every run. Verified live
2791
+ // 2026-06-16 against frogbear. The pull planner must refuse it at planning
2792
+ // time — same policy bucket as the malformed-key / ephemeral filters.
2793
+ const corruptKey = "companies/acme/drafts/reports/signals-2026-06-15.html";
2794
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
2795
+ // Doubly-scoped corrupt key — must be filtered, never downloaded.
2796
+ { key: corruptKey, size: 2152, lastModified: new Date(), etag: '"corrupt"' },
2797
+ // A legitimate bucket-relative key — must still download.
2798
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"ok"' },
2799
+ ]);
2800
+
2801
+ const result = await sync({
2802
+ company: "acme",
2803
+ vaultConfig: mockConfig,
2804
+ hqRoot: tmpDir,
2805
+ });
2806
+
2807
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2808
+ // The corrupt key MUST NOT be materialized at its doubled local path.
2809
+ expect(
2810
+ fs.existsSync(
2811
+ path.join(companyRoot, "companies", "acme", "drafts", "reports", "signals-2026-06-15.html"),
2812
+ ),
2813
+ ).toBe(false);
2814
+ // The vault-service GET must never be attempted for the corrupt key — the
2815
+ // failing presign is the exact symptom this guard removes.
2816
+ for (const call of vi.mocked(s3Module.downloadFile).mock.calls) {
2817
+ expect(call[1]).not.toBe(corruptKey);
2818
+ }
2819
+ // The legitimate bucket-relative key MUST still download.
2820
+ expect(fs.existsSync(path.join(companyRoot, "docs", "handoff.md"))).toBe(true);
2821
+
2822
+ expect(result.filesDownloaded).toBe(1);
2823
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2824
+ expect(result.aborted).toBe(false);
2825
+ });
2826
+
2785
2827
  it("F02: rejects traversal remote keys before they can escape the company root", async () => {
2786
2828
  const escapeName = `${path.basename(tmpDir)}-escaped.md`;
2787
2829
  const traversalKey = `../../../${escapeName}`;
package/src/cli/sync.ts CHANGED
@@ -78,7 +78,7 @@ import {
78
78
  resolveActiveCompany,
79
79
  resolveTransferConcurrency,
80
80
  } from "../sync-core.js";
81
- import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
81
+ import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
82
82
  import { resolveConflict } from "./conflict.js";
83
83
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
84
84
  import {
@@ -433,6 +433,31 @@ export type SyncProgressEvent =
433
433
  path: string;
434
434
  /** The already-journaled spelling that owns this logical path. */
435
435
  journaledKey: string;
436
+ }
437
+ | {
438
+ /**
439
+ * Emitted at most ONCE per `reason` per `share()` push leg when local
440
+ * content the walk saw was deliberately NOT uploaded and would otherwise
441
+ * vanish from every push bucket without a trace (feedback_258e4a86 /
442
+ * feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
443
+ * named nothing that was skipped). `reason` distinguishes the cause:
444
+ *
445
+ * - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
446
+ * disk but the resolver could not place it under the company folder,
447
+ * or could not find it under any base. This is actionable signal — the
448
+ * named target was NOT shipped — so the CLI may treat it as an error.
449
+ * - `"linked-subtree"`: a directory symlink whose target lives OUTSIDE
450
+ * the company folder was recorded as a link but its contents were not
451
+ * descended/uploaded. They sync via their own repo, not the vault;
452
+ * this is informational, not an error.
453
+ *
454
+ * `count` is the number of distinct paths for that reason; `samplePaths`
455
+ * carries up to 10 for display. Not emitted when `count === 0`.
456
+ */
457
+ type: "not-shipped";
458
+ reason: "unreachable-path" | "linked-subtree";
459
+ count: number;
460
+ samplePaths: string[];
436
461
  };
437
462
 
438
463
  export interface SyncOptions {
@@ -2432,6 +2457,19 @@ function computePullPlan(
2432
2457
  continue;
2433
2458
  }
2434
2459
 
2460
+ // Company-vault double-scoped-key filter. A company vault is already
2461
+ // anchored at its company root, so a remote key under `companies/...` is a
2462
+ // corrupt doubly-scoped object the vault-service refuses to presign
2463
+ // (INVALID_KEY_COMPANIES_SCOPED). Without this, the puller enqueues it, the
2464
+ // per-file GET fails, and the whole company wedges at "errored" (runner
2465
+ // exit 2) on every run (frogbear, 2026-06-16). Refuse at plan time,
2466
+ // symmetric with the malformed/ephemeral filters above. Personal vaults
2467
+ // handle `companies/...` keys in the dedicated branch below.
2468
+ if (isForbiddenCompanyVaultKey(remoteFile.key, personalMode)) {
2469
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2470
+ continue;
2471
+ }
2472
+
2435
2473
  // Personal-vault policy must be symmetric across both transfer legs.
2436
2474
  // The push walker refuses derived/machine-local/sensitive paths, but old
2437
2475
  // objects can remain in the bucket from clients that predate an exclusion.
@@ -3085,6 +3123,13 @@ function computePullPlan(
3085
3123
  }
3086
3124
  const localPath = resolveContainedVaultPath(companyRoot, key);
3087
3125
  if (localPath === null) continue;
3126
+ // Scope-invalid journal keys (company mode, `companies/…` prefix): the
3127
+ // download branch skips them (presign rejects), but a journaled entry whose
3128
+ // key is absent from the remote LIST is doubled-tree poison that must
3129
+ // drain — local copy under companies/{slug}/companies/{slug}/… removed and
3130
+ // journal dropped. HEAD verify is skipped in verifyPlannedJournalTombstones
3131
+ // (presign would reject); see the auto-verified branch there.
3132
+ const scopeInvalidJournalKey = isForbiddenCompanyVaultKey(key, personalMode);
3088
3133
  // PersonalMode key gating — mirror the download branch. Local (non-cloud)
3089
3134
  // company keys are tombstone-eligible (a peer's delete should propagate),
3090
3135
  // but team-synced orphans are left alone (the team-bucket pull owns those
@@ -3101,8 +3146,15 @@ function computePullPlan(
3101
3146
  if (isEphemeralPath(key)) continue;
3102
3147
  // Honor the current ignore filter — if a path was previously synced
3103
3148
  // 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;
3149
+ // the local copy. They're keeping it deliberately. Scope-invalid poison
3150
+ // bypasses this: the doubled-tree copy must not survive silently.
3151
+ if (
3152
+ !scopeInvalidJournalKey &&
3153
+ !shouldSync(localPath, false) &&
3154
+ !shouldSync(localPath, true)
3155
+ ) {
3156
+ continue;
3157
+ }
3106
3158
  // Codex P1 (PR #24 round 3): detect local edits before tombstoning.
3107
3159
  // Delete-vs-local-edit race: peer deleted the file remotely while
3108
3160
  // this machine edited it locally before the next sync. Without
@@ -3272,5 +3324,28 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
3272
3324
  console.warn(
3273
3325
  ` ! ${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
3326
  );
3327
+ } else if (event.type === "not-shipped") {
3328
+ if (event.reason === "unreachable-path") {
3329
+ console.warn(
3330
+ ` ! ${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"}):`,
3331
+ );
3332
+ } else {
3333
+ console.warn(
3334
+ ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
3335
+ );
3336
+ }
3337
+ for (const p of event.samplePaths) {
3338
+ console.warn(` · ${p}`);
3339
+ }
3340
+ if (event.count > event.samplePaths.length) {
3341
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
3342
+ }
3343
+ if (event.reason === "linked-subtree") {
3344
+ console.warn(
3345
+ ` To put a file from a linked subtree in the vault, push it by name:\n` +
3346
+ ` hq sync push <path> --company <slug>\n` +
3347
+ ` That uploads a point-in-time copy; re-run it after each edit (regular sync will not track or update it).`,
3348
+ );
3349
+ }
3275
3350
  }
3276
3351
  }