borgmcp 2.1.0 → 2.2.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 (50) hide show
  1. package/README.md +2 -2
  2. package/dist/assimilate-cmd.d.ts +11 -2
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +60 -85
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +26 -8
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/cli-help.d.ts.map +1 -1
  10. package/dist/cli-help.js +7 -5
  11. package/dist/cli-help.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/regen-format.d.ts +1 -6
  16. package/dist/regen-format.d.ts.map +1 -1
  17. package/dist/regen-format.js +9 -8
  18. package/dist/regen-format.js.map +1 -1
  19. package/dist/remote-client.js +1 -1
  20. package/dist/remote-client.js.map +1 -1
  21. package/dist/repository-cube-init.d.ts +16 -1
  22. package/dist/repository-cube-init.d.ts.map +1 -1
  23. package/dist/repository-cube-init.js +167 -22
  24. package/dist/repository-cube-init.js.map +1 -1
  25. package/dist/server-errors.d.ts +11 -0
  26. package/dist/server-errors.d.ts.map +1 -1
  27. package/dist/server-errors.js +20 -0
  28. package/dist/server-errors.js.map +1 -1
  29. package/dist/server-facade.d.ts +2 -1
  30. package/dist/server-facade.d.ts.map +1 -1
  31. package/dist/server-facade.js +16 -10
  32. package/dist/server-facade.js.map +1 -1
  33. package/dist/server-handshake.d.ts +32 -2
  34. package/dist/server-handshake.d.ts.map +1 -1
  35. package/dist/server-handshake.js +161 -8
  36. package/dist/server-handshake.js.map +1 -1
  37. package/docs/EXTRACTION_PROVENANCE.md +9 -7
  38. package/docs/LOCAL_SERVER.md +19 -13
  39. package/docs/RELEASING.md +21 -5
  40. package/package.json +2 -2
  41. package/src/assimilate-cmd.ts +85 -69
  42. package/src/assimilate-deps.ts +37 -7
  43. package/src/cli-help.ts +7 -5
  44. package/src/index.ts +5 -1
  45. package/src/regen-format.ts +11 -9
  46. package/src/remote-client.ts +1 -1
  47. package/src/repository-cube-init.ts +202 -23
  48. package/src/server-errors.ts +27 -0
  49. package/src/server-facade.ts +16 -10
  50. package/src/server-handshake.ts +226 -8
