@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.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +20 -3
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +46 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +178 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +555 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +780 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +113 -13
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +188 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner.test.ts +54 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +974 -2
- package/src/cli/share.ts +675 -32
- package/src/cli/sync.test.ts +209 -0
- package/src/cli/sync.ts +151 -13
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -45,7 +45,8 @@ vi.mock("readline", () => ({
|
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
47
|
import * as readline from "readline";
|
|
48
|
-
import { share, _testing as shareTesting } from "./share.js";
|
|
48
|
+
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, _testing as shareTesting } from "./share.js";
|
|
49
|
+
import type { SyncProgressEvent } from "./sync.js";
|
|
49
50
|
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
50
51
|
import type { EntityContext } from "../types.js";
|
|
51
52
|
import { VaultAuthError } from "../vault-client.js";
|
|
@@ -56,6 +57,12 @@ const mockConfig: VaultServiceConfig = {
|
|
|
56
57
|
region: "us-east-1",
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
61
|
+
const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
|
|
62
|
+
err.code = code;
|
|
63
|
+
return err;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
/**
|
|
60
67
|
* Build a pre-vended EntityContext as if a caller (e.g. AppBar) had already
|
|
61
68
|
* called `/sts/vend-child` and is passing the result into share() via the
|
|
@@ -125,6 +132,220 @@ describe("wrapFilterWithIgnoreVisibility", () => {
|
|
|
125
132
|
});
|
|
126
133
|
});
|
|
127
134
|
|
|
135
|
+
describe("resolveNamedPath (feedback_258e4a86 / feedback_a51cb63d)", () => {
|
|
136
|
+
let tmpDir: string;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolve-test-"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns absolute paths verbatim", () => {
|
|
147
|
+
const abs = path.join(tmpDir, "x.md");
|
|
148
|
+
expect(
|
|
149
|
+
shareTesting.resolveNamedPath(abs, tmpDir, path.join(tmpDir, "companies", "acme"), tmpDir),
|
|
150
|
+
).toBe(abs);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("resolves a company-relative path against the company folder when it does not exist under hqRoot", () => {
|
|
154
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
155
|
+
fs.mkdirSync(path.join(syncRoot, "knowledge", "agents"), { recursive: true });
|
|
156
|
+
const brief = path.join(syncRoot, "knowledge", "agents", "brief.md");
|
|
157
|
+
fs.writeFileSync(brief, "x");
|
|
158
|
+
// hqRoot base would be <tmpDir>/knowledge/agents/brief.md, which is absent;
|
|
159
|
+
// the syncRoot base resolves. This is the reporter's "company-relative
|
|
160
|
+
// spelling reaches nothing" case.
|
|
161
|
+
expect(shareTesting.resolveNamedPath("knowledge/agents/brief.md", tmpDir, syncRoot, tmpDir))
|
|
162
|
+
.toBe(brief);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("resolves against the injected cwd when neither hqRoot nor the company folder has it", () => {
|
|
166
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
167
|
+
const workDir = path.join(tmpDir, "work");
|
|
168
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
169
|
+
fs.writeFileSync(path.join(workDir, "local.md"), "x");
|
|
170
|
+
// cwd is INJECTED, never read ambiently — no process.chdir in this suite.
|
|
171
|
+
expect(shareTesting.resolveNamedPath("local.md", tmpDir, syncRoot, workDir)).toBe(
|
|
172
|
+
path.join(workDir, "local.md"),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("prefers the company folder over an hqRoot homonym outside the company tree", () => {
|
|
177
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
178
|
+
const workDir = path.join(tmpDir, "work");
|
|
179
|
+
for (const base of [tmpDir, syncRoot, workDir]) {
|
|
180
|
+
fs.mkdirSync(path.join(base, "knowledge"), { recursive: true });
|
|
181
|
+
fs.writeFileSync(path.join(base, "knowledge", "brief.md"), base);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// hqRoot/companies/acme is outside syncRoot's parent chain for tmpDir/knowledge,
|
|
185
|
+
// so the unrelated hq-root homonym is skipped and syncRoot wins.
|
|
186
|
+
expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
|
|
187
|
+
path.join(syncRoot, "knowledge", "brief.md"),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// With the company copy gone, cwd is the last resort (hqRoot homonym still skipped).
|
|
191
|
+
fs.rmSync(path.join(syncRoot, "knowledge"), { recursive: true, force: true });
|
|
192
|
+
expect(shareTesting.resolveNamedPath("knowledge/brief.md", tmpDir, syncRoot, workDir)).toBe(
|
|
193
|
+
path.join(workDir, "knowledge", "brief.md"),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("honors hqRoot-first precedence when the hqRoot hit is inside the company tree", () => {
|
|
198
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
199
|
+
const nested = path.join(syncRoot, "nested");
|
|
200
|
+
fs.mkdirSync(path.join(nested, "knowledge"), { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(nested, "knowledge", "brief.md"), "nested");
|
|
202
|
+
fs.mkdirSync(path.join(syncRoot, "knowledge"), { recursive: true });
|
|
203
|
+
fs.writeFileSync(path.join(syncRoot, "knowledge", "brief.md"), "company");
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
shareTesting.resolveNamedPath("nested/knowledge/brief.md", syncRoot, syncRoot, syncRoot),
|
|
207
|
+
).toBe(path.join(nested, "knowledge", "brief.md"));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("falls back to the hqRoot base (preserving the 'does not exist' diagnostic) when nothing resolves", () => {
|
|
211
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
212
|
+
expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot, tmpDir)).toBe(
|
|
213
|
+
path.resolve(tmpDir, "nope/missing.md"),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("defaults cwd to process.cwd() when the parameter is omitted", () => {
|
|
218
|
+
const syncRoot = path.join(tmpDir, "companies", "acme");
|
|
219
|
+
// Nothing exists under any base, so the fallback proves the signature is
|
|
220
|
+
// callable without the injected cwd (production call sites rely on it).
|
|
221
|
+
expect(shareTesting.resolveNamedPath("nope/missing.md", tmpDir, syncRoot)).toBe(
|
|
222
|
+
path.resolve(tmpDir, "nope/missing.md"),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("isWithinLexicalOrReal (feedback_258e4a86 / feedback_a51cb63d)", () => {
|
|
228
|
+
let tmpDir: string;
|
|
229
|
+
let hqRoot: string;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-within-test-"));
|
|
233
|
+
hqRoot = path.join(tmpDir, "hq");
|
|
234
|
+
fs.mkdirSync(hqRoot, { recursive: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("accepts an in-tree path reached through a symlinked ancestor (which realpath would reject)", () => {
|
|
242
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
243
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
244
|
+
const external = path.join(hqRoot, "repos", "knowledge-acme", "agents");
|
|
245
|
+
fs.mkdirSync(external, { recursive: true });
|
|
246
|
+
fs.writeFileSync(path.join(external, "brief.md"), "x");
|
|
247
|
+
fs.symlinkSync(path.join(hqRoot, "repos", "knowledge-acme"), path.join(syncRoot, "knowledge"));
|
|
248
|
+
|
|
249
|
+
const throughLink = path.join(syncRoot, "knowledge", "agents", "brief.md");
|
|
250
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, throughLink, hqRoot)).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("still rejects a path that is genuinely outside the company folder", () => {
|
|
254
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
255
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
256
|
+
const outside = path.join(hqRoot, "outside", "stray.md");
|
|
257
|
+
fs.mkdirSync(path.dirname(outside), { recursive: true });
|
|
258
|
+
fs.writeFileSync(outside, "x");
|
|
259
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, outside, hqRoot)).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("rejects a lexically-inside path whose realpath escapes the HQ tree entirely", () => {
|
|
263
|
+
// The lexical arm is what lets a symlinked ancestor through; without the
|
|
264
|
+
// hqRoot bound it would ALSO let `companies/acme/elsewhere/secret` upload
|
|
265
|
+
// bytes from anywhere on the machine under a company-namespaced key.
|
|
266
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
267
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
268
|
+
const foreign = path.join(tmpDir, "not-hq", "secrets");
|
|
269
|
+
fs.mkdirSync(foreign, { recursive: true });
|
|
270
|
+
fs.writeFileSync(path.join(foreign, "creds.txt"), "x");
|
|
271
|
+
fs.symlinkSync(foreign, path.join(syncRoot, "elsewhere"));
|
|
272
|
+
|
|
273
|
+
const escaping = path.join(syncRoot, "elsewhere", "creds.txt");
|
|
274
|
+
// Lexically inside the company folder…
|
|
275
|
+
const lexicalRel = path.relative(syncRoot, escaping);
|
|
276
|
+
expect(lexicalRel.startsWith("..")).toBe(false);
|
|
277
|
+
// …but the realpath leaves HQ, so it is refused.
|
|
278
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, escaping, hqRoot)).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── Tenant bound on the lexical arm ──────────────────────────────────────
|
|
282
|
+
//
|
|
283
|
+
// Bounding the lexical arm by hqRoot alone is NOT enough: hqRoot contains
|
|
284
|
+
// every other company, so a symlinked ancestor under companies/acme that
|
|
285
|
+
// points at another tenant's bytes would pass "lexically inside acme AND
|
|
286
|
+
// really inside HQ" and upload those bytes into acme's vault under an
|
|
287
|
+
// acme-namespaced key. The bound has to be the TENANT, not the tree.
|
|
288
|
+
it("rejects a lexically-inside path whose realpath lands in another company's folder", () => {
|
|
289
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
290
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
291
|
+
const otherSecrets = path.join(hqRoot, "companies", "other", "secret");
|
|
292
|
+
fs.mkdirSync(otherSecrets, { recursive: true });
|
|
293
|
+
fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
|
|
294
|
+
// A mislinked (or hostile) ancestor pointing straight at a sibling tenant.
|
|
295
|
+
fs.symlinkSync(otherSecrets, path.join(syncRoot, "knowledge"));
|
|
296
|
+
|
|
297
|
+
const crossTenant = path.join(syncRoot, "knowledge", "creds.md");
|
|
298
|
+
// Lexically inside acme, and its realpath is inside HQ — so only the
|
|
299
|
+
// tenant bound can refuse it.
|
|
300
|
+
expect(path.relative(syncRoot, crossTenant).startsWith("..")).toBe(false);
|
|
301
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("rejects a lexically-inside path whose realpath lands in another company's linked knowledge repo", () => {
|
|
305
|
+
// HQ topology pattern 2: a company's knowledge lives in its own repo under
|
|
306
|
+
// repos/private/ and is symlinked into the company folder. That target is
|
|
307
|
+
// outside every companies/<slug> root, so a companies-only bound would
|
|
308
|
+
// miss it — the sibling company's OWN link is what marks it as claimed.
|
|
309
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
310
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
311
|
+
const otherRoot = path.join(hqRoot, "companies", "other");
|
|
312
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
313
|
+
const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
|
|
314
|
+
fs.mkdirSync(otherRepo, { recursive: true });
|
|
315
|
+
fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
|
|
316
|
+
fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
|
|
317
|
+
// acme's ancestor points at the sibling's repo (a typo away from its own).
|
|
318
|
+
fs.symlinkSync(otherRepo, path.join(syncRoot, "knowledge"));
|
|
319
|
+
|
|
320
|
+
const crossTenant = path.join(syncRoot, "knowledge", "roadmap.md");
|
|
321
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, crossTenant, hqRoot)).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("still accepts the company's OWN linked knowledge repo when sibling tenants exist", () => {
|
|
325
|
+
// The tenant bound must not collapse the motivating topology: a sibling
|
|
326
|
+
// company being present (and publishing its own link) is not a reason to
|
|
327
|
+
// refuse acme's own linked subtree.
|
|
328
|
+
const syncRoot = path.join(hqRoot, "companies", "acme");
|
|
329
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
330
|
+
const otherRoot = path.join(hqRoot, "companies", "other");
|
|
331
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
332
|
+
const otherRepo = path.join(hqRoot, "repos", "private", "knowledge-other");
|
|
333
|
+
fs.mkdirSync(otherRepo, { recursive: true });
|
|
334
|
+
fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
|
|
335
|
+
|
|
336
|
+
const acmeRepo = path.join(hqRoot, "repos", "private", "knowledge-acme", "agents");
|
|
337
|
+
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
338
|
+
fs.writeFileSync(path.join(acmeRepo, "brief.md"), "x");
|
|
339
|
+
fs.symlinkSync(
|
|
340
|
+
path.join(hqRoot, "repos", "private", "knowledge-acme"),
|
|
341
|
+
path.join(syncRoot, "knowledge"),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const own = path.join(syncRoot, "knowledge", "agents", "brief.md");
|
|
345
|
+
expect(shareTesting.isWithinLexicalOrReal(syncRoot, own, hqRoot)).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
128
349
|
const mockEntity = {
|
|
129
350
|
uid: "cmp_01ABCDEF",
|
|
130
351
|
slug: "acme",
|
|
@@ -336,17 +557,42 @@ describe("share", () => {
|
|
|
336
557
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json", undefined, expect.anything());
|
|
337
558
|
});
|
|
338
559
|
|
|
339
|
-
it("
|
|
560
|
+
it("never uploads a file outside the company folder, and fails the push by default", async () => {
|
|
340
561
|
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
341
562
|
// File at hqRoot, outside companies/acme/
|
|
342
563
|
const outsideFile = path.join(tmpDir, "stray.md");
|
|
343
564
|
fs.writeFileSync(outsideFile, "stray");
|
|
344
565
|
|
|
566
|
+
// The per-path warning is unchanged; what changed (feedback_a51cb63d ask
|
|
567
|
+
// #2) is that an explicitly named, locally-present, unreachable path is no
|
|
568
|
+
// longer swallowed into a green "Pushed 0 file(s)".
|
|
569
|
+
await expect(
|
|
570
|
+
share({
|
|
571
|
+
paths: [outsideFile],
|
|
572
|
+
company: "acme",
|
|
573
|
+
vaultConfig: mockConfig,
|
|
574
|
+
hqRoot: tmpDir,
|
|
575
|
+
}),
|
|
576
|
+
).rejects.toBeInstanceOf(UnreachablePushPathsError);
|
|
577
|
+
|
|
578
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
579
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
580
|
+
expect.stringMatching(/outside company folder/i),
|
|
581
|
+
);
|
|
582
|
+
warnSpy.mockRestore();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("skips files outside the company folder with a warning under unreachablePathPolicy: 'warn'", async () => {
|
|
586
|
+
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
587
|
+
const outsideFile = path.join(tmpDir, "stray.md");
|
|
588
|
+
fs.writeFileSync(outsideFile, "stray");
|
|
589
|
+
|
|
345
590
|
const result = await share({
|
|
346
591
|
paths: [outsideFile],
|
|
347
592
|
company: "acme",
|
|
348
593
|
vaultConfig: mockConfig,
|
|
349
594
|
hqRoot: tmpDir,
|
|
595
|
+
unreachablePathPolicy: "warn",
|
|
350
596
|
});
|
|
351
597
|
|
|
352
598
|
expect(result.filesUploaded).toBe(0);
|
|
@@ -1735,6 +1981,7 @@ describe("share", () => {
|
|
|
1735
1981
|
e.type === "scope-excluded" ||
|
|
1736
1982
|
e.type === "scope-materialization-gap" ||
|
|
1737
1983
|
e.type === "ignore-excluded" ||
|
|
1984
|
+
e.type === "not-shipped" ||
|
|
1738
1985
|
e.type === "delete-refused-bulk-asymmetry"
|
|
1739
1986
|
) return;
|
|
1740
1987
|
events.push({
|
|
@@ -2556,6 +2803,115 @@ describe("share", () => {
|
|
|
2556
2803
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
2557
2804
|
});
|
|
2558
2805
|
|
|
2806
|
+
it("propagateDeletes: a company-relative directory spelling scopes deletes exactly like its absolute equivalent", async () => {
|
|
2807
|
+
// Resolver symmetry (cluster ask #2). Delete scoping resolved named paths
|
|
2808
|
+
// against hqRoot only, so `in-scope` — the same spelling `collectFiles`
|
|
2809
|
+
// now accepts for upload — resolved to <hqRoot>/in-scope, did not exist,
|
|
2810
|
+
// and was silently dropped from the scope. The result was a push whose
|
|
2811
|
+
// upload leg honored the path and whose delete leg did not.
|
|
2812
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2813
|
+
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2814
|
+
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2815
|
+
|
|
2816
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2817
|
+
fs.writeFileSync(
|
|
2818
|
+
journalPath,
|
|
2819
|
+
JSON.stringify({
|
|
2820
|
+
version: "1",
|
|
2821
|
+
lastSync: new Date().toISOString(),
|
|
2822
|
+
files: {
|
|
2823
|
+
"in-scope/gone.md": {
|
|
2824
|
+
hash: "h",
|
|
2825
|
+
size: 1,
|
|
2826
|
+
syncedAt: new Date().toISOString(),
|
|
2827
|
+
direction: "up",
|
|
2828
|
+
remoteEtag: "in-scope-etag",
|
|
2829
|
+
kind: "file",
|
|
2830
|
+
localDeleteIntent: deleteIntent("in-scope-etag", "h"),
|
|
2831
|
+
},
|
|
2832
|
+
"other/also-gone.md": {
|
|
2833
|
+
hash: "h",
|
|
2834
|
+
size: 1,
|
|
2835
|
+
syncedAt: new Date().toISOString(),
|
|
2836
|
+
direction: "up",
|
|
2837
|
+
remoteEtag: "other-etag",
|
|
2838
|
+
kind: "file",
|
|
2839
|
+
localDeleteIntent: deleteIntent("other-etag", "h"),
|
|
2840
|
+
},
|
|
2841
|
+
},
|
|
2842
|
+
}),
|
|
2843
|
+
);
|
|
2844
|
+
|
|
2845
|
+
const result = await share({
|
|
2846
|
+
paths: ["in-scope"], // company-relative, NOT hq-root-relative
|
|
2847
|
+
company: "acme",
|
|
2848
|
+
vaultConfig: mockConfig,
|
|
2849
|
+
hqRoot: tmpDir,
|
|
2850
|
+
skipUnchanged: true,
|
|
2851
|
+
propagateDeletes: true,
|
|
2852
|
+
propagateDeletePolicy: "owned-only",
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
expect(result.filesDeleted).toBe(1);
|
|
2856
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2857
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2858
|
+
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2859
|
+
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
2860
|
+
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
it("propagateDeletes: a directory reached through a symlinked ancestor anchors NO delete scope", async () => {
|
|
2864
|
+
// Deliberate asymmetry with the upload leg, and the reason delete scoping
|
|
2865
|
+
// keeps realpath containment: a linked subtree's contents are never walked
|
|
2866
|
+
// (they ship via their own repo), so treating it as a delete scope root
|
|
2867
|
+
// would make every vault object under that prefix look locally absent and
|
|
2868
|
+
// sweep it away. Narrower is the only safe direction here.
|
|
2869
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2870
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2871
|
+
const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme");
|
|
2872
|
+
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
2873
|
+
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
2874
|
+
|
|
2875
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2876
|
+
fs.writeFileSync(
|
|
2877
|
+
journalPath,
|
|
2878
|
+
JSON.stringify({
|
|
2879
|
+
version: "1",
|
|
2880
|
+
lastSync: new Date().toISOString(),
|
|
2881
|
+
files: {
|
|
2882
|
+
// Fully delete-eligible: current intent, matching etag, locally
|
|
2883
|
+
// absent. The ONLY thing standing between it and a DeleteObject is
|
|
2884
|
+
// that the linked directory anchors no scope — so a regression that
|
|
2885
|
+
// widened delete containment would fail this test loudly.
|
|
2886
|
+
"knowledge/agents/brief.md": {
|
|
2887
|
+
hash: "h",
|
|
2888
|
+
size: 1,
|
|
2889
|
+
syncedAt: new Date().toISOString(),
|
|
2890
|
+
direction: "up",
|
|
2891
|
+
remoteEtag: "brief-etag",
|
|
2892
|
+
kind: "file",
|
|
2893
|
+
localDeleteIntent: deleteIntent("brief-etag", "h"),
|
|
2894
|
+
},
|
|
2895
|
+
},
|
|
2896
|
+
}),
|
|
2897
|
+
);
|
|
2898
|
+
|
|
2899
|
+
const result = await share({
|
|
2900
|
+
paths: [path.join(companyRoot, "knowledge")],
|
|
2901
|
+
company: "acme",
|
|
2902
|
+
vaultConfig: mockConfig,
|
|
2903
|
+
hqRoot: tmpDir,
|
|
2904
|
+
skipUnchanged: true,
|
|
2905
|
+
propagateDeletes: true,
|
|
2906
|
+
propagateDeletePolicy: "owned-only",
|
|
2907
|
+
});
|
|
2908
|
+
|
|
2909
|
+
expect(result.filesDeleted).toBe(0);
|
|
2910
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2911
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2912
|
+
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2559
2915
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
2560
2916
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2561
2917
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -4015,6 +4371,113 @@ describe("share", () => {
|
|
|
4015
4371
|
);
|
|
4016
4372
|
});
|
|
4017
4373
|
|
|
4374
|
+
it("skips and reports a top-level link whose win32 readlink raises EINVAL", () => {
|
|
4375
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4376
|
+
const targetDir = path.join(tmpDir, "outside", "target");
|
|
4377
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
4378
|
+
fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
|
|
4379
|
+
const unreadableLink = path.join(companyRoot, "knowledge");
|
|
4380
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4381
|
+
fs.symlinkSync(targetDir, unreadableLink, "dir");
|
|
4382
|
+
|
|
4383
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
4384
|
+
const readlinkSpy = vi
|
|
4385
|
+
.spyOn(fs, "readlinkSync")
|
|
4386
|
+
.mockImplementation(((linkPath: fs.PathLike) => {
|
|
4387
|
+
if (linkPath === unreadableLink) throw errnoError("EINVAL");
|
|
4388
|
+
return realReadlinkSync(linkPath);
|
|
4389
|
+
}) as typeof fs.readlinkSync);
|
|
4390
|
+
const warnings = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
4391
|
+
const unreachable: Array<[string, string]> = [];
|
|
4392
|
+
|
|
4393
|
+
try {
|
|
4394
|
+
const collected = shareTesting.collectFiles(
|
|
4395
|
+
[unreadableLink],
|
|
4396
|
+
tmpDir,
|
|
4397
|
+
companyRoot,
|
|
4398
|
+
() => true,
|
|
4399
|
+
{
|
|
4400
|
+
onUnreachablePath: (namedPath, reason) => unreachable.push([namedPath, reason]),
|
|
4401
|
+
},
|
|
4402
|
+
);
|
|
4403
|
+
|
|
4404
|
+
expect(collected).toEqual([]);
|
|
4405
|
+
expect(unreachable).toEqual([[unreadableLink, "unreadable-link"]]);
|
|
4406
|
+
} finally {
|
|
4407
|
+
warnings.mockRestore();
|
|
4408
|
+
readlinkSpy.mockRestore();
|
|
4409
|
+
}
|
|
4410
|
+
});
|
|
4411
|
+
|
|
4412
|
+
it("skips and names a nested unreadable link without descending into its target", async () => {
|
|
4413
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4414
|
+
const policiesDir = path.join(companyRoot, "policies");
|
|
4415
|
+
const targetDir = path.join(tmpDir, "outside", "target");
|
|
4416
|
+
fs.mkdirSync(policiesDir, { recursive: true });
|
|
4417
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
4418
|
+
fs.writeFileSync(path.join(policiesDir, "safe.md"), "safe bytes");
|
|
4419
|
+
fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "private target bytes");
|
|
4420
|
+
const unreadableLink = path.join(policiesDir, "unreadable-link");
|
|
4421
|
+
fs.symlinkSync(targetDir, unreadableLink, "dir");
|
|
4422
|
+
|
|
4423
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
4424
|
+
const readlinkSpy = vi
|
|
4425
|
+
.spyOn(fs, "readlinkSync")
|
|
4426
|
+
.mockImplementation(((linkPath: fs.PathLike) => {
|
|
4427
|
+
if (linkPath === unreadableLink) throw errnoError("EINVAL");
|
|
4428
|
+
return realReadlinkSync(linkPath);
|
|
4429
|
+
}) as typeof fs.readlinkSync);
|
|
4430
|
+
const warnings = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
4431
|
+
const events: Array<{
|
|
4432
|
+
type?: string;
|
|
4433
|
+
reason?: string;
|
|
4434
|
+
samplePaths?: string[];
|
|
4435
|
+
}> = [];
|
|
4436
|
+
|
|
4437
|
+
try {
|
|
4438
|
+
const result = await share({
|
|
4439
|
+
paths: [companyRoot],
|
|
4440
|
+
company: "acme",
|
|
4441
|
+
vaultConfig: mockConfig,
|
|
4442
|
+
hqRoot: tmpDir,
|
|
4443
|
+
onEvent: (event) => events.push(event),
|
|
4444
|
+
});
|
|
4445
|
+
|
|
4446
|
+
expect(result.filesUploaded).toBe(1);
|
|
4447
|
+
expect(result.unreachablePaths).toEqual(["policies/unreadable-link"]);
|
|
4448
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
4449
|
+
expect.anything(),
|
|
4450
|
+
path.join(policiesDir, "safe.md"),
|
|
4451
|
+
"policies/safe.md",
|
|
4452
|
+
undefined,
|
|
4453
|
+
expect.anything(),
|
|
4454
|
+
);
|
|
4455
|
+
expect(uploadFile).not.toHaveBeenCalledWith(
|
|
4456
|
+
expect.anything(),
|
|
4457
|
+
path.join(unreadableLink, "must-not-upload.md"),
|
|
4458
|
+
"policies/unreadable-link/must-not-upload.md",
|
|
4459
|
+
undefined,
|
|
4460
|
+
expect.anything(),
|
|
4461
|
+
);
|
|
4462
|
+
expect(uploadSymlink).not.toHaveBeenCalledWith(
|
|
4463
|
+
expect.anything(),
|
|
4464
|
+
expect.anything(),
|
|
4465
|
+
"policies/unreadable-link",
|
|
4466
|
+
undefined,
|
|
4467
|
+
expect.anything(),
|
|
4468
|
+
);
|
|
4469
|
+
expect(events).toContainEqual({
|
|
4470
|
+
type: "not-shipped",
|
|
4471
|
+
reason: "unreadable-link",
|
|
4472
|
+
count: 1,
|
|
4473
|
+
samplePaths: ["policies/unreadable-link"],
|
|
4474
|
+
});
|
|
4475
|
+
} finally {
|
|
4476
|
+
warnings.mockRestore();
|
|
4477
|
+
readlinkSpy.mockRestore();
|
|
4478
|
+
}
|
|
4479
|
+
});
|
|
4480
|
+
|
|
4018
4481
|
it("accepts a symlink inside the company folder even when its target lives outside", async () => {
|
|
4019
4482
|
// Codex P2 follow-up: pre-fix, isWithin canonicalized the link
|
|
4020
4483
|
// via realpathSync, so a directory symlink whose target lived
|
|
@@ -4200,6 +4663,424 @@ describe("share", () => {
|
|
|
4200
4663
|
});
|
|
4201
4664
|
});
|
|
4202
4665
|
|
|
4666
|
+
// ── Push scope + visibility (feedback_258e4a86 / feedback_a51cb63d) ─────
|
|
4667
|
+
//
|
|
4668
|
+
// New local files under a synced subdir were invisible to every push path:
|
|
4669
|
+
// a company-relative path resolved only against hqRoot ("does not exist"),
|
|
4670
|
+
// an in-tree path reached through a symlinked ancestor was rejected as
|
|
4671
|
+
// "outside company folder", and a directory symlink to an external repo
|
|
4672
|
+
// swallowed its contents from every bucket — all while push still reported
|
|
4673
|
+
// "Pushed 0 file(s)". These pin the resolver + visibility fixes.
|
|
4674
|
+
describe("push scope + visibility (feedback_258e4a86 / feedback_a51cb63d)", () => {
|
|
4675
|
+
it("resolves a company-relative push path against the company folder, not just hqRoot", async () => {
|
|
4676
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4677
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge", "agents"), { recursive: true });
|
|
4678
|
+
const brief = path.join(companyRoot, "knowledge", "agents", "brief.md");
|
|
4679
|
+
fs.writeFileSync(brief, "mission brief");
|
|
4680
|
+
|
|
4681
|
+
// Pre-fix: path.resolve(hqRoot, "knowledge/agents/brief.md") →
|
|
4682
|
+
// <hqRoot>/knowledge/agents/brief.md, which does not exist → the file
|
|
4683
|
+
// was reported "does not exist, skipping" and 0 uploaded no matter where
|
|
4684
|
+
// the operator stood. The company-folder base now resolves it.
|
|
4685
|
+
const result = await share({
|
|
4686
|
+
paths: ["knowledge/agents/brief.md"],
|
|
4687
|
+
company: "acme",
|
|
4688
|
+
vaultConfig: mockConfig,
|
|
4689
|
+
hqRoot: tmpDir,
|
|
4690
|
+
});
|
|
4691
|
+
|
|
4692
|
+
expect(result.filesUploaded).toBe(1);
|
|
4693
|
+
expect(result.unreachablePaths).toEqual([]);
|
|
4694
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
4695
|
+
expect.anything(),
|
|
4696
|
+
brief,
|
|
4697
|
+
"knowledge/agents/brief.md",
|
|
4698
|
+
undefined,
|
|
4699
|
+
expect.anything(),
|
|
4700
|
+
);
|
|
4701
|
+
});
|
|
4702
|
+
|
|
4703
|
+
it("pushes an in-tree file reached through a symlinked ancestor (not 'outside company folder')", async () => {
|
|
4704
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4705
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4706
|
+
const externalKnowledge = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
|
|
4707
|
+
fs.mkdirSync(externalKnowledge, { recursive: true });
|
|
4708
|
+
fs.writeFileSync(path.join(externalKnowledge, "brief.md"), "mission brief");
|
|
4709
|
+
// companies/acme/knowledge → repos/private/knowledge-acme (HQ pattern 2).
|
|
4710
|
+
fs.symlinkSync(
|
|
4711
|
+
path.join(tmpDir, "repos", "private", "knowledge-acme"),
|
|
4712
|
+
path.join(companyRoot, "knowledge"),
|
|
4713
|
+
);
|
|
4714
|
+
|
|
4715
|
+
// Explicitly name the in-tree file THROUGH the symlinked ancestor.
|
|
4716
|
+
// Pre-fix, isWithin's realpath resolved it to repos/... → "outside
|
|
4717
|
+
// company folder, skipping" and 0 uploaded, even though
|
|
4718
|
+
// vaultKeyForLocalPath (lexical) would key it fine. It now uploads under
|
|
4719
|
+
// its logical company-namespaced key.
|
|
4720
|
+
const result = await share({
|
|
4721
|
+
paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
|
|
4722
|
+
company: "acme",
|
|
4723
|
+
vaultConfig: mockConfig,
|
|
4724
|
+
hqRoot: tmpDir,
|
|
4725
|
+
});
|
|
4726
|
+
|
|
4727
|
+
expect(result.filesUploaded).toBe(1);
|
|
4728
|
+
expect(result.unreachablePaths).toEqual([]);
|
|
4729
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
4730
|
+
expect.anything(),
|
|
4731
|
+
expect.anything(),
|
|
4732
|
+
"knowledge/agents/brief.md",
|
|
4733
|
+
undefined,
|
|
4734
|
+
expect.anything(),
|
|
4735
|
+
);
|
|
4736
|
+
});
|
|
4737
|
+
|
|
4738
|
+
it("refuses a named push whose symlinked ancestor points at ANOTHER company's folder", async () => {
|
|
4739
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4740
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4741
|
+
const otherSecrets = path.join(tmpDir, "companies", "other", "secret");
|
|
4742
|
+
fs.mkdirSync(otherSecrets, { recursive: true });
|
|
4743
|
+
fs.writeFileSync(path.join(otherSecrets, "creds.md"), "other tenant's bytes");
|
|
4744
|
+
// Mislinked (or hostile) ancestor inside acme pointing at a sibling tenant.
|
|
4745
|
+
fs.symlinkSync(otherSecrets, path.join(companyRoot, "knowledge"));
|
|
4746
|
+
|
|
4747
|
+
// Bounding the lexical containment arm by hqRoot alone would accept this
|
|
4748
|
+
// — the path is lexically inside acme and its realpath is inside HQ —
|
|
4749
|
+
// and upload company "other"'s bytes into acme's bucket under the
|
|
4750
|
+
// acme-namespaced key "knowledge/creds.md". The bound is the tenant.
|
|
4751
|
+
await expect(
|
|
4752
|
+
share({
|
|
4753
|
+
paths: [path.join(companyRoot, "knowledge", "creds.md")],
|
|
4754
|
+
company: "acme",
|
|
4755
|
+
vaultConfig: mockConfig,
|
|
4756
|
+
hqRoot: tmpDir,
|
|
4757
|
+
}),
|
|
4758
|
+
).rejects.toBeInstanceOf(UnreachablePushPathsError);
|
|
4759
|
+
|
|
4760
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
4761
|
+
});
|
|
4762
|
+
|
|
4763
|
+
it("refuses a named push whose symlinked ancestor points at another company's linked knowledge repo", async () => {
|
|
4764
|
+
// The realistic HQ topology: each company's knowledge is its own repo
|
|
4765
|
+
// under repos/private/, symlinked into the company folder. That target
|
|
4766
|
+
// sits outside every companies/<slug> root, so a companies-only bound
|
|
4767
|
+
// would let a typo'd link ship the sibling's repo into acme's vault.
|
|
4768
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4769
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4770
|
+
const otherRoot = path.join(tmpDir, "companies", "other");
|
|
4771
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
4772
|
+
const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
|
|
4773
|
+
fs.mkdirSync(otherRepo, { recursive: true });
|
|
4774
|
+
fs.writeFileSync(path.join(otherRepo, "roadmap.md"), "other tenant's roadmap");
|
|
4775
|
+
fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
|
|
4776
|
+
fs.symlinkSync(otherRepo, path.join(companyRoot, "knowledge"));
|
|
4777
|
+
|
|
4778
|
+
await expect(
|
|
4779
|
+
share({
|
|
4780
|
+
paths: [path.join(companyRoot, "knowledge", "roadmap.md")],
|
|
4781
|
+
company: "acme",
|
|
4782
|
+
vaultConfig: mockConfig,
|
|
4783
|
+
hqRoot: tmpDir,
|
|
4784
|
+
}),
|
|
4785
|
+
).rejects.toBeInstanceOf(UnreachablePushPathsError);
|
|
4786
|
+
|
|
4787
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
4788
|
+
});
|
|
4789
|
+
|
|
4790
|
+
it("still ships the company's OWN linked knowledge repo when a sibling tenant is present", async () => {
|
|
4791
|
+
// Guard against over-correction: the tenant bound must not refuse the
|
|
4792
|
+
// motivating topology just because another company exists locally.
|
|
4793
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4794
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4795
|
+
const otherRoot = path.join(tmpDir, "companies", "other");
|
|
4796
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
4797
|
+
const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
|
|
4798
|
+
fs.mkdirSync(otherRepo, { recursive: true });
|
|
4799
|
+
fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
|
|
4800
|
+
|
|
4801
|
+
const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
|
|
4802
|
+
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
4803
|
+
fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
|
|
4804
|
+
fs.symlinkSync(
|
|
4805
|
+
path.join(tmpDir, "repos", "private", "knowledge-acme"),
|
|
4806
|
+
path.join(companyRoot, "knowledge"),
|
|
4807
|
+
);
|
|
4808
|
+
|
|
4809
|
+
const result = await share({
|
|
4810
|
+
paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
|
|
4811
|
+
company: "acme",
|
|
4812
|
+
vaultConfig: mockConfig,
|
|
4813
|
+
hqRoot: tmpDir,
|
|
4814
|
+
});
|
|
4815
|
+
|
|
4816
|
+
expect(result.filesUploaded).toBe(1);
|
|
4817
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
4818
|
+
expect.anything(),
|
|
4819
|
+
expect.anything(),
|
|
4820
|
+
"knowledge/agents/brief.md",
|
|
4821
|
+
undefined,
|
|
4822
|
+
expect.anything(),
|
|
4823
|
+
);
|
|
4824
|
+
});
|
|
4825
|
+
|
|
4826
|
+
it("separates own-link from foreign-link within a SINGLE push pass", async () => {
|
|
4827
|
+
// The tenant-root lookup is memoized for the duration of one collect
|
|
4828
|
+
// pass. This pins that the memo keys on the company being pushed rather
|
|
4829
|
+
// than leaking one path's verdict onto the next: in one invocation the
|
|
4830
|
+
// own linked repo ships and the sibling's linked repo does not.
|
|
4831
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4832
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4833
|
+
const otherRoot = path.join(tmpDir, "companies", "other");
|
|
4834
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
4835
|
+
|
|
4836
|
+
const otherRepo = path.join(tmpDir, "repos", "private", "knowledge-other");
|
|
4837
|
+
fs.mkdirSync(otherRepo, { recursive: true });
|
|
4838
|
+
fs.writeFileSync(path.join(otherRepo, "secret.md"), "other tenant bytes");
|
|
4839
|
+
fs.symlinkSync(otherRepo, path.join(otherRoot, "knowledge"));
|
|
4840
|
+
// acme ALSO links the sibling's repo in, under its own name.
|
|
4841
|
+
fs.symlinkSync(otherRepo, path.join(companyRoot, "borrowed"));
|
|
4842
|
+
|
|
4843
|
+
const acmeRepo = path.join(tmpDir, "repos", "private", "knowledge-acme", "agents");
|
|
4844
|
+
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
4845
|
+
fs.writeFileSync(path.join(acmeRepo, "brief.md"), "mission brief");
|
|
4846
|
+
fs.symlinkSync(
|
|
4847
|
+
path.join(tmpDir, "repos", "private", "knowledge-acme"),
|
|
4848
|
+
path.join(companyRoot, "knowledge"),
|
|
4849
|
+
);
|
|
4850
|
+
|
|
4851
|
+
const result = await share({
|
|
4852
|
+
paths: [
|
|
4853
|
+
path.join(companyRoot, "knowledge", "agents", "brief.md"),
|
|
4854
|
+
path.join(companyRoot, "borrowed", "secret.md"),
|
|
4855
|
+
],
|
|
4856
|
+
company: "acme",
|
|
4857
|
+
vaultConfig: mockConfig,
|
|
4858
|
+
hqRoot: tmpDir,
|
|
4859
|
+
// warn so the pass completes and both verdicts are observable at once;
|
|
4860
|
+
// the default 'fail' path is covered by the tests above.
|
|
4861
|
+
unreachablePathPolicy: "warn",
|
|
4862
|
+
});
|
|
4863
|
+
|
|
4864
|
+
expect(result.filesUploaded).toBe(1);
|
|
4865
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
4866
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
4867
|
+
expect.anything(),
|
|
4868
|
+
expect.anything(),
|
|
4869
|
+
"knowledge/agents/brief.md",
|
|
4870
|
+
undefined,
|
|
4871
|
+
expect.anything(),
|
|
4872
|
+
);
|
|
4873
|
+
expect(result.unreachablePaths).toEqual([
|
|
4874
|
+
path.join(companyRoot, "borrowed", "secret.md"),
|
|
4875
|
+
]);
|
|
4876
|
+
});
|
|
4877
|
+
|
|
4878
|
+
it("fails the push when an explicitly named path exists but is outside the company folder (not a silent 0-file success)", async () => {
|
|
4879
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4880
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4881
|
+
fs.writeFileSync(path.join(companyRoot, "in-scope.md"), "shippable");
|
|
4882
|
+
// A real file that lives OUTSIDE the company folder entirely.
|
|
4883
|
+
const stray = path.join(tmpDir, "outside", "stray.md");
|
|
4884
|
+
fs.mkdirSync(path.dirname(stray), { recursive: true });
|
|
4885
|
+
fs.writeFileSync(stray, "outside content");
|
|
4886
|
+
|
|
4887
|
+
// Ask #2 of the report: "error, not warn-skip, when the named file
|
|
4888
|
+
// exists locally but is unreachable by the resolver". Pre-fix this
|
|
4889
|
+
// printed "✓ Pushed 0 file(s)" and exited 0.
|
|
4890
|
+
const events: Array<{ type: string; reason?: string; count?: number }> = [];
|
|
4891
|
+
await expect(
|
|
4892
|
+
share({
|
|
4893
|
+
paths: [stray, path.join(companyRoot, "in-scope.md")],
|
|
4894
|
+
company: "acme",
|
|
4895
|
+
vaultConfig: mockConfig,
|
|
4896
|
+
hqRoot: tmpDir,
|
|
4897
|
+
onEvent: (e) => events.push(e as { type: string; reason?: string; count?: number }),
|
|
4898
|
+
}),
|
|
4899
|
+
).rejects.toBeInstanceOf(UnreachablePushPathsError);
|
|
4900
|
+
|
|
4901
|
+
// The failure is an ATOMIC no-op: it fires before any upload, so the
|
|
4902
|
+
// reachable sibling in the same invocation is not half-shipped either.
|
|
4903
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
4904
|
+
// Still visible on the event stream before the throw.
|
|
4905
|
+
const notShipped = events.find(
|
|
4906
|
+
(e) => e.type === "not-shipped" && e.reason === "unreachable-path",
|
|
4907
|
+
);
|
|
4908
|
+
expect(notShipped).toBeDefined();
|
|
4909
|
+
expect(notShipped?.count).toBe(1);
|
|
4910
|
+
});
|
|
4911
|
+
|
|
4912
|
+
it("names the offending path and its reason on the thrown error", async () => {
|
|
4913
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4914
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4915
|
+
const stray = path.join(tmpDir, "outside", "stray.md");
|
|
4916
|
+
fs.mkdirSync(path.dirname(stray), { recursive: true });
|
|
4917
|
+
fs.writeFileSync(stray, "outside content");
|
|
4918
|
+
|
|
4919
|
+
let err: unknown;
|
|
4920
|
+
try {
|
|
4921
|
+
await share({
|
|
4922
|
+
paths: [stray],
|
|
4923
|
+
company: "acme",
|
|
4924
|
+
vaultConfig: mockConfig,
|
|
4925
|
+
hqRoot: tmpDir,
|
|
4926
|
+
});
|
|
4927
|
+
} catch (e) {
|
|
4928
|
+
err = e;
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
expect(err).toBeInstanceOf(UnreachablePushPathsError);
|
|
4932
|
+
const unreachable = err as UnreachablePushPathsError;
|
|
4933
|
+
expect(unreachable.paths).toEqual([stray]);
|
|
4934
|
+
expect(unreachable.reasons[stray]).toBe("outside-company");
|
|
4935
|
+
// The operator gets the actionable topology hint, not just a code.
|
|
4936
|
+
expect(unreachable.message).toContain("outside the company folder");
|
|
4937
|
+
});
|
|
4938
|
+
|
|
4939
|
+
it("records rather than throws for an unreachable path under unreachablePathPolicy: 'warn'", async () => {
|
|
4940
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4941
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4942
|
+
const stray = path.join(tmpDir, "outside", "stray.md");
|
|
4943
|
+
fs.mkdirSync(path.dirname(stray), { recursive: true });
|
|
4944
|
+
fs.writeFileSync(stray, "outside content");
|
|
4945
|
+
|
|
4946
|
+
// The sync runner walks roots it computed itself, not operator input, so
|
|
4947
|
+
// it opts into the legacy warn behavior instead of failing a whole
|
|
4948
|
+
// multi-company sync on one odd root.
|
|
4949
|
+
const result = await share({
|
|
4950
|
+
paths: [stray],
|
|
4951
|
+
company: "acme",
|
|
4952
|
+
vaultConfig: mockConfig,
|
|
4953
|
+
hqRoot: tmpDir,
|
|
4954
|
+
unreachablePathPolicy: "warn",
|
|
4955
|
+
});
|
|
4956
|
+
|
|
4957
|
+
expect(result.filesUploaded).toBe(0);
|
|
4958
|
+
expect(result.unreachablePaths).toEqual([stray]);
|
|
4959
|
+
});
|
|
4960
|
+
|
|
4961
|
+
it("surfaces a named path that does not exist under any base as unreachable without failing the run", async () => {
|
|
4962
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4963
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4964
|
+
|
|
4965
|
+
// "missing" is deliberately NOT fatal even under the default policy: a
|
|
4966
|
+
// multi-company push plans one leg per membership, including companies
|
|
4967
|
+
// whose folder was never materialized locally. Only "outside-company" —
|
|
4968
|
+
// the report's "exists locally but unreachable" case — fails the run.
|
|
4969
|
+
const result = await share({
|
|
4970
|
+
paths: ["knowledge/agents/nope.md"],
|
|
4971
|
+
company: "acme",
|
|
4972
|
+
vaultConfig: mockConfig,
|
|
4973
|
+
hqRoot: tmpDir,
|
|
4974
|
+
});
|
|
4975
|
+
|
|
4976
|
+
expect(result.filesUploaded).toBe(0);
|
|
4977
|
+
expect(result.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
|
|
4978
|
+
});
|
|
4979
|
+
|
|
4980
|
+
it("reports unreachable paths using the caller's original spelling verbatim", async () => {
|
|
4981
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4982
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4983
|
+
|
|
4984
|
+
// Spelling contract: a relative token is echoed relative (never resolved
|
|
4985
|
+
// to one of the probe bases), so an operator can match the reported path
|
|
4986
|
+
// against the one they typed.
|
|
4987
|
+
const relative = await share({
|
|
4988
|
+
paths: ["knowledge/agents/nope.md"],
|
|
4989
|
+
company: "acme",
|
|
4990
|
+
vaultConfig: mockConfig,
|
|
4991
|
+
hqRoot: tmpDir,
|
|
4992
|
+
});
|
|
4993
|
+
expect(relative.unreachablePaths).toEqual(["knowledge/agents/nope.md"]);
|
|
4994
|
+
|
|
4995
|
+
// …and an absolute token is echoed absolute.
|
|
4996
|
+
const stray = path.join(tmpDir, "outside", "stray.md");
|
|
4997
|
+
fs.mkdirSync(path.dirname(stray), { recursive: true });
|
|
4998
|
+
fs.writeFileSync(stray, "outside content");
|
|
4999
|
+
const absolute = await share({
|
|
5000
|
+
paths: [stray],
|
|
5001
|
+
company: "acme",
|
|
5002
|
+
vaultConfig: mockConfig,
|
|
5003
|
+
hqRoot: tmpDir,
|
|
5004
|
+
unreachablePathPolicy: "warn",
|
|
5005
|
+
});
|
|
5006
|
+
expect(absolute.unreachablePaths).toEqual([stray]);
|
|
5007
|
+
});
|
|
5008
|
+
|
|
5009
|
+
it("reports a directory symlink to an external repo as a linked subtree not shipped to the vault", async () => {
|
|
5010
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5011
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5012
|
+
const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
|
|
5013
|
+
fs.mkdirSync(externalTarget, { recursive: true });
|
|
5014
|
+
fs.writeFileSync(path.join(externalTarget, "brief.md"), "lives in the repo");
|
|
5015
|
+
fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
|
|
5016
|
+
|
|
5017
|
+
const events: Array<{ type: string; reason?: string; samplePaths?: string[] }> = [];
|
|
5018
|
+
const result = await share({
|
|
5019
|
+
paths: [companyRoot],
|
|
5020
|
+
company: "acme",
|
|
5021
|
+
vaultConfig: mockConfig,
|
|
5022
|
+
hqRoot: tmpDir,
|
|
5023
|
+
onEvent: (e) =>
|
|
5024
|
+
events.push(e as { type: string; reason?: string; samplePaths?: string[] }),
|
|
5025
|
+
});
|
|
5026
|
+
|
|
5027
|
+
// Topology contract preserved: only the link record ships; its external
|
|
5028
|
+
// contents do NOT (no regular-file upload for brief.md).
|
|
5029
|
+
expect(result.filesUploaded).toBe(1);
|
|
5030
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
5031
|
+
// But the subtree is now VISIBLE instead of vanishing from every bucket.
|
|
5032
|
+
expect(result.linkedSubtreesNotShipped).toEqual(["knowledge"]);
|
|
5033
|
+
const notShipped = events.find(
|
|
5034
|
+
(e) => e.type === "not-shipped" && e.reason === "linked-subtree",
|
|
5035
|
+
);
|
|
5036
|
+
expect(notShipped).toBeDefined();
|
|
5037
|
+
expect(notShipped?.samplePaths).toEqual(["knowledge"]);
|
|
5038
|
+
});
|
|
5039
|
+
|
|
5040
|
+
it("keeps a file explicitly pushed from under a linked subtree alive across a later full-walk push", async () => {
|
|
5041
|
+
// The documented workaround for a linked subtree is `hq sync push
|
|
5042
|
+
// <path>` — a point-in-time copy. That copy is only useful if the very
|
|
5043
|
+
// next full-company sync does not immediately delete it again: the walk
|
|
5044
|
+
// never descends the link, so the key has no walk-visible counterpart.
|
|
5045
|
+
// Delete candidacy is lstat-based (the file IS on disk through the
|
|
5046
|
+
// link), so the copy survives even the gate-free "all" policy.
|
|
5047
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5048
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5049
|
+
const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
|
|
5050
|
+
fs.mkdirSync(path.join(externalTarget, "agents"), { recursive: true });
|
|
5051
|
+
fs.writeFileSync(path.join(externalTarget, "agents", "brief.md"), "mission brief");
|
|
5052
|
+
fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
|
|
5053
|
+
|
|
5054
|
+
const pushed = await share({
|
|
5055
|
+
paths: [path.join(companyRoot, "knowledge", "agents", "brief.md")],
|
|
5056
|
+
company: "acme",
|
|
5057
|
+
vaultConfig: mockConfig,
|
|
5058
|
+
hqRoot: tmpDir,
|
|
5059
|
+
});
|
|
5060
|
+
expect(pushed.filesUploaded).toBe(1);
|
|
5061
|
+
|
|
5062
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5063
|
+
const afterPush = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5064
|
+
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5065
|
+
|
|
5066
|
+
vi.mocked(deleteRemoteFile).mockClear();
|
|
5067
|
+
const walked = await share({
|
|
5068
|
+
paths: [companyRoot],
|
|
5069
|
+
company: "acme",
|
|
5070
|
+
vaultConfig: mockConfig,
|
|
5071
|
+
hqRoot: tmpDir,
|
|
5072
|
+
propagateDeletes: true,
|
|
5073
|
+
propagateDeletePolicy: "all",
|
|
5074
|
+
});
|
|
5075
|
+
|
|
5076
|
+
expect(walked.filesDeleted).toBe(0);
|
|
5077
|
+
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1] as string);
|
|
5078
|
+
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5079
|
+
const afterWalk = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5080
|
+
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5081
|
+
});
|
|
5082
|
+
});
|
|
5083
|
+
|
|
4203
5084
|
// ── Bulk-asymmetry circuit-breaker ─────────────────────────────────────
|
|
4204
5085
|
//
|
|
4205
5086
|
// Defends against the "local mirror lost, journal still full" failure
|
|
@@ -5646,6 +6527,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5646
6527
|
});
|
|
5647
6528
|
});
|
|
5648
6529
|
|
|
6530
|
+
|
|
5649
6531
|
describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
|
|
5650
6532
|
let tmpDir: string;
|
|
5651
6533
|
let stateDir: string;
|
|
@@ -5760,6 +6642,72 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
5760
6642
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
5761
6643
|
});
|
|
5762
6644
|
|
|
6645
|
+
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
6646
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6647
|
+
const linkKey = "companies/acme/knowledge/unreadable-link";
|
|
6648
|
+
const linkPath = path.join(companyRoot, ...linkKey.split("/"));
|
|
6649
|
+
const targetDir = path.join(tmpDir, "outside", "target");
|
|
6650
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
6651
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
6652
|
+
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6653
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6654
|
+
fs.writeFileSync(
|
|
6655
|
+
journalPath,
|
|
6656
|
+
JSON.stringify({
|
|
6657
|
+
version: "1",
|
|
6658
|
+
lastSync: new Date().toISOString(),
|
|
6659
|
+
files: {
|
|
6660
|
+
[linkKey]: {
|
|
6661
|
+
hash: "recorded-link-hash",
|
|
6662
|
+
size: 0,
|
|
6663
|
+
syncedAt: new Date().toISOString(),
|
|
6664
|
+
direction: "up",
|
|
6665
|
+
remoteEtag: "poison-etag",
|
|
6666
|
+
},
|
|
6667
|
+
},
|
|
6668
|
+
}),
|
|
6669
|
+
);
|
|
6670
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
6671
|
+
let linkReadCount = 0;
|
|
6672
|
+
const readlinkSpy = vi
|
|
6673
|
+
.spyOn(fs, "readlinkSync")
|
|
6674
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
6675
|
+
if (candidate === linkPath && ++linkReadCount === 2) {
|
|
6676
|
+
throw errnoError("EINVAL");
|
|
6677
|
+
}
|
|
6678
|
+
return realReadlinkSync(candidate);
|
|
6679
|
+
}) as typeof fs.readlinkSync);
|
|
6680
|
+
const events: SyncProgressEvent[] = [];
|
|
6681
|
+
|
|
6682
|
+
try {
|
|
6683
|
+
const result = await share({
|
|
6684
|
+
paths: [companyRoot],
|
|
6685
|
+
company: "acme",
|
|
6686
|
+
vaultConfig: mockConfig,
|
|
6687
|
+
hqRoot: tmpDir,
|
|
6688
|
+
skipUnchanged: true,
|
|
6689
|
+
propagateDeletes: true,
|
|
6690
|
+
propagateDeletePolicy: "currency-gated",
|
|
6691
|
+
onEvent: (event) => events.push(event),
|
|
6692
|
+
});
|
|
6693
|
+
|
|
6694
|
+
expect(linkReadCount).toBe(2);
|
|
6695
|
+
expect(result.filesTombstoned).toBe(0);
|
|
6696
|
+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
6697
|
+
expect(events).toContainEqual({
|
|
6698
|
+
type: "not-shipped",
|
|
6699
|
+
reason: "unreadable-link",
|
|
6700
|
+
count: 1,
|
|
6701
|
+
samplePaths: [linkKey],
|
|
6702
|
+
});
|
|
6703
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6704
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
6705
|
+
expect(journal.files[linkKey]).toBeDefined();
|
|
6706
|
+
} finally {
|
|
6707
|
+
readlinkSpy.mockRestore();
|
|
6708
|
+
}
|
|
6709
|
+
});
|
|
6710
|
+
|
|
5763
6711
|
it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
|
|
5764
6712
|
const localCo = path.join(tmpDir, "companies", "localco");
|
|
5765
6713
|
fs.mkdirSync(localCo, { recursive: true });
|
|
@@ -5782,3 +6730,27 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
5782
6730
|
expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
|
|
5783
6731
|
});
|
|
5784
6732
|
});
|
|
6733
|
+
|
|
6734
|
+
// ── Pure-function unit coverage: isForbiddenCompanyVaultKey ───────────────────
|
|
6735
|
+
//
|
|
6736
|
+
// Contract: in a COMPANY vault (personalMode=false) any key under `companies/`
|
|
6737
|
+
// is a doubly-scoped corrupt object and must be refused; in a PERSONAL vault
|
|
6738
|
+
// (personalMode=true) `companies/` keys are legitimate and handled by the
|
|
6739
|
+
// dedicated personalMode branch, so this guard must NOT fire there.
|
|
6740
|
+
describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", () => {
|
|
6741
|
+
it.each([
|
|
6742
|
+
// Company vault: companies/* keys are corrupt → forbidden.
|
|
6743
|
+
["companies/frogbear/drafts/reports/signals-2026-06-15.html", false, true],
|
|
6744
|
+
["companies/acme/knowledge/readme.md", false, true],
|
|
6745
|
+
["companies/manifest.yaml", false, true],
|
|
6746
|
+
// Company vault: bucket-relative keys are legitimate → allowed.
|
|
6747
|
+
["docs/handoff.md", false, false],
|
|
6748
|
+
["knowledge/readme.md", false, false],
|
|
6749
|
+
// Personal vault: companies/* keys are NOT forbidden here (handled by the
|
|
6750
|
+
// personalMode branch, which carries its own nuanced gating).
|
|
6751
|
+
["companies/frogbear/drafts/reports/signals-2026-06-15.html", true, false],
|
|
6752
|
+
["docs/handoff.md", true, false],
|
|
6753
|
+
])("key=%s personalMode=%s → forbidden=%s", (key, personalMode, expected) => {
|
|
6754
|
+
expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
|
|
6755
|
+
});
|
|
6756
|
+
});
|