borgmcp 2.10.2 → 2.11.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 (57) hide show
  1. package/dist/cli-help.js +3 -3
  2. package/dist/cli-help.js.map +1 -1
  3. package/dist/config.d.ts +34 -9
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +487 -105
  6. package/dist/config.js.map +1 -1
  7. package/dist/enrollment-lock.d.ts +10 -0
  8. package/dist/enrollment-lock.d.ts.map +1 -0
  9. package/dist/enrollment-lock.js +49 -0
  10. package/dist/enrollment-lock.js.map +1 -0
  11. package/dist/enrollment-types.d.ts +47 -0
  12. package/dist/enrollment-types.d.ts.map +1 -0
  13. package/dist/enrollment-types.js +2 -0
  14. package/dist/enrollment-types.js.map +1 -0
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/invitation-artifact.d.ts +3 -1
  18. package/dist/invitation-artifact.d.ts.map +1 -1
  19. package/dist/invitation-artifact.js +3 -1
  20. package/dist/invitation-artifact.js.map +1 -1
  21. package/dist/recover-enrollment-cmd.d.ts.map +1 -1
  22. package/dist/recover-enrollment-cmd.js +34 -15
  23. package/dist/recover-enrollment-cmd.js.map +1 -1
  24. package/dist/remote-client.d.ts +3 -2
  25. package/dist/remote-client.d.ts.map +1 -1
  26. package/dist/remote-client.js +32 -11
  27. package/dist/remote-client.js.map +1 -1
  28. package/dist/server-errors.d.ts +4 -0
  29. package/dist/server-errors.d.ts.map +1 -1
  30. package/dist/server-errors.js +8 -0
  31. package/dist/server-errors.js.map +1 -1
  32. package/dist/server-handshake.d.ts +11 -3
  33. package/dist/server-handshake.d.ts.map +1 -1
  34. package/dist/server-handshake.js +103 -45
  35. package/dist/server-handshake.js.map +1 -1
  36. package/dist/server-trust.d.ts +14 -1
  37. package/dist/server-trust.d.ts.map +1 -1
  38. package/dist/server-trust.js +363 -66
  39. package/dist/server-trust.js.map +1 -1
  40. package/dist/tool-manifest.d.ts.map +1 -1
  41. package/dist/tool-manifest.js +4 -10
  42. package/dist/tool-manifest.js.map +1 -1
  43. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  44. package/docs/RELEASING.md +7 -2
  45. package/package.json +1 -1
  46. package/src/cli-help.ts +3 -3
  47. package/src/config.ts +544 -101
  48. package/src/enrollment-lock.ts +53 -0
  49. package/src/enrollment-types.ts +51 -0
  50. package/src/index.ts +1 -1
  51. package/src/invitation-artifact.ts +7 -1
  52. package/src/recover-enrollment-cmd.ts +39 -15
  53. package/src/remote-client.ts +36 -10
  54. package/src/server-errors.ts +7 -0
  55. package/src/server-handshake.ts +130 -52
  56. package/src/server-trust.ts +392 -66
  57. package/src/tool-manifest.ts +4 -10
