borgmcp 3.11.0 → 3.11.1

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.
@@ -1,12 +1,19 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash, randomUUID } from 'crypto';
2
+ import { createHash } from 'crypto';
3
3
  import { createServer } from 'node:net';
4
4
  import { join } from 'path';
5
5
  import { tmpdir } from 'os';
6
6
  import {
7
7
  OPENCODE_INJECTED_ENTRY_METADATA_KEY,
8
8
  OPENCODE_WAKE_IDENTITY_METADATA_KEY,
9
+ OPENCODE_LAUNCH_CORRELATION_METADATA_KEY,
9
10
  } from './opencode-plugin.js';
11
+ import {
12
+ createOpenCodeLaunchTrust,
13
+ isOpenCode256BitIdentity,
14
+ OPENCODE_SERVER_USERNAME,
15
+ type OpenCodeLaunchTrust,
16
+ } from './opencode-launch-trust.js';
10
17
 
11
18
  const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
12
19
  function log(msg: string) {
@@ -16,6 +23,7 @@ function log(msg: string) {
16
23
 
17
24
  interface OpenCodeDroneState {
18
25
  serverUrl: string;
26
+ apiPassword: string;
19
27
  sessionId: string | null;
20
28
  sessionCreatedAt: number | null;
21
29
  knownRootSessionIds: string[];
@@ -39,6 +47,7 @@ let state: OpenCodeDroneState | null = null;
39
47
 
40
48
  interface ConnectDeps {
41
49
  serverUrl: string;
50
+ apiPassword: string;
42
51
  directory: string;
43
52
  droneLabel: string;
44
53
  cubeName: string;
@@ -124,27 +133,23 @@ interface SessionBinding {
124
133
 
125
134
  export interface OpenCodeLaunchKickoff {
126
135
  prompt: string;
127
- nonce: string;
136
+ apiPassword: string;
137
+ correlationIdentity: string;
128
138
  }
129
139
 
130
- // This is correlation metadata, intentionally not an instruction to the
131
- // launched agent. A markdown comment keeps it benign in the user-visible
132
- // kickoff while preserving it in OpenCode's stored message text.
133
- const OPEN_CODE_LAUNCH_NONCE_MARKER = 'borg-opencode-correlation:';
134
-
135
140
  /**
136
- * Add a launch-unique identity to the OpenCode-only copy of the shared
137
- * kickoff. The prompt is what OpenCode records as its first user message, so
138
- * the launcher can later bind the MCP child to this precise launch instead of
139
- * guessing from a repeated kickoff's text or timestamp.
141
+ * Create independent launch trust for OpenCode without changing the shared
142
+ * kickoff text. The plugin writes the correlation identity to hidden metadata
143
+ * on the first qualifying human TextPart; the API password stays in env.
140
144
  */
141
145
  export function createOpenCodeLaunchKickoff(
142
146
  kickoff: string,
143
- nonce: string = randomUUID(),
147
+ trust: Partial<OpenCodeLaunchTrust> = {},
144
148
  ): OpenCodeLaunchKickoff {
149
+ const launchTrust = createOpenCodeLaunchTrust(trust);
145
150
  return {
146
- prompt: `${kickoff}\n\n<!-- ${OPEN_CODE_LAUNCH_NONCE_MARKER}${nonce} -->`,
147
- nonce,
151
+ prompt: kickoff,
152
+ ...launchTrust,
148
153
  };
149
154
  }
150
155
 
@@ -161,9 +166,13 @@ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
161
166
  }
162
167
 
163
168
  export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
169
+ if (!isOpenCode256BitIdentity(deps.apiPassword)) {
170
+ throw new Error('OpenCode API password is missing or unverifiable');
171
+ }
164
172
  abandonOpenCodeDeliveries(state);
165
173
  state = {
166
174
  serverUrl: deps.serverUrl,
175
+ apiPassword: deps.apiPassword,
167
176
  sessionId: null,
168
177
  sessionCreatedAt: null,
169
178
  knownRootSessionIds: [],
@@ -194,6 +203,17 @@ function apiUrl(path: string): string {
194
203
  return `${base}${path}${path.includes('?') ? '&' : '?'}directory=${encodeURIComponent(state!.directory)}`;
195
204
  }
196
205
 
206
+ function authenticatedHeaders(headers: Record<string, string> = {}): Record<string, string> {
207
+ const password = state?.apiPassword;
208
+ if (!isOpenCode256BitIdentity(password)) {
209
+ throw new Error('OpenCode API password is missing or unverifiable');
210
+ }
211
+ return {
212
+ ...headers,
213
+ Authorization: `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${password}`).toString('base64')}`,
214
+ };
215
+ }
216
+
197
217
  const FETCH_TIMEOUT = 10_000;
198
218
 
199
219
  async function rawGet(path: string): Promise<{ status: number; body: string }> {
@@ -201,7 +221,7 @@ async function rawGet(path: string): Promise<{ status: number; body: string }> {
201
221
  const controller = new AbortController();
202
222
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
203
223
  try {
204
- const res = await fetch(url, { signal: controller.signal });
224
+ const res = await fetch(url, { headers: authenticatedHeaders(), signal: controller.signal });
205
225
  const body = await res.text();
206
226
  return { status: res.status, body };
207
227
  } finally {
@@ -216,7 +236,7 @@ async function rawPost(path: string, bodyObj: unknown): Promise<{ status: number
216
236
  try {
217
237
  const res = await fetch(url, {
218
238
  method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
239
+ headers: authenticatedHeaders({ 'Content-Type': 'application/json' }),
220
240
  signal: controller.signal,
221
241
  body: JSON.stringify(bodyObj),
222
242
  });
@@ -453,28 +473,29 @@ async function findUnseenTopLevelSession(knownRootSessionIds: string[]): Promise
453
473
  }
454
474
  }
455
475
 
456
- function kickoffMessageTime(messages: OCMessage[], nonce: string): number | null {
457
- let latest: number | null = null;
476
+ function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
477
+ let count = 0;
458
478
  for (const message of messages) {
459
- if (message.info?.role && message.info.role !== 'user') continue;
460
- const matchesLaunchNonce = message.parts?.some(
461
- (part) => part.type === 'text' && part.text?.includes(`${OPEN_CODE_LAUNCH_NONCE_MARKER}${nonce}`),
462
- );
463
- if (!matchesLaunchNonce) continue;
464
- const created = message.info?.time?.created ?? 0;
465
- latest = latest === null ? created : Math.max(latest, created);
479
+ if (message.info?.role !== 'user') continue;
480
+ for (const part of message.parts ?? []) {
481
+ if (
482
+ part.type === 'text' &&
483
+ part.metadata?.[OPENCODE_LAUNCH_CORRELATION_METADATA_KEY] === correlationIdentity
484
+ ) {
485
+ count++;
486
+ }
487
+ }
466
488
  }
467
- return latest;
489
+ return count;
468
490
  }
469
491
 
470
492
  /**
471
493
  * The launch process is the only place allowed to discover a session from the
472
- * server. It chooses the session that contains this launch's unique nonce,
473
- * rather than choosing by repeated kickoff text or session creation time. A
474
- * fork is therefore allowed only when it was explicitly selected for this
475
- * launch and received the nonce-bearing kickoff.
494
+ * server. It chooses the session containing exactly one hidden metadata match,
495
+ * never repeated prompt text, timestamps, or newest-session order. A fork is
496
+ * therefore allowed only when it received this launch's correlation metadata.
476
497
  */
477
- async function findLaunchSession(nonce: string): Promise<{
498
+ async function findLaunchSession(correlationIdentity: string): Promise<{
478
499
  session: OCSession;
479
500
  knownRootSessionIds: string[];
480
501
  } | null> {
@@ -485,25 +506,17 @@ async function findLaunchSession(nonce: string): Promise<{
485
506
  const knownRootSessionIds = sessions
486
507
  .filter(isTopLevelSession)
487
508
  .map((session) => session.id);
488
- const candidates = await Promise.all(sessions.map(async (session) => {
489
- try {
490
- const messageTime = kickoffMessageTime(
491
- await listSessionMessages(session.id),
492
- nonce,
493
- );
494
- return messageTime === null ? null : { session, messageTime };
495
- } catch {
496
- return null;
497
- }
498
- }));
499
- const matched = candidates.filter(
500
- (candidate): candidate is { session: OCSession; messageTime: number } => candidate !== null,
501
- );
502
- if (matched.length === 0) return null;
503
- const session = matched.reduce((best, candidate) =>
504
- candidate.messageTime > best.messageTime ? candidate : best,
505
- ).session;
506
- return { session, knownRootSessionIds };
509
+ const candidates = await Promise.all(sessions.map(async (session) => ({
510
+ session,
511
+ matchCount: launchCorrelationMatchCount(
512
+ await listSessionMessages(session.id),
513
+ correlationIdentity,
514
+ ),
515
+ })));
516
+ const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
517
+ if (totalMatches !== 1) return null;
518
+ const matched = candidates.find((candidate) => candidate.matchCount === 1);
519
+ return matched ? { session: matched.session, knownRootSessionIds } : null;
507
520
  } catch {
508
521
  return null;
509
522
  }
@@ -795,11 +808,15 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
795
808
 
796
809
  /**
797
810
  * Wait for the OpenCode HTTP server, then capture the session that received
798
- * this launch's nonce-bearing `--prompt` kickoff. The binding survives the separate
799
- * MCP-child process, which must never fall back to a newest-session heuristic.
811
+ * this launch's metadata-correlated `--prompt` kickoff. The binding survives
812
+ * the separate MCP-child process, which must never fall back to a newest-session heuristic.
800
813
  */
801
814
  export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promise<boolean> {
802
815
  if (!state?.connected) { log('kickoff: not connected'); return false; }
816
+ if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
817
+ log('kickoff: correlation identity missing or unverifiable');
818
+ return false;
819
+ }
803
820
 
804
821
  try {
805
822
  // Wait for the server.
@@ -815,9 +832,9 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
815
832
  }
816
833
 
817
834
  // Capture the launch-selected session, including explicit resume/fork
818
- // targets. Unrelated sessions do not contain this launch's nonce.
835
+ // targets. Unrelated sessions do not contain this launch's metadata identity.
819
836
  for (let i = 0; i < 30; i++) {
820
- const binding = await findLaunchSession(launch.nonce);
837
+ const binding = await findLaunchSession(launch.correlationIdentity);
821
838
  if (binding) {
822
839
  saveBinding(binding.session, binding.knownRootSessionIds);
823
840
  log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export const OPENCODE_SERVER_USERNAME = 'opencode';
4
+ export const OPENCODE_SERVER_USERNAME_ENV = 'OPENCODE_SERVER_USERNAME';
5
+ export const OPENCODE_SERVER_PASSWORD_ENV = 'OPENCODE_SERVER_PASSWORD';
6
+ export const BORG_OPENCODE_LAUNCH_CORRELATION_ENV = 'BORG_OPENCODE_LAUNCH_CORRELATION';
7
+ export const OPENCODE_SERVER_PASSWORD_REFERENCE = `{env:${OPENCODE_SERVER_PASSWORD_ENV}}`;
8
+
9
+ export interface OpenCodeLaunchTrust {
10
+ apiPassword: string;
11
+ correlationIdentity: string;
12
+ }
13
+
14
+ function random256BitIdentity(): string {
15
+ return randomBytes(32).toString('base64url');
16
+ }
17
+
18
+ export function createOpenCodeLaunchTrust(overrides: Partial<OpenCodeLaunchTrust> = {}): OpenCodeLaunchTrust {
19
+ const trust = {
20
+ apiPassword: overrides.apiPassword ?? random256BitIdentity(),
21
+ correlationIdentity: overrides.correlationIdentity ?? random256BitIdentity(),
22
+ };
23
+ if (!isOpenCode256BitIdentity(trust.apiPassword) || !isOpenCode256BitIdentity(trust.correlationIdentity)) {
24
+ throw new Error('OpenCode launch trust must contain independent 256-bit identities');
25
+ }
26
+ if (trust.apiPassword === trust.correlationIdentity) {
27
+ throw new Error('OpenCode API password and correlation identity must be independent');
28
+ }
29
+ return trust;
30
+ }
31
+
32
+ export function isOpenCode256BitIdentity(value: unknown): value is string {
33
+ if (typeof value !== 'string' || value.length === 0) return false;
34
+ try {
35
+ const decoded = Buffer.from(value, 'base64url');
36
+ return decoded.length === 32 && decoded.toString('base64url') === value;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export function openCodeApiPasswordFromEnv(env: NodeJS.ProcessEnv): string | null {
43
+ if (env[OPENCODE_SERVER_USERNAME_ENV] !== OPENCODE_SERVER_USERNAME) return null;
44
+ const password = env[OPENCODE_SERVER_PASSWORD_ENV];
45
+ return isOpenCode256BitIdentity(password) ? password : null;
46
+ }
@@ -20,6 +20,7 @@ const COMPACT_FALLBACK =
20
20
  export const OPENCODE_INJECTED_ENTRY_METADATA_KEY = 'borgOpenCodeInjectedEntry';
21
21
  export const OPENCODE_WAKE_IDENTITY_METADATA_KEY = 'borgOpenCodeWakeIdentity';
22
22
  export const OPENCODE_RECOVERY_METADATA_KEY = 'borgOpenCodeSessionOrientation';
23
+ export const OPENCODE_LAUNCH_CORRELATION_METADATA_KEY = 'borgOpenCodeLaunchCorrelation';
23
24
  const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
24
25
 
25
26
  export interface OpenCodePluginCoreDeps {
@@ -45,6 +46,8 @@ export interface OpenCodePluginCoreOptions {
45
46
  confirmationPollAttempts: number;
46
47
  pollDelayMs: number;
47
48
  compactFallback: string;
49
+ launchCorrelationMetadataKey: string;
50
+ launchCorrelationIdentity: string;
48
51
  }
49
52
 
50
53
  /** Pure, dependency-injected behavior core. Its emitted JavaScript function
@@ -55,6 +58,7 @@ export function createOpenCodePluginCore(
55
58
  ) {
56
59
  const claimedSessions = new Set<string>();
57
60
  const humanPromptSessions = new Set<string>();
61
+ let launchCorrelationAttached = false;
58
62
  const textParts = (message: any): any[] => Array.isArray(message?.parts)
59
63
  ? message.parts.filter((part: any) => part?.type === 'text' && typeof part.text === 'string')
60
64
  : [];
@@ -129,7 +133,28 @@ export function createOpenCodePluginCore(
129
133
  output: { message: unknown; parts: any[] },
130
134
  ): Promise<void> => {
131
135
  if (!options.enabled) return;
132
- const current = { info: { role: 'user' }, parts: [...output.parts] };
136
+ let current = { info: { role: 'user' }, parts: [...output.parts] };
137
+ if (
138
+ !launchCorrelationAttached &&
139
+ options.launchCorrelationIdentity &&
140
+ !isInjectedEntry(current) &&
141
+ !isOwnedRecovery(current)
142
+ ) {
143
+ const index = output.parts.findIndex((part) =>
144
+ part?.type === 'text' && typeof part.text === 'string');
145
+ if (index >= 0) {
146
+ const part = output.parts[index];
147
+ output.parts[index] = {
148
+ ...part,
149
+ metadata: {
150
+ ...(part.metadata && typeof part.metadata === 'object' ? part.metadata : {}),
151
+ [options.launchCorrelationMetadataKey]: options.launchCorrelationIdentity,
152
+ },
153
+ };
154
+ launchCorrelationAttached = true;
155
+ current = { info: { role: 'user' }, parts: [...output.parts] };
156
+ }
157
+ }
133
158
  if (!isInjectedEntry(current) && !isOwnedRecovery(current)) {
134
159
  // chat.message fires before the user message is persisted. Record the
135
160
  // human turn synchronously so recovery cannot race that short gap.
@@ -165,6 +190,7 @@ export function buildBorgPluginSource(version: string): string {
165
190
  const createCore = ${createOpenCodePluginCore.toString()};
166
191
  const evaluateAudit = ${evaluateLogAudit.toString()};
167
192
  export default async function (ctx) {
193
+ const launchCorrelationIdentity = process.env.BORG_OPENCODE_LAUNCH_CORRELATION || '';
168
194
  const runRegen = async (source) => {
169
195
  const input = JSON.stringify({ source });
170
196
  const result = await ctx.$\`printf '%s' \${input} | borg-regen\`.quiet().nothrow();
@@ -204,8 +230,12 @@ export default async function (ctx) {
204
230
  confirmationPollAttempts: 6,
205
231
  pollDelayMs: 200,
206
232
  compactFallback: COMPACT_FALLBACK,
233
+ launchCorrelationMetadataKey: OPENCODE_LAUNCH_CORRELATION_METADATA_KEY,
207
234
  })},
208
235
  enabled: process.env.BORG_SESSION === '1',
236
+ launchCorrelationIdentity: /^[A-Za-z0-9_-]{43}$/.test(launchCorrelationIdentity)
237
+ ? launchCorrelationIdentity
238
+ : '',
209
239
  });
210
240
  }
211
241
  `;
@@ -28,7 +28,7 @@ export interface ConfigMutationTarget {
28
28
  * agent CLIs. Paths mirror `config-utils.ts`:
29
29
  * Claude Code: ~/.claude.json (MCP server) + ~/.claude/settings.json (hook)
30
30
  * Codex: ~/.codex/config.toml (MCP server) + ~/.codex/hooks.json (hooks)
31
- * OpenCode: ~/.config/opencode/opencode.json (MCP server)
31
+ * OpenCode: effective global config under ~/.config/opencode/ (MCP server)
32
32
  */
33
33
  export function configMutationTargets(deps: {
34
34
  claude: boolean;
@@ -58,8 +58,8 @@ export function configMutationTargets(deps: {
58
58
  }
59
59
  if (deps.opencode) {
60
60
  targets.push({
61
- file: '~/.config/opencode/opencode.json',
62
- change: 'registers the borg MCP server (with BORG_SESSION activation)',
61
+ file: '~/.config/opencode/',
62
+ change: 'updates the effective global OpenCode configuration to register the borg MCP server (with BORG_SESSION activation)',
63
63
  });
64
64
  }
65
65
  return targets;