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.
- package/dist/assimilate-cmd.d.ts +14 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +187 -14
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +29 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/assimilate-welcome.d.ts.map +1 -1
- package/dist/assimilate-welcome.js +1 -0
- package/dist/assimilate-welcome.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +27 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +13 -3
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +170 -11
- package/dist/config.js.map +1 -1
- package/dist/first-run-server.d.ts +10 -2
- package/dist/first-run-server.d.ts.map +1 -1
- package/dist/first-run-server.js +54 -7
- package/dist/first-run-server.js.map +1 -1
- package/dist/invitation-artifact.d.ts +33 -0
- package/dist/invitation-artifact.d.ts.map +1 -0
- package/dist/invitation-artifact.js +89 -0
- package/dist/invitation-artifact.js.map +1 -0
- package/dist/launch-gate.d.ts +5 -4
- package/dist/launch-gate.d.ts.map +1 -1
- package/dist/launch-gate.js +7 -8
- package/dist/launch-gate.js.map +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -1
- package/dist/parse-assimilate-args.js +9 -3
- package/dist/parse-assimilate-args.js.map +1 -1
- package/dist/recover-enrollment-cmd.d.ts +17 -0
- package/dist/recover-enrollment-cmd.d.ts.map +1 -0
- package/dist/recover-enrollment-cmd.js +62 -0
- package/dist/recover-enrollment-cmd.js.map +1 -0
- package/dist/regen-format.d.ts +9 -0
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +15 -0
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +27 -3
- package/dist/regen.js.map +1 -1
- package/dist/remote-client.d.ts +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +1 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/runtime-metadata.d.ts +1 -1
- package/dist/runtime-metadata.d.ts.map +1 -1
- package/dist/runtime-metadata.js +3 -3
- package/dist/runtime-metadata.js.map +1 -1
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +7 -0
- package/dist/seat-store.js.map +1 -1
- package/dist/server-errors.d.ts +1 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +1 -1
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +2 -2
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +23 -3
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +102 -7
- package/dist/server-handshake.js.map +1 -1
- package/dist/server-trust.d.ts +8 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +165 -5
- package/dist/server-trust.js.map +1 -1
- package/dist/setup.js +5 -2
- package/dist/setup.js.map +1 -1
- package/dist/token-store.d.ts +3 -0
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +2 -0
- package/dist/token-store.js.map +1 -1
- package/dist/tool-manifest.d.ts.map +1 -1
- package/dist/tool-manifest.js +5 -3
- package/dist/tool-manifest.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/dist/update-cmd.d.ts +1 -0
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +8 -3
- package/dist/update-cmd.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +11 -7
- package/docs/LOCAL_SERVER.md +12 -3
- package/docs/RELEASING.md +17 -6
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +234 -20
- package/src/assimilate-deps.ts +29 -0
- package/src/assimilate-welcome.ts +1 -0
- package/src/claude.ts +22 -0
- package/src/cli-help.ts +16 -3
- package/src/config.ts +167 -8
- package/src/first-run-server.ts +71 -8
- package/src/invitation-artifact.ts +116 -0
- package/src/launch-gate.ts +7 -8
- package/src/parse-assimilate-args.ts +11 -4
- package/src/recover-enrollment-cmd.ts +63 -0
- package/src/regen-format.ts +25 -0
- package/src/regen.ts +30 -2
- package/src/remote-client.ts +2 -2
- package/src/runtime-metadata.ts +4 -3
- package/src/seat-store.ts +6 -0
- package/src/server-errors.ts +1 -0
- package/src/server-facade.ts +2 -2
- package/src/server-handshake.ts +140 -4
- package/src/server-trust.ts +180 -5
- package/src/setup.ts +5 -2
- package/src/token-store.ts +5 -0
- package/src/tool-manifest.ts +5 -3
- package/src/unknown-subcommand.ts +1 -0
- package/src/update-cmd.ts +8 -3
package/src/regen-format.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
98
|
+
const source = hookSource;
|
|
71
99
|
|
|
72
100
|
const active = await getActiveCube();
|
|
73
101
|
if (!active) {
|
package/src/remote-client.ts
CHANGED
|
@@ -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 {
|
package/src/runtime-metadata.ts
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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;
|
package/src/server-errors.ts
CHANGED
package/src/server-facade.ts
CHANGED
|
@@ -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
|
}
|
package/src/server-handshake.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/server-trust.ts
CHANGED
|
@@ -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
|
|
267
|
+
dataDirectory?: string,
|
|
232
268
|
): Promise<BorgServerTrust> {
|
|
233
|
-
const
|
|
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(
|
|
239
|
-
readTrustFile(join(
|
|
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
|
+
}
|