@indigoai-us/hq-cloud 6.7.1 → 6.9.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.
Files changed (63) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +33 -1
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +73 -4
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/reindex.d.ts +11 -0
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +1 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +5 -4
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue.d.ts +20 -0
  13. package/dist/cli/rescue.d.ts.map +1 -1
  14. package/dist/cli/rescue.js +36 -2
  15. package/dist/cli/rescue.js.map +1 -1
  16. package/dist/cli/rescue.test.js +38 -1
  17. package/dist/cli/rescue.test.js.map +1 -1
  18. package/dist/cli/share.d.ts.map +1 -1
  19. package/dist/cli/share.js +104 -8
  20. package/dist/cli/share.js.map +1 -1
  21. package/dist/cli/share.test.js +190 -20
  22. package/dist/cli/share.test.js.map +1 -1
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +9 -1
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/machine-auth.test.js +4 -2
  27. package/dist/machine-auth.test.js.map +1 -1
  28. package/dist/object-io.d.ts +28 -2
  29. package/dist/object-io.d.ts.map +1 -1
  30. package/dist/object-io.js +76 -5
  31. package/dist/object-io.js.map +1 -1
  32. package/dist/object-io.test.js +93 -2
  33. package/dist/object-io.test.js.map +1 -1
  34. package/dist/operation-lock.d.ts +81 -10
  35. package/dist/operation-lock.d.ts.map +1 -1
  36. package/dist/operation-lock.js +177 -27
  37. package/dist/operation-lock.js.map +1 -1
  38. package/dist/operation-lock.test.js +122 -11
  39. package/dist/operation-lock.test.js.map +1 -1
  40. package/dist/s3.d.ts +3 -2
  41. package/dist/s3.d.ts.map +1 -1
  42. package/dist/s3.js +10 -5
  43. package/dist/s3.js.map +1 -1
  44. package/dist/vault-client.d.ts +9 -0
  45. package/dist/vault-client.d.ts.map +1 -1
  46. package/dist/vault-client.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/bin/sync-runner.test.ts +83 -4
  49. package/src/bin/sync-runner.ts +39 -1
  50. package/src/cli/reindex.test.ts +5 -4
  51. package/src/cli/reindex.ts +12 -1
  52. package/src/cli/rescue.test.ts +43 -1
  53. package/src/cli/rescue.ts +48 -2
  54. package/src/cli/share.test.ts +245 -9
  55. package/src/cli/share.ts +116 -8
  56. package/src/cognito-auth.ts +9 -1
  57. package/src/machine-auth.test.ts +4 -2
  58. package/src/object-io.test.ts +105 -2
  59. package/src/object-io.ts +121 -8
  60. package/src/operation-lock.test.ts +147 -10
  61. package/src/operation-lock.ts +234 -26
  62. package/src/s3.ts +11 -4
  63. package/src/vault-client.ts +9 -0
@@ -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
- // share() must remain a 3-arg call to uploadFile when no author is
843
- // configured older test stubs and external integrations rely on it.
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
- try {
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
- ? await uploadSymlink(ctx, item.target, relativePath, options.author)
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,
@@ -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.accessToken;
593
+ return machine.idToken;
586
594
  }
587
595
 
588
596
  let cached = loadCachedTokens();
@@ -298,7 +298,7 @@ describe("getValidMachineTokens", () => {
298
298
  // ---------------------------------------------------------------------------
299
299
 
300
300
  describe("getValidAccessToken in machine mode", () => {
301
- it("mints via machine creds instead of refreshing or opening a browser", async () => {
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
- expect(claims.token_use).toBe("access");
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
  });