@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/dist/cli/sync.test.js
CHANGED
|
@@ -7,6 +7,12 @@ import * as path from "path";
|
|
|
7
7
|
import * as os from "os";
|
|
8
8
|
import { clearContextCache } from "../context.js";
|
|
9
9
|
import { lockPathFor } from "../operation-lock.js";
|
|
10
|
+
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
11
|
+
// inject a win32 EINVAL without needing a real Windows reparse point.
|
|
12
|
+
vi.mock("fs", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal();
|
|
14
|
+
return { ...actual };
|
|
15
|
+
});
|
|
10
16
|
// Mock s3 module at the top level
|
|
11
17
|
vi.mock("../s3.js", async (importOriginal) => {
|
|
12
18
|
const actual = await importOriginal();
|
|
@@ -53,6 +59,11 @@ const mockConfig = {
|
|
|
53
59
|
authToken: "test-jwt-token",
|
|
54
60
|
region: "us-east-1",
|
|
55
61
|
};
|
|
62
|
+
function errnoError(code) {
|
|
63
|
+
const err = new Error(`${code}: invalid argument, readlink`);
|
|
64
|
+
err.code = code;
|
|
65
|
+
return err;
|
|
66
|
+
}
|
|
56
67
|
const mockEntity = {
|
|
57
68
|
uid: "cmp_01ABCDEF",
|
|
58
69
|
slug: "acme",
|
|
@@ -312,6 +323,49 @@ describe("sync", () => {
|
|
|
312
323
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
313
324
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
314
325
|
});
|
|
326
|
+
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
327
|
+
const linkKey = "policies/downloaded-unreadable-link";
|
|
328
|
+
const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
329
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
330
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
331
|
+
]);
|
|
332
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, localPath) => {
|
|
333
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
334
|
+
fs.symlinkSync("missing-target.md", localPath);
|
|
335
|
+
return { metadata: {} };
|
|
336
|
+
});
|
|
337
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
338
|
+
const readlinkSpy = vi
|
|
339
|
+
.spyOn(fs, "readlinkSync")
|
|
340
|
+
.mockImplementation(((candidate) => {
|
|
341
|
+
if (candidate === linkPath)
|
|
342
|
+
throw errnoError("EINVAL");
|
|
343
|
+
return realReadlinkSync(candidate);
|
|
344
|
+
}));
|
|
345
|
+
const events = [];
|
|
346
|
+
try {
|
|
347
|
+
const result = await sync({
|
|
348
|
+
company: "acme",
|
|
349
|
+
vaultConfig: mockConfig,
|
|
350
|
+
hqRoot: tmpDir,
|
|
351
|
+
onEvent: (event) => events.push(event),
|
|
352
|
+
});
|
|
353
|
+
expect(result.filesDownloaded).toBe(0);
|
|
354
|
+
expect(result.filesSkipped).toBe(1);
|
|
355
|
+
expect(events).toContainEqual({
|
|
356
|
+
type: "not-shipped",
|
|
357
|
+
reason: "unreadable-link",
|
|
358
|
+
count: 1,
|
|
359
|
+
samplePaths: [linkKey],
|
|
360
|
+
});
|
|
361
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
362
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
363
|
+
expect(journal.files[linkKey]).toBeUndefined();
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
readlinkSpy.mockRestore();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
315
369
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
316
370
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
317
371
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -407,6 +461,67 @@ describe("sync", () => {
|
|
|
407
461
|
expect(journalAfter.files["docs/handoff.md"].hash).not.toBe("stale-hash");
|
|
408
462
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
409
463
|
});
|
|
464
|
+
it("defers a conflict whose downloaded link target cannot be read", async () => {
|
|
465
|
+
const linkKey = "docs/handoff.md";
|
|
466
|
+
const localPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
467
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
468
|
+
fs.writeFileSync(localPath, "local version");
|
|
469
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
470
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
|
|
471
|
+
]);
|
|
472
|
+
let mirrorPath = "";
|
|
473
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination) => {
|
|
474
|
+
mirrorPath = destination;
|
|
475
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
476
|
+
fs.symlinkSync("missing-target.md", destination);
|
|
477
|
+
return { metadata: {} };
|
|
478
|
+
});
|
|
479
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
480
|
+
version: "1",
|
|
481
|
+
lastSync: new Date().toISOString(),
|
|
482
|
+
files: {
|
|
483
|
+
[linkKey]: {
|
|
484
|
+
hash: "stale-hash",
|
|
485
|
+
size: 20,
|
|
486
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
487
|
+
direction: "down",
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
}));
|
|
491
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
492
|
+
const readlinkSpy = vi
|
|
493
|
+
.spyOn(fs, "readlinkSync")
|
|
494
|
+
.mockImplementation(((candidate) => {
|
|
495
|
+
if (candidate === mirrorPath)
|
|
496
|
+
throw errnoError("EINVAL");
|
|
497
|
+
return realReadlinkSync(candidate);
|
|
498
|
+
}));
|
|
499
|
+
const events = [];
|
|
500
|
+
try {
|
|
501
|
+
const result = await sync({
|
|
502
|
+
company: "acme",
|
|
503
|
+
onConflict: "keep",
|
|
504
|
+
vaultConfig: mockConfig,
|
|
505
|
+
hqRoot: tmpDir,
|
|
506
|
+
onEvent: (event) => events.push(event),
|
|
507
|
+
});
|
|
508
|
+
expect(result.conflicts).toBe(0);
|
|
509
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
510
|
+
expect(events).toContainEqual({
|
|
511
|
+
type: "not-shipped",
|
|
512
|
+
reason: "unreadable-link",
|
|
513
|
+
count: 1,
|
|
514
|
+
samplePaths: [linkKey],
|
|
515
|
+
});
|
|
516
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
517
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
518
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
519
|
+
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
readlinkSpy.mockRestore();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
410
525
|
it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
|
|
411
526
|
// Guard the other side of the convergence branch: when the probe bytes
|
|
412
527
|
// differ from local, it remains a real conflict — counted, kept, and the
|
|
@@ -2319,6 +2434,40 @@ describe("sync", () => {
|
|
|
2319
2434
|
expect(result.filesDownloaded).toBe(1);
|
|
2320
2435
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2321
2436
|
});
|
|
2437
|
+
it("skips remote keys under companies/<slug>/ in a company vault (doubly-scoped corrupt object — frogbear exit-2 regression)", async () => {
|
|
2438
|
+
// A company vault is already anchored at its company root, so its keys are
|
|
2439
|
+
// bucket-relative. A remote key literally beginning with `companies/...` is
|
|
2440
|
+
// a doubly-scoped corrupt object: the vault-service refuses to presign it
|
|
2441
|
+
// (INVALID_KEY_COMPANIES_SCOPED), so the per-file GET fails and the whole
|
|
2442
|
+
// company wedges at "errored" (runner exit 2) on every run. Verified live
|
|
2443
|
+
// 2026-06-16 against frogbear. The pull planner must refuse it at planning
|
|
2444
|
+
// time — same policy bucket as the malformed-key / ephemeral filters.
|
|
2445
|
+
const corruptKey = "companies/acme/drafts/reports/signals-2026-06-15.html";
|
|
2446
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
2447
|
+
// Doubly-scoped corrupt key — must be filtered, never downloaded.
|
|
2448
|
+
{ key: corruptKey, size: 2152, lastModified: new Date(), etag: '"corrupt"' },
|
|
2449
|
+
// A legitimate bucket-relative key — must still download.
|
|
2450
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"ok"' },
|
|
2451
|
+
]);
|
|
2452
|
+
const result = await sync({
|
|
2453
|
+
company: "acme",
|
|
2454
|
+
vaultConfig: mockConfig,
|
|
2455
|
+
hqRoot: tmpDir,
|
|
2456
|
+
});
|
|
2457
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2458
|
+
// The corrupt key MUST NOT be materialized at its doubled local path.
|
|
2459
|
+
expect(fs.existsSync(path.join(companyRoot, "companies", "acme", "drafts", "reports", "signals-2026-06-15.html"))).toBe(false);
|
|
2460
|
+
// The vault-service GET must never be attempted for the corrupt key — the
|
|
2461
|
+
// failing presign is the exact symptom this guard removes.
|
|
2462
|
+
for (const call of vi.mocked(s3Module.downloadFile).mock.calls) {
|
|
2463
|
+
expect(call[1]).not.toBe(corruptKey);
|
|
2464
|
+
}
|
|
2465
|
+
// The legitimate bucket-relative key MUST still download.
|
|
2466
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "handoff.md"))).toBe(true);
|
|
2467
|
+
expect(result.filesDownloaded).toBe(1);
|
|
2468
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2469
|
+
expect(result.aborted).toBe(false);
|
|
2470
|
+
});
|
|
2322
2471
|
it("F02: rejects traversal remote keys before they can escape the company root", async () => {
|
|
2323
2472
|
const escapeName = `${path.basename(tmpDir)}-escaped.md`;
|
|
2324
2473
|
const traversalKey = `../../../${escapeName}`;
|
|
@@ -3433,6 +3582,45 @@ describe("sync", () => {
|
|
|
3433
3582
|
expect(result.filesSkipped).toBe(1);
|
|
3434
3583
|
expect(result.conflicts).toBe(0);
|
|
3435
3584
|
});
|
|
3585
|
+
it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
|
|
3586
|
+
const linkKey = "policies/unreadable-link";
|
|
3587
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3588
|
+
const linkPath = path.join(companyRoot, linkKey);
|
|
3589
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
3590
|
+
fs.symlinkSync("target.md", linkPath);
|
|
3591
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3592
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
3593
|
+
]);
|
|
3594
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
3595
|
+
const readlinkSpy = vi
|
|
3596
|
+
.spyOn(fs, "readlinkSync")
|
|
3597
|
+
.mockImplementation(((candidate) => {
|
|
3598
|
+
if (candidate === linkPath)
|
|
3599
|
+
throw errnoError("EINVAL");
|
|
3600
|
+
return realReadlinkSync(candidate);
|
|
3601
|
+
}));
|
|
3602
|
+
try {
|
|
3603
|
+
const events = [];
|
|
3604
|
+
const result = await sync({
|
|
3605
|
+
company: "acme",
|
|
3606
|
+
vaultConfig: mockConfig,
|
|
3607
|
+
hqRoot: tmpDir,
|
|
3608
|
+
onEvent: (event) => events.push(event),
|
|
3609
|
+
});
|
|
3610
|
+
expect(result.filesDownloaded).toBe(0);
|
|
3611
|
+
expect(result.filesSkipped).toBe(1);
|
|
3612
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
3613
|
+
expect(events).toContainEqual({
|
|
3614
|
+
type: "not-shipped",
|
|
3615
|
+
reason: "unreadable-link",
|
|
3616
|
+
count: 1,
|
|
3617
|
+
samplePaths: [linkKey],
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
finally {
|
|
3621
|
+
readlinkSpy.mockRestore();
|
|
3622
|
+
}
|
|
3623
|
+
});
|
|
3436
3624
|
it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
|
|
3437
3625
|
// Codex round-11 P2 follow-up: pre-fix, the conflict executor
|
|
3438
3626
|
// built the resolveConflict prompt with `fs.statSync(localPath).
|