@indigoai-us/hq-cloud 6.8.0 → 6.10.0
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +104 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +190 -20
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +9 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +5 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +15 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/machine-auth.test.js +4 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +28 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +76 -5
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +93 -2
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +3 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +10 -5
- package/dist/s3.js.map +1 -1
- package/dist/vault-client.d.ts +9 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +245 -9
- package/src/cli/share.ts +116 -8
- package/src/cognito-auth.ts +9 -1
- package/src/ignore.test.ts +18 -0
- package/src/ignore.ts +5 -0
- package/src/machine-auth.test.ts +4 -2
- package/src/object-io.test.ts +105 -2
- package/src/object-io.ts +121 -8
- package/src/s3.ts +11 -4
- package/src/vault-client.ts +9 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -158,7 +158,7 @@ describe("share", () => {
|
|
|
158
158
|
expect(result.filesUploaded).toBe(1);
|
|
159
159
|
expect(result.aborted).toBe(false);
|
|
160
160
|
// Remote key must be company-relative, not hqRoot-relative
|
|
161
|
-
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md");
|
|
161
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
it("respects ignore rules", async () => {
|
|
@@ -207,7 +207,7 @@ describe("share", () => {
|
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
// Key is "knowledge/crawl.json", not "companies/acme/knowledge/crawl.json"
|
|
210
|
-
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json");
|
|
210
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json", undefined, expect.anything());
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
it("skips files outside the company folder with a warning", async () => {
|
|
@@ -333,7 +333,7 @@ describe("share", () => {
|
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
expect(result.filesUploaded).toBe(1);
|
|
336
|
-
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
|
|
336
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
337
337
|
});
|
|
338
338
|
|
|
339
339
|
it("populates conflictPaths and emits a conflict event when both local and remote drifted from journal", async () => {
|
|
@@ -586,6 +586,181 @@ describe("share", () => {
|
|
|
586
586
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
587
587
|
});
|
|
588
588
|
|
|
589
|
+
describe("conditional-write fence (If-Match / If-None-Match / 412)", () => {
|
|
590
|
+
const fence412 = () =>
|
|
591
|
+
Object.assign(new Error("Precondition failed"), {
|
|
592
|
+
name: "PreconditionFailed",
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("fences a PUT over an existing remote with If-Match on the observed etag", async () => {
|
|
596
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
597
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
598
|
+
const testFile = path.join(companyRoot, "fenced.md");
|
|
599
|
+
fs.writeFileSync(testFile, "local edit");
|
|
600
|
+
|
|
601
|
+
// Remote exists at the journal baseline; only local changed -> clean
|
|
602
|
+
// push, but the PUT must still assert the observed remote etag so a
|
|
603
|
+
// peer write landing between HEAD and PUT draws a 412 instead of
|
|
604
|
+
// being clobbered (HEAD->PUT TOCTOU).
|
|
605
|
+
const syncedAt = new Date(Date.now() - 60_000).toISOString();
|
|
606
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
607
|
+
lastModified: new Date(Date.parse(syncedAt) - 30_000),
|
|
608
|
+
etag: '"baseline-etag"',
|
|
609
|
+
size: 5,
|
|
610
|
+
});
|
|
611
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
612
|
+
fs.writeFileSync(
|
|
613
|
+
journalPath,
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
version: "1",
|
|
616
|
+
lastSync: syncedAt,
|
|
617
|
+
files: {
|
|
618
|
+
"fenced.md": {
|
|
619
|
+
hash: "stale-local-hash",
|
|
620
|
+
size: 5,
|
|
621
|
+
syncedAt,
|
|
622
|
+
direction: "up",
|
|
623
|
+
remoteEtag: "baseline-etag",
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const result = await share({
|
|
630
|
+
paths: [testFile],
|
|
631
|
+
company: "acme",
|
|
632
|
+
vaultConfig: mockConfig,
|
|
633
|
+
hqRoot: tmpDir,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(result.filesUploaded).toBe(1);
|
|
637
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
638
|
+
expect.anything(),
|
|
639
|
+
testFile,
|
|
640
|
+
"fenced.md",
|
|
641
|
+
undefined,
|
|
642
|
+
{ ifMatch: '"baseline-etag"' },
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("fences a brand-new key with If-None-Match:* (create-only)", async () => {
|
|
647
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
648
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
649
|
+
const testFile = path.join(companyRoot, "brand-new.md");
|
|
650
|
+
fs.writeFileSync(testFile, "fresh");
|
|
651
|
+
|
|
652
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
653
|
+
|
|
654
|
+
await share({
|
|
655
|
+
paths: [testFile],
|
|
656
|
+
company: "acme",
|
|
657
|
+
vaultConfig: mockConfig,
|
|
658
|
+
hqRoot: tmpDir,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
662
|
+
expect.anything(),
|
|
663
|
+
testFile,
|
|
664
|
+
"brand-new.md",
|
|
665
|
+
undefined,
|
|
666
|
+
{ ifNoneMatch: "*" },
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("412 under keep: conflict event + remote mirrored + ledger entry + NO journal stamp", async () => {
|
|
671
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
672
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
673
|
+
const testFile = path.join(companyRoot, "raced.md");
|
|
674
|
+
fs.writeFileSync(testFile, "my local bytes");
|
|
675
|
+
|
|
676
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
677
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
678
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
679
|
+
fs.writeFileSync(dest as string, "the racing peer version");
|
|
680
|
+
return undefined as never;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const events: Array<{ type?: string; path?: string }> = [];
|
|
684
|
+
const result = await share({
|
|
685
|
+
paths: [testFile],
|
|
686
|
+
company: "acme",
|
|
687
|
+
vaultConfig: mockConfig,
|
|
688
|
+
hqRoot: tmpDir,
|
|
689
|
+
onConflict: "keep",
|
|
690
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Surfaced as a push conflict, not an error.
|
|
694
|
+
expect(result.conflictPaths).toEqual(["raced.md"]);
|
|
695
|
+
expect(result.filesUploaded).toBe(0);
|
|
696
|
+
expect(result.aborted).toBe(false);
|
|
697
|
+
expect(
|
|
698
|
+
events.some((e) => e.type === "conflict" && e.path === "raced.md"),
|
|
699
|
+
).toBe(true);
|
|
700
|
+
// The racing remote bytes were preserved next to the local copy.
|
|
701
|
+
const mirrors = fs
|
|
702
|
+
.readdirSync(companyRoot)
|
|
703
|
+
.filter((f) => f.includes(".conflict-"));
|
|
704
|
+
expect(mirrors).toHaveLength(1);
|
|
705
|
+
// Local copy untouched.
|
|
706
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("my local bytes");
|
|
707
|
+
// No journal stamp for the failed PUT — next pass re-evaluates fresh.
|
|
708
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
709
|
+
if (fs.existsSync(journalPath)) {
|
|
710
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
711
|
+
expect(journal.files?.["raced.md"]).toBeUndefined();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("412 under overwrite: retries exactly once WITHOUT the fence (explicit consent)", async () => {
|
|
716
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
717
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
718
|
+
const testFile = path.join(companyRoot, "consented.md");
|
|
719
|
+
fs.writeFileSync(testFile, "clobber me in");
|
|
720
|
+
|
|
721
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
722
|
+
vi.mocked(uploadFile)
|
|
723
|
+
.mockRejectedValueOnce(fence412())
|
|
724
|
+
.mockResolvedValueOnce({ etag: '"retried-etag"' });
|
|
725
|
+
|
|
726
|
+
const result = await share({
|
|
727
|
+
paths: [testFile],
|
|
728
|
+
company: "acme",
|
|
729
|
+
vaultConfig: mockConfig,
|
|
730
|
+
hqRoot: tmpDir,
|
|
731
|
+
onConflict: "overwrite",
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(result.filesUploaded).toBe(1);
|
|
735
|
+
const calls = vi.mocked(uploadFile).mock.calls;
|
|
736
|
+
expect(calls).toHaveLength(2);
|
|
737
|
+
// First attempt fenced; retry deliberately unfenced.
|
|
738
|
+
expect(calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
739
|
+
expect(calls[1][4]).toBeUndefined();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("412 under abort: halts the pass", async () => {
|
|
743
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
744
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
745
|
+
const testFile = path.join(companyRoot, "halting.md");
|
|
746
|
+
fs.writeFileSync(testFile, "stop here");
|
|
747
|
+
|
|
748
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
749
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
750
|
+
|
|
751
|
+
const result = await share({
|
|
752
|
+
paths: [testFile],
|
|
753
|
+
company: "acme",
|
|
754
|
+
vaultConfig: mockConfig,
|
|
755
|
+
hqRoot: tmpDir,
|
|
756
|
+
onConflict: "abort",
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
expect(result.aborted).toBe(true);
|
|
760
|
+
expect(result.conflictPaths).toEqual(["halting.md"]);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
589
764
|
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
590
765
|
// Real case (look-optic): a FILE_ACL grant covered {knowledge,policies,
|
|
591
766
|
// workers}/* + company.yaml, but the upload plan also contained
|
|
@@ -694,6 +869,45 @@ describe("share", () => {
|
|
|
694
869
|
expect(errs[0].message).toMatch(/outside granted ACL scope/i);
|
|
695
870
|
});
|
|
696
871
|
|
|
872
|
+
it("a Forbidden HEAD (presigned-transport denial) skips the key — NEVER an unconditional PUT", async () => {
|
|
873
|
+
// Regression for the 2026-06-10..12 vault regression storm: the presigned
|
|
874
|
+
// transport used to map per-key denials and signed-GET 403s to `null`,
|
|
875
|
+
// which `processUploadItem` read as "no remote object" — skipping every
|
|
876
|
+
// conflict guard and blind-PUTting this machine's (possibly stale) bytes
|
|
877
|
+
// over a newer remote. The transport now throws `name: "Forbidden"`
|
|
878
|
+
// (SDK HeadObject parity); this locks the share side of that contract:
|
|
879
|
+
// a Forbidden HEAD must skip the key, emit a named error, and issue NO PUT.
|
|
880
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
881
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
882
|
+
const staleFile = path.join(companyRoot, "stale-local.md");
|
|
883
|
+
fs.writeFileSync(staleFile, "stale local bytes\n");
|
|
884
|
+
|
|
885
|
+
vi.mocked(headRemoteFile).mockImplementation(async () => {
|
|
886
|
+
const err = new Error(
|
|
887
|
+
"Access denied for stale-local.md: presigned HEAD returned 403",
|
|
888
|
+
);
|
|
889
|
+
(err as { name: string }).name = "Forbidden";
|
|
890
|
+
throw err;
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
894
|
+
const result = await share({
|
|
895
|
+
paths: [staleFile],
|
|
896
|
+
company: "acme",
|
|
897
|
+
vaultConfig: mockConfig,
|
|
898
|
+
hqRoot: tmpDir,
|
|
899
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// The one thing that must never happen: a PUT issued with unknown remote state.
|
|
903
|
+
expect(vi.mocked(uploadFile)).not.toHaveBeenCalled();
|
|
904
|
+
expect(result.filesUploaded).toBe(0);
|
|
905
|
+
expect(result.aborted).toBe(false);
|
|
906
|
+
const errs2 = events.filter((e) => e.type === "error") as Array<{ path?: string }>;
|
|
907
|
+
expect(errs2).toHaveLength(1);
|
|
908
|
+
expect(errs2[0].path).toBe("stale-local.md");
|
|
909
|
+
});
|
|
910
|
+
|
|
697
911
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
698
912
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
699
913
|
// that exists on S3 used to trigger a push conflict because the
|
|
@@ -835,12 +1049,14 @@ describe("share", () => {
|
|
|
835
1049
|
testFile,
|
|
836
1050
|
"attribution.md",
|
|
837
1051
|
{ userSub: "abc-123", email: "alice@example.com" },
|
|
1052
|
+
expect.anything(),
|
|
838
1053
|
);
|
|
839
1054
|
});
|
|
840
1055
|
|
|
841
1056
|
it("omits author arg when not provided (back-compat)", async () => {
|
|
842
|
-
//
|
|
843
|
-
//
|
|
1057
|
+
// With no author configured the author slot is undefined (the
|
|
1058
|
+
// conditional-write fence appended a 5th arg in 6.9.0, so the legacy
|
|
1059
|
+
// 3-arg shape grew to 5; author-less calls pass undefined explicitly).
|
|
844
1060
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
845
1061
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
846
1062
|
const testFile = path.join(companyRoot, "no-author.md");
|
|
@@ -857,6 +1073,8 @@ describe("share", () => {
|
|
|
857
1073
|
expect.anything(),
|
|
858
1074
|
testFile,
|
|
859
1075
|
"no-author.md",
|
|
1076
|
+
undefined,
|
|
1077
|
+
expect.anything(),
|
|
860
1078
|
);
|
|
861
1079
|
});
|
|
862
1080
|
|
|
@@ -1032,7 +1250,7 @@ describe("share", () => {
|
|
|
1032
1250
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
1033
1251
|
// The S3 upload sees the pre-vended credentials, not freshly-vended ones.
|
|
1034
1252
|
// (uploadFile is mocked, so we just verify it was called with our ctx.)
|
|
1035
|
-
expect(uploadFile).toHaveBeenCalledWith(ctx, testFile, "from-appbar.md");
|
|
1253
|
+
expect(uploadFile).toHaveBeenCalledWith(ctx, testFile, "from-appbar.md", undefined, expect.anything());
|
|
1036
1254
|
});
|
|
1037
1255
|
|
|
1038
1256
|
it("falls back to entityContext.slug when company is not specified", async () => {
|
|
@@ -1055,6 +1273,8 @@ describe("share", () => {
|
|
|
1055
1273
|
expect.anything(),
|
|
1056
1274
|
testFile,
|
|
1057
1275
|
"no-company-arg.md",
|
|
1276
|
+
undefined,
|
|
1277
|
+
expect.anything(),
|
|
1058
1278
|
);
|
|
1059
1279
|
});
|
|
1060
1280
|
|
|
@@ -1088,7 +1308,7 @@ describe("share", () => {
|
|
|
1088
1308
|
expect(result.filesUploaded).toBe(1);
|
|
1089
1309
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
1090
1310
|
// The original (still-valid-for-30s) credentials must have been used as-is.
|
|
1091
|
-
expect(uploadFile).toHaveBeenCalledWith(expiringCtx, testFile, "race.md");
|
|
1311
|
+
expect(uploadFile).toHaveBeenCalledWith(expiringCtx, testFile, "race.md", undefined, expect.anything());
|
|
1092
1312
|
});
|
|
1093
1313
|
|
|
1094
1314
|
it("throws when both vaultConfig and entityContext are provided (ambiguous)", async () => {
|
|
@@ -2151,6 +2371,8 @@ describe("share", () => {
|
|
|
2151
2371
|
expect.anything(),
|
|
2152
2372
|
expect.stringContaining("real.md"),
|
|
2153
2373
|
"skills/real.md",
|
|
2374
|
+
undefined,
|
|
2375
|
+
expect.anything(),
|
|
2154
2376
|
);
|
|
2155
2377
|
// Spy was called once total — the conflict mirror never reaches uploadFile.
|
|
2156
2378
|
// (Asserted by count above; the explicit non-call below is defensive.)
|
|
@@ -2634,7 +2856,7 @@ describe("share", () => {
|
|
|
2634
2856
|
});
|
|
2635
2857
|
|
|
2636
2858
|
expect(result.filesUploaded).toBe(1);
|
|
2637
|
-
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), outsideFile, "stray.md");
|
|
2859
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), outsideFile, "stray.md", undefined, expect.anything());
|
|
2638
2860
|
});
|
|
2639
2861
|
|
|
2640
2862
|
it("uploads 0-byte files (e.g. .gitkeep placeholders)", async () => {
|
|
@@ -2658,7 +2880,7 @@ describe("share", () => {
|
|
|
2658
2880
|
});
|
|
2659
2881
|
|
|
2660
2882
|
expect(result.filesUploaded).toBe(1);
|
|
2661
|
-
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), gitkeep, "projects/.gitkeep");
|
|
2883
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), gitkeep, "projects/.gitkeep", undefined, expect.anything());
|
|
2662
2884
|
});
|
|
2663
2885
|
|
|
2664
2886
|
it("personalMode=true + skipUnchanged honors the personal-journal hash", async () => {
|
|
@@ -2737,6 +2959,8 @@ describe("share", () => {
|
|
|
2737
2959
|
expect.anything(),
|
|
2738
2960
|
"target.md",
|
|
2739
2961
|
"link.md",
|
|
2962
|
+
undefined,
|
|
2963
|
+
expect.anything(),
|
|
2740
2964
|
);
|
|
2741
2965
|
// The link itself must NOT be uploaded as a regular file. Pre-fix,
|
|
2742
2966
|
// fs.statSync(link) followed the link and uploadFile got called with
|
|
@@ -2774,11 +2998,15 @@ describe("share", () => {
|
|
|
2774
2998
|
expect.anything(),
|
|
2775
2999
|
realPolicy,
|
|
2776
3000
|
"policies/real.md",
|
|
3001
|
+
undefined,
|
|
3002
|
+
expect.anything(),
|
|
2777
3003
|
);
|
|
2778
3004
|
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
2779
3005
|
expect.anything(),
|
|
2780
3006
|
"real.md",
|
|
2781
3007
|
"policies/link.md",
|
|
3008
|
+
undefined,
|
|
3009
|
+
expect.anything(),
|
|
2782
3010
|
);
|
|
2783
3011
|
});
|
|
2784
3012
|
|
|
@@ -2811,6 +3039,8 @@ describe("share", () => {
|
|
|
2811
3039
|
expect.anything(),
|
|
2812
3040
|
externalTarget,
|
|
2813
3041
|
"knowledge",
|
|
3042
|
+
undefined,
|
|
3043
|
+
expect.anything(),
|
|
2814
3044
|
);
|
|
2815
3045
|
// No "outside company folder" warning for this case.
|
|
2816
3046
|
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
@@ -2876,6 +3106,8 @@ describe("share", () => {
|
|
|
2876
3106
|
expect.anything(),
|
|
2877
3107
|
target,
|
|
2878
3108
|
"case-key.md",
|
|
3109
|
+
undefined,
|
|
3110
|
+
expect.anything(),
|
|
2879
3111
|
);
|
|
2880
3112
|
});
|
|
2881
3113
|
|
|
@@ -2917,6 +3149,8 @@ describe("share", () => {
|
|
|
2917
3149
|
expect.anything(),
|
|
2918
3150
|
externalTarget,
|
|
2919
3151
|
"knowledge",
|
|
3152
|
+
undefined,
|
|
3153
|
+
expect.anything(),
|
|
2920
3154
|
);
|
|
2921
3155
|
expect(uploadFile).not.toHaveBeenCalledWith(
|
|
2922
3156
|
expect.anything(),
|
|
@@ -2952,6 +3186,8 @@ describe("share", () => {
|
|
|
2952
3186
|
expect.anything(),
|
|
2953
3187
|
realDir,
|
|
2954
3188
|
"linked-dir",
|
|
3189
|
+
undefined,
|
|
3190
|
+
expect.anything(),
|
|
2955
3191
|
);
|
|
2956
3192
|
// Crucially, no upload of the dir's contents.
|
|
2957
3193
|
const calls = vi.mocked(uploadFile).mock.calls;
|
package/src/cli/share.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../s3.js";
|
|
23
23
|
import * as crypto from "crypto";
|
|
24
24
|
import type { UploadAuthor } from "../s3.js";
|
|
25
|
+
import type { PutPrecondition } from "../object-io.js";
|
|
25
26
|
import {
|
|
26
27
|
readJournal,
|
|
27
28
|
writeJournal,
|
|
@@ -693,6 +694,20 @@ function isAccessDenied(err: unknown): boolean {
|
|
|
693
694
|
return false;
|
|
694
695
|
}
|
|
695
696
|
|
|
697
|
+
/**
|
|
698
|
+
* A conditional-write fence rejection — the SDK's 412 (`name:
|
|
699
|
+
* "PreconditionFailed"`) or the presigned transport's mirror of it. Means
|
|
700
|
+
* the remote moved past the etag this pass inspected (If-Match) or an
|
|
701
|
+
* object appeared at a key we believed absent (If-None-Match). Always a
|
|
702
|
+
* conflict, never a transport failure.
|
|
703
|
+
*/
|
|
704
|
+
function isPreconditionFailed(err: unknown): boolean {
|
|
705
|
+
if (err && typeof err === "object" && "name" in err) {
|
|
706
|
+
return (err as { name?: unknown }).name === "PreconditionFailed";
|
|
707
|
+
}
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
|
|
696
711
|
/**
|
|
697
712
|
* Wrap an existing path filter (ignore filter, optionally already wrapped with
|
|
698
713
|
* the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
|
|
@@ -1280,11 +1295,29 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1280
1295
|
// after the user signaled abort.
|
|
1281
1296
|
if (aborted) return;
|
|
1282
1297
|
|
|
1298
|
+
// Conditional-write fence (storage-level backstop for the entire
|
|
1299
|
+
// stale-clobber class). Every PUT asserts the remote state this pass
|
|
1300
|
+
// just inspected:
|
|
1301
|
+
// - remote exists → If-Match on the observed etag ("replace exactly
|
|
1302
|
+
// what I HEAD'd"). Closes the HEAD→PUT TOCTOU: a peer's write
|
|
1303
|
+
// landing in the window makes S3 itself reject with 412 instead of
|
|
1304
|
+
// this machine silently regressing the object.
|
|
1305
|
+
// - remote absent → If-None-Match:* ("create only"). If the HEAD was
|
|
1306
|
+
// wrong about absence (any transport/state bug — the 2026-06-10..12
|
|
1307
|
+
// regression storm's shape), the PUT 412s instead of clobbering.
|
|
1308
|
+
// Enforced natively on the SDK transport; on the presigned transport it
|
|
1309
|
+
// activates when files-presign signs the headers (see object-io.ts).
|
|
1310
|
+
const precondition: PutPrecondition = remoteMeta
|
|
1311
|
+
? { ifMatch: remoteMeta.etag }
|
|
1312
|
+
: { ifNoneMatch: "*" };
|
|
1313
|
+
|
|
1283
1314
|
// Upload — symlinks go through uploadSymlink (zero-byte body + target
|
|
1284
1315
|
// metadata), regular files through uploadFile (file contents). The
|
|
1285
1316
|
// discriminator is item.kind set by computePushPlan; both branches
|
|
1286
|
-
// converge on the same journal/event update path below.
|
|
1287
|
-
|
|
1317
|
+
// converge on the same journal/event update path below. Factored into a
|
|
1318
|
+
// closure so the 412 "overwrite" resolution below can re-run it without
|
|
1319
|
+
// the fence after explicit user consent.
|
|
1320
|
+
const performUpload = async (pc: PutPrecondition | undefined): Promise<void> => {
|
|
1288
1321
|
const isSymlinkUpload = item.kind === "symlink";
|
|
1289
1322
|
// Capture lstat post-upload so size + mtimeMs stamped into the
|
|
1290
1323
|
// journal reflect the bytes we actually shipped. lstat (not stat)
|
|
@@ -1296,12 +1329,8 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1296
1329
|
const mtimeMs = lstat.mtimeMs;
|
|
1297
1330
|
|
|
1298
1331
|
const { etag } = isSymlinkUpload
|
|
1299
|
-
? options.author
|
|
1300
|
-
|
|
1301
|
-
: await uploadSymlink(ctx, item.target, relativePath)
|
|
1302
|
-
: options.author
|
|
1303
|
-
? await uploadFile(ctx, absolutePath, relativePath, options.author)
|
|
1304
|
-
: await uploadFile(ctx, absolutePath, relativePath);
|
|
1332
|
+
? await uploadSymlink(ctx, item.target, relativePath, options.author, pc)
|
|
1333
|
+
: await uploadFile(ctx, absolutePath, relativePath, options.author, pc);
|
|
1305
1334
|
|
|
1306
1335
|
// Update journal with optional message; capture the post-upload ETag
|
|
1307
1336
|
// so the next sync can distinguish "remote moved since we last wrote"
|
|
@@ -1323,7 +1352,86 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1323
1352
|
bytes: size,
|
|
1324
1353
|
...(message ? { message } : {}),
|
|
1325
1354
|
});
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
try {
|
|
1358
|
+
await performUpload(precondition);
|
|
1326
1359
|
} catch (err) {
|
|
1360
|
+
if (isPreconditionFailed(err)) {
|
|
1361
|
+
// The fence fired: the remote moved past (If-Match) or appeared at
|
|
1362
|
+
// (If-None-Match) this key between our HEAD and the PUT — exactly
|
|
1363
|
+
// the race the fence exists to catch. Surface as a push conflict;
|
|
1364
|
+
// never silently overwrite.
|
|
1365
|
+
conflictPaths.push(relativePath);
|
|
1366
|
+
const resolution = await resolveConflict(
|
|
1367
|
+
{ path: relativePath, localHash, direction: "push" },
|
|
1368
|
+
onConflict,
|
|
1369
|
+
);
|
|
1370
|
+
emit({
|
|
1371
|
+
type: "conflict",
|
|
1372
|
+
path: relativePath,
|
|
1373
|
+
direction: "push",
|
|
1374
|
+
resolution,
|
|
1375
|
+
});
|
|
1376
|
+
if (resolution === "abort") {
|
|
1377
|
+
aborted = true;
|
|
1378
|
+
abortFlightConflictPaths = [...conflictPaths];
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (resolution === "overwrite") {
|
|
1382
|
+
// Explicit clobber consent — retry once without the fence. A
|
|
1383
|
+
// second failure falls through to the generic error emit.
|
|
1384
|
+
try {
|
|
1385
|
+
await performUpload(undefined);
|
|
1386
|
+
} catch (retryErr) {
|
|
1387
|
+
emit({
|
|
1388
|
+
type: "error",
|
|
1389
|
+
path: relativePath,
|
|
1390
|
+
message:
|
|
1391
|
+
retryErr instanceof Error ? retryErr.message : String(retryErr),
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
// keep/skip: preserve the racing remote next to the local copy so
|
|
1397
|
+
// both versions survive — same mirror routine as the fresh-collision
|
|
1398
|
+
// branch above.
|
|
1399
|
+
try {
|
|
1400
|
+
const detectedAt = new Date().toISOString();
|
|
1401
|
+
const machineId = readShortMachineId(hqRoot);
|
|
1402
|
+
const originalRelative = path.relative(hqRoot, absolutePath);
|
|
1403
|
+
const conflictRelative = buildConflictPath(
|
|
1404
|
+
originalRelative,
|
|
1405
|
+
detectedAt,
|
|
1406
|
+
machineId,
|
|
1407
|
+
);
|
|
1408
|
+
const conflictAbs = path.join(hqRoot, conflictRelative);
|
|
1409
|
+
await downloadFile(ctx, relativePath, conflictAbs);
|
|
1410
|
+
appendConflictEntry(hqRoot, {
|
|
1411
|
+
id: buildConflictId(originalRelative, detectedAt),
|
|
1412
|
+
originalPath: originalRelative,
|
|
1413
|
+
conflictPath: conflictRelative,
|
|
1414
|
+
detectedAt,
|
|
1415
|
+
side: "push",
|
|
1416
|
+
machineId,
|
|
1417
|
+
localHash,
|
|
1418
|
+
// remoteMeta (if any) predates the racing write that fired the
|
|
1419
|
+
// fence — record what we knew ("" when the key was believed
|
|
1420
|
+
// absent); the mirror file carries the authoritative remote bytes.
|
|
1421
|
+
remoteHash: remoteMeta ? normalizeEtag(remoteMeta.etag) : "",
|
|
1422
|
+
});
|
|
1423
|
+
} catch (mirrorErr) {
|
|
1424
|
+
emit({
|
|
1425
|
+
type: "error",
|
|
1426
|
+
path: relativePath,
|
|
1427
|
+
message: `conflict mirror write failed: ${
|
|
1428
|
+
mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)
|
|
1429
|
+
}`,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
filesSkipped++;
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1327
1435
|
emit({
|
|
1328
1436
|
type: "error",
|
|
1329
1437
|
path: relativePath,
|
package/src/cognito-auth.ts
CHANGED
|
@@ -580,9 +580,17 @@ export async function getValidAccessToken(
|
|
|
580
580
|
|
|
581
581
|
// Machine identities (company agents) never refresh or open a browser —
|
|
582
582
|
// they re-mint via USER_PASSWORD_AUTH on demand.
|
|
583
|
+
// Machine identities return the ID token: every hq-cloud caller of this
|
|
584
|
+
// getter talks to the VAULT API, whose authorizer accepts ID tokens — and
|
|
585
|
+
// the agent's identity claims (custom:entityType/entityUid) ride the ID
|
|
586
|
+
// token ONLY. Returning the access token made the first post-cutover
|
|
587
|
+
// agent box (Petyr, 2026-06-12) resolve as NOBODY: /membership/me -> []
|
|
588
|
+
// -> 'setup-needed' forever, while the identical call with the ID token
|
|
589
|
+
// returned its membership. (Consumers needing token_use=access — e.g.
|
|
590
|
+
// the deploy API — pick tokens per call in hq-cli, not here.)
|
|
583
591
|
if (isMachineIdentity()) {
|
|
584
592
|
const machine = await getValidMachineTokens(config);
|
|
585
|
-
return machine.
|
|
593
|
+
return machine.idToken;
|
|
586
594
|
}
|
|
587
595
|
|
|
588
596
|
let cached = loadCachedTokens();
|
package/src/ignore.test.ts
CHANGED
|
@@ -31,6 +31,24 @@ describe("createIgnoreFilter", () => {
|
|
|
31
31
|
expect(shouldSync(path.join(hqRoot, ".env"))).toBe(false);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("permissive mode: pnpm content-addressed store is ignored by default", () => {
|
|
35
|
+
// .pnpm-store/ is pnpm's content-addressed package store. Like
|
|
36
|
+
// node_modules/ it is large and machine-local and must never sync — a
|
|
37
|
+
// synced store writes a `.conflict-` copy of every blob each cycle, which
|
|
38
|
+
// then re-syncs and amplifies into tens of thousands of files. Regression
|
|
39
|
+
// guard for that storm.
|
|
40
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
41
|
+
// The store directory and everything beneath it is excluded.
|
|
42
|
+
expect(shouldSync(path.join(hqRoot, ".pnpm-store"), true)).toBe(false);
|
|
43
|
+
expect(shouldSync(path.join(hqRoot, ".pnpm-store/v10/files/00/abc123"))).toBe(false);
|
|
44
|
+
// Caught at any depth — a project store nested under a synced subtree.
|
|
45
|
+
expect(
|
|
46
|
+
shouldSync(path.join(hqRoot, "companies/indigo/repos/x/.pnpm-store/v10/blob")),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
// node_modules/ is already covered; asserted here so the pair travels together.
|
|
49
|
+
expect(shouldSync(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
34
52
|
it("permissive mode: .hqignore patterns are honored", () => {
|
|
35
53
|
fs.writeFileSync(path.join(hqRoot, ".hqignore"), "companies/*/data/\n");
|
|
36
54
|
const shouldSync = createIgnoreFilter(hqRoot);
|
package/src/ignore.ts
CHANGED
|
@@ -35,6 +35,11 @@ export const DEFAULT_IGNORES = [
|
|
|
35
35
|
|
|
36
36
|
// Node / JS
|
|
37
37
|
"node_modules/",
|
|
38
|
+
// pnpm's content-addressed package store. Large and machine-local like
|
|
39
|
+
// node_modules/; a synced store writes a `.conflict-` copy of every blob
|
|
40
|
+
// each cycle, which then re-syncs and amplifies into tens of thousands of
|
|
41
|
+
// files. Never round-trip it.
|
|
42
|
+
".pnpm-store/",
|
|
38
43
|
"dist/",
|
|
39
44
|
"build/",
|
|
40
45
|
".next/",
|
package/src/machine-auth.test.ts
CHANGED
|
@@ -298,7 +298,7 @@ describe("getValidMachineTokens", () => {
|
|
|
298
298
|
// ---------------------------------------------------------------------------
|
|
299
299
|
|
|
300
300
|
describe("getValidAccessToken in machine mode", () => {
|
|
301
|
-
it("mints via machine creds
|
|
301
|
+
it("mints via machine creds and returns the ID token (vault-API identity)", async () => {
|
|
302
302
|
writeCreds();
|
|
303
303
|
const { calls } = stubMintFetch();
|
|
304
304
|
const { getValidAccessToken } = await importModule();
|
|
@@ -309,6 +309,8 @@ describe("getValidAccessToken in machine mode", () => {
|
|
|
309
309
|
const claims = JSON.parse(
|
|
310
310
|
Buffer.from(token.split(".")[1], "base64url").toString(),
|
|
311
311
|
);
|
|
312
|
-
|
|
312
|
+
// ID, NOT access: agent identity rides the ID token; the access-token
|
|
313
|
+
// return lobotomized first-sync on the first post-cutover box.
|
|
314
|
+
expect(claims.token_use).toBe("id");
|
|
313
315
|
});
|
|
314
316
|
});
|