@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.
@@ -0,0 +1,210 @@
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
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
25
+ import * as fs from "fs";
26
+ import * as path from "path";
27
+ import * as os from "os";
28
+ import { clearContextCache } from "../context.js";
29
+ vi.mock("../s3.js", () => ({
30
+ toPosixKey: (key) => key.split("\\").join("/"),
31
+ uploadFile: vi.fn(),
32
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
33
+ downloadFile: vi.fn().mockResolvedValue(undefined),
34
+ listRemoteFiles: vi.fn().mockResolvedValue([]),
35
+ deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
36
+ headRemoteFile: vi.fn(),
37
+ primeObjectTransport: vi.fn().mockResolvedValue(undefined),
38
+ primeUploads: vi.fn().mockResolvedValue(undefined),
39
+ }));
40
+ vi.mock("readline", () => ({
41
+ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
42
+ }));
43
+ import { share } from "./share.js";
44
+ import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
45
+ const mockConfig = {
46
+ apiUrl: "https://vault-api.test",
47
+ authToken: "test-jwt-token",
48
+ region: "us-east-1",
49
+ };
50
+ const mockEntity = {
51
+ uid: "cmp_01ABCDEF",
52
+ slug: "acme",
53
+ bucketName: "hq-vault-acme-123",
54
+ status: "active",
55
+ };
56
+ function setupFetchMock() {
57
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url) => {
58
+ const u = String(url);
59
+ if (u.includes("/entity/check-slug/me"))
60
+ return {
61
+ ok: true,
62
+ status: 200,
63
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
64
+ text: async () => "",
65
+ };
66
+ if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
67
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
68
+ if (u.includes("/sts/vend"))
69
+ return {
70
+ ok: true,
71
+ status: 200,
72
+ json: async () => ({
73
+ credentials: {
74
+ accessKeyId: "ASIA_TEST",
75
+ secretAccessKey: "s",
76
+ sessionToken: "t",
77
+ expiration: new Date(Date.now() + 9e5).toISOString(),
78
+ },
79
+ expiresAt: new Date(Date.now() + 9e5).toISOString(),
80
+ }),
81
+ text: async () => "",
82
+ };
83
+ return { ok: false, status: 404, text: async () => "Not found" };
84
+ }));
85
+ }
86
+ /** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
87
+ function countConflictMirrors(root) {
88
+ const out = [];
89
+ const walk = (d) => {
90
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
91
+ const p = path.join(d, e.name);
92
+ if (e.isDirectory())
93
+ walk(p);
94
+ else if (/\.conflict-/.test(e.name))
95
+ out.push(p);
96
+ }
97
+ };
98
+ walk(root);
99
+ return out;
100
+ }
101
+ /**
102
+ * In-memory single-writer S3: the object's ETag is always exactly what THIS
103
+ * device last uploaded (no peer ever writes). `lastModified` advances on each
104
+ * PUT to model S3 stamping it server-side. The S3-module signatures are
105
+ * `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
106
+ * `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
107
+ */
108
+ function wireSingleWriterRemote() {
109
+ const remote = new Map();
110
+ let clock = Date.now();
111
+ let seq = 0;
112
+ vi.mocked(uploadFile).mockImplementation(async (_ctx, localPath, key) => {
113
+ const etag = `"etag-${seq++}"`;
114
+ clock += 1000;
115
+ remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
116
+ return { etag };
117
+ });
118
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
119
+ const r = remote.get(key);
120
+ return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
121
+ });
122
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
123
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
124
+ fs.writeFileSync(dest, "remote-bytes\n");
125
+ return {};
126
+ });
127
+ return remote;
128
+ }
129
+ describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
130
+ let tmpDir;
131
+ let stateDir;
132
+ beforeEach(() => {
133
+ clearContextCache();
134
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
135
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
136
+ process.env.HQ_STATE_DIR = stateDir;
137
+ setupFetchMock();
138
+ });
139
+ afterEach(() => {
140
+ vi.unstubAllGlobals();
141
+ vi.clearAllMocks();
142
+ fs.rmSync(tmpDir, { recursive: true, force: true });
143
+ fs.rmSync(stateDir, { recursive: true, force: true });
144
+ delete process.env.HQ_STATE_DIR;
145
+ });
146
+ it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
147
+ const companyRoot = path.join(tmpDir, "companies", "acme");
148
+ fs.mkdirSync(companyRoot, { recursive: true });
149
+ const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
150
+ for (const f of files)
151
+ fs.writeFileSync(f, "v0\n");
152
+ wireSingleWriterRemote();
153
+ const conflictPathsSeen = [];
154
+ // The event-push runner pushes the changed company subtree. Mirror that:
155
+ // share() over the company root on every settled change.
156
+ const eventPush = async () => {
157
+ const r = await share({
158
+ paths: [companyRoot],
159
+ company: "acme",
160
+ vaultConfig: mockConfig,
161
+ hqRoot: tmpDir,
162
+ onConflict: "keep",
163
+ onEvent: () => { },
164
+ });
165
+ conflictPathsSeen.push(...r.conflictPaths);
166
+ };
167
+ // Initial sync establishes the journal baseline (first upload of each file).
168
+ await eventPush();
169
+ // An editing session: 30 ordinary local edits, round-robin across the
170
+ // files, each followed by its targeted event-push.
171
+ for (let i = 0; i < 30; i++) {
172
+ fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
173
+ await eventPush();
174
+ }
175
+ expect(countConflictMirrors(tmpDir)).toEqual([]);
176
+ expect(conflictPathsSeen).toEqual([]);
177
+ });
178
+ it("positive control: a genuine peer write is still detected as a conflict", async () => {
179
+ const companyRoot = path.join(tmpDir, "companies", "acme");
180
+ fs.mkdirSync(companyRoot, { recursive: true });
181
+ const file = path.join(companyRoot, "contested.md");
182
+ fs.writeFileSync(file, "v0\n");
183
+ const remote = wireSingleWriterRemote();
184
+ const conflictPathsSeen = [];
185
+ const eventPush = async () => {
186
+ const r = await share({
187
+ paths: [companyRoot],
188
+ company: "acme",
189
+ vaultConfig: mockConfig,
190
+ hqRoot: tmpDir,
191
+ onConflict: "keep",
192
+ onEvent: () => { },
193
+ });
194
+ conflictPathsSeen.push(...r.conflictPaths);
195
+ };
196
+ await eventPush(); // baseline
197
+ // A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
198
+ // keys are company-relative, so the key is "contested.md".
199
+ remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
200
+ // ...and we also edit locally → both sides moved → a genuine conflict.
201
+ fs.writeFileSync(file, "v1-local\n");
202
+ await eventPush();
203
+ // Existing-entry push conflicts surface via conflictPaths (the inspection
204
+ // mirror is written by the pull leg). The contract under test: a REAL
205
+ // divergence is still flagged — the single-writer guard above did not
206
+ // over-correct into swallowing true conflicts.
207
+ expect(conflictPathsSeen).toContain("contested.md");
208
+ });
209
+ });
210
+ //# sourceMappingURL=watch-event-push-conflict.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-event-push-conflict.test.js","sourceRoot":"","sources":["../../src/cli/watch-event-push-conflict.test.ts"],"names":[],"mappings":"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"}
@@ -35,6 +35,28 @@ export declare function coalescePrefixes(prefixes: readonly string[]): string[];
35
35
  * `startsWith` semantics as `coalescePrefixes`.
