borgmcp 3.10.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.
Files changed (78) hide show
  1. package/dist/assimilate-cmd.d.ts +14 -1
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +27 -15
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/claude.d.ts.map +1 -1
  6. package/dist/claude.js +33 -4
  7. package/dist/claude.js.map +1 -1
  8. package/dist/cli-help.d.ts +2 -0
  9. package/dist/cli-help.d.ts.map +1 -1
  10. package/dist/cli-help.js +28 -1
  11. package/dist/cli-help.js.map +1 -1
  12. package/dist/clone-cmd.d.ts +28 -0
  13. package/dist/clone-cmd.d.ts.map +1 -0
  14. package/dist/clone-cmd.js +227 -0
  15. package/dist/clone-cmd.js.map +1 -0
  16. package/dist/clone-security.d.ts +9 -0
  17. package/dist/clone-security.d.ts.map +1 -0
  18. package/dist/clone-security.js +41 -0
  19. package/dist/clone-security.js.map +1 -0
  20. package/dist/config-utils.d.ts +13 -12
  21. package/dist/config-utils.d.ts.map +1 -1
  22. package/dist/config-utils.js +150 -27
  23. package/dist/config-utils.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/launch-all-cmd.d.ts +11 -0
  28. package/dist/launch-all-cmd.d.ts.map +1 -1
  29. package/dist/launch-all-cmd.js +24 -2
  30. package/dist/launch-all-cmd.js.map +1 -1
  31. package/dist/opencode-drone.d.ts +10 -8
  32. package/dist/opencode-drone.d.ts.map +1 -1
  33. package/dist/opencode-drone.js +56 -46
  34. package/dist/opencode-drone.js.map +1 -1
  35. package/dist/opencode-launch-trust.d.ts +13 -0
  36. package/dist/opencode-launch-trust.d.ts.map +1 -0
  37. package/dist/opencode-launch-trust.js +40 -0
  38. package/dist/opencode-launch-trust.js.map +1 -0
  39. package/dist/opencode-plugin.d.ts +3 -0
  40. package/dist/opencode-plugin.d.ts.map +1 -1
  41. package/dist/opencode-plugin.js +26 -1
  42. package/dist/opencode-plugin.js.map +1 -1
  43. package/dist/parse-clone-args.d.ts +17 -0
  44. package/dist/parse-clone-args.d.ts.map +1 -0
  45. package/dist/parse-clone-args.js +38 -0
  46. package/dist/parse-clone-args.js.map +1 -0
  47. package/dist/parse-quickstart-args.d.ts +18 -0
  48. package/dist/parse-quickstart-args.d.ts.map +1 -0
  49. package/dist/parse-quickstart-args.js +43 -0
  50. package/dist/parse-quickstart-args.js.map +1 -0
  51. package/dist/quickstart-cmd.d.ts +23 -0
  52. package/dist/quickstart-cmd.d.ts.map +1 -0
  53. package/dist/quickstart-cmd.js +357 -0
  54. package/dist/quickstart-cmd.js.map +1 -0
  55. package/dist/setup-confirm.d.ts +1 -1
  56. package/dist/setup-confirm.js +3 -3
  57. package/dist/setup-confirm.js.map +1 -1
  58. package/dist/unknown-subcommand.d.ts +1 -1
  59. package/dist/unknown-subcommand.d.ts.map +1 -1
  60. package/dist/unknown-subcommand.js +2 -0
  61. package/dist/unknown-subcommand.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/assimilate-cmd.ts +48 -15
  64. package/src/claude.ts +38 -4
  65. package/src/cli-help.ts +34 -1
  66. package/src/clone-cmd.ts +243 -0
  67. package/src/clone-security.ts +40 -0
  68. package/src/config-utils.ts +147 -23
  69. package/src/index.ts +7 -0
  70. package/src/launch-all-cmd.ts +37 -2
  71. package/src/opencode-drone.ts +71 -54
  72. package/src/opencode-launch-trust.ts +46 -0
  73. package/src/opencode-plugin.ts +31 -1
  74. package/src/parse-clone-args.ts +44 -0
  75. package/src/parse-quickstart-args.ts +54 -0
  76. package/src/quickstart-cmd.ts +396 -0
  77. package/src/setup-confirm.ts +3 -3
  78. package/src/unknown-subcommand.ts +2 -0
package/src/claude.ts CHANGED
@@ -42,8 +42,12 @@ import { runSpawn } from './spawn.js';
42
42
  import { buildClaudeLaunchArgs } from './claude-launch-args.js';
43
43
  import { parseCleanupArgs, runCleanup } from './cleanup-cmd.js';
44
44
  import { parseAssimilateArgs } from './parse-assimilate-args.js';
45
+ import { parseQuickstartArgs } from './parse-quickstart-args.js';
46
+ import { parseCloneArgs, safeCloneParseError } from './parse-clone-args.js';
45
47
  import { runAssimilate } from './assimilate-cmd.js';