@@ -0,0 +1,53 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { withStoreLock } from './seat-store.js';
4
+ import { BORG_USER_ROOT, SERVER_CREDENTIALS_FILE } from './credential-paths.js';
5
+
6
+ const processTails = new Map<string, Promise<void>>();
7
+ const heldOrigins = new AsyncLocalStorage<ReadonlySet<string>>();
8
+
9
+ function enrollmentLockPath(origin: string): string {
10
+ const binding = createHash('sha256').update(origin).digest('hex');
11
+ return `${SERVER_CREDENTIALS_FILE}.enrollment-${binding}.lock`;
12
+ }
13
+
14
+ async function withProcessOriginLock<T>(origin: string, operation: () => Promise<T>): Promise<T> {
15
+ const prior = processTails.get(origin) ?? Promise.resolve();
16
+ let release!: () => void;
17
+ const current = new Promise<void>((resolve) => { release = resolve; });
18
+ const tail = prior.then(() => current);
19
+ processTails.set(origin, tail);
20
+ await prior;
21
+ try {
22
+ return await operation();
23
+ } finally {
24
+ release();
25
+ if (processTails.get(origin) === tail) processTails.delete(origin);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Serialize every authoritative enrollment read, commit, and recovery for one
31
+ * server origin. The in-process queue also covers injected test backends; the
32
+ * file lock is the cross-process authority in production.
33
+ */
34
+ export async function withEnrollmentOriginLock<T>(
35
+ origin: string,
36
+ operation: () => Promise<T>,
37
+ options: { processShared?: boolean } = {},
38
+ ): Promise<T> {
39
+ const held = heldOrigins.getStore();
40
+ if (held?.has(origin)) return operation();
41
+ return withProcessOriginLock(origin, async () => {
42
+ const run = () => heldOrigins.run(new Set([...(held ?? []), origin]), operation);
43
+ if (options.processShared === false) return run();
44
+ return withStoreLock(enrollmentLockPath(origin), run, {
45
+ secureRoot: BORG_USER_ROOT,
46
+ rootMode: 'owner-controlled',
47
+ });
48
+ });
49
+ }
50
+
51
+ export function __clearEnrollmentOriginLocksForTest(): void {
52
+ processTails.clear();
53
+ }
@@ -0,0 +1,51 @@
1
+ export interface EnrollmentTrustPointer {
2
+ version: 1;
3
+ origin: string;
4
+ generationId: string;
5
+ trustIdentity: string;
6
+ }
7
+
8
+ export interface EnrollmentArtifactBinding {
9
+ artifactFormatVersion: number;
10
+ artifactDigest: string;
11
+ endpoint: string;
12
+ caSpkiSha256: string;
13
+ trustIdentity: string;
14
+ expectedAuthority: 'owner' | 'client';
15
+ stagedGenerationId: string;
16
+ }
17
+
18
+ export interface EnrollmentReplacementCapability {
19
+ token: string;
20
+ priorAccountsDigest: string;
21
+ endpoint: string;
22
+ caSpkiSha256: string;
23
+ trustIdentity: string;
24
+ retryKey: string;
25
+ artifactDigest: string;
26
+ }
27
+
28
+ export interface EnrollmentRollbackSnapshot {
29
+ activeAccounts: Record<string, string>;
30
+ pendingAccount: string;
31
+ pendingValue: string;
32
+ }
33
+
34
+ export interface EnrollmentRollbackRecord {
35
+ version: 1;
36
+ state: 'rollback-snapshot';
37
+ origin: string;
38
+ snapshot: EnrollmentRollbackSnapshot;
39
+ }
40
+
41
+ export interface AcceptedEnrollmentMarker {
42
+ version: 1;
43
+ state: 'accepted';
44
+ origin: string;
45
+ trustIdentity: string;
46
+ generationId: string;
47
+ previousPointer: EnrollmentTrustPointer | null;
48
+ activeDigest: string;
49
+ rollbackAccount: string;
50
+ rollbackDigest: string;
51
+ }
package/src/index.ts CHANGED
@@ -1017,7 +1017,7 @@ export async function main() {
1017
1017
  if (typeof args?.name === 'string') updates.name = args.name as string;
1018
1018
  if (typeof args?.cube_directive === 'string') updates.cube_directive = args.cube_directive as string;
1019
1019
  if (Array.isArray(args?.message_taxonomy)) updates.message_taxonomy = args.message_taxonomy as MessageTaxonomy;
1020
- if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: name, cube_directive, message_taxonomy.');
1020
+ if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: cube_directive, message_taxonomy.');
1021
1021
  const { cube } = await updateCube(cubeId, updates);
1022
1022
  return { content: [{ type: 'text', text: `Updated cube **${cube.name}** (id: ${cube.id}).` }] };
1023
1023
  }
@@ -22,7 +22,13 @@ export const STORAGE_INVITATION_ERROR =
22
22
  'Borg could not prepare local trust state for this invitation. No invitation or credential was sent and no local trust or credential state was changed. Check that Borg can write its private local state, then retry.';
23
23
 
24
24
  export const RECOVERY_INVITATION_ERROR =
25
- 'Borg could not complete or undo the enrollment change. Prior local access may be unavailable. Run `borg recover-enrollment` to clear only this server enrollment transaction. The invitation used for this attempt has been consumed; after recovery, ask the server operator for a fresh invitation and retry.';
25
+ 'Borg could not complete or undo the enrollment change. Prior local access may be unavailable. Run `borg recover-enrollment` to restore or clear only this server enrollment transaction; it does not change unrelated server enrollments or accounts. The invitation used for this attempt has been consumed; after recovery, ask the server operator for a fresh invitation and retry.';
26
+
27
+ export const MISKEYED_RECOVERY_ERROR =
28
+ 'Borg found a failed enrollment record where it does not belong. No state was changed. This release has no supported way to recover that record; keep it intact.';
29
+
30
+ export const RECOVERY_TRANSACTION_CHANGED_ERROR =
31
+ 'The failed enrollment transaction you reviewed is no longer present. No state was changed. Re-run `borg recover-enrollment` to review the current transaction.';
26
32
 
27
33
  export class InvitationArtifactCompatibilityError extends Error {
28
34
  constructor(message = COMPATIBILITY_INVITATION_ERROR) {
@@ -1,6 +1,11 @@
1
1
  import { normalizeServerEndpoint } from './server-endpoint.js';
2
- import { clearEnrollmentTransaction, findPendingServerEnrollment } from './config.js';
3
- import { clearBorgServerTrust } from './server-trust.js';
2
+ import { clearEnrollmentTransaction, findEnrollmentRecoveryTransaction } from './config.js';
3
+ import {
4
+ InvitationArtifactRecoveryError,
5
+ RECOVERY_TRANSACTION_CHANGED_ERROR,
6
+ } from './invitation-artifact.js';
7
+ import { withEnrollmentOriginLock } from './enrollment-lock.js';
8
+ import { clearStagedBorgServerTrust, restoreBorgServerEnrollment } from './server-trust.js';
4
9
 
5
10
  export interface RecoverEnrollmentFlags {
6
11
  host?: string;
@@ -31,33 +36,52 @@ export async function runRecoverEnrollment(
31
36
  flags: RecoverEnrollmentFlags,
32
37
  deps: { prompt: (message: string) => Promise<string>; stderr: (line: string) => void; stdout: (line: string) => void },
33
38
  ): Promise<number> {
34
- const pending = await findPendingServerEnrollment();
35
- if (!pending) {
36
- deps.stderr('No recoverable Borg enrollment transaction was found. No state was changed.\n');
37
- return 1;
38
- }
39
- let origin = pending.origin;
39
+ let selectedOrigin: string | undefined;
40
40
  if (flags.host !== undefined) {
41
- try { origin = normalizeServerEndpoint(flags.host); } catch (error) {
41
+ try { selectedOrigin = normalizeServerEndpoint(flags.host); } catch (error) {
42
42
  deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
43
43
  return 1;
44
44
  }
45
- if (origin !== pending.origin) {
46
- deps.stderr('The recovery host does not match the failed enrollment transaction. No state was changed.\n');
45
+ }
46
+ const transaction = await findEnrollmentRecoveryTransaction(selectedOrigin);
47
+ if (!transaction) {
48
+ if (selectedOrigin !== undefined && await findEnrollmentRecoveryTransaction()) {
49
+ deps.stderr('The recovery host does not match the failed enrollment transaction. No state was changed. Re-run without `--host` to review the current transaction.\n');
47
50
  return 1;
48
51
  }
52
+ deps.stderr('No recoverable Borg enrollment transaction was found. No state was changed.\n');
53
+ return 1;
49
54
  }
55
+ const enrollment = transaction.kind === 'accepted' ? transaction.marker : transaction.pending;
56
+ const origin = enrollment.origin;
50
57
  if (!flags.yes) {
51
58
  const answer = await deps.prompt(
52
- `Clear only the failed enrollment for ${origin}? Other server enrollments and accounts will not be touched. [y/N]: `,
59
+ `${transaction.kind === 'accepted' ? 'Restore the prior enrollment' : 'Clear the failed enrollment transaction'} for ${origin}? Other server enrollments and accounts will not be touched. [y/N]: `,
53
60
  );
54
61
  if (!/^y(?:es)?$/i.test(answer.trim())) {
55
62
  deps.stderr('Enrollment recovery was not confirmed. No state was changed.\n');
56
63
  return 1;
57
64
  }
58
65
  }
59
- await clearEnrollmentTransaction(origin, pending.trustIdentity);
60
- await clearBorgServerTrust(origin);
61
- deps.stdout(`Cleared the failed enrollment transaction for ${origin}; other server enrollments and accounts were left unchanged.\n`);
66
+ await withEnrollmentOriginLock(origin, async () => {
67
+ if (transaction.kind === 'accepted') {
68
+ if (!await restoreBorgServerEnrollment(transaction.marker)) {
69
+ throw new InvitationArtifactRecoveryError(RECOVERY_TRANSACTION_CHANGED_ERROR);
70
+ }
71
+ return;
72
+ }
73
+ if (!await clearEnrollmentTransaction(transaction.pending)) {
74
+ throw new InvitationArtifactRecoveryError(RECOVERY_TRANSACTION_CHANGED_ERROR);
75
+ }
76
+ await clearStagedBorgServerTrust(
77
+ origin,
78
+ transaction.pending.artifactBinding?.stagedGenerationId,
79
+ );
80
+ });
81
+ if (transaction.kind === 'accepted') {
82
+ deps.stdout(`Restored the prior enrollment state for ${origin}; other server enrollments and accounts were left unchanged.\n`);
83
+ } else {
84
+ deps.stdout(`Cleared the failed enrollment transaction for ${origin}; other server enrollments and accounts were left unchanged.\n`);
85
+ }
62
86
  return 0;
63
87
  }
@@ -45,6 +45,7 @@ import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js'
45
45
  import type { WorkingRepo } from './working-repo.js';
46
46
  import { buildRuntimeMetadataPatch } from './runtime-metadata.js';
47
47
  import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
48
+ import { withEnrollmentOriginLock } from './enrollment-lock.js';
48
49
  import {
49
50
  BorgServerError,
50
51
  BorgServerHttpError,
@@ -54,6 +55,7 @@ import {
54
55
  CubeDeletionConfirmationError,
55
56
  LocalManageCredentialUnavailableError,
56
57
  LocalManageRequiredError,
58
+ LocalUnsupportedError,
57
59
  } from './server-errors.js';
58
60
  import { getActiveCube, type ActiveCube } from './cubes.js';
59
61
  import { markSeatRejected } from './seats.js';
@@ -190,7 +192,7 @@ async function localAuthorityContext(
190
192
  }
191
193
 
192
194
  function localUnsupported(capability: string): never {
193
- throw new Error(`Local Borg server does not support ${capability}`);
195
+ throw new LocalUnsupportedError(capability);
194
196
  }
195
197
 
196
198
  function waitForLocalRequest<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
@@ -633,7 +635,13 @@ async function authedFetch(
633
635
  let requestFetch: ServerFetch;
634
636
  let token: string;
635
637
  {
636
- const trust = await loadBorgServerTrust(baseUrl);
638
+ const pair = droneSession === undefined && authToken === undefined
639
+ ? await withEnrollmentOriginLock(baseUrl, async () => ({
640
+ trust: await loadBorgServerTrust(baseUrl),
641
+ stored: await getServerCredential(baseUrl, serverTrustIdentity),
642
+ }))
643
+ : { trust: await loadBorgServerTrust(baseUrl), stored: null };
644
+ const trust = pair.trust;
637
645
  if (trust.identity !== serverTrustIdentity) {
638
646
  // CR5: a TYPED terminal trust verdict — never inferred from error text.
639
647
  throw new BorgServerTrustError('Borg server trust identity changed; refusing the connection');
@@ -646,7 +654,7 @@ async function authedFetch(
646
654
  } else if (authToken !== undefined) {
647
655
  token = authToken;
648
656
  } else {
649
- const stored = await getServerCredential(baseUrl, serverTrustIdentity);
657
+ const stored = pair.stored;
650
658
  if (!stored) {
651
659
  throw new Error('No credential is stored for the selected Borg server identity');
652
660
  }
@@ -1012,11 +1020,28 @@ export async function removeDecision(
1012
1020
  selector: { topic: string } | { decision_id: string },
1013
1021
  serverTrustIdentity?: string,
1014
1022
  ): Promise<{ decision: any }> {
1015
- void sessionToken;
1016
- void apiUrl;
1017
- void selector;
1018
- void serverTrustIdentity;
1019
- localUnsupported('decision removal');
1023
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
1024
+ let payload: { decision: any } | null;
1025
+ try {
1026
+ payload = await localManageRequest<{ decision: any }>(
1027
+ local,
1028
+ `/api/cubes/${local.cubeId}/decisions`,
1029
+ 'DELETE',
1030
+ {
1031
+ operation: `remove a decision from cube ${manageCopyValue(local.name)}`,
1032
+ cubeName: local.name,
1033
+ noMutation: 'No decision was removed.',
1034
+ },
1035
+ selector,
1036
+ );
1037
+ } catch (error) {
1038
+ if (error instanceof BorgServerHttpError && error.status === 404) {
1039
+ localUnsupported('decision removal');
1040
+ }
1041
+ throw error;
1042
+ }
1043
+ if (!payload) throw new Error('Local Borg server returned an empty decision removal response');
1044
+ return payload;
1020
1045
  }
1021
1046
 
1022
1047
  /**
@@ -1268,8 +1293,9 @@ export async function createCube(
1268
1293
  }
1269
1294
 
1270
1295
  /**
1271
- * Update a cube's name and/or cube_directive. Both fields are optional;
1272
- * pass only what changes.
1296
+ * Update a cube's directive and/or message taxonomy. Rename is not supported
1297
+ * by the local server API and remains an explicit typed failure for callers
1298
+ * that bypass the public tool schema.
1273
1299
  */
1274
1300
  export async function updateCube(
1275
1301
  cubeId: string,
@@ -78,6 +78,13 @@ export class LocalManageCredentialUnavailableError extends Error {
78
78
  }
79
79
  }
80
80
 
81
+ export class LocalUnsupportedError extends Error {
82
+ constructor(public readonly capability: string) {
83
+ super(`Local Borg server does not support ${capability}`);
84
+ this.name = 'LocalUnsupportedError';
85
+ }
86
+ }
87
+
81
88
  /**
82
89
  * CR5: a STABLE TYPED terminal trust verdict — the pinned server identity no longer
83
90
  * matches. This is a security boundary: it must be classified from the error TYPE,