@indigoai-us/hq-cloud 6.2.4 → 6.2.6

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.
package/src/cli/share.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  import { resolveConflict } from "./conflict.js";
42
42
  import type { ConflictStrategy } from "./conflict.js";
43
43
  import type { SyncProgressEvent } from "./sync.js";
44
+ import { isCoveredByAny, isDirInScope } from "../prefix-coalesce.js";
44
45
  import {
45
46
  buildConflictId,
46
47
  buildConflictPath,
@@ -218,6 +219,21 @@ export function isEphemeralPath(p: string): boolean {
218
219
  return EPHEMERAL_PATH_PATTERN.test(p);
219
220
  }
220
221
 
222
+ /**
223
+ * A vault key containing a backslash is never legitimate. HQ keys are POSIX
224
+ * (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
225
+ * hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
226
+ * from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
227
+ * verified live 2026-06-10: one such client duplicated 5,711 keys
228
+ * (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
229
+ * puller then materialized them as junk single-filename-with-backslash files
230
+ * that churned conflicts forever. The pull walker refuses these keys
231
+ * (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
232
+ */
233
+ export function isMalformedVaultKey(key: string): boolean {
234
+ return key.includes("\\");
235
+ }
236
+
221
237
  /**
222
238
  * Test-only export. Kept under a `_testing` namespace so the module's public
223
239
  * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
@@ -576,6 +592,20 @@ export interface ShareOptions {
576
592
  * `sync-journal.personal.json` file.
577
593
  */
578
594
  journalSlug?: string;
595
+ /**
596
+ * Effective ACL push scope as a list of company-relative prefixes (the same
597
+ * coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When
598
+ * provided, the upload + delete plans are filtered to paths covered by these
599
+ * prefixes — any candidate outside them is skipped (and surfaced via a
600
+ * `scope-excluded` event) rather than PUT, because the vended child
601
+ * credential is scoped to exactly these prefixes and an out-of-scope PUT
602
+ * draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`.
603
+ *
604
+ * `undefined` (the owner/`all` case, and `hq share <file>`) applies NO scope
605
+ * filter — full access. An empty array means "no granted prefixes" → every
606
+ * path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
607
+ */
608
+ prefixSet?: string[];
579
609
  }
580
610
 