46
48
  import { buildDefaultAssimilateDeps } from './assimilate-deps.js';
49
+ import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
50
+ import { buildDefaultCloneDeps, runClone } from './clone-cmd.js';
47
51
  import {
48
52
  parseResetLocalSeatArgs,
49
53
  runResetLocalSeat,
@@ -104,6 +108,12 @@ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
104
108
  import { configureResolvedCli } from './resolved-cli-config.js';
105
109
  import { installBorgPlugin } from './opencode-plugin.js';
106
110
  import { allocateOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff, openCodeLaunchBinding } from './opencode-drone.js';
111
+ import {
112
+ BORG_OPENCODE_LAUNCH_CORRELATION_ENV,
113
+ OPENCODE_SERVER_PASSWORD_ENV,
114
+ OPENCODE_SERVER_USERNAME,
115
+ OPENCODE_SERVER_USERNAME_ENV,
116
+ } from './opencode-launch-trust.js';
107
117
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
108
118
  import { isClientOwnedCubeInitArgv, runEarlyServerFacade } from './server-facade.js';
109
119
  import { runEarlyUpdate } from './update-cmd.js';
@@ -155,7 +165,13 @@ export function launchOpenCodeProcess(options: {
155
165
  process: ReturnType<typeof spawn>;
156
166
  } {
157
167
  const plan = createOpenCodeLaunchPlan(options.cwd, options.port, options.prompt, options.passthroughArgs);
158
- const launchEnv = { ...options.env, BORG_OPENCODE_PORT: plan.envPort };
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
+ };
159
175
  // OpenCode's bind can still race this allocation; client#298 tracks the
160
176
  // residual pre-bind window outside this slice.
161
177
  const child = (options.spawnProcess ?? spawn)('opencode', plan.launchArgs, {
@@ -165,6 +181,7 @@ export function launchOpenCodeProcess(options: {
165
181
  });
166
182
  (options.connect ?? connectOpenCodeDrone)({
167
183
  serverUrl: plan.serverUrl,
184
+ apiPassword: options.kickoff.apiPassword,
168
185
  directory: options.cwd,
169
186
  droneLabel: options.droneLabel,
170
187
  cubeName: options.cubeName,
@@ -251,6 +268,24 @@ async function main() {
251
268
  const code = await runAssimilateEntry(process.argv.slice(3));
252
269
  process.exit(code);
253
270
  }
271
+ if (process.argv[2] === 'clone') {
272
+ const parsed = parseCloneArgs(process.argv.slice(3));
273
+ if (!parsed.ok) {
274
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg clone: ${safeCloneParseError(parsed)}\n`));
275
+ process.stderr.write(`Run \`borg clone --help\` for usage.\n`);
276
+ process.exit(1);
277
+ }
278
+ process.exit(await runClone(parsed.args, buildDefaultCloneDeps()));
279
+ }
280
+ if (process.argv[2] === 'quickstart') {
281
+ const parsed = parseQuickstartArgs(process.argv.slice(3));
282
+ if (!parsed.ok) {
283
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg quickstart: ${parsed.error}\n`));
284
+ process.stderr.write(`Run \`borg quickstart --help\` for usage.\n`);
285
+ process.exit(1);
286
+ }
287
+ process.exit(await runQuickstart(parsed.args, buildDefaultQuickstartDeps()));
288
+ }
254
289
  if (process.argv[2] === 'reset-local-connection') {
255
290
  const parsed = parseResetLocalSeatArgs(process.argv.slice(3));
256
291
  if (!parsed.ok) {
@@ -604,9 +639,8 @@ async function main() {
604
639
  monitorClause,
605
640
  codexWakePathClause,
606
641
  });
607
- // This stays separate from the shared kickoff so Claude and Codex preserve
608
- // their existing launch prompts. OpenCode records this nonce-bearing copy
609
- // and later uses the nonce to bind its separately spawned MCP child.
642
+ // This stays separate from the shared kickoff so OpenCode can carry
643
+ // launch-only API and correlation identities outside argv and prompt text.
610
644
  let openCodeKickoff: ReturnType<typeof createOpenCodeLaunchKickoff> | null = null;
611
645
  let openCodePort: number | undefined;
612
646
  let launchArgs: string[];
package/src/cli-help.ts CHANGED
@@ -51,6 +51,35 @@ export function launchAllHelpText(version: string): string {
51
51
  );
52
52
  }
53
53
 
54
+ export function quickstartHelpText(version: string): string {
55
+ return (
56
+ `borg quickstart (borgmcp ${version}) — create, staff, and launch this repository's cube\n\n` +
57
+ `Usage:\n` +
58
+ ` borg quickstart [options]\n\n` +
59
+ `Options:\n` +
60
+ ` --template ${NEW_CUBE_TEMPLATE_OPTIONS} Choose the new-cube template without prompting\n` +
61
+ ` --role <slug>[:<count>] Fully specify the roster (repeatable)\n` +
62
+ ` --yes, -y Accept the displayed plan\n` +
63
+ ` --help, -h Show this help\n\n` +
64
+ `Quickstart requires a running Borg server and never starts one. Rerun the same\n` +
65
+ `command after a partial failure; existing drones are kept and skipped.\n`
66
+ );
67
+ }
68
+
69
+ export function cloneHelpText(version: string): string {
70
+ return (
71
+ `borg clone (borgmcp ${version}) — clone a repository and run quickstart\n\n` +
72
+ `Usage:\n` +
73
+ ` borg clone <repository-url> [directory] [--no-launch]\n\n` +
74
+ `Options:\n` +
75
+ ` --no-launch Stop after the checkout is ready; do not create or launch a cube\n` +
76
+ ` --help, -h Show this help\n\n` +
77
+ `A repeated command reuses a checkout only when its origin matches. Default\n` +
78
+ `destination names avoid non-repository collisions. Credential-bearing URLs are\n` +
79
+ `refused; use a Git credential helper or SSH configuration instead.\n`
80
+ );
81
+ }
82
+
54
83
  export function seatsHelpText(version: string): string {
55
84
  return (
56
85
  `borg drones (borgmcp ${version}) — list this machine's registered drones\n\n` +
@@ -91,7 +120,9 @@ export function clientSubcommandHelpText(
91
120
  if (!args.some(isHelpFlag)) return null;
92
121
  switch (command) {
93
122
  case 'setup': return setupHelpText(version);
123
+ case 'clone': return cloneHelpText(version);
94
124
  case 'assimilate': return assimilateHelpText(version);
125
+ case 'quickstart': return quickstartHelpText(version);
95
126
  case 'reset-local-connection': return resetLocalSeatHelpText(version);
96
127
  case 'recover-enrollment': return recoverEnrollmentHelpText(version);
97
128
  case 'cleanup': return cleanupHelpText(version);
@@ -107,7 +138,7 @@ export function setupNextStepsText(): string {
107
138
  return (
108
139
  `◼ Next steps:\n` +
109
140
  `1. Run \`borg server start\` and leave that terminal open.\n` +
110
- `2. In a second terminal, cd into your project's Git repository and run \`borg assimilate\`.\n`
141
+ `2. In a second terminal, cd into your project's Git repository and run \`borg quickstart\`.\n`
111
142
  );
112
143
  }
113
144
 
@@ -129,6 +160,8 @@ export function topLevelHelpText(version: string): string {
129
160
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
130
161
  ` borg update Update the client and installed local server together\n` +
131
162
  ` borg doctor Check agent hook commands, versions, configs, and the OpenCode plugin\n` +
163
+ ` borg clone <url> [dir] Clone a repository, then create and launch its cube\n` +
164
+ ` borg quickstart Create a cube and a drone for every role, then launch them\n` +
132
165
  ` borg assimilate [role] Join or create a cube\n` +
133
166
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
134
167
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
@@ -0,0 +1,243 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { basename, dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { hasCloneCredentials, redactCloneSecrets } from './clone-security.js';
6
+ import type { CloneArgs } from './parse-clone-args.js';
7
+ import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
8
+ import { shellEscape } from './shell-escape.js';
9
+
10
+ export interface GitRunResult {
11
+ status: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ }
15
+
16
+ export interface CloneDeps {
17
+ cwd: () => string;
18
+ chdir: (path: string) => void;
19
+ runSync: (cmd: string, args: string[], cwd?: string) => GitRunResult;
20
+ pathExists: (path: string) => boolean;
21
+ isDirectory: (path: string) => boolean;
22
+ readDirectory: (path: string) => string[];
23
+ createDirectory: (path: string) => boolean;
24
+ removeTree: (path: string) => void;
25
+ quickstart: (cwd: string) => Promise<number>;
26
+ stdout: (text: string) => void;
27
+ stderr: (text: string) => void;
28
+ }
29
+
30
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
31
+ const ALLOWED_SCHEMES = new Set(['file:', 'git:', 'git+ssh:', 'http:', 'https:', 'ssh:']);
32
+ const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
33
+
34
+ function defaultRunSync(cmd: string, args: string[], cwd?: string): GitRunResult {
35
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
36
+ return {
37
+ status: result.status,
38
+ stdout: result.stdout ?? '',
39
+ stderr: result.stderr ?? (result.error instanceof Error ? result.error.message : ''),
40
+ };
41
+ }
42
+
43
+ export function buildDefaultCloneDeps(): CloneDeps {
44
+ return {
45
+ cwd: () => process.cwd(),
46
+ chdir: (path) => process.chdir(path),
47
+ runSync: defaultRunSync,
48
+ pathExists: existsSync,
49
+ isDirectory: (path) => {
50
+ try { return statSync(path).isDirectory(); } catch { return false; }
51
+ },
52
+ readDirectory: (path) => readdirSync(path),
53
+ createDirectory: (path) => {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ try {
56
+ mkdirSync(path);
57
+ return true;
58
+ } catch (error) {
59
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') return false;
60
+ throw error;
61
+ }
62
+ },
63
+ removeTree: (path) => rmSync(path, { recursive: true, force: true }),
64
+ quickstart: async (cwd) => {
65
+ process.chdir(cwd);
66
+ return runQuickstart({ roles: [], yes: false }, buildDefaultQuickstartDeps());
67
+ },
68
+ stdout: (text) => process.stdout.write(text),
69
+ stderr: (text) => process.stderr.write(text),
70
+ };
71
+ }
72
+
73
+ export function validateCloneRepositoryUrl(value: string): { ok: true } | { ok: false; error: string } {
74
+ if (!value || value.trim() !== value || /\s/.test(value) || CONTROL_RE.test(value)) {
75
+ return { ok: false, error: 'repository URL contains whitespace or control characters' };
76
+ }
77
+ if (value.startsWith('-')) return { ok: false, error: 'repository URL must not start with a hyphen' };
78
+ if (hasCloneCredentials(value)) {
79
+ return { ok: false, error: 'credential-bearing repository URLs are not accepted; use a credential helper or SSH configuration instead' };
80
+ }
81
+ if (SCP_REMOTE_RE.test(value)) return { ok: true };
82
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1];
83
+ if (!scheme) return { ok: true }; // Local path.
84
+ if (!value.toLowerCase().startsWith(`${scheme.toLowerCase()}://`) && scheme.toLowerCase() !== 'file') {
85
+ return { ok: false, error: 'repository URL is not a supported hierarchical URL' };
86
+ }
87
+ try {
88
+ const parsed = new URL(value);
89
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) return { ok: false, error: `unsupported repository URL scheme ${parsed.protocol}` };
90
+ if (parsed.search || parsed.hash) return { ok: false, error: 'repository URLs with query strings or fragments are not accepted' };
91
+ if (parsed.protocol !== 'file:' && !parsed.hostname) return { ok: false, error: 'repository URL must include a host' };
92
+ if (!parsed.pathname || parsed.pathname === '/') return { ok: false, error: 'repository URL must include a repository path' };
93
+ return { ok: true };
94
+ } catch {
95
+ return { ok: false, error: 'repository URL is not valid' };
96
+ }
97
+ }
98
+
99
+ function sourceName(value: string): string {
100
+ let leaf = value;
101
+ try {
102
+ const parsed = new URL(value);
103
+ leaf = parsed.protocol === 'file:' ? fileURLToPath(parsed) : parsed.pathname;
104
+ } catch {
105
+ if (SCP_REMOTE_RE.test(value)) leaf = value.slice(value.indexOf(':') + 1);
106
+ }
107
+ const name = basename(leaf.replace(/\/+$/, '')).replace(/\.git$/i, '')
108
+ .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
109
+ return name || 'repository';
110
+ }
111
+
112
+ function remoteKey(value: string, base: string): string {
113
+ if (SCP_REMOTE_RE.test(value)) {
114
+ const colon = value.indexOf(':');
115
+ const login = value.slice(0, colon);
116
+ const at = login.lastIndexOf('@');
117
+ const user = login.slice(0, at);
118
+ const host = login.slice(at + 1).toLowerCase();
119
+ return `ssh://${user}@${host}/${value.slice(colon + 1).replace(/\/+$/, '').replace(/\.git$/i, '')}`;
120
+ }
121
+ try {
122
+ const parsed = new URL(value);
123
+ if (parsed.protocol === 'file:') return `file:${resolve(fileURLToPath(parsed))}`;
124
+ const user = parsed.username ? `${parsed.username}@` : '';
125
+ const port = parsed.port ? `:${parsed.port}` : '';
126
+ return `${parsed.protocol}//${user}${parsed.hostname.toLowerCase()}${port}${parsed.pathname.replace(/\/+$/, '').replace(/\.git$/i, '')}`;
127
+ } catch {
128
+ return `file:${resolve(base, value)}`;
129
+ }
130
+ }
131
+
132
+ function readOrigin(deps: CloneDeps, directory: string): string | null {
133
+ const result = deps.runSync('git', ['remote', 'get-url', 'origin'], directory);
134
+ return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
135
+ }
136
+
137
+ function repositoryAt(deps: CloneDeps, directory: string): boolean {
138
+ return deps.runSync('git', ['rev-parse', '--is-inside-work-tree'], directory).status === 0;
139
+ }
140
+
141
+ function validateDestination(value: string): string | null {
142
+ if (!value || value.trim() !== value || CONTROL_RE.test(value) || value.startsWith('-') || hasCloneCredentials(value)) {
143
+ return 'destination contains an unsafe path value';
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function chooseDestination(deps: CloneDeps, args: CloneArgs): { path: string } | { error: string } {
149
+ const base = deps.cwd();
150
+ if (args.destination !== undefined) {
151
+ const error = validateDestination(args.destination);
152
+ return error ? { error } : { path: resolve(base, args.destination) };
153
+ }
154
+ const name = sourceName(args.repositoryUrl);
155
+ for (let n = 1; n < 10_000; n += 1) {
156
+ const candidate = resolve(base, n === 1 ? name : `${name}-${n}`);
157
+ if (!deps.pathExists(candidate)) return { path: candidate };
158
+ if (deps.isDirectory(candidate) && repositoryAt(deps, candidate)) {
159
+ const origin = readOrigin(deps, candidate);
160
+ if (origin && !hasCloneCredentials(origin) && remoteKey(origin, candidate) === remoteKey(args.repositoryUrl, base)) {
161
+ return { path: candidate };
162
+ }
163
+ if (n === 1) return { error: `existing checkout at ${candidate} points at another remote; choose an explicit destination` };
164
+ }
165
+ }
166
+ return { error: `could not find a collision-safe destination for ${name}` };
167
+ }
168
+
169
+ export async function runClone(args: CloneArgs, rawDeps: CloneDeps): Promise<number> {
170
+ const deps: CloneDeps = {
171
+ ...rawDeps,
172
+ stdout: (text) => rawDeps.stdout(redactCloneSecrets(text)),
173
+ stderr: (text) => rawDeps.stderr(redactCloneSecrets(text)),
174
+ };
175
+ const valid = validateCloneRepositoryUrl(args.repositoryUrl);
176
+ if (!valid.ok) {
177
+ deps.stderr(`borg clone: ${valid.error}.\n`);
178
+ return 1;
179
+ }
180
+ const selected = chooseDestination(deps, args);
181
+ if ('error' in selected) {
182
+ deps.stderr(`borg clone: ${selected.error}.\n`);
183
+ return 1;
184
+ }
185
+ const destination = selected.path;
186
+ let cloned = false;
187
+ const destinationExisted = deps.pathExists(destination);
188
+ const emptyDestination = destinationExisted && deps.isDirectory(destination) && deps.readDirectory(destination).length === 0;
189
+ if (destinationExisted && !emptyDestination) {
190
+ if (!deps.isDirectory(destination) || !repositoryAt(deps, destination)) {
191
+ deps.stderr(`borg clone: destination ${destination} exists and is not a Git checkout; it was left untouched.\n`);
192
+ return 1;
193
+ }
194
+ const origin = readOrigin(deps, destination);
195
+ if (!origin || hasCloneCredentials(origin)) {
196
+ deps.stderr(`borg clone: the existing checkout has no safe, readable origin remote; it was left untouched.\n`);
197
+ return 1;
198
+ }
199
+ if (remoteKey(origin, destination) !== remoteKey(args.repositoryUrl, deps.cwd())) {
200
+ deps.stderr(`borg clone: remote mismatch at ${destination}; existing ${origin}, requested ${args.repositoryUrl}. The checkout was left untouched.\n`);
201
+ return 1;
202
+ }
203
+ deps.stdout(`Reusing existing checkout at ${destination}; its origin remote matches.\n`);
204
+ } else {
205
+ const createdDestination = destinationExisted ? false : deps.createDirectory(destination);
206
+ if (!destinationExisted && !createdDestination) {
207
+ deps.stderr(`borg clone: destination ${destination} appeared while preparing the clone; it was left untouched. Retry with another directory.\n`);
208
+ return 1;
209
+ }
210
+ const result = deps.runSync('git', ['clone', '--', args.repositoryUrl, destination], deps.cwd());
211
+ if (result.status !== 0) {
212
+ if (createdDestination && deps.pathExists(destination)) {
213
+ deps.removeTree(destination);
214
+ }
215
+ deps.stderr(
216
+ `borg clone: clone failed${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}.\n` +
217
+ `Rollback: ${destinationExisted
218
+ ? `the pre-existing empty destination ${destination} was preserved; inspect it for partial Git files`
219
+ : deps.pathExists(destination) ? `partial checkout remains at ${destination}` : `the directory borg created at ${destination} was removed`}.\n`,
220
+ );
221
+ return 1;
222
+ }
223
+ cloned = true;
224
+ deps.stdout(`Cloned ${sourceName(args.repositoryUrl)} into ${destination}.\n`);
225
+ }
226
+
227
+ if (args.noLaunch) {
228
+ deps.stdout(
229
+ `Checkout ready at ${destination}. No cube or drone was created.\n` +
230
+ `Next: cd ${shellEscape(destination)} && borg quickstart\n`,
231
+ );
232
+ return 0;
233
+ }
234
+ deps.chdir(destination);
235
+ const code = await deps.quickstart(destination);
236
+ if (code !== 0) {
237
+ deps.stderr(
238
+ `The checkout${cloned ? '' : ' you already had'} is ready at ${destination}, but quickstart did not finish. ` +
239
+ `It was left untouched; cd there and run \`borg quickstart\` again.\n`,
240
+ );
241
+ }
242
+ return code;
243
+ }
@@ -0,0 +1,40 @@
1
+ /** Redaction and fail-closed credential detection for untrusted Git remotes. */
2
+
3
+ const URL_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+)@/gi;
4
+ const URL_SUFFIX_RE = /([a-z][a-z0-9+.-]*:\/\/[^\s?#)]+)([?#])[^\s)]*/gi;
5
+ const NAMED_SECRET_RE = /([?&](?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^&#\s)]+/gi;
6
+ const NAMED_SECRET_TEST_RE = new RegExp(NAMED_SECRET_RE.source, 'i');
7
+ const OPTION_SECRET_RE = /((?:^|[\s("'`])--(?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^\s)]*/gi;
8
+ const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
9
+
10
+ export function redactCloneSecrets(value: string): string {
11
+ return value
12
+ .replace(URL_USERINFO_RE, '$1<credentials>@')
13
+ .replace(URL_SUFFIX_RE, '$1$2<redacted>')
14
+ .replace(NAMED_SECRET_RE, '$1<redacted>')
15
+ .replace(OPTION_SECRET_RE, '$1<redacted>')
16
+ .replace(/(^|[\s("'`])[^/\s:@]+:[^@\s/]+@(?=[^\s/])/g, '$1<credentials>@');
17
+ }
18
+
19
+ /**
20
+ * True means the value must never reach subprocess argv, output, or Git config.
21
+ * Opaque `scheme:...@...` forms fail closed; URL() accepts those but exposes no
22
+ * username/password fields, which is precisely the class that escaped the old flow.
23
+ */
24
+ export function hasCloneCredentials(value: string): boolean {
25
+ if (NAMED_SECRET_TEST_RE.test(value)) return true;
26
+ if (SCP_REMOTE_RE.test(value)) return false;
27
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1].toLowerCase();
28
+ if (scheme && !value.toLowerCase().startsWith(`${scheme}://`) && scheme !== 'file') {
29
+ return value.includes('@');
30
+ }
31
+ try {
32
+ const parsed = new URL(value);
33
+ if (parsed.protocol === 'ssh:' || parsed.protocol === 'git+ssh:') {
34
+ return parsed.password.length > 0;
35
+ }
36
+ return parsed.username.length > 0 || parsed.password.length > 0;
37
+ } catch {
38
+ return /[^/\s:@]+:[^@\s/]+@/.test(value);
39
+ }
40
+ }
@@ -24,6 +24,10 @@ import { shellEscape } from './shell-escape.js';
24
24
  import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
25
25
  import type { LaunchAccessPaths } from './launch-access.js';
26
26
  import { BORG_LAUNCH_EXPECTED_SEAT_ENV } from './cubes.js';
27
+ import {
28
+ OPENCODE_SERVER_PASSWORD_ENV,
29
+ OPENCODE_SERVER_PASSWORD_REFERENCE,
30
+ } from './opencode-launch-trust.js';
27
31
 
28
32
  // Get __dirname equivalent in ESM
29
33
  const __filename = fileURLToPath(import.meta.url);
@@ -53,6 +57,7 @@ const CLAUDE_CONFIG_PATH = path.join(CONFIG_HOME, '.claude.json');
53
57
  const CODEX_CONFIG_PATH = path.join(CONFIG_HOME, '.codex', 'config.toml');
54
58
  const CODEX_HOOKS_PATH = path.join(CONFIG_HOME, '.codex', 'hooks.json');
55
59
  const OPENCODE_CONFIG_PATH = path.join(CONFIG_HOME, '.config', 'opencode', 'opencode.json');
60
+ const OPENCODE_GLOBAL_CONFIG_FILENAMES = ['config.json', 'opencode.json', 'opencode.jsonc'] as const;
56
61
  const MCP_SERVER_NAME = 'borg';
57
62
 
58
63
  function settingsPath(): string {
@@ -80,6 +85,125 @@ function readJsonFile(p: string): any {
80
85
  return JSON.parse(text);
81
86
  }
82
87
 
88
+ function parseJsonc(text: string): unknown {
89
+ let stripped = '';
90
+ let inString = false;
91
+ let escaped = false;
92
+ let lineComment = false;
93
+ let blockComment = false;
94
+
95
+ for (let index = 0; index < text.length; index++) {
96
+ const char = text[index];
97
+ const next = text[index + 1];
98
+ if (lineComment) {
99
+ if (char === '\n' || char === '\r') {
100
+ lineComment = false;
101
+ stripped += char;
102
+ } else {
103
+ stripped += ' ';
104
+ }
105
+ continue;
106
+ }
107
+ if (blockComment) {
108
+ if (char === '*' && next === '/') {
109
+ stripped += ' ';
110
+ index++;
111
+ blockComment = false;
112
+ } else {
113
+ stripped += char === '\n' || char === '\r' ? char : ' ';
114
+ }
115
+ continue;
116
+ }
117
+ if (inString) {
118
+ stripped += char;
119
+ if (escaped) escaped = false;
120
+ else if (char === '\\') escaped = true;
121
+ else if (char === '"') inString = false;
122
+ continue;
123
+ }
124
+ if (char === '"') {
125
+ inString = true;
126
+ stripped += char;
127
+ continue;
128
+ }
129
+ if (char === '/' && next === '/') {
130
+ stripped += ' ';
131
+ index++;
132
+ lineComment = true;
133
+ continue;
134
+ }
135
+ if (char === '/' && next === '*') {
136
+ stripped += ' ';
137
+ index++;
138
+ blockComment = true;
139
+ continue;
140
+ }
141
+ stripped += char;
142
+ }
143
+
144
+ if (inString || blockComment) throw new SyntaxError('unterminated JSONC token');
145
+
146
+ let withoutTrailingCommas = '';
147
+ inString = false;
148
+ escaped = false;
149
+ for (let index = 0; index < stripped.length; index++) {
150
+ const char = stripped[index];
151
+ if (inString) {
152
+ withoutTrailingCommas += char;
153
+ if (escaped) escaped = false;
154
+ else if (char === '\\') escaped = true;
155
+ else if (char === '"') inString = false;
156
+ continue;
157
+ }
158
+ if (char === '"') {
159
+ inString = true;
160
+ withoutTrailingCommas += char;
161
+ continue;
162
+ }
163
+ if (char === ',') {
164
+ let lookahead = index + 1;
165
+ while (/\s/.test(stripped[lookahead] ?? '')) lookahead++;
166
+ if (stripped[lookahead] === '}' || stripped[lookahead] === ']') continue;
167
+ }
168
+ withoutTrailingCommas += char;
169
+ }
170
+
171
+ return JSON.parse(withoutTrailingCommas.replace(/^\uFEFF/, ''));
172
+ }
173
+
174
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
175
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
176
+ }
177
+
178
+ function mergeOpenCodeConfigValue(target: unknown, source: unknown): unknown {
179
+ if (!isPlainRecord(target) || !isPlainRecord(source)) return source;
180
+ const merged = Object.assign(Object.create(null) as Record<string, unknown>, target);
181
+ for (const [key, value] of Object.entries(source)) {
182
+ merged[key] = key in merged ? mergeOpenCodeConfigValue(merged[key], value) : value;
183
+ }
184
+ return merged;
185
+ }
186
+
187
+ function readOpenCodeGlobalConfig(configPath: string): Record<string, unknown> {
188
+ const basename = path.basename(configPath);
189
+ const paths = OPENCODE_GLOBAL_CONFIG_FILENAMES.includes(
190
+ basename as typeof OPENCODE_GLOBAL_CONFIG_FILENAMES[number],
191
+ )
192
+ ? OPENCODE_GLOBAL_CONFIG_FILENAMES.map((filename) => path.join(path.dirname(configPath), filename))
193
+ : [configPath];
194
+ let merged: unknown = {};
195
+ for (const candidate of paths) {
196
+ if (!fs.existsSync(candidate)) continue;
197
+ const text = fs.readFileSync(candidate, 'utf-8');
198
+ if (text.length === 0) continue;
199
+ const parsed = parseJsonc(text);
200
+ if (!isPlainRecord(parsed)) throw new TypeError(`OpenCode config is not an object: ${candidate}`);
201
+ merged = mergeOpenCodeConfigValue(merged, parsed);
202
+ }
203
+ if (!isPlainRecord(merged)) throw new TypeError('OpenCode global config is not an object');
204
+ return merged;
205
+ }
206
+
83
207
  function writeJsonFile(p: string, data: any): void {
84
208
  fs.mkdirSync(path.dirname(p), { recursive: true });
85
209
  fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
@@ -1162,22 +1286,18 @@ export function isCodexUserPromptSubmitHookRegistered(hooksPath: string = CODEX_
1162
1286
  // ─── OpenCode MCP integration ────────────────────────────────────────────
1163
1287
 
1164
1288
  /**
1165
- * Detect whether the borg MCP server is already registered in the opencode
1166
- * config (`~/.config/opencode/opencode.json` `mcp.borg`).
1289
+ * Detect whether the borg MCP server is registered in OpenCode's effective
1290
+ * global config. OpenCode 1.18.15 loads `config.json`, `opencode.json`, then
1291
+ * `opencode.jsonc`, merging later files over earlier ones.
1167
1292
  *
1168
- * Reads the config as JSON and checks for a `mcp.borg` entry with
1293
+ * Reads JSON or JSONC and checks the effective `mcp.borg` entry for
1169
1294
  * `type: "local"`. Safe-default: any read error returns `false`.
1170
1295
  */
1171
1296
  export function isOpenCodeMcpServerConfigured(
1172
1297
  configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json')
1173
1298
  ): boolean {
1174
1299
  try {
1175
- if (!fs.existsSync(configPath)) return false;
1176
- const text = fs.readFileSync(configPath, 'utf-8');
1177
- if (!text.trim()) return false;
1178
- const parsed = JSON.parse(text);
1179
- if (!parsed || typeof parsed !== 'object') return false;
1180
- const borgServer = (parsed as any).mcp?.borg;
1300
+ const borgServer = (readOpenCodeGlobalConfig(configPath) as any).mcp?.borg;
1181
1301
  if (!borgServer || typeof borgServer !== 'object') return false;
1182
1302
  return borgServer.type === 'local';
1183
1303
  } catch {
@@ -1186,22 +1306,24 @@ export function isOpenCodeMcpServerConfigured(
1186
1306
  }
1187
1307
 
1188
1308
  /**
1189
- * Launch-time OpenCode registration check. Ordinary launches retain the
1190
- * existing configured/not-configured behavior. A targeted `borg launch`,
1191
- * identified by its non-empty expected-seat marker, additionally requires the
1192
- * config substitution that carries that marker into OpenCode's MCP child.
1309
+ * Launch-time OpenCode registration check. Every launch requires the exact
1310
+ * password substitution that carries its ephemeral credential into OpenCode's
1311
+ * MCP child. A targeted `borg launch`, identified by its non-empty expected-seat
1312
+ * marker, additionally requires the substitution that carries that marker.
1193
1313
  */
1194
1314
  export function isOpenCodeMcpServerConfiguredForLaunch(
1195
1315
  configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json'),
1196
1316
  env: NodeJS.ProcessEnv = process.env,
1197
1317
  ): boolean {
1198
1318
  if (!isOpenCodeMcpServerConfigured(configPath)) return false;
1199
- if (!env[BORG_LAUNCH_EXPECTED_SEAT_ENV]) return true;
1200
1319
  try {
1201
- const borgServer = readJsonFile(configPath)?.mcp?.borg;
1320
+ const borgServer = (readOpenCodeGlobalConfig(configPath) as any).mcp?.borg;
1202
1321
  const environment = borgServer?.environment ?? borgServer?.env;
1203
- return environment?.[BORG_LAUNCH_EXPECTED_SEAT_ENV] ===
1204
- OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE;
1322
+ if (environment?.[OPENCODE_SERVER_PASSWORD_ENV] !== OPENCODE_SERVER_PASSWORD_REFERENCE) {
1323
+ return false;
1324
+ }
1325
+ if (!env[BORG_LAUNCH_EXPECTED_SEAT_ENV]) return true;
1326
+ return environment?.[BORG_LAUNCH_EXPECTED_SEAT_ENV] === OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE;
1205
1327
  } catch {
1206
1328
  return false;
1207
1329
  }
@@ -1278,11 +1400,11 @@ export function addOpenCodeLaunchAccess(
1278
1400
 
1279
1401
  /**
1280
1402
  * Add borg MCP server to OpenCode using `opencode mcp add` CLI.
1281
- * Pins activation and agent-kind signals plus an OpenCode config substitution
1282
- * for the launch-scoped expected seat. OpenCode resolves `{env:NAME}` from its
1283
- * own launch environment before starting the MCP child, so `borg launch`
1284
- * reaches the identity check without persisting one launch's value. Existing
1285
- * configs with BORG_OPENCODE remain supported by the runtime fallback.
1403
+ * Pins activation and agent-kind signals plus OpenCode config substitutions
1404
+ * for the launch-scoped password and expected seat. OpenCode resolves
1405
+ * `{env:NAME}` from its own launch environment before starting the MCP child,
1406
+ * so no launch credential is persisted. Existing configs with BORG_OPENCODE
1407
+ * remain supported by the runtime fallback after launch-time self-healing.
1286
1408
  */
1287
1409
  export function addOpenCodeMcpServer(): void {
1288
1410
  try {
@@ -1295,8 +1417,10 @@ export function addOpenCodeMcpServer(): void {
1295
1417
  : '';
1296
1418
  const launchExpectedSeatEnvArg =
1297
1419
  ` --env ${BORG_LAUNCH_EXPECTED_SEAT_ENV}=${shellQuote(OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE)}`;
1420
+ const apiPasswordEnvArg =
1421
+ ` --env ${OPENCODE_SERVER_PASSWORD_ENV}=${shellQuote(OPENCODE_SERVER_PASSWORD_REFERENCE)}`;
1298
1422
  execSync(
1299
- `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg}${launchExpectedSeatEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
1423
+ `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg}${launchExpectedSeatEnvArg}${apiPasswordEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
1300
1424
  { stdio: 'inherit', env: borgAgentConfigEnv(process.env) }
1301
1425
  );
1302
1426
  } catch (error: any) {