@indigoai-us/hq-cloud 6.14.0 → 6.14.2
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 +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +39 -10
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +40 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +53 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +44 -13
- package/src/cli/reindex.ts +74 -21
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +22 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +82 -0
- package/src/watcher.ts +69 -0
|
@@ -461,4 +461,94 @@ describe("sync — scope-aware download (US-005)", () => {
|
|
|
461
461
|
expect(second.scopeOrphansRemoved).toBe(0);
|
|
462
462
|
expect(second.scopeOrphansBlocked).toBe(0);
|
|
463
463
|
});
|
|
464
|
+
|
|
465
|
+
// ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
|
|
466
|
+
// The sessions prefix is PUSH-ONLY: pushed into the vault but never
|
|
467
|
+
// auto-pulled onto grantee laptops in ANY mode (including `all`, which
|
|
468
|
+
// otherwise pulls the whole bucket). The engine expresses this as
|
|
469
|
+
// `excludePrefixes`, applied to the DOWNLOAD filter only — never to
|
|
470
|
+
// scope-shrink, so an authored/pinned session is never orphaned.
|
|
471
|
+
describe("sessions push-only exclusion (US-006)", () => {
|
|
472
|
+
const withSession = () => {
|
|
473
|
+
REMOTE.current = [
|
|
474
|
+
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
475
|
+
{ key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
|
|
476
|
+
];
|
|
477
|
+
};
|
|
478
|
+
const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
|
|
479
|
+
|
|
480
|
+
it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
|
|
481
|
+
withSession();
|
|
482
|
+
const result = await sync({
|
|
483
|
+
company: "acme",
|
|
484
|
+
vaultConfig: mockConfig,
|
|
485
|
+
hqRoot: tmpDir,
|
|
486
|
+
syncMode: "all",
|
|
487
|
+
excludePrefixes: ["sessions/"],
|
|
488
|
+
});
|
|
489
|
+
// `all` normally pulls everything; the exclusion carves sessions out.
|
|
490
|
+
expect(result.filesDownloaded).toBe(1);
|
|
491
|
+
expect(result.filesOutOfScope).toBe(1);
|
|
492
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
493
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
|
|
497
|
+
withSession();
|
|
498
|
+
// 1. Full pull WITHOUT the exclusion: the session materializes and journals
|
|
499
|
+
// as a pulled (direction:"down") entry under an `all`-mode PullRecord.
|
|
500
|
+
const first = await sync({
|
|
501
|
+
company: "acme",
|
|
502
|
+
vaultConfig: mockConfig,
|
|
503
|
+
hqRoot: tmpDir,
|
|
504
|
+
syncMode: "all",
|
|
505
|
+
});
|
|
506
|
+
expect(first.filesDownloaded).toBe(2);
|
|
507
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
508
|
+
|
|
509
|
+
// 2. Full pull WITH the sessions exclusion. The session is now
|
|
510
|
+
// skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
|
|
511
|
+
// set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
|
|
512
|
+
// (covers everything). If it WERE fed in, the file would be orphaned.
|
|
513
|
+
const second = await sync({
|
|
514
|
+
company: "acme",
|
|
515
|
+
vaultConfig: mockConfig,
|
|
516
|
+
hqRoot: tmpDir,
|
|
517
|
+
syncMode: "all",
|
|
518
|
+
excludePrefixes: ["sessions/"],
|
|
519
|
+
});
|
|
520
|
+
expect(second.filesOutOfScope).toBe(1);
|
|
521
|
+
expect(second.scopeOrphansRemoved).toBe(0);
|
|
522
|
+
expect(second.scopeOrphansBlocked).toBe(0);
|
|
523
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
|
|
527
|
+
withSession();
|
|
528
|
+
// Materialize both under all-mode (knowledge sync + an on-demand session get).
|
|
529
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
530
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
531
|
+
|
|
532
|
+
// A scoped shared pull whose inclusion prefixSet includes the pinned
|
|
533
|
+
// session key (as resolvePullScope would after `hq files get` pins it),
|
|
534
|
+
// plus the push-only sessions exclusion. The session is NOT re-pulled
|
|
535
|
+
// (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
|
|
536
|
+
// it is retained rather than orphaned.
|
|
537
|
+
const scoped = await sync({
|
|
538
|
+
company: "acme",
|
|
539
|
+
vaultConfig: mockConfig,
|
|
540
|
+
hqRoot: tmpDir,
|
|
541
|
+
syncMode: "shared",
|
|
542
|
+
prefixSet: ["knowledge/", sessionPath],
|
|
543
|
+
excludePrefixes: ["sessions/"],
|
|
544
|
+
});
|
|
545
|
+
expect(scoped.scopeOrphansRemoved).toBe(0);
|
|
546
|
+
expect(scoped.scopeOrphansBlocked).toBe(0);
|
|
547
|
+
// Session is not re-downloaded (excluded) but survives on disk...
|
|
548
|
+
expect(scoped.filesOutOfScope).toBe(1);
|
|
549
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
550
|
+
// ...and the in-scope knowledge file is retained too.
|
|
551
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
464
554
|
});
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -3589,3 +3589,157 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3589
3589
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
3590
3590
|
});
|
|
3591
3591
|
});
|
|
3592
|
+
|
|
3593
|
+
describe("scope-invalid key hardening (companies/ prefix in a company vault — incident 2026-07-11)", () => {
|
|
3594
|
+
let tmpDir: string;
|
|
3595
|
+
let stateDir: string;
|
|
3596
|
+
let journalPath: string;
|
|
3597
|
+
|
|
3598
|
+
beforeEach(() => {
|
|
3599
|
+
clearContextCache();
|
|
3600
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-poison-test-"));
|
|
3601
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
|
|
3602
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
3603
|
+
journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3604
|
+
setupFetchMock();
|
|
3605
|
+
// Earlier tests in this file replace the downloadFile implementation with
|
|
3606
|
+
// throwing probes; vi.clearAllMocks() keeps implementations, so restore
|
|
3607
|
+
// the default writer here to keep these tests order-independent.
|
|
3608
|
+
vi.mocked(s3Module.downloadFile).mockReset();
|
|
3609
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
3610
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
3611
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
3612
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
3613
|
+
return { metadata: {} };
|
|
3614
|
+
},
|
|
3615
|
+
);
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
afterEach(() => {
|
|
3619
|
+
vi.unstubAllGlobals();
|
|
3620
|
+
vi.clearAllMocks();
|
|
3621
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
3622
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
3623
|
+
delete process.env.HQ_STATE_DIR;
|
|
3624
|
+
});
|
|
3625
|
+
|
|
3626
|
+
it("skips a companies/-prefixed remote key instead of erroring the whole company sync", async () => {
|
|
3627
|
+
// The incident shape: 25 poisoned `companies/indigo/…` objects in the
|
|
3628
|
+
// company bucket; the presign download API rejects them
|
|
3629
|
+
// (INVALID_KEY_COMPANIES_SCOPED), so attempting to download errored the
|
|
3630
|
+
// whole company sync and gated heartbeat toolset refreshes fleet-wide.
|
|
3631
|
+
// One bad object must never take down a company sync.
|
|
3632
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3633
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3634
|
+
{ key: "companies/acme/knowledge/poison.md", size: 10, lastModified: new Date(), etag: '"p1"' },
|
|
3635
|
+
{ key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"g1"' },
|
|
3636
|
+
]);
|
|
3637
|
+
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
|
3638
|
+
const result = await sync({
|
|
3639
|
+
company: "acme",
|
|
3640
|
+
vaultConfig: mockConfig,
|
|
3641
|
+
hqRoot: tmpDir,
|
|
3642
|
+
onEvent: (e) => events.push(e as never),
|
|
3643
|
+
});
|
|
3644
|
+
|
|
3645
|
+
// The good file syncs; the poisoned key is never downloaded.
|
|
3646
|
+
expect(result.aborted).toBe(false);
|
|
3647
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3648
|
+
expect(
|
|
3649
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
|
|
3650
|
+
).toBe(true);
|
|
3651
|
+
expect(
|
|
3652
|
+
fs.existsSync(
|
|
3653
|
+
path.join(tmpDir, "companies", "acme", "companies", "acme", "knowledge", "poison.md"),
|
|
3654
|
+
),
|
|
3655
|
+
).toBe(false);
|
|
3656
|
+
const downloadedKeys = vi
|
|
3657
|
+
.mocked(s3Module.downloadFile)
|
|
3658
|
+
.mock.calls.map((c) => c[1]);
|
|
3659
|
+
expect(downloadedKeys).not.toContain("companies/acme/knowledge/poison.md");
|
|
3660
|
+
|
|
3661
|
+
// Counted as policy-excluded, surfaced as a benign per-key warning event —
|
|
3662
|
+
// deliberately NOT type:"error" (an error event flips the runner to exit 2).
|
|
3663
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
3664
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
3665
|
+
expect(skipEvents).toEqual([
|
|
3666
|
+
{ type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
|
|
3667
|
+
]);
|
|
3668
|
+
expect(events.filter((e) => e.type === "error")).toEqual([]);
|
|
3669
|
+
});
|
|
3670
|
+
|
|
3671
|
+
it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
|
|
3672
|
+
// A doubled local tree (companies/acme/companies/acme/…) previously
|
|
3673
|
+
// poisoned the bucket; the poisoned objects were deleted server-side.
|
|
3674
|
+
// The journal entry at the invalid key must drain as a tombstone —
|
|
3675
|
+
// local copy removed, journal entry dropped — and must NOT be
|
|
3676
|
+
// HEAD-verified (the presign transport rejects the key, which would
|
|
3677
|
+
// defer the tombstone forever) and must NOT be re-uploaded.
|
|
3678
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3679
|
+
const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
|
|
3680
|
+
fs.mkdirSync(doubled, { recursive: true });
|
|
3681
|
+
fs.writeFileSync(path.join(doubled, "poison.md"), "poison content");
|
|
3682
|
+
const crypto = await import("node:crypto");
|
|
3683
|
+
const baseline = crypto
|
|
3684
|
+
.createHash("sha256")
|
|
3685
|
+
.update("poison content")
|
|
3686
|
+
.digest("hex");
|
|
3687
|
+
fs.writeFileSync(
|
|
3688
|
+
journalPath,
|
|
3689
|
+
JSON.stringify({
|
|
3690
|
+
version: "1",
|
|
3691
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
3692
|
+
files: {
|
|
3693
|
+
"companies/acme/knowledge/poison.md": {
|
|
3694
|
+
hash: baseline,
|
|
3695
|
+
size: 14,
|
|
3696
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
3697
|
+
direction: "up",
|
|
3698
|
+
remoteEtag: "poison-etag",
|
|
3699
|
+
},
|
|
3700
|
+
},
|
|
3701
|
+
}),
|
|
3702
|
+
);
|
|
3703
|
+
// Remote LIST no longer contains the poisoned key (manual cleanup done).
|
|
3704
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3705
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([]);
|
|
3706
|
+
|
|
3707
|
+
const result = await sync({
|
|
3708
|
+
company: "acme",
|
|
3709
|
+
vaultConfig: mockConfig,
|
|
3710
|
+
hqRoot: tmpDir,
|
|
3711
|
+
});
|
|
3712
|
+
|
|
3713
|
+
// Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
|
|
3714
|
+
expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
|
|
3715
|
+
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
3716
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3717
|
+
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
3718
|
+
// No HEAD verify against the invalid key — the server validator would
|
|
3719
|
+
// reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
|
|
3720
|
+
const headKeys = vi
|
|
3721
|
+
.mocked(s3Module.headRemoteFile)
|
|
3722
|
+
.mock.calls.map((c) => c[1]);
|
|
3723
|
+
expect(headKeys).not.toContain("companies/acme/knowledge/poison.md");
|
|
3724
|
+
// Never re-uploaded.
|
|
3725
|
+
expect(vi.mocked(s3Module.uploadFile)).not.toHaveBeenCalled();
|
|
3726
|
+
});
|
|
3727
|
+
|
|
3728
|
+
it("personal-mode pull still downloads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
|
|
3729
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3730
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3731
|
+
{ key: "companies/localco/notes.md", size: 4, lastModified: new Date(), etag: '"n1"' },
|
|
3732
|
+
]);
|
|
3733
|
+
const events: Array<{ type: string }> = [];
|
|
3734
|
+
const result = await sync({
|
|
3735
|
+
company: "acme",
|
|
3736
|
+
vaultConfig: mockConfig,
|
|
3737
|
+
hqRoot: tmpDir,
|
|
3738
|
+
personalMode: true,
|
|
3739
|
+
onEvent: (e) => events.push(e as never),
|
|
3740
|
+
});
|
|
3741
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3742
|
+
expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
|
|
3743
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
|
|
3744
|
+
});
|
|
3745
|
+
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -12,7 +12,11 @@ import type {
|
|
|
12
12
|
VaultServiceConfig,
|
|
13
13
|
SyncJournal,
|
|
14
14
|
} from "../types.js";
|
|
15
|
-
import type
|
|
15
|
+
import { VaultClient, type SyncMode } from "../vault-client.js";
|
|
16
|
+
import {
|
|
17
|
+
emitCloudTelemetry,
|
|
18
|
+
type TelemetryClaims,
|
|
19
|
+
} from "../telemetry-events.js";
|
|
16
20
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
17
21
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
18
22
|
import {
|
|
@@ -333,13 +337,31 @@ export type SyncProgressEvent =
|
|
|
333
337
|
* pushed it into the runner's `errors[]` and made every watch pass return
|
|
334
338
|
* exit 2; the menubar supervisor then reported that as
|
|
335
339
|
* "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick —
|
|
336
|
-
* the
|
|
340
|
+
* the HQ-SYNC-4 flood across user machines. Surfaced here purely for visibility
|
|
337
341
|
* (`bytes` is the offending file's size) so a UI/CLI can tell the user
|
|
338
342
|
* which file was too big without treating it as a failure.
|
|
339
343
|
*/
|
|
340
344
|
type: "skip-size-limit";
|
|
341
345
|
path: string;
|
|
342
346
|
bytes: number;
|
|
347
|
+
}
|
|
348
|
+
| {
|
|
349
|
+
/**
|
|
350
|
+
* Emitted by the PULL leg once per remote key skipped because it is
|
|
351
|
+
* invalid for the vault's scope — today, a `companies/…`-prefixed key in
|
|
352
|
+
* a company-scoped vault (the server rejects these with
|
|
353
|
+
* INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
|
|
354
|
+
* Such keys are bucket poisoning from a client with a stale doubled
|
|
355
|
+
* local tree writing via direct S3 (incident 2026-07-11). Like
|
|
356
|
+
* `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
|
|
357
|
+
* object must never error a whole company sync (erroring gated
|
|
358
|
+
* heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
|
|
359
|
+
* visibility + cloud telemetry; per policy
|
|
360
|
+
* hq-alert-baseline-calibration the healthy baseline is ZERO such keys,
|
|
361
|
+
* so any occurrence is signal.
|
|
362
|
+
*/
|
|
363
|
+
type: "skip-invalid-scoped-key";
|
|
364
|
+
path: string;
|
|
343
365
|
};
|
|
344
366
|
|
|
345
367
|
export interface SyncOptions {
|
|
@@ -409,6 +431,23 @@ export interface SyncOptions {
|
|
|
409
431
|
* failure can never silently prune the local tree.
|
|
410
432
|
*/
|
|
411
433
|
prefixSet?: ScopePrefixInput[];
|
|
434
|
+
/**
|
|
435
|
+
* Company-relative prefixes SUBTRACTED from the effective pull scope in EVERY
|
|
436
|
+
* mode — the push-only set (US-006). Carries `sessions/`: session transcripts
|
|
437
|
+
* are pushed into the vault but never auto-pulled onto grantee laptops, even
|
|
438
|
+
* under `syncMode: "all"` (which otherwise pulls the whole bucket). A remote
|
|
439
|
+
* key downloads iff it is covered by the inclusion scope (`prefixSet`, or all
|
|
440
|
+
* of `all` mode) AND NOT covered by any `excludePrefixes` entry; excluded
|
|
441
|
+
* keys are classified `skip-out-of-scope`.
|
|
442
|
+
*
|
|
443
|
+
* CRITICAL: this set is applied ONLY to the download filter. It is
|
|
444
|
+
* deliberately NOT fed into the scope-shrink pass (which keeps using the
|
|
445
|
+
* inclusion `prefixSet` + pins), so a locally-authored (`direction:"up"`) or
|
|
446
|
+
* pinned on-demand-materialized session is NEVER orphaned/pruned — it is
|
|
447
|
+
* merely not re-pulled. The runner derives this from
|
|
448
|
+
* `resolvePullScope().excludePrefixes`.
|
|
449
|
+
*/
|
|
450
|
+
excludePrefixes?: ScopePrefixInput[];
|
|
412
451
|
/**
|
|
413
452
|
* When the effective scope shrinks relative to the last pull and the shrink
|
|
414
453
|
* would orphan locally-modified ("dirty") files, `sync()` aborts with a
|
|
@@ -444,6 +483,12 @@ export interface SyncOptions {
|
|
|
444
483
|
* it from disk, so it stays pure/hermetic; undefined degrades safely.
|
|
445
484
|
*/
|
|
446
485
|
callerSub?: string;
|
|
486
|
+
/**
|
|
487
|
+
* Optional decoded Cognito claims used only for action telemetry attribution
|
|
488
|
+
* (agentUid when `custom:entityType=agent`). Sync behavior never branches on
|
|
489
|
+
* this field.
|
|
490
|
+
*/
|
|
491
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
447
492
|
/**
|
|
448
493
|
* Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
|
|
449
494
|
* mirrors + workers registry). By default, when a sync changes on-disk
|
|
@@ -547,6 +592,14 @@ interface PullRunContext {
|
|
|
547
592
|
remoteFiles: RemoteFile[];
|
|
548
593
|
syncMode: SyncMode;
|
|
549
594
|
currentPrefixSet: string[];
|
|
595
|
+
/**
|
|
596
|
+
* Coalesced push-only exclusion prefixes (US-006: `sessions/`), subtracted
|
|
597
|
+
* from the download filter in EVERY mode. Kept SEPARATE from
|
|
598
|
+
* `currentPrefixSet` on purpose: the scope-shrink pass reads only
|
|
599
|
+
* `currentPrefixSet` (+ pins), so excluded-but-authored/pinned session files
|
|
600
|
+
* are never orphaned.
|
|
601
|
+
*/
|
|
602
|
+
currentExcludeSet: string[];
|
|
550
603
|
fileTombstones: ReadonlyMap<string, CompanyTombstone>;
|
|
551
604
|
}
|
|
552
605
|
|
|
@@ -625,6 +678,7 @@ export async function reportNewFilesToNotify(
|
|
|
625
678
|
companyUid: string,
|
|
626
679
|
companySlug: string,
|
|
627
680
|
files: Array<{ path: string; bytes: number; addedBy: string | null }>,
|
|
681
|
+
telemetryClaims?: TelemetryClaims | null,
|
|
628
682
|
): Promise<void> {
|
|
629
683
|
if (files.length === 0) return;
|
|
630
684
|
|
|
@@ -639,16 +693,19 @@ export async function reportNewFilesToNotify(
|
|
|
639
693
|
return;
|
|
640
694
|
}
|
|
641
695
|
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
696
|
+
const telemetryClient = new VaultClient(vaultConfig);
|
|
697
|
+
const batchCount = Math.ceil(files.length / NOTIFY_FILE_ADDED_MAX_BATCH);
|
|
642
698
|
|
|
643
699
|
for (let i = 0; i < files.length; i += NOTIFY_FILE_ADDED_MAX_BATCH) {
|
|
644
700
|
const batch = files.slice(i, i + NOTIFY_FILE_ADDED_MAX_BATCH);
|
|
701
|
+
const batchIndex = Math.floor(i / NOTIFY_FILE_ADDED_MAX_BATCH) + 1;
|
|
645
702
|
const controller = new AbortController();
|
|
646
703
|
const timer = setTimeout(
|
|
647
704
|
() => controller.abort(),
|
|
648
705
|
NOTIFY_FILE_ADDED_TIMEOUT_MS,
|
|
649
706
|
);
|
|
650
707
|
try {
|
|
651
|
-
await fetch(`${base}/v1/notify/file-added`, {
|
|
708
|
+
const response = await fetch(`${base}/v1/notify/file-added`, {
|
|
652
709
|
method: "POST",
|
|
653
710
|
headers: {
|
|
654
711
|
Authorization: `Bearer ${token}`,
|
|
@@ -665,16 +722,44 @@ export async function reportNewFilesToNotify(
|
|
|
665
722
|
}),
|
|
666
723
|
signal: controller.signal,
|
|
667
724
|
});
|
|
725
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
726
|
+
eventName: "new_files_notification_reported",
|
|
727
|
+
source: "hq-sync",
|
|
728
|
+
companyUid,
|
|
729
|
+
properties: {
|
|
730
|
+
result: response.ok ? "success" : "failure",
|
|
731
|
+
fileCount: batch.length,
|
|
732
|
+
batchIndex,
|
|
733
|
+
batchCount,
|
|
734
|
+
statusCode: response.status,
|
|
735
|
+
},
|
|
736
|
+
}, { claims: telemetryClaims });
|
|
668
737
|
} catch (err) {
|
|
669
738
|
// Best-effort per chunk: never let notification reporting affect the sync
|
|
670
739
|
// result, and a failed chunk must not abort the remaining chunks.
|
|
671
740
|
logNotifyFailure(err);
|
|
741
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
742
|
+
eventName: "new_files_notification_reported",
|
|
743
|
+
source: "hq-sync",
|
|
744
|
+
companyUid,
|
|
745
|
+
properties: {
|
|
746
|
+
result: "failure",
|
|
747
|
+
fileCount: batch.length,
|
|
748
|
+
batchIndex,
|
|
749
|
+
batchCount,
|
|
750
|
+
errorCode: safeErrorName(err),
|
|
751
|
+
},
|
|
752
|
+
}, { claims: telemetryClaims });
|
|
672
753
|
} finally {
|
|
673
754
|
clearTimeout(timer);
|
|
674
755
|
}
|
|
675
756
|
}
|
|
676
757
|
}
|
|
677
758
|
|
|
759
|
+
function safeErrorName(err: unknown): string {
|
|
760
|
+
return err instanceof Error && err.name ? err.name : "error";
|
|
761
|
+
}
|
|
762
|
+
|
|
678
763
|
/** Log a non-fatal notify failure without ever throwing out of the logger. */
|
|
679
764
|
function logNotifyFailure(err: unknown): void {
|
|
680
765
|
try {
|
|
@@ -724,6 +809,7 @@ async function syncWithOperationLockHeld(
|
|
|
724
809
|
const plan = planPull(run);
|
|
725
810
|
|
|
726
811
|
emitPullPlan(run.emit, plan);
|
|
812
|
+
reportInvalidScopedKeys(run, plan);
|
|
727
813
|
|
|
728
814
|
const scopePlan = planScopeShrink(run);
|
|
729
815
|
const scopeRun = executeScopeShrink(run, scopePlan);
|
|
@@ -795,6 +881,13 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
795
881
|
const syncMode: SyncMode = options.syncMode ?? "all";
|
|
796
882
|
const currentPrefixSet: string[] =
|
|
797
883
|
syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
|
|
884
|
+
// Push-only exclusions (US-006). Coalesced independently of `currentPrefixSet`
|
|
885
|
+
// and applied to EVERY mode's download filter — including `all` — but kept out
|
|
886
|
+
// of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
|
|
887
|
+
// passes nothing (legacy behavior: no exclusions).
|
|
888
|
+
const currentExcludeSet: string[] = coalescePrefixes(
|
|
889
|
+
options.excludePrefixes ?? [],
|
|
890
|
+
);
|
|
798
891
|
|
|
799
892
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
800
893
|
const fileTombstones =
|
|
@@ -817,6 +910,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
817
910
|
remoteFiles,
|
|
818
911
|
syncMode,
|
|
819
912
|
currentPrefixSet,
|
|
913
|
+
currentExcludeSet,
|
|
820
914
|
fileTombstones,
|
|
821
915
|
};
|
|
822
916
|
}
|
|
@@ -831,6 +925,7 @@ function planPull(run: PullRunContext): PullPlan {
|
|
|
831
925
|
run.options.teamSyncedSlugs ?? null,
|
|
832
926
|
run.currentPrefixSet,
|
|
833
927
|
run.fileTombstones,
|
|
928
|
+
run.currentExcludeSet,
|
|
834
929
|
);
|
|
835
930
|
}
|
|
836
931
|
|
|
@@ -847,6 +942,33 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
|
|
|
847
942
|
});
|
|
848
943
|
}
|
|
849
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Surface scope-invalid remote keys the planner skipped (see the
|
|
947
|
+
* `invalidScopedKeys` doc on PullPlan). One warning event per key on the
|
|
948
|
+
* existing sync event channel (deliberately NOT `type: "error"` — see the
|
|
949
|
+
* `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
|
|
950
|
+
* event on the channel sync already uses (emitCloudTelemetry). Per policy
|
|
951
|
+
* hq-alert-baseline-calibration the documented healthy baseline is ZERO
|
|
952
|
+
* INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
|
|
953
|
+
* on any non-zero count with no threshold tuning.
|
|
954
|
+
*/
|
|
955
|
+
function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
|
|
956
|
+
if (plan.invalidScopedKeys.length === 0) return;
|
|
957
|
+
for (const key of plan.invalidScopedKeys) {
|
|
958
|
+
run.emit({ type: "skip-invalid-scoped-key", path: key });
|
|
959
|
+
}
|
|
960
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
961
|
+
eventName: "sync_invalid_scoped_key_skipped",
|
|
962
|
+
source: "hq-sync",
|
|
963
|
+
companyUid: run.ctx.uid,
|
|
964
|
+
properties: {
|
|
965
|
+
count: plan.invalidScopedKeys.length,
|
|
966
|
+
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
967
|
+
leg: "pull",
|
|
968
|
+
},
|
|
969
|
+
}, { claims: run.options.telemetryClaims });
|
|
970
|
+
}
|
|
971
|
+
|
|
850
972
|
function createPullCounters(): PullCounters {
|
|
851
973
|
return {
|
|
852
974
|
filesDownloaded: 0,
|
|
@@ -870,6 +992,12 @@ function planScopeShrink(run: PullRunContext): ScopeShrinkPlanState {
|
|
|
870
992
|
journal: run.journal,
|
|
871
993
|
hqRoot: run.companyRoot,
|
|
872
994
|
lastPrefixSet,
|
|
995
|
+
// INCLUSION scope only (+ pins, already folded into currentPrefixSet by the
|
|
996
|
+
// resolver). The push-only exclude set (US-006: `sessions/`) is DELIBERATELY
|
|
997
|
+
// NOT subtracted here: a session file that was authored locally
|
|
998
|
+
// (`direction:"up"`) or pinned + materialized on demand must never be
|
|
999
|
+
// orphaned just because sessions are excluded from the download filter.
|
|
1000
|
+
// Feeding `currentExcludeSet` in here would prune exactly those files.
|
|
873
1001
|
currentPrefixSet: run.currentPrefixSet,
|
|
874
1002
|
callerSub: run.options.callerSub,
|
|
875
1003
|
protectUnknownAuthors: true,
|
|
@@ -1429,6 +1557,7 @@ async function emitAndReportNewFiles(
|
|
|
1429
1557
|
run.ctx.uid,
|
|
1430
1558
|
run.ctx.slug,
|
|
1431
1559
|
enrichedNewFiles,
|
|
1560
|
+
run.options.telemetryClaims,
|
|
1432
1561
|
);
|
|
1433
1562
|
}
|
|
1434
1563
|
|
|
@@ -1438,6 +1567,29 @@ async function verifyPlannedJournalTombstones(
|
|
|
1438
1567
|
): Promise<void> {
|
|
1439
1568
|
if (plan.tombstones.length === 0) return;
|
|
1440
1569
|
|
|
1570
|
+
// Scope-invalid journal keys (company mode, `companies/…` prefix) skip the
|
|
1571
|
+
// HEAD verify: the presign transport's server validator rejects such keys
|
|
1572
|
+
// outright (INVALID_KEY_COMPANIES_SCOPED), so a HEAD can never succeed and
|
|
1573
|
+
// would defer the tombstone forever — leaving the poisoned journal entry
|
|
1574
|
+
// (and its doubled-tree local file) re-firing on every sync. The key can
|
|
1575
|
+
// never legitimately exist in a company vault, so treating it as verified
|
|
1576
|
+
// is safe: the executor cleans the local copy + journal entry instead of
|
|
1577
|
+
// the push leg ever re-uploading it (tombstone-not-reupload).
|
|
1578
|
+
const autoVerified: string[] = [];
|
|
1579
|
+
const toHeadVerify: string[] = [];
|
|
1580
|
+
for (const key of plan.tombstones) {
|
|
1581
|
+
if (run.options.personalMode !== true && key.startsWith("companies/")) {
|
|
1582
|
+
autoVerified.push(key);
|
|
1583
|
+
} else {
|
|
1584
|
+
toHeadVerify.push(key);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
plan.tombstones = toHeadVerify;
|
|
1588
|
+
if (plan.tombstones.length === 0) {
|
|
1589
|
+
plan.tombstones = autoVerified;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1441
1593
|
await primeObjectTransport(run.ctx, "get", plan.tombstones);
|
|
1442
1594
|
|
|
1443
1595
|
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
@@ -1466,7 +1618,7 @@ async function verifyPlannedJournalTombstones(
|
|
|
1466
1618
|
if (k !== null) verified.push(k);
|
|
1467
1619
|
}
|
|
1468
1620
|
}
|
|
1469
|
-
plan.tombstones = verified;
|
|
1621
|
+
plan.tombstones = [...autoVerified, ...verified];
|
|
1470
1622
|
}
|
|
1471
1623
|
|
|
1472
1624
|
function executeJournalTombstoneDeletes(
|
|
@@ -1545,7 +1697,19 @@ function finalizePullRun(
|
|
|
1545
1697
|
scopeRun.scopeOrphansRemoved > 0;
|
|
1546
1698
|
if (!run.options.skipReindex && changedOnDisk) {
|
|
1547
1699
|
try {
|
|
1548
|
-
reindex({ repoRoot: run.hqRoot, skipLock: true });
|
|
1700
|
+
const result = reindex({ repoRoot: run.hqRoot, skipLock: true });
|
|
1701
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
1702
|
+
eventName: "reindex_run",
|
|
1703
|
+
source: "hq-sync",
|
|
1704
|
+
companyUid: run.ctx.uid,
|
|
1705
|
+
properties: {
|
|
1706
|
+
result: result.status === 0 ? "completed" : "failed",
|
|
1707
|
+
statusCode: result.status,
|
|
1708
|
+
lockResult: "skipped",
|
|
1709
|
+
changedCount:
|
|
1710
|
+
counters.changedPaths.length + scopeRun.changedPaths.length,
|
|
1711
|
+
},
|
|
1712
|
+
}, { claims: run.options.telemetryClaims });
|
|
1549
1713
|
} catch (err) {
|
|
1550
1714
|
logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
|
|
1551
1715
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
@@ -1780,6 +1944,15 @@ interface PullPlan {
|
|
|
1780
1944
|
* `filesToDelete` axis; the per-item executor applies the local delete.
|
|
1781
1945
|
*/
|
|
1782
1946
|
filesToTombstoneDelete: number;
|
|
1947
|
+
/**
|
|
1948
|
+
* Remote keys skipped because they are invalid for the vault's scope
|
|
1949
|
+
* (company mode only: literal `companies/…` keys — bucket poisoning from a
|
|
1950
|
+
* doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
|
|
1951
|
+
* carried by key so the caller can log one warning per key and emit
|
|
1952
|
+
* telemetry. Healthy baseline is an empty array (policy
|
|
1953
|
+
* hq-alert-baseline-calibration: zero expected, any occurrence is signal).
|
|
1954
|
+
*/
|
|
1955
|
+
invalidScopedKeys: string[];
|
|
1783
1956
|
}
|
|
1784
1957
|
|
|
1785
1958
|
/**
|
|
@@ -1847,8 +2020,18 @@ function computePullPlan(
|
|
|
1847
2020
|
// preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
|
|
1848
2021
|
// shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
|
|
1849
2022
|
fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
|
|
2023
|
+
// Push-only exclusion prefixes (US-006: `sessions/`). Keys covered by any of
|
|
2024
|
+
// these are classified `skip-out-of-scope` in EVERY mode — including `all` —
|
|
2025
|
+
// AFTER passing the inclusion check. Empty (the default) preserves legacy
|
|
2026
|
+
// behavior bit-for-bit. Applied ONLY here (the download filter), never to
|
|
2027
|
+
// scope-shrink, so an authored/pinned excluded key is not re-pulled yet also
|
|
2028
|
+
// never orphaned.
|
|
2029
|
+
excludePrefixes: readonly ScopePrefixInput[] = [],
|
|
1850
2030
|
): PullPlan {
|
|
1851
2031
|
const items: PullPlanItem[] = [];
|
|
2032
|
+
// Company-mode remote keys invalid for the vault's scope (`companies/…` in a
|
|
2033
|
+
// company bucket) — collected so the caller can warn + emit telemetry.
|
|
2034
|
+
const invalidScopedKeys: string[] = [];
|
|
1852
2035
|
// Clean/current/non-divergent files missing locally — collected here, then
|
|
1853
2036
|
// resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
|
|
1854
2037
|
const intentionalDeleteCandidates: Array<{
|
|
@@ -1874,6 +2057,23 @@ function computePullPlan(
|
|
|
1874
2057
|
continue;
|
|
1875
2058
|
}
|
|
1876
2059
|
|
|
2060
|
+
// Scope-invalid key filter (incident 2026-07-11). A company vault bucket
|
|
2061
|
+
// is already company-scoped, so a literal `companies/…` key can only be
|
|
2062
|
+
// bucket poisoning — a client with a stale doubled local tree
|
|
2063
|
+
// (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
|
|
2064
|
+
// transport, which bypasses the server validator. The presign download
|
|
2065
|
+
// API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
|
|
2066
|
+
// download them errors the WHOLE company sync. Skip them here — one bad
|
|
2067
|
+
// object must never take down a company sync — and record them on the
|
|
2068
|
+
// plan so the caller can warn + emit telemetry (baseline is zero; any
|
|
2069
|
+
// occurrence is signal). Mirrors the personalMode/malformed-key filters
|
|
2070
|
+
// above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
|
|
2071
|
+
if (!personalMode && remoteFile.key.startsWith("companies/")) {
|
|
2072
|
+
invalidScopedKeys.push(remoteFile.key);
|
|
2073
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1877
2077
|
if (
|
|
1878
2078
|
personalMode &&
|
|
1879
2079
|
remoteFile.key.startsWith("companies/") &&
|
|
@@ -1917,6 +2117,20 @@ function computePullPlan(
|
|
|
1917
2117
|
continue;
|
|
1918
2118
|
}
|
|
1919
2119
|
|
|
2120
|
+
// Push-only exclusion filter (US-006). A key that PASSED the inclusion check
|
|
2121
|
+
// above is STILL not downloaded if it falls under a push-only exclude prefix
|
|
2122
|
+
// (`sessions/`). This is what makes sessions push-only in EVERY mode — it
|
|
2123
|
+
// subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
|
|
2124
|
+
// and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
|
|
2125
|
+
// default) makes this a no-op. Deliberately AFTER inclusion and reported on
|
|
2126
|
+
// the same `filesOutOfScope` axis. The matching local file (if a session was
|
|
2127
|
+
// previously pulled, authored locally, or pinned+materialized) is NOT pruned
|
|
2128
|
+
// here or by scope-shrink — see `planScopeShrink`, which never sees this set.
|
|
2129
|
+
if (isCoveredByAny(remoteFile.key, excludePrefixes)) {
|
|
2130
|
+
items.push({ action: "skip-out-of-scope", remoteFile, localPath });
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1920
2134
|
// LIST gives us no kind signal for the remote object — we don't
|
|
1921
2135
|
// know whether this key is a regular file or a symlink record
|
|
1922
2136
|
// until we either HEAD it (expensive — N extra calls per pull) or
|
|
@@ -2420,6 +2634,7 @@ function computePullPlan(
|
|
|
2420
2634
|
filesOutOfScope,
|
|
2421
2635
|
filesToTombstoneDelete,
|
|
2422
2636
|
tombstones,
|
|
2637
|
+
invalidScopedKeys,
|
|
2423
2638
|
};
|
|
2424
2639
|
}
|
|
2425
2640
|
|
|
@@ -2502,5 +2717,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2502
2717
|
console.warn(
|
|
2503
2718
|
` ! ${event.path} skipped — ${mb} MB exceeds the 50 MB sync size limit`,
|
|
2504
2719
|
);
|
|
2720
|
+
} else if (event.type === "skip-invalid-scoped-key") {
|
|
2721
|
+
console.warn(
|
|
2722
|
+
` ! ${event.path} skipped — invalid 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
|
|
2723
|
+
);
|
|
2505
2724
|
}
|
|
2506
2725
|
}
|