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.
- package/dist/assimilate-cmd.d.ts +14 -1
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +27 -15
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +33 -4
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +28 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/clone-cmd.d.ts +28 -0
- package/dist/clone-cmd.d.ts.map +1 -0
- package/dist/clone-cmd.js +227 -0
- package/dist/clone-cmd.js.map +1 -0
- package/dist/clone-security.d.ts +9 -0
- package/dist/clone-security.d.ts.map +1 -0
- package/dist/clone-security.js +41 -0
- package/dist/clone-security.js.map +1 -0
- package/dist/config-utils.d.ts +13 -12
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +150 -27
- package/dist/config-utils.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/launch-all-cmd.d.ts +11 -0
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +24 -2
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/opencode-drone.d.ts +10 -8
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +56 -46
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-launch-trust.d.ts +13 -0
- package/dist/opencode-launch-trust.d.ts.map +1 -0
- package/dist/opencode-launch-trust.js +40 -0
- package/dist/opencode-launch-trust.js.map +1 -0
- package/dist/opencode-plugin.d.ts +3 -0
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +26 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/parse-clone-args.d.ts +17 -0
- package/dist/parse-clone-args.d.ts.map +1 -0
- package/dist/parse-clone-args.js +38 -0
- package/dist/parse-clone-args.js.map +1 -0
- package/dist/parse-quickstart-args.d.ts +18 -0
- package/dist/parse-quickstart-args.d.ts.map +1 -0
- package/dist/parse-quickstart-args.js +43 -0
- package/dist/parse-quickstart-args.js.map +1 -0
- package/dist/quickstart-cmd.d.ts +23 -0
- package/dist/quickstart-cmd.d.ts.map +1 -0
- package/dist/quickstart-cmd.js +357 -0
- package/dist/quickstart-cmd.js.map +1 -0
- package/dist/setup-confirm.d.ts +1 -1
- package/dist/setup-confirm.js +3 -3
- package/dist/setup-confirm.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +2 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +48 -15
- package/src/claude.ts +38 -4
- package/src/cli-help.ts +34 -1
- package/src/clone-cmd.ts +243 -0
- package/src/clone-security.ts +40 -0
- package/src/config-utils.ts +147 -23
- package/src/index.ts +7 -0
- package/src/launch-all-cmd.ts +37 -2
- package/src/opencode-drone.ts +71 -54
- package/src/opencode-launch-trust.ts +46 -0
- package/src/opencode-plugin.ts +31 -1
- package/src/parse-clone-args.ts +44 -0
- package/src/parse-quickstart-args.ts +54 -0
- package/src/quickstart-cmd.ts +396 -0
- package/src/setup-confirm.ts +3 -3
- package/src/unknown-subcommand.ts +2 -0
package/src/index.ts
CHANGED
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
openCodeLaunchBinding,
|
|
140
140
|
} from './opencode-drone.js';
|
|
141
141
|
import { installBorgPlugin } from './opencode-plugin.js';
|
|
142
|
+
import { openCodeApiPasswordFromEnv } from './opencode-launch-trust.js';
|
|
142
143
|
import { setModuleInjectOpenCode } from './log-stream.js';
|
|
143
144
|
import {
|
|
144
145
|
lifecycleSignalForMessage,
|
|
@@ -261,9 +262,15 @@ export async function connectOpenCodeRuntime(
|
|
|
261
262
|
console.error(OPEN_CODE_PORT_MISSING_DIAGNOSTIC);
|
|
262
263
|
return false;
|
|
263
264
|
}
|
|
265
|
+
const apiPassword = openCodeApiPasswordFromEnv(env);
|
|
266
|
+
if (apiPassword === null) {
|
|
267
|
+
console.error('OpenCode API credential is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
264
270
|
const binding = openCodeLaunchBinding(configuredPort);
|
|
265
271
|
await (deps.connect ?? connectOpenCodeDrone)({
|
|
266
272
|
serverUrl: binding.serverUrl,
|
|
273
|
+
apiPassword,
|
|
267
274
|
directory: active.worktree ?? findProjectRoot(),
|
|
268
275
|
droneLabel: active.droneLabel,
|
|
269
276
|
cubeName: active.name,
|
package/src/launch-all-cmd.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { runPastelistBackend } from './backends/launch-all-pastelist.js';
|
|
|
16
16
|
|
|
17
17
|
type Backend = 'tmux' | 'terminals' | 'pastelist';
|
|
18
18
|
|
|
19
|
+
/** Internal strict-seam result: requested sessions were not dispatched. */
|
|
20
|
+
export const LAUNCH_ALL_NO_DISPATCH_EXIT_CODE = 2;
|
|
21
|
+
|
|
19
22
|
const TMUX_INSTALL_HINT =
|
|
20
23
|
'borg launch-all: tmux not found.\n' +
|
|
21
24
|
' macOS: brew install tmux\n' +
|
|
@@ -239,6 +242,12 @@ export interface RunLaunchAllOptions {
|
|
|
239
242
|
sleep?: (ms: number) => Promise<void>;
|
|
240
243
|
nowISO?: () => string;
|
|
241
244
|
borgPath?: string;
|
|
245
|
+
/** Internal quickstart filter: launch only the requested staffed roster. */
|
|
246
|
+
droneIds?: readonly string[];
|
|
247
|
+
/** Fail when any requested registered drone cannot be discovered or launched. */
|
|
248
|
+
requireAllRequested?: boolean;
|
|
249
|
+
/** Internal resolved target avoids ambiguous same-name lookup during composition. */
|
|
250
|
+
targetCube?: { cubeId: string; name: string };
|
|
242
251
|
}
|
|
243
252
|
|
|
244
253
|
export async function runLaunchAll(
|
|
@@ -253,7 +262,7 @@ export async function runLaunchAll(
|
|
|
253
262
|
const launchDelayMs = resolveLaunchDelayMs(args.flags.launchDelayMs, deps.getEnv('BORG_LAUNCH_DELAY_MS'));
|
|
254
263
|
|
|
255
264
|
// 1. resolve target cube
|
|
256
|
-
const resolved = await resolveTargetCube(args, deps);
|
|
265
|
+
const resolved = opts.targetCube ?? await resolveTargetCube(args, deps);
|
|
257
266
|
if ('error' in resolved) {
|
|
258
267
|
deps.stderr(`borg launch-all: ${resolved.error}\n`);
|
|
259
268
|
return 1;
|
|
@@ -264,7 +273,18 @@ export async function runLaunchAll(
|
|
|
264
273
|
sweepStaleLocks(deps, cubeId, now());
|
|
265
274
|
|
|
266
275
|
// 3. discover candidates
|
|
267
|
-
|
|
276
|
+
let discovered = await discoverDroneCandidates({ targetCubeId: cubeId, only: args.flags.only }, deps);
|
|
277
|
+
if (opts.droneIds !== undefined) {
|
|
278
|
+
const requested = new Set(opts.droneIds);
|
|
279
|
+
discovered = discovered.filter((candidate) => requested.has(candidate.droneId));
|
|
280
|
+
if (opts.requireAllRequested && discovered.length !== requested.size) {
|
|
281
|
+
deps.stderr(
|
|
282
|
+
`borg launch-all: found ${discovered.length} of ${requested.size} requested registered drone worktrees; ` +
|
|
283
|
+
'nothing was launched. Run `borg drones` to inspect the missing local registration.\n',
|
|
284
|
+
);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
268
288
|
if (discovered.length === 0) {
|
|
269
289
|
if (args.flags.only !== undefined) {
|
|
270
290
|
deps.stdout(`No worktrees matched --only '${args.flags.only}' for cube '${cubeName}'\n`);
|
|
@@ -392,6 +412,14 @@ export async function runLaunchAll(
|
|
|
392
412
|
}
|
|
393
413
|
launchable.push(c);
|
|
394
414
|
}
|
|
415
|
+
const terminalSkipCount = evictedCount + revokedCount + rejectedCount + trustMismatchCount + credentialRejectedCount;
|
|
416
|
+
if (opts.requireAllRequested && terminalSkipCount > 0) {
|
|
417
|
+
deps.stderr(
|
|
418
|
+
`borg launch-all: ${terminalSkipCount} requested registered drone(s) are not launchable; ` +
|
|
419
|
+
'nothing was launched. Resolve the cause reported above, then retry.\n',
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
395
423
|
if (launchable.length === 0) {
|
|
396
424
|
// Accurate cause counts — an all-rejected sweep must NOT claim "evicted"
|
|
397
425
|
// (and vice-versa). Only name a cause when at least one seat hit it.
|
|
@@ -441,6 +469,13 @@ export async function runLaunchAll(
|
|
|
441
469
|
deps.stderr(sel.hardFail);
|
|
442
470
|
return 1;
|
|
443
471
|
}
|
|
472
|
+
if (sel.backend === 'pastelist' && opts.requireAllRequested) {
|
|
473
|
+
deps.stderr(
|
|
474
|
+
'borg launch-all: pastelist mode prints commands for manual use but does not launch sessions; ' +
|
|
475
|
+
'nothing was launched. Run `borg launch-all` separately to print the commands.\n',
|
|
476
|
+
);
|
|
477
|
+
return LAUNCH_ALL_NO_DISPATCH_EXIT_CODE;
|
|
478
|
+
}
|
|
444
479
|
const sessionName = sanitizeSessionName(cubeName);
|
|
445
480
|
const launchStartISO = nowISO();
|
|
446
481
|
|
package/src/opencode-drone.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
|
-
import { createHash
|
|
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
|
-
|
|
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
|
-
*
|
|
137
|
-
* kickoff. The
|
|
138
|
-
* the
|
|
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
|
-
|
|
147
|
+
trust: Partial<OpenCodeLaunchTrust> = {},
|
|
144
148
|
): OpenCodeLaunchKickoff {
|
|
149
|
+
const launchTrust = createOpenCodeLaunchTrust(trust);
|
|
145
150
|
return {
|
|
146
|
-
prompt:
|
|
147
|
-
|
|
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
|
|
457
|
-
let
|
|
476
|
+
function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
|
|
477
|
+
let count = 0;
|
|
458
478
|
for (const message of messages) {
|
|
459
|
-
if (message.info?.role
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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
|
|
473
|
-
*
|
|
474
|
-
*
|
|
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(
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
`;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { redactCloneSecrets } from './clone-security.js';
|
|
2
|
+
|
|
3
|
+
export interface CloneArgs {
|
|
4
|
+
repositoryUrl: string;
|
|
5
|
+
destination?: string;
|
|
6
|
+
noLaunch: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ParseCloneResult =
|
|
10
|
+
| { ok: true; args: CloneArgs }
|
|
11
|
+
| { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
export function parseCloneArgs(rawArgs: readonly string[]): ParseCloneResult {
|
|
14
|
+
let repositoryUrl: string | undefined;
|
|
15
|
+
let destination: string | undefined;
|
|
16
|
+
let noLaunch = false;
|
|
17
|
+
for (const arg of rawArgs) {
|
|
18
|
+
if (arg === '--no-launch') {
|
|
19
|
+
if (noLaunch) return { ok: false, error: '--no-launch was provided more than once' };
|
|
20
|
+
noLaunch = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg.startsWith('-')) {
|
|
24
|
+
const option = arg.startsWith('--') ? arg.split('=', 1)[0] : arg.slice(0, 2);
|
|
25
|
+
return { ok: false, error: `unknown option ${option}; the only option is --no-launch` };
|
|
26
|
+
}
|
|
27
|
+
if (repositoryUrl === undefined) repositoryUrl = arg;
|
|
28
|
+
else if (destination === undefined) destination = arg;
|
|
29
|
+
else return { ok: false, error: 'unexpected extra argument' };
|
|
30
|
+
}
|
|
31
|
+
if (!repositoryUrl) return { ok: false, error: 'a repository URL is required' };
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
args: {
|
|
35
|
+
repositoryUrl,
|
|
36
|
+
...(destination === undefined ? {} : { destination }),
|
|
37
|
+
noLaunch,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function safeCloneParseError(result: Extract<ParseCloneResult, { ok: false }>): string {
|
|
43
|
+
return redactCloneSecrets(result.error);
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NEW_CUBE_TEMPLATE_PRESENTATIONS } from 'borgmcp-shared/templates';
|
|
2
|
+
|
|
3
|
+
export interface QuickstartRoleRequest {
|
|
4
|
+
slug: string;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface QuickstartArgs {
|
|
9
|
+
template?: string;
|
|
10
|
+
roles: QuickstartRoleRequest[];
|
|
11
|
+
yes: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ParseQuickstartResult =
|
|
15
|
+
| { ok: true; args: QuickstartArgs }
|
|
16
|
+
| { ok: false; error: string };
|
|
17
|
+
|
|
18
|
+
const templates = new Set<string>(NEW_CUBE_TEMPLATE_PRESENTATIONS.map(({ name }) => name));
|
|
19
|
+
|
|
20
|
+
function parseRole(value: string): QuickstartRoleRequest | null {
|
|
21
|
+
const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)(?::([1-9]\d*))?$/.exec(value);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
24
|
+
return Number.isSafeInteger(count) ? { slug: match[1], count } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseQuickstartArgs(rawArgs: readonly string[]): ParseQuickstartResult {
|
|
28
|
+
const args: QuickstartArgs = { roles: [], yes: false };
|
|
29
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
30
|
+
const arg = rawArgs[i];
|
|
31
|
+
if (arg === '--yes' || arg === '-y') {
|
|
32
|
+
args.yes = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === '--template') {
|
|
36
|
+
const value = rawArgs[++i];
|
|
37
|
+
if (!value) return { ok: false, error: '--template requires software-dev, starter, or local-model' };
|
|
38
|
+
if (!templates.has(value)) return { ok: false, error: `unknown template '${value}'; choose software-dev, starter, or local-model` };
|
|
39
|
+
if (args.template !== undefined) return { ok: false, error: '--template was provided more than once' };
|
|
40
|
+
args.template = value;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg === '--role') {
|
|
44
|
+
const value = rawArgs[++i];
|
|
45
|
+
if (!value) return { ok: false, error: '--role requires <slug>[:<count>]' };
|
|
46
|
+
const role = parseRole(value);
|
|
47
|
+
if (!role) return { ok: false, error: `invalid role '${value}'; use <slug>[:<positive-count>]` };
|
|
48
|
+
args.roles.push(role);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, error: `unknown option: ${arg}. Supported: --template, --role, --yes/-y` };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, args };
|
|
54
|
+
}
|