borgmcp 4.6.1 → 4.6.2

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.
@@ -14,7 +14,7 @@ import {
14
14
  } from 'fs';
15
15
  import { createHash, randomUUID } from 'crypto';
16
16
  import { createServer } from 'node:net';
17
- import { join } from 'path';
17
+ import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
19
  import {
20
20
  borgConfigRoot,
@@ -44,8 +44,9 @@ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
44
44
  const diagnosticLogPathsForTests = new Set<string>();
45
45
 
46
46
  function stateIdentityDigest(current: OpenCodeDroneState): string {
47
- const key = [current.directory, current.cubeName, current.droneLabel].join('\0');
48
- return createHash('sha256').update(key).digest('hex').slice(0, 24);
47
+ // Diagnostic files follow the stable worktree location; launch placeholders
48
+ // must not select a second log before session identity resolves.
49
+ return createHash('sha256').update(resolve(current.directory)).digest('hex').slice(0, 24);
49
50
  }
50
51
 
51
52
  export function openCodeStartupDiagnosticLogPath(): string {
@@ -166,6 +167,7 @@ interface OpenCodeDroneState {
166
167
  directory: string;
167
168
  droneLabel: string;
168
169
  cubeName: string;
170
+ launchIdentity: string;
169
171
  connected: boolean;
170
172
  totalEntriesInjected: number;
171
173
  totalEntriesRetried: number;
@@ -204,6 +206,7 @@ interface ConnectDeps {
204
206
  directory: string;
205
207
  droneLabel: string;
206
208
  cubeName: string;
209
+ launchIdentity: string;
207
210
  }
208
211
 
209
212
  interface OCSession {
@@ -270,7 +273,7 @@ const OPEN_CODE_RECONCILIATION_ATTEMPTS = 20;
270
273
  const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
271
274
 
272
275
  interface SessionBinding {
273
- version: 4;
276
+ version: 5;
274
277
  sessionId: string;
275
278
  sessionCreatedAt: number;
276
279
  knownRootSessionIds: string[];
@@ -278,6 +281,7 @@ interface SessionBinding {
278
281
  directory: string;
279
282
  droneLabel: string;
280
283
  cubeName: string;
284
+ launchIdentity: string;
281
285
  pendingSubmissions: Array<{
282
286
  entryId: string;
283
287
  sourceEntryId: string;
@@ -285,6 +289,13 @@ interface SessionBinding {
285
289
  }>;
286
290
  }
287
291
 
292
+ interface SessionBindingClaim {
293
+ version: 1;
294
+ directory: string;
295
+ droneLabel: string;
296
+ cubeName: string;
297
+ }
298
+
288
299
  export interface OpenCodeLaunchKickoff {
289
300
  prompt: string;
290
301
  apiPassword: string;
@@ -323,6 +334,9 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
323
334
  if (!isOpenCode256BitIdentity(deps.apiPassword)) {
324
335
  throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
325
336
  }
337
+ if (!isOpenCode256BitIdentity(deps.launchIdentity)) {
338
+ throw new OpenCodeAuthenticationError('OpenCode launch identity is missing or unverifiable');
339
+ }
326
340
  await ensurePrivateBorgConfigRoot(borgConfigRoot());
327
341
  abandonOpenCodeDeliveries(state);
328
342
  state = {
@@ -334,6 +348,7 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
334
348
  directory: deps.directory,
335
349
  droneLabel: deps.droneLabel,
336
350
  cubeName: deps.cubeName,
351
+ launchIdentity: deps.launchIdentity,
337
352
  connected: true,
338
353
  totalEntriesInjected: 0,
339
354
  totalEntriesRetried: 0,
@@ -546,17 +561,28 @@ async function promptSession(id: string, bodyObj: Record<string, unknown>): Prom
546
561
 
547
562
  function bindingPath(): string {
548
563
  const current = state!;
549
- const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
564
+ const identity = createHash('sha256').update(current.launchIdentity).digest('hex').slice(0, 24);
565
+ const path = join(tmpdir(), `borg-opencode-session-${identity}.json`);
550
566
  bindingPathsForTests.add(path);
551
567
  return path;
552
568
  }
553
569
 
570
+ function bindingClaimPath(): string {
571
+ return `${bindingPath()}.claim`;
572
+ }
573
+
554
574
  function bindingMatchesState(binding: SessionBinding): boolean {
555
575
  const current = state!;
556
- return binding.version === 4
576
+ const sameResolvedSeat = binding.droneLabel === current.droneLabel
577
+ && binding.cubeName === current.cubeName;
578
+ // The 256-bit launch identity lets the resolved MCP child claim only its
579
+ // launcher's placeholder binding; resolved ownership still requires labels.
580
+ const unclaimedLaunch = binding.droneLabel === 'opencode'
581
+ && binding.cubeName === 'borg';
582
+ return binding.version === 5
557
583
  && binding.directory === current.directory
558
- && binding.droneLabel === current.droneLabel
559
- && binding.cubeName === current.cubeName
584
+ && binding.launchIdentity === current.launchIdentity
585
+ && (sameResolvedSeat || unclaimedLaunch)
560
586
  && typeof binding.sessionId === 'string'
561
587
  && typeof binding.sessionCreatedAt === 'number'
562
588
  && Array.isArray(binding.knownRootSessionIds)
@@ -599,15 +625,14 @@ function clearBinding(): void {
599
625
 
600
626
  function writeBinding(binding: SessionBinding): boolean {
601
627
  const current = state!;
602
- current.sessionId = binding.sessionId;
603
- current.sessionCreatedAt = binding.sessionCreatedAt;
604
- current.knownRootSessionIds = binding.knownRootSessionIds;
605
-
606
628
  try {
607
629
  const path = bindingPath();
608
630
  const temporary = `${path}.${process.pid}.tmp`;
609
631
  writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
610
632
  renameSync(temporary, path);
633
+ current.sessionId = binding.sessionId;
634
+ current.sessionCreatedAt = binding.sessionCreatedAt;
635
+ current.knownRootSessionIds = binding.knownRootSessionIds;
611
636
  return true;
612
637
  } catch (err) {
613
638
  log(`session binding write failed: ${err}`);
@@ -615,10 +640,46 @@ function writeBinding(binding: SessionBinding): boolean {
615
640
  }
616
641
  }
617
642
 
643
+ function claimPlaceholderBinding(): boolean {
644
+ const current = state!;
645
+ const claim: SessionBindingClaim = {
646
+ version: 1,
647
+ directory: current.directory,
648
+ droneLabel: current.droneLabel,
649
+ cubeName: current.cubeName,
650
+ };
651
+ const path = bindingClaimPath();
652
+ try {
653
+ writeFileSync(path, JSON.stringify(claim), { mode: 0o600, flag: 'wx' });
654
+ return true;
655
+ } catch (error) {
656
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
657
+ log(`session binding claim failed: ${error}`);
658
+ return false;
659
+ }
660
+ }
661
+ try {
662
+ const existing = JSON.parse(readFileSync(path, 'utf8')) as SessionBindingClaim;
663
+ return existing.version === 1
664
+ && existing.directory === claim.directory
665
+ && existing.droneLabel === claim.droneLabel
666
+ && existing.cubeName === claim.cubeName;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+
672
+ function dropCachedBinding(): void {
673
+ if (!state) return;
674
+ state.sessionId = null;
675
+ state.sessionCreatedAt = null;
676
+ state.knownRootSessionIds = [];
677
+ }
678
+
618
679
  function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
619
680
  const current = state!;
620
681
  const binding: SessionBinding = {
621
- version: 4,
682
+ version: 5,
622
683
  sessionId: session.id,
623
684
  sessionCreatedAt: session.time.created,
624
685
  knownRootSessionIds,
@@ -626,6 +687,7 @@ function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
626
687
  directory: current.directory,
627
688
  droneLabel: current.droneLabel,
628
689
  cubeName: current.cubeName,
690
+ launchIdentity: current.launchIdentity,
629
691
  pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
630
692
  entryId,
631
693
  sourceEntryId: pending.sourceEntryId,
@@ -639,7 +701,7 @@ function persistCurrentBinding(): boolean {
639
701
  const current = state;
640
702
  if (!current?.sessionId || current.sessionCreatedAt === null) return false;
641
703
  return writeBinding({
642
- version: 4,
704
+ version: 5,
643
705
  sessionId: current.sessionId,
644
706
  sessionCreatedAt: current.sessionCreatedAt,
645
707
  knownRootSessionIds: current.knownRootSessionIds,
@@ -647,6 +709,7 @@ function persistCurrentBinding(): boolean {
647
709
  directory: current.directory,
648
710
  droneLabel: current.droneLabel,
649
711
  cubeName: current.cubeName,
712
+ launchIdentity: current.launchIdentity,
650
713
  pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
651
714
  entryId,
652
715
  sourceEntryId: pending.sourceEntryId,
@@ -658,25 +721,34 @@ function persistCurrentBinding(): boolean {
658
721
  function restoreBinding(): SessionBinding | null {
659
722
  if (!state) return null;
660
723
  if (state.sessionId && state.sessionCreatedAt !== null) {
661
- return {
662
- version: 4,
663
- sessionId: state.sessionId,
664
- sessionCreatedAt: state.sessionCreatedAt,
665
- knownRootSessionIds: state.knownRootSessionIds,
666
- serverUrl: state.serverUrl,
667
- directory: state.directory,
668
- droneLabel: state.droneLabel,
669
- cubeName: state.cubeName,
670
- pendingSubmissions: [...state.pendingSubmissions].map(([entryId, pending]) => ({
671
- entryId,
672
- sourceEntryId: pending.sourceEntryId,
673
- sessionId: pending.sessionId,
674
- })),
675
- };
724
+ const persisted = readBinding();
725
+ if (
726
+ persisted?.droneLabel === state.droneLabel
727
+ && persisted.cubeName === state.cubeName
728
+ ) return persisted;
729
+ dropCachedBinding();
730
+ return null;
676
731
  }
677
732
 
678
- const binding = readBinding();
733
+ let binding = readBinding();
679
734
  if (!binding) return null;
735
+ if (binding.droneLabel === 'opencode' && binding.cubeName === 'borg') {
736
+ if (!claimPlaceholderBinding()) return null;
737
+ if (!writeBinding({
738
+ ...binding,
739
+ droneLabel: state.droneLabel,
740
+ cubeName: state.cubeName,
741
+ })) return null;
742
+ binding = readBinding();
743
+ if (
744
+ !binding
745
+ || binding.droneLabel !== state.droneLabel
746
+ || binding.cubeName !== state.cubeName
747
+ ) {
748
+ dropCachedBinding();
749
+ return null;
750
+ }
751
+ }
680
752
  state.sessionId = binding.sessionId;
681
753
  state.sessionCreatedAt = binding.sessionCreatedAt;
682
754
  state.knownRootSessionIds = binding.knownRootSessionIds;
@@ -1560,6 +1632,11 @@ export function __getOpenCodeDiagnosticLogPathForTests(): string {
1560
1632
  return diagnosticLogPath(state);
1561
1633
  }
1562
1634
 
1635
+ export function __getOpenCodeBindingPathForTests(): string {
1636
+ if (!state) throw new Error('OpenCode drone is not connected');
1637
+ return bindingPath();
1638
+ }
1639
+
1563
1640
  export function __getOpenCodeLastObservationForTests(): OpenCodeLastObservation {
1564
1641
  if (!state) throw new Error('OpenCode drone is not connected');
1565
1642
  return { ...state.lastObservation };
@@ -1649,6 +1726,11 @@ export function __resetOpenCodeDroneForTests(): void {
1649
1726
  } catch {
1650
1727
  // Already removed.
1651
1728
  }
1729
+ try {
1730
+ unlinkSync(`${path}.claim`);
1731
+ } catch {
1732
+ // Already removed.
1733
+ }
1652
1734
  }
1653
1735
  bindingPathsForTests.clear();
1654
1736
  for (const path of diagnosticLogPathsForTests) {
@@ -44,3 +44,8 @@ export function openCodeApiPasswordFromEnv(env: NodeJS.ProcessEnv): string | nul
44
44
  const password = env[OPENCODE_SERVER_PASSWORD_ENV];
45
45
  return isOpenCode256BitIdentity(password) ? password : null;
46
46
  }
47
+
48
+ export function openCodeLaunchCorrelationFromEnv(env: NodeJS.ProcessEnv): string | null {
49
+ const identity = env[BORG_OPENCODE_LAUNCH_CORRELATION_ENV];
50
+ return isOpenCode256BitIdentity(identity) ? identity : null;
51
+ }