581
611
  export interface ShareResult {
@@ -629,6 +659,16 @@ export interface ShareResult {
629
659
  * emitted exactly once if this is > 0).
630
660
  */
631
661
  filesExcludedByPolicy: number;
662
+ /**
663
+ * Number of distinct company-relative paths/prefixes skipped because they
664
+ * fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always
665
+ * 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in
666
+ * scope. Mirrors the `count` field of the `scope-excluded` event (emitted
667
+ * once if this is > 0). These paths were never PUT, so the server's correct
668
+ * 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs
669
+ * its in-scope subset.
670
+ */
671
+ filesExcludedByScope: number;
632
672
  /**
633
673
  * Paths (company-relative) that were detected as push conflicts. Mirrors
634
674
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -638,6 +678,56 @@ export interface ShareResult {
638
678
  aborted: boolean;
639
679
  }
640
680
 
681
+ /**
682
+ * Is this error the S3/STS "access denied" class? Expected when a scoped
683
+ * member/guest credential touches a key outside its granted ACL prefixes
684
+ * (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
685
+ * Forbidden). Mirrors the pull-side `isAccessDenied` in sync.ts so the push
686
+ * leg can treat a stray out-of-scope key as a skip rather than a fatal throw.
687
+ */
688
+ function isAccessDenied(err: unknown): boolean {
689
+ if (err && typeof err === "object" && "name" in err) {
690
+ const name = (err as { name?: unknown }).name;
691
+ return name === "AccessDenied" || name === "Forbidden";
692
+ }
693
+ return false;
694
+ }
695
+
696
+ /**
697
+ * Wrap an existing path filter (ignore filter, optionally already wrapped with
698
+ * the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
699
+ * are rejected before they enter the upload OR delete plan. Keeps the
700
+ * `(absolutePath, isDir) => boolean` shape the rest of share() already uses
701
+ * (collectFiles / walkDir / computeDeletePlan), so wiring is one line at the
702
+ * filter-construction site — exactly like `wrapFilterWithPersonalVaultDefaults`.
703
+ *
704
+ * Files use `isCoveredByAny` (plain `startsWith`); directories use
705
+ * `isDirInScope` (descend if the dir is inside a grant OR a grant is inside
706
+ * the dir) so the walk still reaches deep/exact-file grants. Rejected entries
707
+ * are tagged via `onScopeExcluded` (directories with a trailing slash) so the
708
+ * runner can emit a single `scope-excluded` summary naming what was skipped.
709
+ */
710
+ function wrapFilterWithScope(
711
+ underlying: (absPath: string, isDir?: boolean) => boolean,
712
+ syncRoot: string,
713
+ prefixSet: readonly string[],
714
+ onScopeExcluded: (rel: string) => void,
715
+ ): (absPath: string, isDir?: boolean) => boolean {
716
+ return (absPath: string, isDir?: boolean) => {
717
+ if (!underlying(absPath, isDir)) return false;
718
+ const rel = path.relative(syncRoot, absPath).split(path.sep).join("/");
719
+ if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
720
+ if (isDir) {
721
+ if (isDirInScope(rel, prefixSet)) return true;
722
+ onScopeExcluded(rel.endsWith("/") ? rel : rel + "/");
723
+ return false;
724
+ }
725
+ if (isCoveredByAny(rel, prefixSet)) return true;
726
+ onScopeExcluded(rel);
727
+ return false;
728
+ };
729
+ }
730
+
641
731
  /**
642
732
  * Share local file(s) to the entity vault.
643
733
  */
@@ -753,9 +843,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
753
843
  excludedSet.add(rel);
754
844
  excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
755
845
  };
756
- const shouldSync = options.personalMode === true
846
+ // ACL scope filter (member/guest scoped push). The vended child credential
847
+ // is scoped to `options.prefixSet`; any candidate outside those prefixes
848
+ // would draw the server's correct 403 SCOPE_EXCEEDS_PARENT on PUT and abort
849
+ // the whole company. Pre-filter the plan to the granted subset instead —
850
+ // the push-side analogue of the pull leg's `skip-out-of-scope` (US-005).
851
+ // `undefined` = owner/`all` → no scope filter (full access).
852
+ const scopeExcludedSet = new Set<string>();
853
+ const onScopeExcluded = (rel: string) => {
854
+ scopeExcludedSet.add(rel);
855
+ };
856
+ const baseFilter = options.personalMode === true
757
857
  ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
758
858
  : ignoreFilter;
859
+ const shouldSync = options.prefixSet !== undefined
860
+ ? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
861
+ : baseFilter;
759
862
  const journalSlug = options.journalSlug ?? ctx.slug;
760
863
  // Seed the canonical personal-vault journal from the legacy `personal` file
761
864
  // exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
@@ -993,7 +1096,32 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
993
1096
  // single-part etag) and compare. Match → no conflict, silently skip
994
1097
  // the PUT (the bytes are already there). Mismatch → treat as a
995
1098
  // conflict in the same shared branch below.