@@ -4,8 +4,12 @@ import {
4
4
  ENROLLMENT_EXCHANGE_PATH,
5
5
  HEALTH_PATH,
6
6
  PROTOCOL_INFO_PATH,
7
+ REPOSITORY_CUBE_ASSOCIATION_PATH,
8
+ REPOSITORY_CUBE_RESOLVE_PATH,
7
9
  createAttachRequestEnvelope,
8
10
  createProtocolEnvelope,
11
+ decodeAssociateRepositoryCubeRequest,
12
+ decodeAssociateRepositoryCubeResponseEnvelope,
9
13
  decodeAttachResponseEnvelope,
10
14
  decodeCreateCubeRequest,
11
15
  decodeCreateCubeResponseEnvelope,
@@ -13,12 +17,16 @@ import {
13
17
  decodeEnrollmentExchangeResponseEnvelope,
14
18
  decodeProtocolErrorEnvelope,
15
19
  decodeProtocolTagPreflight,
20
+ decodeResolveRepositoryCubeRequest,
21
+ decodeResolveRepositoryCubeResponseEnvelope,
16
22
  ErrorCode,
23
+ type AssociateRepositoryCubeResponse,
17
24
  type CreateCubeRepository,
18
25
  type CreateCubeResponse,
19
26
  type CubeTemplate,
20
27
  type DroneRuntimeMetadata,
21
28
  type ProtocolTagPreflight,
29
+ type ResolveRepositoryCubeResponse,
22
30
  type ServerCapability,
23
31
  } from 'borgmcp-shared/protocol';
24
32
  import { createHash, randomUUID } from 'node:crypto';
@@ -49,6 +57,9 @@ import {
49
57
  BorgServerUnreachableError,
50
58
  CubeCreationConfirmationError,
51
59
  CubeCreationOutcomeUnknownError,
60
+ RepositoryAssociationOperationError,
61
+ RepositoryAssociationOutcomeUnknownError,
62
+ RepositoryAssociationResolutionError,
52
63
  } from './server-errors.js';
53
64
  import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
54
65
  import { readBoundedResponseBody } from './server-response.js';
@@ -185,7 +196,7 @@ export interface ServerAttachResult {
185
196
  }
186
197
 
187
198
  /**
188
- * Attach an enrolled client principal to one granted cube/role over protocol v4.
199
+ * Attach an enrolled client principal to one granted cube/role over protocol v5.
189
200
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
190
201
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
191
202
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -558,6 +569,210 @@ export async function resumeBorgServerEnrollment(
558
569
  });
559
570
  }
560
571
 
572
+ async function requireMatchingServerCredential(
573
+ origin: string,
574
+ trustIdentity: string,
575
+ parentCredential: string,
576
+ loadCredentialRecord: typeof getServerCredentialRecord,
577
+ ): Promise<void> {
578
+ const active = await loadCredentialRecord(origin, trustIdentity);
579
+ if (!active || active.credential !== parentCredential || !active.clientId) {
580
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'stored Borg server credential was rejected');
581
+ }
582
+ }
583
+
584
+ async function protocolErrorCode(response: Response): Promise<ErrorCode | undefined> {
585
+ try {
586
+ return decodeProtocolErrorEnvelope(
587
+ JSON.parse(await readHandshakeBodyWithTimeout(response)),
588
+ ).error.code;
589
+ } catch {
590
+ return undefined;
591
+ }
592
+ }
593
+
594
+ /** Resolve one client-scoped repository association without mutation. */
595
+ export async function resolveBorgServerRepositoryCube(
596
+ origin: string,
597
+ trustIdentity: string,
598
+ parentCredential: string,
599
+ input: { workingRepoName: string; repository: CreateCubeRepository },
600
+ deps: {
601
+ fetchImpl?: FetchLike;
602
+ loadCredentialRecord?: typeof getServerCredentialRecord;
603
+ } = {},
604
+ ): Promise<ResolveRepositoryCubeResponse> {
605
+ await requireMatchingServerCredential(
606
+ origin,
607
+ trustIdentity,
608
+ parentCredential,
609
+ deps.loadCredentialRecord ?? getServerCredentialRecord,
610
+ );
611
+ const request = decodeResolveRepositoryCubeRequest({
612
+ working_repo_name: input.workingRepoName,
613
+ repository: input.repository,
614
+ });
615
+ const controller = new AbortController();
616
+ const timeout = setTimeout(() => controller.abort(), HANDSHAKE_TIMEOUT_MS);
617
+ let response: Response;
618
+ try {
619
+ response = await (deps.fetchImpl ?? fetch)(handshakeUrl(origin, REPOSITORY_CUBE_RESOLVE_PATH), {
620
+ method: 'POST',
621
+ redirect: 'error',
622
+ signal: controller.signal,
623
+ headers: {
624
+ Accept: 'application/json',
625
+ 'Content-Type': 'application/json',
626
+ Authorization: `Bearer ${parentCredential}`,
627
+ },
628
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), request)),
629
+ });
630
+ } catch (error) {
631
+ throw new RepositoryAssociationResolutionError();
632
+ } finally {
633
+ clearTimeout(timeout);
634
+ }
635
+ if (response.status === 401 || response.status === 403) {
636
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server enrollment was rejected');
637
+ }
638
+ if (response.status !== 200) {
639
+ throw new RepositoryAssociationResolutionError();
640
+ }
641
+ try {
642
+ const decoded = decodeResolveRepositoryCubeResponseEnvelope(
643
+ JSON.parse(await readHandshakeBodyWithTimeout(response)),
644
+ ).payload;
645
+ if (
646
+ decoded.result === 'resolved' &&
647
+ (decoded.repository.kind !== request.repository.kind ||
648
+ decoded.repository.value !== request.repository.value ||
649
+ decoded.working_repo_name !== request.working_repo_name)
650
+ ) {
651
+ throw new Error('association mismatch');
652
+ }
653
+ return decoded;
654
+ } catch {
655
+ throw new RepositoryAssociationResolutionError();
656
+ }
657
+ }
658
+
659
+ export async function resolveLocalBorgServerRepositoryCube(
660
+ origin: string,
661
+ trustIdentity: string,
662
+ parentCredential: string,
663
+ input: { workingRepoName: string; repository: CreateCubeRepository },
664
+ deps: { loadTrust?: typeof loadBorgServerTrust } = {},
665
+ ): Promise<ResolveRepositoryCubeResponse> {
666
+ const trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
667
+ if (trust.identity !== trustIdentity) {
668
+ throw new BorgServerTrustError('Borg server trust identity changed; refusing repository resolution');
669
+ }
670
+ return resolveBorgServerRepositoryCube(origin, trustIdentity, parentCredential, input, {
671
+ fetchImpl: trust.fetchImpl,
672
+ });
673
+ }
674
+
675
+ /** Atomically associate an explicit accessible cube after local confirmation. */
676
+ export async function associateBorgServerRepositoryCube(
677
+ origin: string,
678
+ trustIdentity: string,
679
+ parentCredential: string,
680
+ input: { cubeId: string; workingRepoName: string; repository: CreateCubeRepository },
681
+ deps: {
682
+ fetchImpl?: FetchLike;
683
+ loadCredentialRecord?: typeof getServerCredentialRecord;
684
+ } = {},
685
+ ): Promise<AssociateRepositoryCubeResponse> {
686
+ await requireMatchingServerCredential(
687
+ origin,
688
+ trustIdentity,
689
+ parentCredential,
690
+ deps.loadCredentialRecord ?? getServerCredentialRecord,
691
+ );
692
+ const request = decodeAssociateRepositoryCubeRequest({
693
+ cube_id: input.cubeId,
694
+ working_repo_name: input.workingRepoName,
695
+ repository: input.repository,
696
+ });
697
+ const controller = new AbortController();
698
+ const timeout = setTimeout(() => controller.abort(), HANDSHAKE_TIMEOUT_MS);
699
+ let response: Response;
700
+ try {
701
+ response = await (deps.fetchImpl ?? fetch)(handshakeUrl(origin, REPOSITORY_CUBE_ASSOCIATION_PATH), {
702
+ method: 'PUT',
703
+ redirect: 'error',
704
+ signal: controller.signal,
705
+ headers: {
706
+ Accept: 'application/json',
707
+ 'Content-Type': 'application/json',
708
+ Authorization: `Bearer ${parentCredential}`,
709
+ },
710
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), request)),
711
+ });
712
+ } catch {
713
+ throw new RepositoryAssociationOutcomeUnknownError();
714
+ } finally {
715
+ clearTimeout(timeout);
716
+ }
717
+ if (response.status === 401) {
718
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server enrollment was rejected');
719
+ }
720
+ if (response.status === 403) {
721
+ if (await protocolErrorCode(response) !== ErrorCode.ACCESS_DENIED) {
722
+ throw new RepositoryAssociationOutcomeUnknownError();
723
+ }
724
+ throw new RepositoryAssociationOperationError('access-denied');
725
+ }
726
+ if (response.status === 409) {
727
+ const code = await protocolErrorCode(response);
728
+ if (code === ErrorCode.REPOSITORY_ALREADY_ASSOCIATED) {
729
+ throw new RepositoryAssociationOperationError('repository-already-associated');
730
+ }
731
+ if (code === ErrorCode.CUBE_ALREADY_ASSOCIATED) {
732
+ throw new RepositoryAssociationOperationError('cube-already-associated');
733
+ }
734
+ if (code === ErrorCode.INVALID_INPUT) {
735
+ throw new RepositoryAssociationOperationError('invalid-cube');
736
+ }
737
+ throw new RepositoryAssociationOutcomeUnknownError();
738
+ }
739
+ if (response.status !== 200) {
740
+ throw new RepositoryAssociationOutcomeUnknownError();
741
+ }
742
+ try {
743
+ const decoded = decodeAssociateRepositoryCubeResponseEnvelope(
744
+ JSON.parse(await readHandshakeBodyWithTimeout(response)),
745
+ ).payload;
746
+ if (
747
+ decoded.cube_id !== request.cube_id ||
748
+ decoded.repository.kind !== request.repository.kind ||
749
+ decoded.repository.value !== request.repository.value ||
750
+ decoded.working_repo_name !== request.working_repo_name
751
+ ) {
752
+ throw new Error('association mismatch');
753
+ }
754
+ return decoded;
755
+ } catch (error) {
756
+ throw new RepositoryAssociationOutcomeUnknownError();
757
+ }
758
+ }
759
+
760
+ export async function associateLocalBorgServerRepositoryCube(
761
+ origin: string,
762
+ trustIdentity: string,
763
+ parentCredential: string,
764
+ input: { cubeId: string; workingRepoName: string; repository: CreateCubeRepository },
765
+ deps: { loadTrust?: typeof loadBorgServerTrust } = {},
766
+ ): Promise<AssociateRepositoryCubeResponse> {
767
+ const trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
768
+ if (trust.identity !== trustIdentity) {
769
+ throw new BorgServerTrustError('Borg server trust identity changed; refusing repository association');
770
+ }
771
+ return associateBorgServerRepositoryCube(origin, trustIdentity, parentCredential, input, {
772
+ fetchImpl: trust.fetchImpl,
773
+ });
774
+ }
775
+
561
776
  /**
562
777
  * Create one repository cube through the narrow owner capability. The retry
563
778
  * key is persisted in the OS keychain before network I/O and reused exactly
@@ -652,16 +867,15 @@ export async function createBorgServerCube(
652
867
  throw new CubeCreationConfirmationError('The Borg server rejected the repository cube operation identity.');
653
868
  }
654
869
  if (response.status !== 201) {
655
- throw new Error(`Borg server cube creation failed (HTTP ${response.status})`);
870
+ throw new CubeCreationOutcomeUnknownError();
656
871
  }
657
872
  let decoded: ReturnType<typeof decodeCreateCubeResponseEnvelope>;
658
873
  try {
659
874
  decoded = decodeCreateCubeResponseEnvelope(
660
875
  JSON.parse(await readHandshakeBodyWithTimeout(response)),
661
876
  );
662
- } catch (error) {
663
- if (error instanceof Error && error.message.includes('response limit')) throw error;
664
- throw new Error('Borg server returned an invalid cube creation envelope');
877
+ } catch {
878
+ throw new CubeCreationOutcomeUnknownError();
665
879
  }
666
880
  if (
667
881
  decoded.payload.repository.kind !== pending.repository.kind ||
@@ -669,9 +883,13 @@ export async function createBorgServerCube(
669
883
  ) {
670
884
  throw new CubeCreationConfirmationError();
671
885
  }
672
- await (deps.clearCubeCreation ?? clearPendingServerCubeCreation)(
673
- pending as PendingServerCubeCreationRecord,
674
- );
886
+ try {
887
+ await (deps.clearCubeCreation ?? clearPendingServerCubeCreation)(
888
+ pending as PendingServerCubeCreationRecord,
889
+ );
890
+ } catch {
891
+ throw new CubeCreationConfirmationError('The server confirmed the cube, but local retry state could not be finalized.');
892
+ }
675
893
  return decoded.payload;
676
894
  }
677
895