borgmcp 2.0.11 → 2.1.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/README.md +11 -3
  2. package/dist/assimilate-cmd.d.ts +13 -4
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +134 -110
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts +7 -1
  7. package/dist/assimilate-deps.d.ts.map +1 -1
  8. package/dist/assimilate-deps.js +44 -18
  9. package/dist/assimilate-deps.js.map +1 -1
  10. package/dist/claude.d.ts +3 -1
  11. package/dist/claude.d.ts.map +1 -1
  12. package/dist/claude.js +26 -12
  13. package/dist/claude.js.map +1 -1
  14. package/dist/cli-help.d.ts +2 -0
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +22 -5
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/config.d.ts +7 -4
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +21 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/remote-client.js +1 -1
  23. package/dist/repository-cube-init.d.ts +57 -0
  24. package/dist/repository-cube-init.d.ts.map +1 -0
  25. package/dist/repository-cube-init.js +196 -0
  26. package/dist/repository-cube-init.js.map +1 -0
  27. package/dist/repository-identity.d.ts +29 -0
  28. package/dist/repository-identity.d.ts.map +1 -0
  29. package/dist/repository-identity.js +143 -0
  30. package/dist/repository-identity.js.map +1 -0
  31. package/dist/server-errors.d.ts +6 -0
  32. package/dist/server-errors.d.ts.map +1 -1
  33. package/dist/server-errors.js +12 -0
  34. package/dist/server-errors.js.map +1 -1
  35. package/dist/server-facade.d.ts +11 -1
  36. package/dist/server-facade.d.ts.map +1 -1
  37. package/dist/server-facade.js +33 -3
  38. package/dist/server-facade.js.map +1 -1
  39. package/dist/server-handshake.d.ts +8 -4
  40. package/dist/server-handshake.d.ts.map +1 -1
  41. package/dist/server-handshake.js +15 -6
  42. package/dist/server-handshake.js.map +1 -1
  43. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  44. package/docs/LOCAL_SERVER.md +35 -6
  45. package/docs/RELEASING.md +11 -3
  46. package/package.json +2 -2
  47. package/src/assimilate-cmd.ts +166 -140
  48. package/src/assimilate-deps.ts +59 -17
  49. package/src/claude.ts +34 -14
  50. package/src/cli-help.ts +25 -5
  51. package/src/config.ts +30 -8
  52. package/src/remote-client.ts +1 -1
  53. package/src/repository-cube-init.ts +273 -0
  54. package/src/repository-identity.ts +199 -0
  55. package/src/server-errors.ts +14 -0
  56. package/src/server-facade.ts +47 -2
  57. package/src/server-handshake.ts +33 -7