36
36
  */
37
37
  export declare function isCoveredByAny(path: string, prefixSet: readonly string[]): boolean;
38
+ /**
39
+ * Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
40
+ * into directory `relDir` (company-relative, no leading slash) given the
41
+ * granted `prefixSet`?
42
+ *
43
+ * A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
44
+ * descended whenever it COULD contain an in-scope file — which is true in two
45
+ * directions:
46
+ * - the directory sits INSIDE a granted prefix (`knowledge/sub` under
47
+ * `knowledge/`), or
48
+ * - a granted prefix sits INSIDE the directory (`knowledge/` under the
49
+ * company root `""`, or `knowledge/README.md` under `knowledge/`).
50
+ * Without the second case the walk would refuse to descend into `knowledge/`
51
+ * to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
52
+ * to nothing.
53
+ *
54
+ * The directory is normalized to a trailing-slash form so the `startsWith`
55
+ * comparisons line up with coalesced prefixes (which are themselves either
56
+ * trailing-slash dir prefixes or exact-file keys). The empty string (company
57
+ * root) always descends when any prefix exists.
58
+ */
59
+ export declare function isDirInScope(relDir: string, prefixSet: readonly string[]): boolean;
38
60
  /**
39
61
  * Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
40
62
  * for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
@@ -1 +1 @@
1
- {"version":3,"file":"prefix-coalesce.d.ts","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAyBtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAKT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAezE"}
1
+ {"version":3,"file":"prefix-coalesce.d.ts","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAyBtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAKT;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAST;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAezE"}
@@ -66,6 +66,41 @@ export function isCoveredByAny(path, prefixSet) {
66
66
  }
67
67
  return false;
68
68
  }
69
+ /**
70
+ * Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
71
+ * into directory `relDir` (company-relative, no leading slash) given the
72
+ * granted `prefixSet`?
73
+ *
74
+ * A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
75
+ * descended whenever it COULD contain an in-scope file — which is true in two
76
+ * directions:
77
+ * - the directory sits INSIDE a granted prefix (`knowledge/sub` under
78
+ * `knowledge/`), or
79
+ * - a granted prefix sits INSIDE the directory (`knowledge/` under the
80
+ * company root `""`, or `knowledge/README.md` under `knowledge/`).
81
+ * Without the second case the walk would refuse to descend into `knowledge/`
82
+ * to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
83
+ * to nothing.
84
+ *
85
+ * The directory is normalized to a trailing-slash form so the `startsWith`
86
+ * comparisons line up with coalesced prefixes (which are themselves either
87
+ * trailing-slash dir prefixes or exact-file keys). The empty string (company
88
+ * root) always descends when any prefix exists.
89
+ */
90
+ export function isDirInScope(relDir, prefixSet) {
91
+ const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
92
+ for (const p of prefixSet) {
93
+ if (p === "")
94
+ return true; // full scope
95
+ if (dir === "")
96
+ return true; // company root — descend to reach grants
97
+ if (dir.startsWith(p))
98
+ return true; // dir is inside a granted prefix
99
+ if (p.startsWith(dir))
100
+ return true; // a granted prefix is inside dir
101
+ }
102
+ return false;
103
+ }
69
104
  /**
70
105
  * Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
71
106
  * for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
@@ -1 +1 @@
1
- {"version":3,"file":"prefix-coalesce.js","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,UAAU,gBAAgB,CAAC,QAA2B;IAC1D,wBAAwB;IACxB,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;YACtC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,uEAAuE;IACvE,0EAA0E;IAC1E,oBAAoB;IACpB,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;IAClC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,QAAQ,GAAkB,IAAI,CAAC;IACnC,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,IAAI,QAAQ,KAAK,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAChD,uCAAuC;YACvC,SAAS;QACX,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACf,QAAQ,GAAG,CAAC,CAAC;IACf,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAY,EACZ,SAA4B;IAE5B,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,KAAK,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAiB,EAAE,IAAY;IAC/D,IAAI,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC9C,2CAA2C;IAC3C,MAAM,aAAa,GAAG,aAAa,IAAI,GAAG,CAAC;IAC3C,MAAM,UAAU,GAAG,GAAG,IAAI,GAAG,CAAC;IAC9B,IAAI,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAChC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;SAAM,IAAI,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACpC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IACD,wDAAwD;IACxD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC,CAAC,oCAAoC;IAC1E,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB;IAChE,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB;IAC9D,OAAO,CAAC,CAAC,CAAC,iEAAiE;AAC7E,CAAC"}
1
+ {"version":3,"file":"prefix-coalesce.js","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.2.4",
3
+ "version": "6.2.6",
4
4
  "description": "HQ by Indigo cloud sync engine \u2014 bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -120,6 +120,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
120
120
  filesRefusedStale: 0,
121
121
  filesRefusedStalePaths: [],
122
122
  filesExcludedByPolicy: 0,
123
+ filesExcludedByScope: 0,
123
124
  conflictPaths: [],
124
125
  aborted: false,
125
126
  ...overrides,
@@ -1781,6 +1782,44 @@ describe("--direction", () => {
1781
1782
  expect(opts.paths).toEqual(["/tmp/fake-hq/companies/acme"]);
1782
1783
  expect(opts.company).toBe("cmp_a");
1783
1784
  expect(opts.hqRoot).toBe("/tmp/fake-hq");
1785
+ // Owner / `all` scope (no membership sync-config) → NO prefixSet forwarded,
1786
+ // preserving the pre-fix company-target args shape (full access).
1787
+ expect(opts.prefixSet).toBeUndefined();
1788
+ });
1789
+
1790
+ it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
1791
+ // Plumbing regression for the fresh fix: a member/guest's push must be
1792
+ // scoped to their granted prefixes so out-of-scope keys are filtered
1793
+ // instead of drawing the server's 403 and aborting the whole company.
1794
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1795
+ const client = {
1796
+ ...makeVaultStub({
1797
+ memberships: [{ companyUid: "cmp_a" }],
1798
+ entityGet: (uid: string) =>
1799
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1800
+ }),
1801
+ getMembershipSyncConfig: async () => ({
1802
+ membershipId: "mk_a",
1803
+ syncMode: "shared",
1804
+ isDefault: false,
1805
+ }),
1806
+ listMyExplicitGrants: async () => [
1807
+ { companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
1808
+ { companyUid: "cmp_a", path: "policies/", permission: "read", source: "group" },
1809
+ ],
1810
+ } as unknown as ReturnType<typeof makeVaultStub>;
1811
+ const deps = makeDeps({
1812
+ createVaultClient: () => client,
1813
+ sync: vi.fn(),
1814
+ share: shareSpy,
1815
+ });
1816
+
1817
+ await runRunner(
1818
+ ["--companies", "--direction", "push", "--hq-root", "/tmp/fake-hq"],
1819
+ deps,
1820
+ );
1821
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
1822
+ expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
1784
1823
  });
1785
1824
 
1786
1825
  it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
@@ -274,6 +274,7 @@ export type RunnerEvent =
274
274
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
275
275
  | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
276
276
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
277
+ | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
277
278
  | ({
278
279
  type: "complete";
279
280
  company: string;
@@ -1243,6 +1244,16 @@ export async function runRunner(
1243
1244
  company: companyLabel,
1244
1245
  files: event.files,
1245
1246
  });
1247
+ } else if (event.type === "scope-excluded") {
1248
+ // Push-side ACL scope exclusions — surface the named paths tagged to
1249
+ // this company so the menubar/CLI can show "N skipped, outside your
1250
+ // access" instead of the file silently never uploading.
1251
+ emit({
1252
+ type: "scope-excluded",
1253
+ company: companyLabel,
1254
+ count: event.count,
1255
+ samplePaths: event.samplePaths,
1256
+ });
1246
1257
  }
1247
1258
  };
1248
1259
 
@@ -1256,6 +1267,7 @@ export async function runRunner(
1256
1267
  filesRefusedStale: 0,
1257
1268
  filesRefusedStalePaths: [],
1258
1269
  filesExcludedByPolicy: 0,
1270
+ filesExcludedByScope: 0,
1259
1271
  conflictPaths: [],
1260
1272
  aborted: false,
1261
1273
  };
@@ -1307,6 +1319,26 @@ export async function runRunner(
1307
1319
  .map((p) => p.slug),
1308
1320
  );
1309
1321
 
1322
+ // Resolve the membership's effective ACL scope ONCE so BOTH the push and
1323
+ // pull legs respect the granted prefixes. The vault vends a child
1324
+ // credential scoped to these prefixes; without filtering the PUSH plan to
1325
+ // them, share() would HEAD/PUT keys outside the grant and the server's
1326
+ // correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
1327
+ // generic error + exit 2. Personal-vault legs have no membership
1328
+ // sync-config — they stay full-scope ("all"). Degrades to "all" on any
1329
+ // error (a transient failure must never silently filter/prune the tree).
1330
+ // Hoisted above the push block (it used to be resolved only for pull) so
1331
+ // push gets the same scope; the pull leg below reuses this value.
1332
+ const scope: PullScope =
1333
+ target.personalMode === true
1334
+ ? { syncMode: "all" }
1335
+ : await resolvePullScope(
1336
+ client,
1337
+ target.uid,
1338
+ target.slug,
1339
+ parsed.hqRoot,
1340
+ );
1341
+
1310
1342
  if (doPush) {
1311
1343
  activePhase = "push";
1312
1344
  // For the personal slot we hand share() both (a) the top-level
@@ -1365,6 +1397,13 @@ export async function runRunner(
1365
1397
  ...(decommissionPrefixes && decommissionPrefixes.length > 0
1366
1398
  ? { decommissionPrefixes }
1367
1399
  : {}),
1400
+ // US-005 symmetry: scope the PUSH plan to the membership's granted
1401
+ // ACL prefixes so out-of-scope keys are skipped (and surfaced via a
1402
+ // `scope-excluded` event) instead of drawing the server's 403 and
1403
+ // aborting the company. `undefined` for `syncMode: "all"` (owner /
1404
+ // personal) → no scope filter, identical to the pre-fix shape so the
1405
+ // "company-target args" contract test stays green.
1406
+ ...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
1368
1407
  });
1369
1408
  }
1370
1409
 
@@ -1373,20 +1412,11 @@ export async function runRunner(
1373
1412
  // whichever side `--on-conflict abort` just protected.
1374
1413
  if (doPull && !pushResult.aborted) {
1375
1414
  activePhase = "pull";
1376
- // US-005: resolve the membership's effective download scope so the
1377
- // pull only materializes in-scope keys (and prunes clean orphans when
1378
- // scope shrank). Personal-vault legs have no membership sync-config
1379
- // they stay full-scope (`all`). Degrades to `all` on any error so a
1380
- // transient failure can't silently prune the tree.
1381
- const pullScope: PullScope =
1382
- target.personalMode === true
1383
- ? { syncMode: "all" }
1384
- : await resolvePullScope(
1385
- client,
1386
- target.uid,
1387
- target.slug,
1388
- parsed.hqRoot,
1389
- );
1415
+ // US-005: the pull only materializes in-scope keys (and prunes clean
1416
+ // orphans when scope shrank). Reuse the `scope` resolved once above so
1417
+ // push and pull apply the SAME granted prefixes and we avoid a second
1418
+ // `listMyExplicitGrants` round-trip per company.
1419
+ const pullScope: PullScope = scope;
1390
1420
  pullResult = await syncFn({
1391
1421
  company: target.uid,
1392
1422
  vaultConfig,
@@ -586,6 +586,114 @@ describe("share", () => {
586
586
  expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
587
587
  });
588
588
 
589
+ it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
590
+ // Real case (look-optic): a FILE_ACL grant covered {knowledge,policies,
591
+ // workers}/* + company.yaml, but the upload plan also contained
592
+ // settings/.gitkeep + projects/.gitkeep. Pre-fix, the push walked the
593
+ // whole company tree, HEAD'd an out-of-scope key, the scoped child
594
+ // credential drew a 403 SCOPE_EXCEEDS_PARENT, and the runner aborted the
595
+ // ENTIRE company (exit 2) naming no path. The fix scopes the push plan to
596
+ // the granted prefixSet (symmetric with the pull-side skip-out-of-scope):
597
+ // in-scope files upload, out-of-scope paths are skipped + surfaced via a
598
+ // `scope-excluded` event, and the company completes.
599
+ const companyRoot = path.join(tmpDir, "companies", "acme");
600
+ fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
601
+ fs.mkdirSync(path.join(companyRoot, "policies"), { recursive: true });
602
+ fs.mkdirSync(path.join(companyRoot, "settings"), { recursive: true });
603
+ fs.mkdirSync(path.join(companyRoot, "projects"), { recursive: true });
604
+ fs.writeFileSync(path.join(companyRoot, "company.yaml"), "name: Acme\n");
605
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "readme.md"), "# kb\n");
606
+ fs.writeFileSync(path.join(companyRoot, "policies", "p.md"), "policy\n");
607
+ fs.writeFileSync(path.join(companyRoot, "settings", ".gitkeep"), "");
608
+ fs.writeFileSync(path.join(companyRoot, "projects", ".gitkeep"), "");
609
+
610
+ // No remote anywhere → every in-scope file is a clean upload.
611
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
612
+
613
+ const events: Array<{ type?: string; path?: string; count?: number; samplePaths?: string[] }> = [];
614
+ const result = await share({
615
+ paths: [companyRoot],
616
+ company: "acme",
617
+ vaultConfig: mockConfig,
618
+ hqRoot: tmpDir,
619
+ // Coalesced company-relative grant prefixes (what resolvePullScope hands
620
+ // the runner for a shared membership).
621
+ prefixSet: ["company.yaml", "knowledge/", "policies/", "workers/"],
622
+ onEvent: (e) => events.push(e as { type?: string }),
623
+ });
624
+
625
+ // In-scope subset uploaded; out-of-scope never PUT. (company.yaml is in
626
+ // the grant but is independently dropped by the base ignore filter — it's
627
+ // in DEFAULT_IGNORES — so it never uploads regardless of scope; the scope
628
+ // filter layers on top of the ignore filter, not under it.)
629
+ const uploadedPaths = events
630
+ .filter((e) => e.type === "progress")
631
+ .map((e) => e.path)
632
+ .sort();
633
+ expect(uploadedPaths).toEqual(["knowledge/readme.md", "policies/p.md"]);
634
+ expect(result.filesUploaded).toBe(2);
635
+ // The two out-of-scope directories were excluded and named.
636
+ expect(result.filesExcludedByScope).toBe(2);
637
+ const scopeEv = events.find((e) => e.type === "scope-excluded") as
638
+ | { count: number; samplePaths: string[] }
639
+ | undefined;
640
+ expect(scopeEv).toBeDefined();
641
+ expect(scopeEv!.count).toBe(2);
642
+ expect(scopeEv!.samplePaths.sort()).toEqual(["projects/", "settings/"]);
643
+ // No conflict, no error — the company completed cleanly (no abort).
644
+ expect(result.aborted).toBe(false);
645
+ expect(events.some((e) => e.type === "error")).toBe(false);
646
+ // uploadFile was never called for an out-of-scope key.
647
+ const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
648
+ expect(putKeys).not.toContain("settings/.gitkeep");
649
+ expect(putKeys).not.toContain("projects/.gitkeep");
650
+ });
651
+
652
+ it("scoped push defense-in-depth: a 403 on HEAD skips that key (named error) instead of aborting the company", async () => {
653
+ // Belt-and-suspenders: even if a key slips past the prefix filter (a grant
654
+ // that changed mid-run, a pin outside the grant, prefix-coalesce
655
+ // imprecision), the server's correct 403 on the HEAD must NOT abort the
656
+ // whole company. Pre-fix the HEAD sat outside the per-file PUT try/catch,
657
+ // so the throw bubbled to workerErrors -> `throw first` -> exit 2.
658
+ const companyRoot = path.join(tmpDir, "companies", "acme");
659
+ fs.mkdirSync(companyRoot, { recursive: true });
660
+ const okFile = path.join(companyRoot, "in-scope.md");
661
+ const blockedFile = path.join(companyRoot, "out-of-reach.md");
662
+ fs.writeFileSync(okFile, "ok\n");
663
+ fs.writeFileSync(blockedFile, "denied\n");
664
+
665
+ // The scoped credential 403s on the out-of-scope key's HEAD; the other
666
+ // key heads cleanly (no remote).
667
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
668
+ if (key === "out-of-reach.md") {
669
+ const err = new Error("access denied");
670
+ (err as { name: string }).name = "AccessDenied";
671
+ throw err;
672
+ }
673
+ return null;
674
+ });
675
+
676
+ const events: Array<{ type?: string; path?: string; message?: string }> = [];
677
+ const result = await share({
678
+ paths: [okFile, blockedFile],
679
+ company: "acme",
680
+ vaultConfig: mockConfig,
681
+ hqRoot: tmpDir,
682
+ onEvent: (e) => events.push(e as { type?: string }),
683
+ });
684
+
685
+ // Company did NOT abort; the in-scope file still uploaded.
686
+ expect(result.aborted).toBe(false);
687
+ expect(result.filesUploaded).toBe(1);
688
+ const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
689
+ expect(putKeys).toEqual(["in-scope.md"]);
690
+ // The blocked key surfaced as a path-named, scope-clear error event.
691
+ const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
692
+ expect(errs).toHaveLength(1);
693
+ expect(errs[0].path).toBe("out-of-reach.md");
694
+ expect(errs[0].message).toMatch(/outside granted ACL scope/i);
695
+ });
696
+
589
697
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
590
698
  // Regression for hq-cloud#<conflict-detection>: a local edit to a file
591
699
  // that exists on S3 used to trigger a push conflict because the
@@ -810,6 +918,7 @@ describe("share", () => {
810
918
  e.type === "plan" ||
811
919
  e.type === "new-files" ||
812
920
  e.type === "personal-vault-out-of-policy" ||
921
+ e.type === "scope-excluded" ||
813
922
  e.type === "delete-refused-bulk-asymmetry"
814
923
  ) return;
815
924
  events.push({