996
- const remoteMeta = await headRemoteFile(ctx, relativePath);
1099
+ // Defense-in-depth for the scoped-push 403: the `prefixSet` filter above
1100
+ // should already have dropped any out-of-scope key from the plan, but a
1101
+ // grant that changed mid-run, a pinned prefix outside the grant, or
1102
+ // prefix-coalesce imprecision can still leave an out-of-scope key here.
1103
+ // This HEAD sits OUTSIDE the per-file PUT try/catch below, so a thrown
1104
+ // 403 used to bubble to `workerErrors` and abort the ENTIRE company with
1105
+ // a generic message and exit 2. Catch the access-denied class, surface
1106
+ // the offending PATH clearly, and skip just this key — the rest of the
1107
+ // company still syncs. Non-access-denied errors re-throw unchanged.
1108
+ let remoteMeta: Awaited<ReturnType<typeof headRemoteFile>>;
1109
+ try {
1110
+ remoteMeta = await headRemoteFile(ctx, relativePath);
1111
+ } catch (headErr) {
1112
+ if (isAccessDenied(headErr)) {
1113
+ emit({
1114
+ type: "error",
1115
+ path: relativePath,
1116
+ message:
1117
+ "skipped: outside granted ACL scope (server returned 403 " +
1118
+ "SCOPE_EXCEEDS_PARENT / access denied on HEAD). Grant this path " +
1119
+ "to push it, or it stays local-only.",
1120
+ });
1121
+ return;
1122
+ }
1123
+ throw headErr;
1124
+ }
997
1125
  if (remoteMeta) {
998
1126
  const journalEntry = journal.files[relativePath];
999
1127
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
@@ -1199,7 +1327,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1199
1327
  emit({
1200
1328
  type: "error",
1201
1329
  path: relativePath,
1202
- message: err instanceof Error ? err.message : String(err),
1330
+ message: isAccessDenied(err)
1331
+ ? "skipped: outside granted ACL scope (server returned 403 " +
1332
+ "SCOPE_EXCEEDS_PARENT / access denied on PUT). Grant this path " +
1333
+ "to push it, or it stays local-only."
1334
+ : err instanceof Error
1335
+ ? err.message
1336
+ : String(err),
1203
1337
  });
1204
1338
  }
1205
1339
  };
@@ -1269,6 +1403,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1269
1403
  // abort (matches the existing convention: abort short-circuits
1270
1404
  // before the end-of-run telemetry emits).
1271
1405
  filesExcludedByPolicy: excludedSet.size,
1406
+ // Scope exclusions are likewise computed during the upload walk, so the
1407
+ // count is meaningful on the abort path too.
1408
+ filesExcludedByScope: scopeExcludedSet.size,
1272
1409
  // Use the snapshot of conflictPaths taken at the moment the abort
1273
1410
  // fired — additional in-flight items may have appended to the
1274
1411
  // shared array after the abort signal, and those should not show
@@ -1388,6 +1525,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1388
1525
  });
1389
1526
  }
1390
1527
 
1528
+ // ACL scope-exclusion summary. Emit at most once when one or more candidate
1529
+ // paths fell outside the run's granted `prefixSet` and were skipped from the
1530
+ // upload/delete plan. Informational (NOT an error): the company still syncs
1531
+ // its in-scope subset and the run exits 0. Sample capped at 10 (insertion
1532
+ // order = walk order, deterministic).
1533
+ if (scopeExcludedSet.size > 0) {
1534
+ const samplePaths: string[] = [];
1535
+ for (const p of scopeExcludedSet) {
1536
+ samplePaths.push(p);
1537
+ if (samplePaths.length >= 10) break;
1538
+ }
1539
+ emit({
1540
+ type: "scope-excluded",
1541
+ count: scopeExcludedSet.size,
1542
+ samplePaths,
1543
+ });
1544
+ }
1545
+
1391
1546
  // Codex P1 (5.36.x): if any worker rejected (unhandled error in
1392
1547
  // headRemoteFile / refreshEntityContext / resolveConflict — paths
1393
1548
  // outside the per-item PUT try/catch), we deliberately let the pool
@@ -1414,6 +1569,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1414
1569
  filesRefusedStale,
1415
1570
  filesRefusedStalePaths,
1416
1571
  filesExcludedByPolicy: excludedSet.size,
1572
+ filesExcludedByScope: scopeExcludedSet.size,
1417
1573
  conflictPaths,
1418
1574
  aborted: false,
1419
1575
  };
@@ -1100,6 +1100,42 @@ describe("sync", () => {
1100
1100
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
1101
1101
  });
1102
1102
 