@@ -0,0 +1,199 @@
1
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { basename, isAbsolute, join } from 'node:path';
4
+ import { realpath } from 'node:fs/promises';
5
+ import type { CreateCubeRepository, CubeTemplate } from 'borgmcp-shared/protocol';
6
+ import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
7
+ import { normalizeCubeName } from './cube-name.js';
8
+ import { borgConfigRoot, ensurePrivateBorgConfigRoot } from './private-root.js';
9
+ import { atomicWrite0600, readStoreFile, withStoreLock } from './seat-store.js';
10
+
11
+ const STORE_VERSION = 1;
12
+ const SECRET_FILE = 'repository-identity.key';
13
+ const STATE_FILE = 'repository-identities.json';
14
+ const LOCK_FILE = 'repository-identities.lock';
15
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
+ const DISPLAY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
17
+
18
+ export interface GitRepositoryContext {
19
+ root: string;
20
+ commonDir: string;
21
+ derivedName: string;
22
+ publicRepository: Extract<CreateCubeRepository, { kind: 'origin' }> | null;
23
+ publicRepositoryName: string | null;
24
+ }
25
+
26
+ export interface RepositoryAssociation {
27
+ cubeId: string;
28
+ name: string;
29
+ workingRepoName: string;
30
+ template: CubeTemplate;
31
+ }
32
+
33
+ interface RepositoryIdentityState {
34
+ version: 1;
35
+ localIdentities: Record<string, string>;
36
+ associations: Record<string, RepositoryAssociation>;
37
+ }
38
+
39
+ export interface RepositoryIdentityDeps {
40
+ runGit?: (cwd: string, args: string[]) => { status: number | null; stdout?: string | null };
41
+ canonicalPath?: (path: string) => Promise<string>;
42
+ root?: string;
43
+ }
44
+
45
+ function defaultRunGit(cwd: string, args: string[]) {
46
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
47
+ return { status: result.status, stdout: result.stdout };
48
+ }
49
+
50
+ function output(result: { status: number | null; stdout?: string | null }): string | null {
51
+ const value = result.status === 0 ? result.stdout?.trim() : null;
52
+ return value || null;
53
+ }
54
+
55
+ export async function resolveGitRepositoryContext(
56
+ cwd: string,
57
+ deps: RepositoryIdentityDeps = {},
58
+ ): Promise<GitRepositoryContext | null> {
59
+ const runGit = deps.runGit ?? defaultRunGit;
60
+ const canonicalPath = deps.canonicalPath ?? realpath;
61
+ const rootRaw = output(runGit(cwd, ['rev-parse', '--show-toplevel']));
62
+ if (!rootRaw || !isAbsolute(rootRaw)) return null;
63
+ const bare = output(runGit(cwd, ['rev-parse', '--is-bare-repository']));
64
+ if (bare === 'true') throw new Error('BARE_REPOSITORY');
65
+ const commonRaw = output(runGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
66
+ if (!commonRaw || !isAbsolute(commonRaw)) return null;
67
+ const root = await canonicalPath(rootRaw);
68
+ const commonDir = await canonicalPath(commonRaw);
69
+ const derivedName = normalizeCubeName(basename(root));
70
+ if (!derivedName) return null;
71
+
72
+ const originRaw = output(runGit(root, ['config', '--get', 'remote.origin.url']));
73
+ const canonical = originRaw ? canonicalizeWorkingRepoIdentity(originRaw) : null;
74
+ return {
75
+ root,
76
+ commonDir,
77
+ derivedName,
78
+ publicRepository: canonical?.origin
79
+ ? { kind: 'origin', value: canonical.origin }
80
+ : null,
81
+ publicRepositoryName: canonical?.name ?? null,
82
+ };
83
+ }
84
+
85
+ function parseSecret(raw: string | null): Buffer | null {
86
+ if (raw === null) return null;
87
+ if (!/^[a-f0-9]{64}$/.test(raw.trim())) {
88
+ throw new Error('Borg repository identity secret is malformed');
89
+ }
90
+ return Buffer.from(raw.trim(), 'hex');
91
+ }
92
+
93
+ function emptyState(): RepositoryIdentityState {
94
+ return { version: STORE_VERSION, localIdentities: {}, associations: {} };
95
+ }
96
+
97
+ function parseState(raw: string | null): RepositoryIdentityState {
98
+ if (raw === null) return emptyState();
99
+ const parsed = JSON.parse(raw) as Partial<RepositoryIdentityState>;
100
+ if (
101
+ parsed.version !== STORE_VERSION ||
102
+ !parsed.localIdentities || typeof parsed.localIdentities !== 'object' || Array.isArray(parsed.localIdentities) ||
103
+ !parsed.associations || typeof parsed.associations !== 'object' || Array.isArray(parsed.associations)
104
+ ) {
105
+ throw new Error('Borg repository identity store is malformed or unsupported');
106
+ }
107
+ for (const [key, value] of Object.entries(parsed.localIdentities)) {
108
+ if (!/^[a-f0-9]{64}$/.test(key) || typeof value !== 'string' || !UUID_RE.test(value)) {
109
+ throw new Error('Borg repository identity store is malformed or unsupported');
110
+ }
111
+ }
112
+ for (const [key, value] of Object.entries(parsed.associations)) {
113
+ if (
114
+ !/^[a-f0-9]{64}$/.test(key) ||
115
+ !value || typeof value !== 'object' || Array.isArray(value) ||
116
+ typeof value.cubeId !== 'string' || !UUID_RE.test(value.cubeId) ||
117
+ typeof value.name !== 'string' || Buffer.byteLength(value.name, 'utf8') > 120 ||
118
+ !DISPLAY_NAME_RE.test(value.name) ||
119
+ typeof value.workingRepoName !== 'string' || Buffer.byteLength(value.workingRepoName, 'utf8') > 120 ||
120
+ !DISPLAY_NAME_RE.test(value.workingRepoName) ||
121
+ (value.template !== 'software-dev' && value.template !== 'starter' && value.template !== 'default')
122
+ ) {
123
+ throw new Error('Borg repository identity store is malformed or unsupported');
124
+ }
125
+ }
126
+ return parsed as RepositoryIdentityState;
127
+ }
128
+
129
+ function digest(secret: Buffer, purpose: string, value: string): string {
130
+ return createHmac('sha256', secret).update(purpose).update('\0').update(value).digest('hex');
131
+ }
132
+
133
+ async function withIdentityState<T>(
134
+ operation: (secret: Buffer, state: RepositoryIdentityState) => Promise<{ result: T; changed?: boolean }>,
135
+ deps: RepositoryIdentityDeps = {},
136
+ ): Promise<T> {
137
+ const root = deps.root ?? borgConfigRoot();
138
+ await ensurePrivateBorgConfigRoot(root);
139
+ const options = { secureRoot: root, rootMode: 'private' as const };
140
+ return withStoreLock(join(root, LOCK_FILE), async () => {
141
+ const secretPath = join(root, SECRET_FILE);
142
+ let secret = parseSecret(await readStoreFile(secretPath, options));
143
+ if (!secret) {
144
+ secret = randomBytes(32);
145
+ await atomicWrite0600(secretPath, `${secret.toString('hex')}\n`, options);
146
+ }
147
+ const statePath = join(root, STATE_FILE);
148
+ const state = parseState(await readStoreFile(statePath, options));
149
+ const outcome = await operation(secret, state);
150
+ if (outcome.changed) {
151
+ await atomicWrite0600(statePath, `${JSON.stringify(state, null, 2)}\n`, options);
152
+ }
153
+ return outcome.result;
154
+ }, options);
155
+ }
156
+
157
+ export async function getOrCreateRepositoryIdentity(
158
+ context: GitRepositoryContext,
159
+ deps: RepositoryIdentityDeps = {},
160
+ ): Promise<CreateCubeRepository> {
161
+ if (context.publicRepository) return context.publicRepository;
162
+ return withIdentityState(async (secret, state) => {
163
+ const key = digest(secret, 'git-common-dir', context.commonDir);
164
+ let value = state.localIdentities[key];
165
+ let changed = false;
166
+ if (!value) {
167
+ value = randomUUID();
168
+ state.localIdentities[key] = value;
169
+ changed = true;
170
+ }
171
+ return { result: { kind: 'local' as const, value }, changed };
172
+ }, deps);
173
+ }
174
+
175
+ function associationBinding(secret: Buffer, trustIdentity: string, repository: CreateCubeRepository): string {
176
+ return digest(secret, 'association', `${trustIdentity}\0${repository.kind}\0${repository.value}`);
177
+ }
178
+
179
+ export async function getRepositoryAssociation(
180
+ trustIdentity: string,
181
+ repository: CreateCubeRepository,
182
+ deps: RepositoryIdentityDeps = {},
183
+ ): Promise<RepositoryAssociation | null> {
184
+ return withIdentityState(async (secret, state) => ({
185
+ result: state.associations[associationBinding(secret, trustIdentity, repository)] ?? null,
186
+ }), deps);
187
+ }
188
+
189
+ export async function saveRepositoryAssociation(
190
+ trustIdentity: string,
191
+ repository: CreateCubeRepository,
192
+ association: RepositoryAssociation,
193
+ deps: RepositoryIdentityDeps = {},
194
+ ): Promise<void> {
195
+ await withIdentityState(async (secret, state) => {
196
+ state.associations[associationBinding(secret, trustIdentity, repository)] = association;
197
+ return { result: undefined, changed: true };
198
+ }, deps);
199
+ }
@@ -92,6 +92,20 @@ export class BorgServerUnreachableError extends Error {
92
92
  }
93
93
  }
