borgmcp 2.8.0 → 2.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.
Files changed (118) hide show
  1. package/dist/assimilate-cmd.d.ts +14 -0
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +187 -14
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/assimilate-deps.d.ts.map +1 -1
  6. package/dist/assimilate-deps.js +29 -1
  7. package/dist/assimilate-deps.js.map +1 -1
  8. package/dist/assimilate-welcome.d.ts.map +1 -1
  9. package/dist/assimilate-welcome.js +1 -0
  10. package/dist/assimilate-welcome.js.map +1 -1
  11. package/dist/claude.d.ts.map +1 -1
  12. package/dist/claude.js +27 -0
  13. package/dist/claude.js.map +1 -1
  14. package/dist/cli-help.d.ts +1 -0
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +13 -3
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/config.d.ts +12 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +170 -11
  21. package/dist/config.js.map +1 -1
  22. package/dist/first-run-server.d.ts +10 -2
  23. package/dist/first-run-server.d.ts.map +1 -1
  24. package/dist/first-run-server.js +54 -7
  25. package/dist/first-run-server.js.map +1 -1
  26. package/dist/invitation-artifact.d.ts +33 -0
  27. package/dist/invitation-artifact.d.ts.map +1 -0
  28. package/dist/invitation-artifact.js +89 -0
  29. package/dist/invitation-artifact.js.map +1 -0
  30. package/dist/launch-gate.d.ts +5 -4
  31. package/dist/launch-gate.d.ts.map +1 -1
  32. package/dist/launch-gate.js +7 -8
  33. package/dist/launch-gate.js.map +1 -1
  34. package/dist/parse-assimilate-args.d.ts.map +1 -1
  35. package/dist/parse-assimilate-args.js +9 -3
  36. package/dist/parse-assimilate-args.js.map +1 -1
  37. package/dist/recover-enrollment-cmd.d.ts +17 -0
  38. package/dist/recover-enrollment-cmd.d.ts.map +1 -0
  39. package/dist/recover-enrollment-cmd.js +62 -0
  40. package/dist/recover-enrollment-cmd.js.map +1 -0
  41. package/dist/regen-format.d.ts +9 -0
  42. package/dist/regen-format.d.ts.map +1 -1
  43. package/dist/regen-format.js +15 -0
  44. package/dist/regen-format.js.map +1 -1
  45. package/dist/regen.js +27 -3
  46. package/dist/regen.js.map +1 -1
  47. package/dist/remote-client.d.ts +1 -1
  48. package/dist/remote-client.d.ts.map +1 -1
  49. package/dist/remote-client.js +1 -1
  50. package/dist/remote-client.js.map +1 -1
  51. package/dist/runtime-metadata.d.ts +1 -1
  52. package/dist/runtime-metadata.d.ts.map +1 -1
  53. package/dist/runtime-metadata.js +3 -3
  54. package/dist/runtime-metadata.js.map +1 -1
  55. package/dist/seat-store.d.ts.map +1 -1
  56. package/dist/seat-store.js +7 -0
  57. package/dist/seat-store.js.map +1 -1
  58. package/dist/server-errors.d.ts +1 -1
  59. package/dist/server-errors.d.ts.map +1 -1
  60. package/dist/server-errors.js.map +1 -1
  61. package/dist/server-facade.d.ts +1 -1
  62. package/dist/server-facade.d.ts.map +1 -1
  63. package/dist/server-facade.js +2 -2
  64. package/dist/server-facade.js.map +1 -1
  65. package/dist/server-handshake.d.ts +23 -3
  66. package/dist/server-handshake.d.ts.map +1 -1
  67. package/dist/server-handshake.js +102 -7
  68. package/dist/server-handshake.js.map +1 -1
  69. package/dist/server-trust.d.ts +8 -0
  70. package/dist/server-trust.d.ts.map +1 -1
  71. package/dist/server-trust.js +165 -5
  72. package/dist/server-trust.js.map +1 -1
  73. package/dist/setup.js +5 -2
  74. package/dist/setup.js.map +1 -1
  75. package/dist/token-store.d.ts +3 -0
  76. package/dist/token-store.d.ts.map +1 -1
  77. package/dist/token-store.js +2 -0
  78. package/dist/token-store.js.map +1 -1
  79. package/dist/tool-manifest.d.ts.map +1 -1
  80. package/dist/tool-manifest.js +5 -3
  81. package/dist/tool-manifest.js.map +1 -1
  82. package/dist/unknown-subcommand.d.ts +1 -1
  83. package/dist/unknown-subcommand.d.ts.map +1 -1
  84. package/dist/unknown-subcommand.js +1 -0
  85. package/dist/unknown-subcommand.js.map +1 -1
  86. package/dist/update-cmd.d.ts +1 -0
  87. package/dist/update-cmd.d.ts.map +1 -1
  88. package/dist/update-cmd.js +8 -3
  89. package/dist/update-cmd.js.map +1 -1
  90. package/docs/EXTRACTION_PROVENANCE.md +11 -7
  91. package/docs/LOCAL_SERVER.md +12 -3
  92. package/docs/RELEASING.md +17 -6
  93. package/package.json +2 -2
  94. package/src/assimilate-cmd.ts +234 -20
  95. package/src/assimilate-deps.ts +29 -0
  96. package/src/assimilate-welcome.ts +1 -0
  97. package/src/claude.ts +22 -0
  98. package/src/cli-help.ts +16 -3
  99. package/src/config.ts +167 -8
  100. package/src/first-run-server.ts +71 -8
  101. package/src/invitation-artifact.ts +116 -0
  102. package/src/launch-gate.ts +7 -8
  103. package/src/parse-assimilate-args.ts +11 -4
  104. package/src/recover-enrollment-cmd.ts +63 -0
  105. package/src/regen-format.ts +25 -0
  106. package/src/regen.ts +30 -2
  107. package/src/remote-client.ts +2 -2
  108. package/src/runtime-metadata.ts +4 -3
  109. package/src/seat-store.ts +6 -0
  110. package/src/server-errors.ts +1 -0
  111. package/src/server-facade.ts +2 -2
  112. package/src/server-handshake.ts +140 -4
  113. package/src/server-trust.ts +180 -5
  114. package/src/setup.ts +5 -2
  115. package/src/token-store.ts +5 -0
  116. package/src/tool-manifest.ts +5 -3
  117. package/src/unknown-subcommand.ts +1 -0
  118. package/src/update-cmd.ts +8 -3
