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.
package/src/claude.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { spawn } from 'child_process';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { realpathSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
21
22
  import { basename } from 'node:path';
22
23
  import { createInterface } from 'node:readline/promises';
23
24
  import { fileURLToPath } from 'node:url';
@@ -102,6 +103,7 @@ import {
102
103
  addCodexUserPromptSubmitHook,
103
104
  addProjectSessionStartHook,
104
105
  addUserPromptSubmitHook,
106
+ provisionLaunchAccess,
105
107
  removeSessionStartHook,
106
108
  } from './config-utils.js';
107
109
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
@@ -118,6 +120,7 @@ import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals
118
120
  import { isClientOwnedCubeInitArgv, runEarlyServerFacade } from './server-facade.js';
119
121
  import { runEarlyUpdate } from './update-cmd.js';
120
122
  import { runDoctor, warnIfAgentIntegrationUnhealthy } from './agent-integration-health.js';
123
+ import { scratchRootForSeat } from './launch-access.js';
121
124
 
122
125
  export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
123
126
 
@@ -148,7 +151,7 @@ export function createOpenCodeLaunchPlan(
148
151
  };
149
152
  }
150
153
 
151
- export function launchOpenCodeProcess(options: {
154
+ export async function launchOpenCodeProcess(options: {
152
155
  cwd: string;
153
156
  port: number;
154
157
  prompt: string;
@@ -159,36 +162,76 @@ export function launchOpenCodeProcess(options: {
159
162
  kickoff: ReturnType<typeof createOpenCodeLaunchKickoff>;
160
163
  spawnProcess?: typeof spawn;
161
164
  connect?: typeof connectOpenCodeDrone;
162
- }): {
165
+ injectKickoff?: typeof injectInitialKickoff;
166
+ allocatePort?: typeof allocateOpenCodePort;
167
+ }): Promise<{
163
168
  launchArgs: string[];
164
169
  launchEnv: NodeJS.ProcessEnv;
165
170
  process: ReturnType<typeof spawn>;
166
- } {
167
- const plan = createOpenCodeLaunchPlan(options.cwd, options.port, options.prompt, options.passthroughArgs);
168
- const launchEnv = {
169
- ...options.env,
170
- BORG_OPENCODE_PORT: plan.envPort,
171
- [OPENCODE_SERVER_USERNAME_ENV]: OPENCODE_SERVER_USERNAME,
172
- [OPENCODE_SERVER_PASSWORD_ENV]: options.kickoff.apiPassword,
173
- [BORG_OPENCODE_LAUNCH_CORRELATION_ENV]: options.kickoff.correlationIdentity,
174
- };
175
- // OpenCode's bind can still race this allocation; client#298 tracks the
176
- // residual pre-bind window outside this slice.
177
- const child = (options.spawnProcess ?? spawn)('opencode', plan.launchArgs, {
178
- stdio: 'inherit',
179
- shell: false,
180
- env: launchEnv,
181
- });
182
- (options.connect ?? connectOpenCodeDrone)({
183
- serverUrl: plan.serverUrl,
184
- apiPassword: options.kickoff.apiPassword,
185
- directory: options.cwd,
186
- droneLabel: options.droneLabel,
187
- cubeName: options.cubeName,
188
- })
189
- .then(() => injectInitialKickoff(options.kickoff))
190
- .catch(() => {});
191
- return { launchArgs: plan.launchArgs, launchEnv, process: child };
171
+ }> {
172
+ let port = options.port;
173
+ let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
174
+ // Each child gets independent trust so a retry cannot bind a session or
175
+ // durable binding left by an earlier attempt.
176
+ for (let attempt = 0; attempt < 3; attempt++) {
177
+ const attemptKickoff = attempt === 0
178
+ ? options.kickoff
179
+ : createOpenCodeLaunchKickoff(options.prompt);
180
+ const plan = createOpenCodeLaunchPlan(options.cwd, port, options.prompt, options.passthroughArgs);
181
+ const launchEnv = {
182
+ ...options.env,
183
+ BORG_OPENCODE_PORT: plan.envPort,
184
+ [OPENCODE_SERVER_USERNAME_ENV]: OPENCODE_SERVER_USERNAME,
185
+ [OPENCODE_SERVER_PASSWORD_ENV]: attemptKickoff.apiPassword,
186
+ [BORG_OPENCODE_LAUNCH_CORRELATION_ENV]: attemptKickoff.correlationIdentity,
187
+ };
188
+ const child = (options.spawnProcess ?? spawn)('opencode', plan.launchArgs, {
189
+ stdio: 'inherit',
190
+ shell: false,
191
+ env: launchEnv,
192
+ });
193
+ type Stopped =
194
+ | { kind: 'error'; error: NodeJS.ErrnoException }
195
+ | { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null };
196
+ let markStopped: (outcome: Stopped) => void;
197
+ const stopped = new Promise<Stopped>((resolve) => { markStopped = resolve; });
198
+ const onError = (error: NodeJS.ErrnoException) => markStopped({ kind: 'error', error });
199
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
200
+ markStopped({ kind: 'exit', code, signal });
201
+ };
202
+ child.once('error', onError);
203
+ child.once('exit', onExit);
204
+ await (options.connect ?? connectOpenCodeDrone)({
205
+ serverUrl: plan.serverUrl,
206
+ apiPassword: attemptKickoff.apiPassword,
207
+ directory: options.cwd,
208
+ droneLabel: options.droneLabel,
209
+ cubeName: options.cubeName,
210
+ launchIdentity: attemptKickoff.correlationIdentity,
211
+ });
212
+ const outcome = await Promise.race([
213
+ (options.injectKickoff ?? injectInitialKickoff)(attemptKickoff).then(
214
+ (bound) => bound ? { kind: 'ready' as const } : { kind: 'unbound' as const },
215
+ ),
216
+ stopped,
217
+ ]);
218
+ child.off('error', onError);
219
+ child.off('exit', onExit);
220
+ if (outcome.kind === 'ready') {
221
+ return { launchArgs: plan.launchArgs, launchEnv, process: child };
222
+ }
223
+ if (outcome.kind === 'unbound') {
224
+ child.kill();
225
+ throw new Error('OpenCode started but its launch session could not be identified');
226
+ }
227
+ if (outcome.kind === 'error') throw outcome.error;
228
+ lastExit = outcome;
229
+ if (attempt < 2) port = await (options.allocatePort ?? allocateOpenCodePort)();
230
+ }
231
+ throw new Error(
232
+ `OpenCode exited before launch readiness after 3 attempts `
233
+ + `(code=${lastExit?.code ?? 'none'}, signal=${lastExit?.signal ?? 'none'})`,
234
+ );
192
235
  }
193
236
 
194
237
  export async function runAssimilateEntry(
@@ -510,7 +553,19 @@ async function main() {
510
553
  cli = configureSelectedLaunchCli(
511
554
  cli,
512
555
  launchAction,
513
- (selectedCli) => ensureResolvedCliConfigured(selectedCli, active),
556
+ (selectedCli) => {
557
+ ensureResolvedCliConfigured(selectedCli, active);
558
+ if (selectedCli === 'opencode' && active) {
559
+ const worktree = findProjectRoot(process.cwd());
560
+ provisionLaunchAccess(selectedCli, worktree, {
561
+ worktree,
562
+ scratch: scratchRootForSeat(homedir(), active.droneLabel, active.droneId),
563
+ // OpenCode does not grant Git internals; this field is used only by
564
+ // the Codex launch-argument path.
565
+ commonDir: worktree,
566
+ });
567
+ }
568
+ },
514
569
  );
515
570
 
516
571
  if (active && !parsedCli.force) {
@@ -682,7 +737,7 @@ async function main() {
682
737
  console.error(`${consolePrefix()}${chalk.blue(`◼ Launching ${cliDisplayName}…`)}`);
683
738
 
684
739
  const agentProcess = cli === 'opencode' && openCodeKickoff && openCodePort !== undefined
685
- ? launchOpenCodeProcess({
740
+ ? (await launchOpenCodeProcess({
686
741
  cwd: process.cwd(),
687
742
  port: openCodePort,
688
743
  prompt: openCodeKickoff.prompt,
@@ -691,7 +746,7 @@ async function main() {
691
746
  droneLabel: active?.droneLabel ?? 'opencode',
692
747
  cubeName: active?.name ?? 'borg',
693
748
  kickoff: openCodeKickoff,
694
- }).process
749
+ })).process
695
750
  : spawn(cli, launchArgs, { stdio: 'inherit', shell: false, env: launchEnv });
696
751
 
697
752
  // gh#857 WI-2: wake-target recording is codex-only (app-server bridge).
@@ -807,6 +862,11 @@ if (isEntryInvocation()) {
807
862
  process.stderr.write(`${error.message}\n`);
808
863
  process.exit(1);
809
864
  }
865
+ if (error?.code === 'ENOENT' && error?.path === 'opencode') {
866
+ console.error(`${consolePrefix()}${chalk.red('\n◼ Failed to launch opencode')}`);
867
+ console.error(`${consolePrefix()}${chalk.gray('Make sure opencode is installed.\n')}`);
868
+ process.exit(1);
869
+ }
810
870
  console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
811
871
  process.exit(1);
812
872
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execSync } from 'child_process';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import fs from 'fs';
9
10
  import path from 'path';
10
11
  import { fileURLToPath } from 'url';
@@ -23,7 +24,7 @@ import {
23
24
  import { shellEscape } from './shell-escape.js';
24
25
  import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
25
26
  import type { LaunchAccessPaths } from './launch-access.js';
26
- import { BORG_LAUNCH_EXPECTED_SEAT_ENV } from './cubes.js';
27
+ import { BORG_LAUNCH_EXPECTED_SEAT_ENV, type BorgCli } from './cubes.js';
27
28
  import {
28
29
  OPENCODE_SERVER_PASSWORD_ENV,
29
30
  OPENCODE_SERVER_PASSWORD_REFERENCE,
@@ -209,6 +210,132 @@ function writeJsonFile(p: string, data: any): void {
209
210
  fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
210
211
  }
211
212
 
213
+ interface OpenCodeProjectConfigSnapshot {
214
+ config: unknown;
215
+ identity: { dev: number; ino: number } | null;
216
+ mode: number;
217
+ }
218
+
219
+ function unsafeOpenCodeConfigPath(configPath: string, detail: string): Error {
220
+ return new Error(`OpenCode config path ${configPath} is unsafe: ${detail}`);
221
+ }
222
+
223
+ function assertOpenCodeProjectConfigDirectory(projectRoot: string, configPath: string): string {
224
+ const root = path.resolve(projectRoot);
225
+ let rootStat: fs.Stats;
226
+ try {
227
+ rootStat = fs.lstatSync(root);
228
+ } catch {
229
+ throw unsafeOpenCodeConfigPath(configPath, 'project root is missing or unreadable');
230
+ }
231
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory() || fs.realpathSync(root) !== root) {
232
+ throw unsafeOpenCodeConfigPath(configPath, 'project root must be a canonical real directory');
233
+ }
234
+
235
+ const configDir = path.join(root, '.opencode');
236
+ try {
237
+ fs.mkdirSync(configDir, { mode: 0o700 });
238
+ } catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
240
+ }
241
+ let directoryStat: fs.Stats;
242
+ try {
243
+ directoryStat = fs.lstatSync(configDir);
244
+ } catch {
245
+ throw unsafeOpenCodeConfigPath(configPath, '.opencode is missing or unreadable');
246
+ }
247
+ if (
248
+ directoryStat.isSymbolicLink()
249
+ || !directoryStat.isDirectory()
250
+ || fs.realpathSync(configDir) !== configDir
251
+ ) {
252
+ throw unsafeOpenCodeConfigPath(configPath, '.opencode must be a canonical real directory, not a symlink');
253
+ }
254
+ return configDir;
255
+ }
256
+
257
+ function inspectOpenCodeProjectConfigFile(configPath: string): fs.Stats | null {
258
+ let metadata: fs.Stats;
259
+ try {
260
+ metadata = fs.lstatSync(configPath);
261
+ } catch (error) {
262
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
263
+ throw error;
264
+ }
265
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.nlink !== 1) {
266
+ throw unsafeOpenCodeConfigPath(configPath, 'final path must be one regular file, not a symlink');
267
+ }
268
+ return metadata;
269
+ }
270
+
271
+ function readOpenCodeProjectConfig(projectRoot: string, configPath: string): OpenCodeProjectConfigSnapshot {
272
+ assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
273
+ const metadata = inspectOpenCodeProjectConfigFile(configPath);
274
+ if (!metadata) return { config: {}, identity: null, mode: 0o600 };
275
+
276
+ let descriptor: number | null = null;
277
+ try {
278
+ descriptor = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
279
+ const opened = fs.fstatSync(descriptor);
280
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== metadata.dev || opened.ino !== metadata.ino) {
281
+ throw unsafeOpenCodeConfigPath(configPath, 'final file changed while opening');
282
+ }
283
+ const text = fs.readFileSync(descriptor, 'utf8');
284
+ return {
285
+ config: text.trim() ? JSON.parse(text) : {},
286
+ identity: { dev: opened.dev, ino: opened.ino },
287
+ mode: opened.mode & 0o777,
288
+ };
289
+ } finally {
290
+ if (descriptor !== null) fs.closeSync(descriptor);
291
+ }
292
+ }
293
+
294
+ function writeOpenCodeProjectConfig(
295
+ projectRoot: string,
296
+ configPath: string,
297
+ data: unknown,
298
+ snapshot: OpenCodeProjectConfigSnapshot,
299
+ ): void {
300
+ const configDir = assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
301
+ const temporary = path.join(
302
+ configDir,
303
+ `.opencode.json.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,
304
+ );
305
+ let descriptor: number | null = null;
306
+ try {
307
+ descriptor = fs.openSync(
308
+ temporary,
309
+ fs.constants.O_WRONLY
310
+ | fs.constants.O_CREAT
311
+ | fs.constants.O_EXCL
312
+ | (fs.constants.O_NOFOLLOW ?? 0),
313
+ snapshot.mode,
314
+ );
315
+ fs.writeFileSync(descriptor, JSON.stringify(data, null, 2) + '\n', 'utf8');
316
+ fs.fsyncSync(descriptor);
317
+ fs.closeSync(descriptor);
318
+ descriptor = null;
319
+
320
+ assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
321
+ const current = inspectOpenCodeProjectConfigFile(configPath);
322
+ const unchanged = snapshot.identity === null
323
+ ? current === null
324
+ : current !== null
325
+ && current.dev === snapshot.identity.dev
326
+ && current.ino === snapshot.identity.ino;
327
+ if (!unchanged) {
328
+ throw unsafeOpenCodeConfigPath(configPath, 'final file changed before replacement');
329
+ }
330
+ fs.renameSync(temporary, configPath);
331
+ } finally {
332
+ if (descriptor !== null) {
333
+ try { fs.closeSync(descriptor); } catch { /* Preserve the primary failure. */ }
334
+ }
335
+ try { fs.unlinkSync(temporary); } catch { /* Renamed or never created. */ }
336
+ }
337
+ }
338
+
212
339
  /**
213
340
  * Register a Claude Code SessionStart hook that runs `borg-regen` at the
214
341
  * start of every session. Idempotent: re-running won't add duplicates.
@@ -1339,11 +1466,13 @@ export function addOpenCodeLaunchAccess(
1339
1466
  paths: LaunchAccessPaths,
1340
1467
  ): boolean {
1341
1468
  const configPath = path.join(projectRoot, '.opencode', 'opencode.json');
1469
+ let snapshot: OpenCodeProjectConfigSnapshot;
1342
1470
  let config: any;
1343
1471
  try {
1344
- config = readJsonFile(configPath);
1472
+ snapshot = readOpenCodeProjectConfig(projectRoot, configPath);
1473
+ config = snapshot.config;
1345
1474
  } catch (err: any) {
1346
- throw new Error(`Could not parse ${configPath}: ${err.message}`);
1475
+ throw new Error(`Could not safely read OpenCode config ${configPath}: ${err.message}`);
1347
1476
  }
1348
1477
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
1349
1478
  throw new Error(`OpenCode config ${configPath} is not an object`);
@@ -1394,10 +1523,33 @@ export function addOpenCodeLaunchAccess(
1394
1523
  config.permission = permissionObject;
1395
1524
 
1396
1525
  const changed = JSON.stringify(config) !== before;
1397
- if (changed) writeJsonFile(configPath, config);
1526
+ if (changed) {
1527
+ try {
1528
+ writeOpenCodeProjectConfig(projectRoot, configPath, config, snapshot);
1529
+ } catch (err: any) {
1530
+ throw new Error(`Could not safely update OpenCode config ${configPath}: ${err.message}`);
1531
+ }
1532
+ }
1398
1533
  return changed;
1399
1534
  }
1400
1535
 
1536
+ /** Provision the project-local path access required by the selected CLI. */
1537
+ export function provisionLaunchAccess(
1538
+ cli: BorgCli,
1539
+ projectRoot: string,
1540
+ paths: LaunchAccessPaths,
1541
+ ): void {
1542
+ if (cli === 'claude') {
1543
+ addClaudeLaunchAccess(projectRoot, paths);
1544
+ } else if (cli === 'codex') {
1545
+ // Codex receives path grants on its launch command. Its global hook reads
1546
+ // the scoped launch environment and only supplies the reminder.
1547
+ addCodexForeignPathReminderHook();
1548
+ } else {
1549
+ addOpenCodeLaunchAccess(projectRoot, paths);
1550
+ }
1551
+ }
1552
+
1401
1553
  /**
1402
1554
  * Add borg MCP server to OpenCode using `opencode mcp add` CLI.
1403
1555
  * Pins activation and agent-kind signals plus OpenCode config substitutions
package/src/index.ts CHANGED
@@ -153,7 +153,7 @@ import {
153
153
  writeOpenCodeStartupDiagnostic,
154
154
  } from './opencode-drone.js';
155
155
  import { installBorgPlugin } from './opencode-plugin.js';
156
- import { openCodeApiPasswordFromEnv } from './opencode-launch-trust.js';
156
+ import { openCodeApiPasswordFromEnv, openCodeLaunchCorrelationFromEnv } from './opencode-launch-trust.js';
157
157
  import { setModuleInjectOpenCode } from './log-stream.js';
158
158
  import {
159
159
  lifecycleSignalForMessage,
@@ -312,6 +312,11 @@ export async function connectOpenCodeRuntime(
312
312
  console.error('OpenCode API credential is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
313
313
  return false;
314
314
  }
315
+ const launchIdentity = openCodeLaunchCorrelationFromEnv(env);
316
+ if (launchIdentity === null) {
317
+ console.error('OpenCode launch identity is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
318
+ return false;
319
+ }
315
320
  const binding = openCodeLaunchBinding(configuredPort);
316
321
  await (deps.connect ?? connectOpenCodeDrone)({
317
322
  serverUrl: binding.serverUrl,
@@ -319,6 +324,7 @@ export async function connectOpenCodeRuntime(
319
324
  directory: active.worktree ?? findProjectRoot(),
320
325
  droneLabel: active.droneLabel,
321
326
  cubeName: active.name,
327
+ launchIdentity,
322
328
  });
323
329
  return true;
324
330
  }