1103
+ it("skips remote keys containing backslashes (malformed Windows-client keys)", async () => {
1104
+ // A pre-5.47.2 Windows client built S3 keys with path.sep, duplicating a
1105
+ // company tree under keys like `skills\\demo-hq\\SKILL.md` (verified live
1106
+ // 2026-06-10: 5,711 such keys in one vault). On POSIX, downloading one
1107
+ // creates a junk FILE whose name contains backslashes, which then churns
1108
+ // conflict mirrors on every sync. The pull planner must refuse them at
1109
+ // planning time — same policy bucket as the ephemeral-mirror filter.
1110
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1111
+ // Malformed Windows key — must be filtered, never downloaded.
1112
+ {
1113
+ key: "skills\\demo\\SKILL.md",
1114
+ size: 44,
1115
+ lastModified: new Date(),
1116
+ etag: '"abc"',
1117
+ },
1118
+ // Its legitimate POSIX twin — must still download.
1119
+ { key: "skills/demo/SKILL.md", size: 30, lastModified: new Date(), etag: '"def"' },
1120
+ ]);
1121
+
1122
+ const result = await sync({
1123
+ company: "acme",
1124
+ vaultConfig: mockConfig,
1125
+ hqRoot: tmpDir,
1126
+ });
1127
+
1128
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1129
+ // The malformed key MUST NOT be materialized — neither as a literal
1130
+ // backslash-named file nor as a nested path.
1131
+ expect(fs.existsSync(path.join(companyRoot, "skills\\demo\\SKILL.md"))).toBe(false);
1132
+ // The legitimate twin MUST download.
1133
+ expect(fs.existsSync(path.join(companyRoot, "skills", "demo", "SKILL.md"))).toBe(true);
1134
+
1135
+ expect(result.filesDownloaded).toBe(1);
1136
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
1137
+ });
1138
+
1103
1139
  it("overwrites local on --on-conflict overwrite", async () => {
1104
1140
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
1105
1141
  fs.mkdirSync(companyDocs, { recursive: true });
package/src/cli/sync.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  } from "../scope-shrink.js";
43
43
  import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
44
44
  import { createIgnoreFilter } from "../ignore.js";
45
- import { isEphemeralPath } from "./share.js";
45
+ import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
46
46
  import { resolveConflict } from "./conflict.js";
47
47
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
48
48
  import {
@@ -241,6 +241,28 @@ export type SyncProgressEvent =
241
241
  count: number;
242
242
  samplePaths: string[];
243
243
  byId: Record<string, number>;
244
+ }
245
+ | {
246
+ /**
247
+ * Emitted at most ONCE per `share()` call (push leg) when the run is
248
+ * scoped to a member/guest's ACL prefixes (`prefixSet`) and one or more
249
+ * candidate paths fell OUTSIDE the granted prefixes. Those paths are
250
+ * skipped from the upload (and delete) plan instead of being PUT — the
251
+ * vended child credential is scoped to the granted prefixes, so pushing
252
+ * them would draw the server's correct 403 `SCOPE_EXCEEDS_PARENT` and
253
+ * (pre-fix) abort the WHOLE company. This is the push-side analogue of
254
+ * the pull-side `skip-out-of-scope` action.
255
+ *
256
+ * `count` is the number of distinct company-relative paths/prefixes the
257
+ * scope filter excluded; `samplePaths` carries up to 10 for diagnostic
258
+ * display. Informational, NOT an error — the company still syncs its
259
+ * in-scope subset and the run exits 0.
260
+ *
261
+ * Not emitted when `count === 0` — silent on a fully in-scope tree.
262
+ */
263
+ type: "scope-excluded";
264
+ count: number;
265
+ samplePaths: string[];
244
266
  };
245
267
 
246
268
  export interface SyncOptions {
@@ -1477,6 +1499,17 @@ function computePullPlan(
1477
1499
  continue;
1478
1500
  }
1479
1501
 
1502
+ // Malformed-key filter — keys with backslash separators pushed by
1503
+ // pre-5.47.2 Windows clients. Downloading one materializes a junk local
1504
+ // file whose NAME contains backslashes (it is not a path on POSIX), which
1505
+ // then churns conflict mirrors forever. Refuse at planning time, same
1506
+ // policy bucket as the ephemeral filter above. The bogus keys themselves
1507
+ // are cleaned server-side; this keeps clean trees clean in the meantime.
1508
+ if (isMalformedVaultKey(remoteFile.key)) {
1509
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
1510
+ continue;
1511
+ }
1512
+
1480
1513
  if (personalMode && remoteFile.key.startsWith("companies/")) {
1481
1514
  // Default: drop every `companies/...` key — the legacy contract
1482
1515
  // is that the personal bucket should never contain them.
@@ -1866,5 +1899,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1866
1899
  console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
1867
1900
  }
1868
1901
  }
1902
+ } else if (event.type === "scope-excluded") {
1903
+ console.log(
1904
+ ` ~ ${event.count} path${event.count === 1 ? "" : "s"} skipped — outside your granted access (synced the in-scope subset):`,
1905
+ );
1906
+ for (const p of event.samplePaths) {
1907
+ console.log(` · ${p}`);
1908
+ }
1909
+ if (event.count > event.samplePaths.length) {
1910
+ console.log(` ... and ${event.count - event.samplePaths.length} more`);
1911
+ }
1869
1912
  }