@@ -19,6 +19,7 @@ import {
19
19
  import { shellEscape } from './shell-escape.js';
20
20
  import { resolveInboxMonitorPath } from './self-path.js';
21
21
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
22
+ import { isBorgSession } from './launch-gate.js';
22
23
 
23
24
  /**
24
25
  * Extract the SessionStart `source` from a Claude Code hook payload (gh#926).
@@ -46,6 +47,30 @@ export function parseHookSource(raw: string): string | null {
46
47
  }
47
48
  }
48
49
 
50
+ /** Best-effort relay directive for a plain Claude/Codex session. */
51
+ export function formatPlainSessionReminder(): string {
52
+ return [
53
+ 'Relay exactly this one line to the user once, without paraphrasing:',
54
+ 'This repository is connected to a Borg cube, but this session was not launched with `borg`; cube coordination is inactive for this session. Relaunch with `borg` to use cube coordination.',
55
+ ].join('\n');
56
+ }
57
+
58
+ export function shouldRelayPlainSessionReminder(args: {
59
+ source: string | null;
60
+ agentKind: AgentKind;
61
+ hasActiveCube: boolean;
62
+ borgSession: string | undefined;
63
+ disabled: string | undefined;
64
+ }): boolean {
65
+ return (
66
+ args.source === 'startup' &&
67
+ args.agentKind !== 'opencode' &&
68
+ args.hasActiveCube &&
69
+ !isBorgSession({ BORG_SESSION: args.borgSession }) &&
70
+ args.disabled === undefined
71
+ );
72
+ }
73
+
49
74
  /** The agent runtime a session runs under — drives the wake-path branch. */
50
75
  export type AgentKind = 'claude' | 'codex' | 'opencode';
51
76
 
package/src/regen.ts CHANGED
@@ -22,12 +22,18 @@ import { monitorStateRootForWorktree } from './inbox-monitor.js';
22
22
  import {
23
23
  parseHookSource,
24
24
  formatLeanOrientation,
25
+ formatPlainSessionReminder,
26
+ shouldRelayPlainSessionReminder,
25
27
  type AgentKind,
26
28
  } from './regen-format.js';
27
29
  import { resolveSessionAgentKind } from './codex-app-wake.js';
28
30
  import { resolveReportableSessionAgentKind } from './agent-runtime.js';
29
31
  import { handleVersionFlag } from './version.js';
30
- import { gateAllowsActivation } from './launch-gate.js';
32
+ import {
33
+ BORG_LAUNCH_REMINDER_DISABLED_ENV,
34
+ BORG_SESSION_ENV,
35
+ gateAllowsActivation,
36
+ } from './launch-gate.js';
31
37
  import { resolveWorkingRepo } from './working-repo.js';
32
38
  import {
33
39
  confirmDisplayIdentity,
@@ -56,18 +62,40 @@ async function readStdin(): Promise<string> {
56
62
 
57
63
  async function main(): Promise<void> {
58
64
  handleVersionFlag();
65
+ const hookSource = parseHookSource(await readStdin());
59
66
  // gh#673 P1 (WI-4): the SessionStart orientation only activates in
60
67
  // borg-launched sessions — a vanilla `claude` anywhere (including an
61
68
  // assimilated repo) stays vanilla. Exit-0 no-op: a hook must never
62
69
  // block session start. ACTIVATION-only, never a security gate.
63
70
  if (!gateAllowsActivation('borg-regen SessionStart hook')) {
71
+ // launch-reminder-ux: plain sessions get one model-mediated relay only on
72
+ // the initial SessionStart event. No repo connection means no output.
73
+ let hasActiveCube = false;
74
+ try {
75
+ hasActiveCube = (await getActiveCube()) !== null;
76
+ } catch {
77
+ hasActiveCube = false;
78
+ }
79
+ if (shouldRelayPlainSessionReminder({
80
+ source: hookSource,
81
+ agentKind: resolveSessionAgentKind(),
82
+ hasActiveCube,
83
+ borgSession: process.env[BORG_SESSION_ENV],
84
+ disabled: process.env[BORG_LAUNCH_REMINDER_DISABLED_ENV],
85
+ })) {
86
+ try {
87
+ process.stdout.write(`${formatPlainSessionReminder()}\n`);
88
+ } catch {
89
+ // Plain-session discovery is best-effort and must stay silent on failure.
90
+ }
91
+ }
64
92
  return;
65
93
  }
66
94
  // gh#926/gh#927: the SessionStart `source` (startup/resume/clear/compact)
67
95
  // tags the orientation; on `/clear` Claude Code has cleared the
68
96
  // session-scoped `/loop` + `ScheduleWakeup`, and the kickoff prompt is
69
97
  // gone, so the lean orientation adds a "re-establish your wake path" note.
70
- const source = parseHookSource(await readStdin());
98
+ const source = hookSource;
71
99
 
72
100
  const active = await getActiveCube();
73
101
  if (!active) {
@@ -1041,7 +1041,7 @@ export async function regen(
1041
1041
  opts: {
1042
1042
  since?: string;
1043
1043
  /** Advisory self-report from the running agent; never model-routing config. */
1044
- reportedModel?: string;
1044
+ reportedModel?: string | null;
1045
1045
  /** Positively identified running Agent CLI; null means explicitly unknown. */
1046
1046
  agentKind?: AgentKind | null;
1047
1047
  /** Current cwd-derived identity; refreshed each regen to avoid stale routing data. */
@@ -1077,7 +1077,7 @@ export async function regen(
1077
1077
  ) {
1078
1078
  const patch = buildRuntimeMetadataPatch({
1079
1079
  agentKind: opts.agentKind ?? null,
1080
- reportedModel: opts.reportedModel,
1080
+ reportedModel: opts.reportedModel ?? null,
1081
1081
  workingRepo: opts.workingRepo,
1082
1082
  });
1083
1083
  try {
@@ -53,13 +53,14 @@ export function buildRuntimeMetadataReport(input: {
53
53
 
54
54
  export function buildRuntimeMetadataPatch(input: {
55
55
  agentKind: AgentKind | null;
56
- reportedModel?: string;
56
+ reportedModel?: string | null;
57
57
  workingRepo?: WorkingRepo;
58
58
  }): DroneRuntimeMetadataPatch {
59
59
  const patch: DroneRuntimeMetadataPatch = { agent_kind: input.agentKind };
60
60
  if (input.reportedModel !== undefined) {
61
- const model = safeReportedModel(input.reportedModel);
62
- if (model !== null) patch.reported_model = model;
61
+ patch.reported_model = input.reportedModel === null
62
+ ? null
63
+ : validateReportedModel(input.reportedModel);
63
64
  }
64
65
  const repository = reportableRepository(input.workingRepo);
65
66
  if (repository) Object.assign(patch, repository);
package/src/seat-store.ts CHANGED
@@ -238,6 +238,12 @@ export async function atomicWrite0600(
238
238
  await assertSecureFile(filePath);
239
239
  }
240
240
  await rename(tmp, filePath);
241
+ const parent = await open(dirname(filePath), 'r');
242
+ try {
243
+ await parent.sync();
244
+ } finally {
245
+ await parent.close();
246
+ }
241
247
  } catch (err) {
242
248
  await unlink(tmp).catch(() => {});
243
249
  throw err;
@@ -2,6 +2,7 @@ export type BorgServerErrorCode =
2
2
  | 'NOT_ENROLLED'
3
3
  | 'CREDENTIAL_REJECTED'
4
4
  | 'INVITATION_REJECTED'
5
+ | 'LOCAL_CREDENTIAL_EXISTS'
5
6
  | 'CREATE_CUBE_DENIED'
6
7
  | 'ATTACH_CONFLICT'
7
8
  | 'SESSION_REJECTED'
@@ -5,7 +5,7 @@ import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
5
5
  import { consolePrefix } from './console-prefix.js';
6
6
  import { getPackageVersion } from './version.js';
7
7
 
8
- export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'dashboard'] as const;
8
+ export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'cert-reissue', 'dashboard'] as const;
9
9
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
10
10
 
11
11
  export type ParsedServerFacadeArgs =
@@ -162,7 +162,7 @@ function inertCommand(command: string): string {
162
162
  export function unknownServerCommandText(command: string): string {
163
163
  return (
164
164
  `Unknown server command: ${inertCommand(command)}.\n` +
165
- `Available commands: setup, start, stop, status, update, invite, dashboard, cube init.\n` +
165
+ `Available commands: setup, start, stop, status, update, invite, cert-reissue, dashboard, cube init.\n` +
166
166
  `Next: run borg server --help.\n`
167
167
  );
168
168
  }
@@ -8,6 +8,7 @@ import {
8
8
  REPOSITORY_CUBE_RESOLVE_PATH,
9
9
  createAttachRequestEnvelope,
10
10
  createProtocolEnvelope,
11
+ encodeInvitationArtifact,
11
12
  decodeAssociateRepositoryCubeRequest,
12
13
  decodeAssociateRepositoryCubeResponseEnvelope,
13
14
  decodeAttachResponseEnvelope,
@@ -17,6 +18,7 @@ import {
17
18
  decodeEnrollmentExchangeResponseEnvelope,
18
19
  decodeProtocolErrorEnvelope,
19
20
  decodeProtocolTagPreflight,
21
+ type InvitationArtifact,
20
22
  decodeResolveRepositoryCubeRequest,
21
23
  decodeResolveRepositoryCubeResponseEnvelope,
22
24
  ErrorCode,
@@ -36,6 +38,7 @@ import {
36
38
  clearPendingServerEnrollment,
37
39
  getServerCredential,
38
40
  getServerCredentialRecord,
41
+ hasServerCredentialForOrigin,
39
42
  getPendingServerEnrollment,
40
43
  getOrCreatePendingServerCubeCreation,
41
44
  getOrCreatePendingServerEnrollment,
@@ -65,8 +68,17 @@ import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
65
68
  import { readBoundedResponseBody } from './server-response.js';
66
69
  import {
67
70
  loadBorgServerTrust,
71
+ loadBorgServerTrustFromPresentedChain,
68
72
  type BorgServerTrust,
73
+ type StagedBorgServerTrust,
69
74
  } from './server-trust.js';
75
+ import {
76
+ InvitationArtifactCompatibilityError,
77
+ InvitationArtifactStorageError,
78
+ InvitationArtifactTransportError,
79
+ InvitationArtifactTrustError,
80
+ decodeAndVerifyInvitationArtifact,
81
+ } from './invitation-artifact.js';
70
82
 
71
83
  const HANDSHAKE_BODY_LIMIT = 64 * 1024;
72
84
  const HANDSHAKE_TIMEOUT_MS = 5_000;
@@ -452,20 +464,57 @@ export async function enrollBorgServer(
452
464
  prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
453
465
  activateEnrollment?: typeof activatePendingServerEnrollment;
454
466
  clearPendingEnrollment?: typeof clearPendingServerEnrollment;
467
+ loadCredentialRecord?: typeof getServerCredentialRecord;
468
+ hasCredentialForOrigin?: typeof hasServerCredentialForOrigin;
469
+ confirmReplacement?: () => Promise<boolean>;
455
470
  clientName?: string;
471
+ expectedAuthority?: InvitationArtifact['authority'];
472
+ commitTrust?: (activate: () => Promise<void>) => Promise<void>;
473
+ discardTrust?: () => Promise<void>;
456
474
  } = {},
457
475
  ): Promise<NewServerEnrollment> {
458
476
  const fetchImpl = deps.fetchImpl ?? fetch;
459
477
  // Credential-free tag preflight FIRST (CR fb4d6eba): after pinned TLS, an
460
478
  // incompatible server must be rejected before any credential is created or
461
479
  // persisted and before any invitation/secret-bearing request is sent.
462
- const protocol = await preflightBorgServerTag(origin, fetchImpl);
480
+ let protocol: ProtocolTagPreflight;
481
+ try {
482
+ protocol = await preflightBorgServerTag(origin, fetchImpl);
483
+ } catch (error) {
484
+ await deps.discardTrust?.();
485
+ throw error;
486
+ }
487
+
488
+ const existingCredential = await (deps.loadCredentialRecord ?? getServerCredentialRecord)(
489
+ origin,
490
+ trustIdentity,
491
+ );
492
+ const existingOriginCredential = existingCredential !== null || await (
493
+ deps.hasCredentialForOrigin ?? hasServerCredentialForOrigin
494
+ )(origin);
495
+ let replacementConfirmed = false;
496
+ let replacementCapability: string | undefined;
497
+ if (existingOriginCredential) {
498
+ const confirmed = deps.confirmReplacement !== undefined
499
+ ? await deps.confirmReplacement()
500
+ : false;
501
+ if (!confirmed) {
502
+ await deps.discardTrust?.();
503
+ throw new BorgServerError(
504
+ 'LOCAL_CREDENTIAL_EXISTS',
505
+ 'a local enrollment already exists for this server and was not replaced',
506
+ );
507
+ }
508
+ replacementConfirmed = true;
509
+ replacementCapability = randomUUID();
510
+ }
463
511
 
464
512
  const pending = await (deps.prepareEnrollment ?? getOrCreatePendingServerEnrollment)({
465
513
  origin,
466
514
  trustIdentity,
467
515
  invitation,
468
516
  ...(deps.clientName ? { clientName: deps.clientName } : {}),
517
+ ...(replacementCapability === undefined ? {} : { replacementCapability }),
469
518
  });
470
519
  const request = decodeEnrollmentExchangeRequest({
471
520
  invitation: pending.invitation,
@@ -495,9 +544,13 @@ export async function enrollBorgServer(
495
544
  clearTimeout(timeout);
496
545
  }
497
546
  }
498
- if (!response) throw lastTransportError;
547
+ if (!response) {
548
+ await deps.discardTrust?.();
549
+ throw lastTransportError;
550
+ }
499
551
 
500
552
  if (response.status === 401 || response.status === 403) {
553
+ await deps.discardTrust?.();
501
554
  await (deps.clearPendingEnrollment ?? clearPendingServerEnrollment)(
502
555
  origin,
503
556
  trustIdentity,
@@ -509,6 +562,7 @@ export async function enrollBorgServer(
509
562
  );
510
563
  }
511
564
  if (response.status !== 201) {
565
+ await deps.discardTrust?.();
512
566
  throw new Error(`Borg server enrollment failed (HTTP ${response.status})`);
513
567
  }
514
568
  let decoded: ReturnType<typeof decodeEnrollmentExchangeResponseEnvelope>;
@@ -517,18 +571,37 @@ export async function enrollBorgServer(
517
571
  JSON.parse(await readHandshakeBodyWithTimeout(response)),
518
572
  );
519
573
  } catch (error) {
574
+ await deps.discardTrust?.();
520
575
  if (error instanceof Error && error.message.includes('response limit')) throw error;
521
576
  throw new Error('Borg server returned an invalid enrollment envelope');
522
577
  }
523
578
 
524
- await (deps.activateEnrollment ?? activatePendingServerEnrollment)({
579
+ if (deps.expectedAuthority !== undefined) {
580
+ const owner = decoded.payload.server_capabilities.length > 0;
581
+ if ((deps.expectedAuthority === 'owner') !== owner) {
582
+ await (deps.clearPendingEnrollment ?? clearPendingServerEnrollment)(
583
+ origin,
584
+ trustIdentity,
585
+ pending.retryKey,
586
+ );
587
+ await deps.discardTrust?.();
588
+ throw new InvitationArtifactTrustError();
589
+ }
590
+ }
591
+
592
+ const activate = () => (deps.activateEnrollment ?? activatePendingServerEnrollment)({
525
593
  origin,
526
594
  trustIdentity,
527
595
  retryKey: pending.retryKey,
528
596
  credential: pending.credential,
529
597
  clientId: decoded.payload.client_id,
530
598
  serverCapabilities: decoded.payload.server_capabilities,
599
+ ...(replacementConfirmed || pending.replacementCapability !== undefined
600
+ ? { allowReplacement: true, ...(pending.replacementCapability === undefined ? {} : { replacementCapability: pending.replacementCapability }) }
601
+ : {}),
531
602
  });
603
+ if (deps.commitTrust) await deps.commitTrust(activate);
604
+ else await activate();
532
605
  return {
533
606
  token: pending.credential,
534
607
  trustIdentity,
@@ -1011,6 +1084,8 @@ export async function enrollLocalBorgServer(
1011
1084
  prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
1012
1085
  activateEnrollment?: typeof activatePendingServerEnrollment;
1013
1086
  clearPendingEnrollment?: typeof clearPendingServerEnrollment;
1087
+ loadCredentialRecord?: typeof getServerCredentialRecord;
1088
+ confirmReplacement?: () => Promise<boolean>;
1014
1089
  clientName?: string;
1015
1090
  } = {},
1016
1091
  ): Promise<NewServerEnrollment> {
@@ -1022,7 +1097,62 @@ export async function enrollLocalBorgServer(
1022
1097
  ...(deps.clearPendingEnrollment === undefined
1023
1098
  ? {}
1024
1099
  : { clearPendingEnrollment: deps.clearPendingEnrollment }),
1100
+ ...(deps.loadCredentialRecord === undefined
1101
+ ? {}
1102
+ : { loadCredentialRecord: deps.loadCredentialRecord }),
1103
+ ...(deps.confirmReplacement === undefined
1104
+ ? {}
1105
+ : { confirmReplacement: deps.confirmReplacement }),
1106
+ ...(deps.clientName === undefined ? {} : { clientName: deps.clientName }),
1107
+ });
1108
+ }
1109
+
1110
+ /** Enroll from a verified v2 artifact whose endpoint and CA pin are authoritative. */
1111
+ export async function enrollLocalBorgServerArtifact(
1112
+ artifact: InvitationArtifact,
1113
+ deps: {
1114
+ invitation?: string;
1115
+ confirmReplacement?: () => Promise<boolean>;
1116
+ clientName?: string;
1117
+ loadTrustFromPresentedChain?: typeof loadBorgServerTrustFromPresentedChain;
1118
+ prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
1119
+ activateEnrollment?: typeof activatePendingServerEnrollment;
1120
+ clearPendingEnrollment?: typeof clearPendingServerEnrollment;
1121
+ loadCredentialRecord?: typeof getServerCredentialRecord;
1122
+ hasCredentialForOrigin?: typeof hasServerCredentialForOrigin;
1123
+ } = {},
1124
+ ): Promise<NewServerEnrollment> {
1125
+ // Preserve the byte-exact opaque artifact from the operator's prompt. The
1126
+ // decoded object is enough to inspect the endpoint/pin, but it is not the
1127
+ // enrollment protocol's invitation material: replacing it with
1128
+ // `verifiedArtifact.secret` silently degrades v2 composite artifacts.
1129
+ const opaqueArtifact = deps.invitation ?? encodeInvitationArtifact(artifact);
1130
+ const verifiedArtifact = decodeAndVerifyInvitationArtifact(opaqueArtifact);
1131
+ let trust: StagedBorgServerTrust;
1132
+ try {
1133
+ trust = await (deps.loadTrustFromPresentedChain ?? loadBorgServerTrustFromPresentedChain)(
1134
+ verifiedArtifact.endpoint,
1135
+ verifiedArtifact.ca_spki_sha256,
1136
+ );
1137
+ } catch (error) {
1138
+ if (error instanceof InvitationArtifactCompatibilityError) throw error;
1139
+ if (error instanceof InvitationArtifactTransportError) throw error;
1140
+ if (error instanceof InvitationArtifactTrustError) throw error;
1141
+ if (error instanceof InvitationArtifactStorageError) throw error;
1142
+ throw new InvitationArtifactTransportError();
1143
+ }
1144
+ return enrollBorgServer(verifiedArtifact.endpoint, trust.identity, opaqueArtifact, {
1145
+ fetchImpl: trust.fetchImpl,
1146
+ ...(deps.confirmReplacement === undefined ? {} : { confirmReplacement: deps.confirmReplacement }),
1025
1147
  ...(deps.clientName === undefined ? {} : { clientName: deps.clientName }),
1148
+ ...(deps.prepareEnrollment === undefined ? {} : { prepareEnrollment: deps.prepareEnrollment }),
1149
+ ...(deps.activateEnrollment === undefined ? {} : { activateEnrollment: deps.activateEnrollment }),
1150
+ ...(deps.clearPendingEnrollment === undefined ? {} : { clearPendingEnrollment: deps.clearPendingEnrollment }),
1151
+ ...(deps.loadCredentialRecord === undefined ? {} : { loadCredentialRecord: deps.loadCredentialRecord }),
1152
+ ...(deps.hasCredentialForOrigin === undefined ? {} : { hasCredentialForOrigin: deps.hasCredentialForOrigin }),
1153
+ expectedAuthority: verifiedArtifact.authority,
1154
+ commitTrust: trust.commitTrust,
1155
+ discardTrust: trust.discardTrust,
1026
1156
  });
1027
1157
  }
1028
1158
 
@@ -1035,7 +1165,13 @@ export async function resumeLocalBorgServerEnrollment(
1035
1165
  onPending?: () => void;
1036
1166
  } = {},
1037
1167
  ): Promise<NewServerEnrollment | null> {
1038
- const trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
1168
+ let trust: BorgServerTrust;
1169
+ try {
1170
+ trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
1171
+ } catch (error) {
1172
+ if (error instanceof Error && /trust files were not found|trust metadata/i.test(error.message)) return null;
1173
+ throw error;
1174
+ }
1039
1175
  return resumeBorgServerEnrollment(origin, trust.identity, {
1040
1176
  fetchImpl: trust.fetchImpl,
1041
1177
  ...(deps.loadPendingEnrollment === undefined
@@ -1,12 +1,20 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash, timingSafeEqual, X509Certificate } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
- import { open } from 'node:fs/promises';
4
+ import { access, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
5
5
  import { request as httpsRequest } from 'node:https';
6
+ import { connect as tlsConnect } from 'node:tls';
6
7
  import { homedir } from 'node:os';
7
8
  import { join, resolve } from 'node:path';
8
9
  import { Readable } from 'node:stream';
9
10
  import { BorgServerTrustError } from './server-errors.js';
11
+ import {
12
+ InvitationArtifactCompatibilityError,
13
+ InvitationArtifactRecoveryError,
14
+ InvitationArtifactStorageError,
15
+ InvitationArtifactTransportError,
16
+ InvitationArtifactTrustError,
17
+ } from './invitation-artifact.js';
10
18
 
11
19
  // CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
12
20
  // CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
@@ -48,6 +56,11 @@ export interface BorgServerTrust {
48
56
  fetchImpl: ServerFetch;
49
57
  }
50
58
 
59
+ export interface StagedBorgServerTrust extends BorgServerTrust {
60
+ commitTrust: (activate: () => Promise<void>) => Promise<void>;
61
+ discardTrust: () => Promise<void>;
62
+ }
63
+
51
64
  interface ServerTrustConfig {
52
65
  ca_spki_sha256: string;
53
66
  }
@@ -58,6 +71,20 @@ function serverDataDirectory(): string {
58
71
  return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
59
72
  }
60
73
 
74
+ function remoteTrustDirectory(origin: string): string {
75
+ const key = createHash('sha256').update(origin).digest('hex');
76
+ return join(homedir(), '.borg', 'server-trust', key);
77
+ }
78
+
79
+ async function trustFilesExist(directory: string): Promise<boolean> {
80
+ try {
81
+ await Promise.all([access(join(directory, 'ca.crt')), access(join(directory, 'server.json'))]);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
61
88
  async function readTrustFile(path: string): Promise<string> {
62
89
  let handle;
63
90
  try {
@@ -82,6 +109,15 @@ async function readTrustFile(path: string): Promise<string> {
82
109
  }
83
110
  }
84
111
 
112
+ async function syncPath(path: string): Promise<void> {
113
+ const handle = await open(path, 'r');
114
+ try {
115
+ await handle.sync();
116
+ } finally {
117
+ await handle.close();
118
+ }
119
+ }
120
+
85
121
  function decodeTrustConfig(value: string): ServerTrustConfig {
86
122
  let parsed: unknown;
87
123
  try {
@@ -228,15 +264,22 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
228
264
 
229
265
  export async function loadBorgServerTrust(
230
266
  origin: string,
231
- dataDirectory = serverDataDirectory(),
267
+ dataDirectory?: string,
232
268
  ): Promise<BorgServerTrust> {
233
- const key = `${dataDirectory}\0${origin}`;
269
+ const requestedDirectory = dataDirectory ?? serverDataDirectory();
270
+ const key = `${requestedDirectory}\0${origin}`;
234
271
  let pending = trustCache.get(key);
235
272
  if (!pending) {
236
273
  pending = (async () => {
274
+ const remoteDirectory = remoteTrustDirectory(origin);
275
+ const directory = dataDirectory === undefined && await trustFilesExist(remoteDirectory)
276
+ ? remoteDirectory
277
+ : dataDirectory === undefined && !(await trustFilesExist(requestedDirectory))
278
+ ? remoteDirectory
279
+ : requestedDirectory;
237
280
  const [certificate, configText] = await Promise.all([
238
- readTrustFile(join(dataDirectory, 'ca.crt')),
239
- readTrustFile(join(dataDirectory, 'server.json')),
281
+ readTrustFile(join(directory, 'ca.crt')),
282
+ readTrustFile(join(directory, 'server.json')),
240
283
  ]);
241
284
  const config = decodeTrustConfig(configText);
242
285
  const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
@@ -251,6 +294,138 @@ export async function loadBorgServerTrust(
251
294
  return pending;
252
295
  }
253
296
 
297
+ function pemCertificate(raw: Buffer): string {
298
+ const body = raw.toString('base64').match(/.{1,64}/g)?.join('\n') ?? '';
299
+ return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
300
+ }
301
+
302
+ function chainCertificates(socket: { getPeerCertificate(detailed: true): any }): Buffer[] {
303
+ const result: Buffer[] = [];
304
+ const seen = new Set<string>();
305
+ let current = socket.getPeerCertificate(true);
306
+ while (current?.raw instanceof Buffer) {
307
+ const key = current.raw.toString('base64');
308
+ if (seen.has(key)) break;
309
+ seen.add(key);
310
+ result.push(current.raw);
311
+ current = current.issuerCertificate;
312
+ }
313
+ return result;
314
+ }
315
+
316
+ async function fetchPresentedChain(origin: string): Promise<Buffer[]> {
317
+ const url = new URL(origin);
318
+ return new Promise((resolveChain, rejectChain) => {
319
+ const socket = tlsConnect({
320
+ host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
321
+ port: url.port === '' ? 443 : Number(url.port),
322
+ rejectUnauthorized: false,
323
+ servername: url.hostname.replace(/^\[(.*)\]$/, '$1'),
324
+ });
325
+ const timeout = setTimeout(() => {
326
+ socket.destroy();
327
+ rejectChain(new InvitationArtifactTransportError());
328
+ }, 5_000);
329
+ socket.once('secureConnect', () => {
330
+ clearTimeout(timeout);
331
+ const chain = chainCertificates(socket);
332
+ socket.destroy();
333
+ resolveChain(chain);
334
+ });
335
+ socket.once('error', () => {
336
+ clearTimeout(timeout);
337
+ rejectChain(new InvitationArtifactTransportError());
338
+ });
339
+ });
340
+ }
341
+
342
+ /** Bootstrap a remote pinned transport from the CA chain presented by the server. */
343
+ export async function loadBorgServerTrustFromPresentedChain(
344
+ origin: string,
345
+ caSpkiSha256: string,
346
+ ): Promise<StagedBorgServerTrust> {
347
+ const chain = await fetchPresentedChain(origin);
348
+ let sawCa = false;
349
+ for (const raw of chain) {
350
+ try {
351
+ const certificate = new X509Certificate(raw);
352
+ if (!certificate.ca) continue;
353
+ sawCa = true;
354
+ const actual = createHash('sha256')
355
+ .update(certificate.publicKey.export({ type: 'spki', format: 'der' }))
356
+ .digest('hex');
357
+ if (actual !== caSpkiSha256) continue;
358
+ return stageBorgServerTrust(origin, pemCertificate(raw), `spki-sha256:${actual}`);
359
+ } catch {
360
+ // Ignore malformed chain members; the typed compatibility failure below is safer.
361
+ }
362
+ }
363
+ if (!sawCa) throw new InvitationArtifactCompatibilityError();
364
+ throw new InvitationArtifactTrustError();
365
+ }
366
+
367
+ export async function stageBorgServerTrust(
368
+ origin: string,
369
+ certificate: string,
370
+ identity: string,
371
+ ): Promise<StagedBorgServerTrust> {
372
+ const directory = remoteTrustDirectory(origin);
373
+ const parent = resolve(directory, '..');
374
+ await mkdir(parent, { recursive: true, mode: 0o700 });
375
+ const suffix = `${process.pid}-${Date.now()}`;
376
+ const temporaryDirectory = `${directory}.tmp-${suffix}`;
377
+ const temporaryCertificate = join(temporaryDirectory, 'ca.crt');
378
+ const temporaryConfig = join(temporaryDirectory, 'server.json');
379
+ try {
380
+ await mkdir(temporaryDirectory, { recursive: false, mode: 0o700 });
381
+ await Promise.all([
382
+ writeFile(temporaryCertificate, certificate, { mode: 0o600, flag: 'wx' }),
383
+ writeFile(temporaryConfig, JSON.stringify({ ca_spki_sha256: identity.replace(/^spki-sha256:/, '') }), { mode: 0o600, flag: 'wx' }),
384
+ ]);
385
+ await Promise.all([syncPath(temporaryCertificate), syncPath(temporaryConfig), syncPath(temporaryDirectory)]);
386
+ } catch {
387
+ await rm(temporaryDirectory, { recursive: true, force: true });
388
+ throw new InvitationArtifactStorageError();
389
+ }
390
+ let committed = false;
391
+ return {
392
+ identity,
393
+ fetchImpl: createPinnedServerFetch(origin, certificate),
394
+ commitTrust: async (activate) => {
395
+ if (committed) return;
396
+ const backupDirectory = `${directory}.backup-${suffix}`;
397
+ let backedUp = false;
398
+ try {
399
+ await rename(directory, backupDirectory).then(() => { backedUp = true; }).catch((error: unknown) => {
400
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
401
+ });
402
+ await rename(temporaryDirectory, directory);
403
+ await syncPath(resolve(directory, '..'));
404
+ await activate();
405
+ committed = true;
406
+ await rm(backupDirectory, { recursive: true, force: true }).catch(() => undefined);
407
+ } catch (error) {
408
+ await rm(directory, { recursive: true, force: true });
409
+ await rm(temporaryDirectory, { recursive: true, force: true });
410
+ if (backedUp) {
411
+ await rename(backupDirectory, directory);
412
+ await syncPath(resolve(directory, '..'));
413
+ }
414
+ throw new InvitationArtifactRecoveryError();
415
+ }
416
+ },
417
+ discardTrust: async () => {
418
+ if (committed) return;
419
+ await rm(temporaryDirectory, { recursive: true, force: true });
420
+ },
421
+ };
422
+ }
423
+
254
424
  export function __clearServerTrustCacheForTest(): void {
255
425
  trustCache.clear();
256
426
  }
427
+
428
+ export async function clearBorgServerTrust(origin: string): Promise<void> {
429
+ await rm(remoteTrustDirectory(origin), { recursive: true, force: true });
430
+ trustCache.delete(`${serverDataDirectory()}\0${origin}`);
431
+ }