94
94
 
95
+ export class CubeCreationOutcomeUnknownError extends Error {
96
+ constructor() {
97
+ super('Cube creation outcome is unknown.');
98
+ this.name = 'CubeCreationOutcomeUnknownError';
99
+ }
100
+ }
101
+
102
+ export class CubeCreationConfirmationError extends Error {
103
+ constructor(message = 'The Borg server returned conflicting repository cube state.') {
104
+ super(message);
105
+ this.name = 'CubeCreationConfirmationError';
106
+ }
107
+ }
108
+
95
109
  /** Exact retired TTL-replacement state: two saved bearers and no safe implicit winner. */
96
110
  export class LegacySessionCredentialCollisionError extends Error {
97
111
  constructor(public readonly origin: string) {
@@ -1,12 +1,14 @@
1
1
  import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
- import { serverHelpText } from './cli-help.js';
3
+ import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
4
4
 
5
5
  export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite'] as const;
6
6
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
7
7
 
8
8
  export type ParsedServerFacadeArgs =
9
9
  | { kind: 'help' }
10
+ | { kind: 'cube-init-help' }
11
+ | { kind: 'cube-init'; args: string[] }
10
12
  | { kind: 'command'; command: ServerLifecycleCommand; args: string[] }
11
13
  | { kind: 'error'; reason: 'unknown-command'; command: string };
12
14
 
@@ -15,6 +17,12 @@ export function parseServerFacadeArgs(args: readonly string[]): ParsedServerFaca
15
17
  if (command === undefined || command === '--help' || command === '-h') {
16
18
  return { kind: 'help' };
17
19
  }
20
+ if (command === 'cube' && rest[0] === 'init') {
21
+ const args = rest.slice(1);
22
+ return args.some(isHelpFlag)
23
+ ? { kind: 'cube-init-help' }
24
+ : { kind: 'cube-init', args };
25
+ }
18
26
  if (!(SERVER_LIFECYCLE_COMMANDS as readonly string[]).includes(command)) {
19
27
  return { kind: 'error', reason: 'unknown-command', command };
20
28
  }
@@ -49,6 +57,12 @@ export interface ServerFacadeOutputDeps {
49
57
  writeStderr(text: string): void;
50
58
  }
51
59
 
60
+ export interface ServerFacadeClientDeps {
61
+ cubeInit(args: readonly string[]): Promise<number>;
62
+ }
63
+
64
+ export type AssimilateDepsBuilder = typeof import('./assimilate-deps.js').buildDefaultAssimilateDeps;
65
+
52
66
  export type ServerFacadeProcessResult =
53
67
  | { kind: 'exited'; code: number }
54
68
  | { kind: 'signaled'; signal: NodeJS.Signals }
@@ -65,6 +79,31 @@ const defaultOutputDeps: ServerFacadeOutputDeps = {
65
79
  writeStderr: (text) => process.stderr.write(text),
66
80
  };
67
81
 
82
+ export function buildDefaultServerFacadeClientDeps(
83
+ buildDeps?: AssimilateDepsBuilder,
84
+ ): ServerFacadeClientDeps {
85
+ return {
86
+ cubeInit: async (args) => {
87
+ const [{ parseAssimilateArgs }, { buildDefaultAssimilateDeps }, { runAssimilate }] = await Promise.all([
88
+ import('./parse-assimilate-args.js'),
89
+ import('./assimilate-deps.js'),
90
+ import('./assimilate-cmd.js'),
91
+ ]);
92
+ const parsed = parseAssimilateArgs([...args]);
93
+ if (!parsed.ok || parsed.role !== undefined) {
94
+ process.stderr.write(`${parsed.ok ? 'borg server cube init does not accept a role' : parsed.error}\n`);
95
+ return 1;
96
+ }
97
+ return runAssimilate(
98
+ { role: undefined, flags: parsed.flags, mode: 'cube-init' },
99
+ (buildDeps ?? buildDefaultAssimilateDeps)(),
100
+ );
101
+ },
102
+ };
103
+ }
104
+
105
+ const defaultClientDeps = buildDefaultServerFacadeClientDeps();
106
+
68
107
  const MAX_RENDERED_COMMAND_CODE_POINTS = 80;
69
108
 
70
109
  function inertCommand(command: string): string {
@@ -86,7 +125,7 @@ function inertCommand(command: string): string {
86
125
  export function unknownServerCommandText(command: string): string {
87
126
  return (
88
127
  `Unknown server command: ${inertCommand(command)}.\n` +
89
- `Available commands: setup, start, stop, status, update, invite.\n` +
128
+ `Available commands: setup, start, stop, status, update, invite, cube init.\n` +
90
129
  `Next: run borg server --help.\n`
91
130
  );
92
131
  }
@@ -168,6 +207,7 @@ export async function runEarlyServerFacade(
168
207
  argv: readonly string[],
169
208
  deps: ServerFacadeProcessDeps = defaultProcessDeps,
170
209
  output: ServerFacadeOutputDeps = defaultOutputDeps,
210
+ client: ServerFacadeClientDeps = defaultClientDeps,
171
211
  ): Promise<number | null> {
172
212
  if (argv[2] !== 'server') return null;
173
213
  const parsed = parseServerFacadeArgs(argv.slice(3));
@@ -175,10 +215,15 @@ export async function runEarlyServerFacade(
175
215
  output.writeStdout(serverHelpText());
176
216
  return 0;
177
217
  }
218
+ if (parsed.kind === 'cube-init-help') {
219
+ output.writeStdout(cubeInitHelpText());
220
+ return 0;
221
+ }
178
222
  if (parsed.kind === 'error') {
179
223
  output.writeStderr(unknownServerCommandText(parsed.command));
180
224
  return 1;
181
225
  }
226
+ if (parsed.kind === 'cube-init') return client.cubeInit(parsed.args);
182
227
 
183
228
  const result = await runServerFacadeProcess(parsed, deps);
184
229
  if (result.kind === 'spawn-error') {
@@ -14,7 +14,9 @@ import {
14
14
  decodeProtocolErrorEnvelope,
15
15
  decodeProtocolTagPreflight,
16
16
  ErrorCode,
17
+ type CreateCubeRepository,
17
18
  type CreateCubeResponse,
19
+ type CubeTemplate,
18
20
  type DroneRuntimeMetadata,
19
21
  type ProtocolTagPreflight,
20
22
  type ServerCapability,
@@ -45,6 +47,8 @@ import {
45
47
  BorgServerError,
46
48
  BorgServerTrustError,
47
49
  BorgServerUnreachableError,
50
+ CubeCreationConfirmationError,
51
+ CubeCreationOutcomeUnknownError,
48
52
  } from './server-errors.js';
49
53
  import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
50
54
  import { readBoundedResponseBody } from './server-response.js';
@@ -181,7 +185,7 @@ export interface ServerAttachResult {
181
185
  }
182
186
 
183
187
  /**
184
- * Attach an enrolled client principal to one granted cube/role over protocol v3.
188
+ * Attach an enrolled client principal to one granted cube/role over protocol v4.
185
189
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
186
190
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
187
191
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -564,7 +568,12 @@ export async function createBorgServerCube(
564
568
  origin: string,
565
569
  trustIdentity: string,
566
570
  parentCredential: string,
567
- input: { projectRoot: string; name: string },
571
+ input: {
572
+ name: string;
573
+ workingRepoName: string;
574
+ repository: CreateCubeRepository;
575
+ template: CubeTemplate;
576
+ },
568
577
  deps: {
569
578
  fetchImpl?: FetchLike;
570
579
  loadCredentialRecord?: typeof getServerCredentialRecord;
@@ -589,13 +598,16 @@ export async function createBorgServerCube(
589
598
  origin,
590
599
  trustIdentity,
591
600
  clientId: active.clientId,
592
- projectRoot: input.projectRoot,
593
601
  name: input.name,
594
- template: 'default',
602
+ workingRepoName: input.workingRepoName,
603
+ repository: input.repository,
604
+ template: input.template,
595
605
  });
596
606
  const request = decodeCreateCubeRequest({
597
607
  retry_key: pending.retryKey,
598
608
  name: pending.name,
609
+ working_repo_name: pending.workingRepoName,
610
+ repository: pending.repository,
599
611
  template: pending.template,
600
612
  });
601
613
 
@@ -623,7 +635,10 @@ export async function createBorgServerCube(
623
635
  clearTimeout(timeout);
624
636
  }
625
637
  }
626
- if (!response) throw lastTransportError;
638
+ if (!response) {
639
+ void lastTransportError;
640
+ throw new CubeCreationOutcomeUnknownError();
641
+ }
627
642
  if (response.status === 401 || response.status === 403) {
628
643
  throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server enrollment was rejected');
629
644
  }
@@ -634,7 +649,7 @@ export async function createBorgServerCube(
634
649
  );
635
650
  }
636
651
  if (response.status === 409) {
637
- throw new Error('Borg server cube creation retry state conflicted');
652
+ throw new CubeCreationConfirmationError('The Borg server rejected the repository cube operation identity.');
638
653
  }
639
654
  if (response.status !== 201) {
640
655
  throw new Error(`Borg server cube creation failed (HTTP ${response.status})`);
@@ -648,6 +663,12 @@ export async function createBorgServerCube(
648
663
  if (error instanceof Error && error.message.includes('response limit')) throw error;
649
664
  throw new Error('Borg server returned an invalid cube creation envelope');
650
665
  }
666
+ if (
667
+ decoded.payload.repository.kind !== pending.repository.kind ||
668
+ decoded.payload.repository.value !== pending.repository.value
669
+ ) {
670
+ throw new CubeCreationConfirmationError();
671
+ }
651
672
  await (deps.clearCubeCreation ?? clearPendingServerCubeCreation)(
652
673
  pending as PendingServerCubeCreationRecord,
653
674
  );
@@ -658,7 +679,12 @@ export async function createLocalBorgServerCube(
658
679
  origin: string,
659
680
  trustIdentity: string,
660
681
  parentCredential: string,
661
- input: { projectRoot: string; name: string },
682
+ input: {
683
+ name: string;
684
+ workingRepoName: string;
685
+ repository: CreateCubeRepository;
686
+ template: CubeTemplate;
687
+ },
662
688
  deps: { loadTrust?: typeof loadBorgServerTrust } = {},
663
689
  ): Promise<CreateCubeResponse> {
664
690
  const trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);