1870
1913
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Regression guard: the watch + event-push path must NOT mint phantom
3
+ * `.conflict-*` mirrors for an ordinary single-writer local edit.
4
+ *
5
+ * Background (feedback_ef2b7c8c): a user editing files under
6
+ * `companies/{slug}/` saw the menubar's watch/event-push runner mint a
7
+ * `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
8
+ * thousands of phantom files in a single session. The watch/event-push runner
9
+ * reacts to a local change by running a targeted `--direction push` pass, i.e.
10
+ * `share()` over the changed company subtree, so the conflict-minting logic is
11
+ * share()'s push-side conflict gate. The original gate flagged a conflict on
12
+ * `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
13
+ * every edit of any already-synced file. The gate now requires
14
+ * `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
15
+ * has `remoteChanged` (the live S3 ETag still equals the journal's recorded
16
+ * baseline), so an ordinary edit is uploaded, not mirrored.
17
+ *
18
+ * This test pins that contract end-to-end through `share()` using the same
19
+ * mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
20
+ * ETag is always exactly what THIS device last uploaded. A positive control
21
+ * (a genuine out-of-band peer write) proves the guard is not trivially
22
+ * always-zero: real divergence is still detected.
23
+ */
24
+
25
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
26
+ import * as fs from "fs";
27
+ import * as path from "path";
28
+ import * as os from "os";
29
+ import { clearContextCache } from "../context.js";
30
+ import type { VaultServiceConfig } from "../types.js";
31
+
32
+ vi.mock("../s3.js", () => ({
33
+ toPosixKey: (key: string) => key.split("\\").join("/"),
34
+ uploadFile: vi.fn(),
35
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
36
+ downloadFile: vi.fn().mockResolvedValue(undefined),
37
+ listRemoteFiles: vi.fn().mockResolvedValue([]),
38
+ deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
39
+ headRemoteFile: vi.fn(),
40
+ primeObjectTransport: vi.fn().mockResolvedValue(undefined),
41
+ primeUploads: vi.fn().mockResolvedValue(undefined),
42
+ }));
43
+
44
+ vi.mock("readline", () => ({
45
+ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
46
+ }));
47
+
48
+ import { share } from "./share.js";
49
+ import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
50
+
51
+ const mockConfig: VaultServiceConfig = {
52
+ apiUrl: "https://vault-api.test",
53
+ authToken: "test-jwt-token",
54
+ region: "us-east-1",
55
+ };
56
+
57
+ const mockEntity = {
58
+ uid: "cmp_01ABCDEF",
59
+ slug: "acme",
60
+ bucketName: "hq-vault-acme-123",
61
+ status: "active",
62
+ };
63
+
64
+ function setupFetchMock() {
65
+ vi.stubGlobal(
66
+ "fetch",
67
+ vi.fn().mockImplementation(async (url: string) => {
68
+ const u = String(url);
69
+ if (u.includes("/entity/check-slug/me"))
70
+ return {
71
+ ok: true,
72
+ status: 200,
73
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
74
+ text: async () => "",
75
+ };
76
+ if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
77
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
78
+ if (u.includes("/sts/vend"))
79
+ return {
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({
83
+ credentials: {
84
+ accessKeyId: "ASIA_TEST",
85
+ secretAccessKey: "s",
86
+ sessionToken: "t",
87
+ expiration: new Date(Date.now() + 9e5).toISOString(),
88
+ },
89
+ expiresAt: new Date(Date.now() + 9e5).toISOString(),
90
+ }),
91
+ text: async () => "",
92
+ };
93
+ return { ok: false, status: 404, text: async () => "Not found" };
94
+ }),
95
+ );
96
+ }
97
+
98
+ /** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
99
+ function countConflictMirrors(root: string): string[] {
100
+ const out: string[] = [];
101
+ const walk = (d: string) => {
102
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
103
+ const p = path.join(d, e.name);
104
+ if (e.isDirectory()) walk(p);
105
+ else if (/\.conflict-/.test(e.name)) out.push(p);
106
+ }
107
+ };
108
+ walk(root);
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * In-memory single-writer S3: the object's ETag is always exactly what THIS
114
+ * device last uploaded (no peer ever writes). `lastModified` advances on each
115
+ * PUT to model S3 stamping it server-side. The S3-module signatures are
116
+ * `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
117
+ * `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
118
+ */
119
+ function wireSingleWriterRemote() {
120
+ const remote = new Map<string, { etag: string; lastModified: Date; size: number }>();
121
+ let clock = Date.now();
122
+ let seq = 0;
123
+ vi.mocked(uploadFile).mockImplementation(async (_ctx: unknown, localPath: string, key: string) => {
124
+ const etag = `"etag-${seq++}"`;
125
+ clock += 1000;
126
+ remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
127
+ return { etag };
128
+ });
129
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx: unknown, key: string) => {
130
+ const r = remote.get(key);
131
+ return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
132
+ });
133
+ vi.mocked(downloadFile).mockImplementation(async (_ctx: unknown, _key: string, dest: string) => {
134
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
135
+ fs.writeFileSync(dest, "remote-bytes\n");
136
+ return {};
137
+ });
138
+ return remote;
139
+ }
140
+
141
+ describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
142
+ let tmpDir: string;
143
+ let stateDir: string;
144
+
145
+ beforeEach(() => {
146
+ clearContextCache();
147
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
148
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
149
+ process.env.HQ_STATE_DIR = stateDir;
150
+ setupFetchMock();
151
+ });
152
+
153
+ afterEach(() => {
154
+ vi.unstubAllGlobals();
155
+ vi.clearAllMocks();
156
+ fs.rmSync(tmpDir, { recursive: true, force: true });
157
+ fs.rmSync(stateDir, { recursive: true, force: true });
158
+ delete process.env.HQ_STATE_DIR;
159
+ });
160
+
161
+ it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
162
+ const companyRoot = path.join(tmpDir, "companies", "acme");
163
+ fs.mkdirSync(companyRoot, { recursive: true });
164
+ const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
165
+ for (const f of files) fs.writeFileSync(f, "v0\n");
166
+
167
+ wireSingleWriterRemote();
168
+
169
+ const conflictPathsSeen: string[] = [];
170
+ // The event-push runner pushes the changed company subtree. Mirror that:
171
+ // share() over the company root on every settled change.
172
+ const eventPush = async () => {
173
+ const r = await share({
174
+ paths: [companyRoot],
175
+ company: "acme",
176
+ vaultConfig: mockConfig,
177
+ hqRoot: tmpDir,
178
+ onConflict: "keep",
179
+ onEvent: () => {},
180
+ });
181
+ conflictPathsSeen.push(...r.conflictPaths);
182
+ };
183
+
184
+ // Initial sync establishes the journal baseline (first upload of each file).
185
+ await eventPush();
186
+
187
+ // An editing session: 30 ordinary local edits, round-robin across the
188
+ // files, each followed by its targeted event-push.
189
+ for (let i = 0; i < 30; i++) {
190
+ fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
191
+ await eventPush();
192
+ }
193
+
194
+ expect(countConflictMirrors(tmpDir)).toEqual([]);
195
+ expect(conflictPathsSeen).toEqual([]);
196
+ });
197
+
198
+ it("positive control: a genuine peer write is still detected as a conflict", async () => {
199
+ const companyRoot = path.join(tmpDir, "companies", "acme");
200
+ fs.mkdirSync(companyRoot, { recursive: true });
201
+ const file = path.join(companyRoot, "contested.md");
202
+ fs.writeFileSync(file, "v0\n");
203
+
204
+ const remote = wireSingleWriterRemote();
205
+
206
+ const conflictPathsSeen: string[] = [];
207
+ const eventPush = async () => {
208
+ const r = await share({
209
+ paths: [companyRoot],
210
+ company: "acme",
211
+ vaultConfig: mockConfig,
212
+ hqRoot: tmpDir,
213
+ onConflict: "keep",
214
+ onEvent: () => {},
215
+ });
216
+ conflictPathsSeen.push(...r.conflictPaths);
217
+ };
218
+
219
+ await eventPush(); // baseline
220
+
221
+ // A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
222
+ // keys are company-relative, so the key is "contested.md".
223
+ remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
224
+ // ...and we also edit locally → both sides moved → a genuine conflict.
225
+ fs.writeFileSync(file, "v1-local\n");
226
+ await eventPush();
227
+
228
+ // Existing-entry push conflicts surface via conflictPaths (the inspection
229
+ // mirror is written by the pull leg). The contract under test: a REAL
230
+ // divergence is still flagged — the single-writer guard above did not
231
+ // over-correct into swallowing true conflicts.
232
+ expect(conflictPathsSeen).toContain("contested.md");
233
+ });
234
+ });
@@ -71,6 +71,41 @@ export function isCoveredByAny(
71
71
  return false;
72
72
  }
73
73
 
74
+ /**
75
+ * Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
76
+ * into directory `relDir` (company-relative, no leading slash) given the
77
+ * granted `prefixSet`?
78
+ *
79
+ * A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
80
+ * descended whenever it COULD contain an in-scope file — which is true in two
81
+ * directions:
82
+ * - the directory sits INSIDE a granted prefix (`knowledge/sub` under
83
+ * `knowledge/`), or
84
+ * - a granted prefix sits INSIDE the directory (`knowledge/` under the
85
+ * company root `""`, or `knowledge/README.md` under `knowledge/`).
86
+ * Without the second case the walk would refuse to descend into `knowledge/`
87
+ * to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
88
+ * to nothing.
89
+ *
90
+ * The directory is normalized to a trailing-slash form so the `startsWith`
91
+ * comparisons line up with coalesced prefixes (which are themselves either
92
+ * trailing-slash dir prefixes or exact-file keys). The empty string (company
93
+ * root) always descends when any prefix exists.
94
+ */
95
+ export function isDirInScope(
96
+ relDir: string,
97
+ prefixSet: readonly string[],
98
+ ): boolean {
99
+ const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
100
+ for (const p of prefixSet) {
101
+ if (p === "") return true; // full scope
102
+ if (dir === "") return true; // company root — descend to reach grants
103
+ if (dir.startsWith(p)) return true; // dir is inside a granted prefix
104
+ if (p.startsWith(dir)) return true; // a granted prefix is inside dir
105
+ }
106
+ return false;
107
+ }
108
+
74
109
  /**
75
110
  * Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
76
111
  * for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`