borgmcp 3.11.0 → 3.12.0
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/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +8 -3
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +11 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +11 -4
- package/dist/claude.js.map +1 -1
- package/dist/codex-app-wake.d.ts +3 -2
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +42 -9
- package/dist/codex-app-wake.js.map +1 -1
- 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/cube-activity-wake-copy.d.ts +1 -1
- package/dist/cube-activity-wake-copy.d.ts.map +1 -1
- package/dist/cube-activity-wake-copy.js +1 -1
- package/dist/cube-activity-wake-copy.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/log-stream.d.ts +3 -3
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +8 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +11 -9
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +72 -48
- 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/remote-client.d.ts +2 -21
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -6
- package/dist/remote-client.js.map +1 -1
- package/dist/server-handshake.d.ts +6 -1
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +1 -0
- package/dist/server-handshake.js.map +1 -1
- package/dist/setup-confirm.d.ts +1 -1
- package/dist/setup-confirm.js +3 -3
- package/dist/setup-confirm.js.map +1 -1
- package/docs/LOCAL_SERVER.md +4 -8
- package/docs/RELEASING.md +32 -308
- package/package.json +3 -2
- package/src/assimilate-cmd.ts +13 -3
- package/src/assimilate-deps.ts +11 -0
- package/src/claude.ts +16 -4
- package/src/codex-app-wake.ts +42 -8
- package/src/config-utils.ts +147 -23
- package/src/cube-activity-wake-copy.ts +1 -1
- package/src/index.ts +7 -0
- package/src/log-stream.ts +24 -15
- package/src/opencode-drone.ts +85 -55
- package/src/opencode-launch-trust.ts +46 -0
- package/src/opencode-plugin.ts +31 -1
- package/src/remote-client.ts +11 -25
- package/src/server-handshake.ts +4 -1
- package/src/setup-confirm.ts +3 -3
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;
|
|
@@ -80,6 +89,7 @@ interface OpenCodeDelivery {
|
|
|
80
89
|
acceptedSubmission: boolean;
|
|
81
90
|
sessionId: string | null;
|
|
82
91
|
settled: boolean;
|
|
92
|
+
isSourcePending?: () => Promise<boolean>;
|
|
83
93
|
state: Exclude<OpenCodeDeliveryState, 'failed'>;
|
|
84
94
|
resolve: (delivered: boolean) => void;
|
|
85
95
|
promise: Promise<boolean>;
|
|
@@ -124,27 +134,23 @@ interface SessionBinding {
|
|
|
124
134
|
|
|
125
135
|
export interface OpenCodeLaunchKickoff {
|
|
126
136
|
prompt: string;
|
|
127
|
-
|
|
137
|
+
apiPassword: string;
|
|
138
|
+
correlationIdentity: string;
|
|
128
139
|
}
|
|
129
140
|
|
|
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
141
|
/**
|
|
136
|
-
*
|
|
137
|
-
* kickoff. The
|
|
138
|
-
* the
|
|
139
|
-
* guessing from a repeated kickoff's text or timestamp.
|
|
142
|
+
* Create independent launch trust for OpenCode without changing the shared
|
|
143
|
+
* kickoff text. The plugin writes the correlation identity to hidden metadata
|
|
144
|
+
* on the first qualifying human TextPart; the API password stays in env.
|
|
140
145
|
*/
|
|
141
146
|
export function createOpenCodeLaunchKickoff(
|
|
142
147
|
kickoff: string,
|
|
143
|
-
|
|
148
|
+
trust: Partial<OpenCodeLaunchTrust> = {},
|
|
144
149
|
): OpenCodeLaunchKickoff {
|
|
150
|
+
const launchTrust = createOpenCodeLaunchTrust(trust);
|
|
145
151
|
return {
|
|
146
|
-
prompt:
|
|
147
|
-
|
|
152
|
+
prompt: kickoff,
|
|
153
|
+
...launchTrust,
|
|
148
154
|
};
|
|
149
155
|
}
|
|
150
156
|
|
|
@@ -161,9 +167,13 @@ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
|
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
170
|
+
if (!isOpenCode256BitIdentity(deps.apiPassword)) {
|
|
171
|
+
throw new Error('OpenCode API password is missing or unverifiable');
|
|
172
|
+
}
|
|
164
173
|
abandonOpenCodeDeliveries(state);
|
|
165
174
|
state = {
|
|
166
175
|
serverUrl: deps.serverUrl,
|
|
176
|
+
apiPassword: deps.apiPassword,
|
|
167
177
|
sessionId: null,
|
|
168
178
|
sessionCreatedAt: null,
|
|
169
179
|
knownRootSessionIds: [],
|
|
@@ -194,6 +204,17 @@ function apiUrl(path: string): string {
|
|
|
194
204
|
return `${base}${path}${path.includes('?') ? '&' : '?'}directory=${encodeURIComponent(state!.directory)}`;
|
|
195
205
|
}
|
|
196
206
|
|
|
207
|
+
function authenticatedHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
|
208
|
+
const password = state?.apiPassword;
|
|
209
|
+
if (!isOpenCode256BitIdentity(password)) {
|
|
210
|
+
throw new Error('OpenCode API password is missing or unverifiable');
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
...headers,
|
|
214
|
+
Authorization: `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${password}`).toString('base64')}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
const FETCH_TIMEOUT = 10_000;
|
|
198
219
|
|
|
199
220
|
async function rawGet(path: string): Promise<{ status: number; body: string }> {
|
|
@@ -201,7 +222,7 @@ async function rawGet(path: string): Promise<{ status: number; body: string }> {
|
|
|
201
222
|
const controller = new AbortController();
|
|
202
223
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
203
224
|
try {
|
|
204
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
225
|
+
const res = await fetch(url, { headers: authenticatedHeaders(), signal: controller.signal });
|
|
205
226
|
const body = await res.text();
|
|
206
227
|
return { status: res.status, body };
|
|
207
228
|
} finally {
|
|
@@ -216,7 +237,7 @@ async function rawPost(path: string, bodyObj: unknown): Promise<{ status: number
|
|
|
216
237
|
try {
|
|
217
238
|
const res = await fetch(url, {
|
|
218
239
|
method: 'POST',
|
|
219
|
-
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
headers: authenticatedHeaders({ 'Content-Type': 'application/json' }),
|
|
220
241
|
signal: controller.signal,
|
|
221
242
|
body: JSON.stringify(bodyObj),
|
|
222
243
|
});
|
|
@@ -453,28 +474,29 @@ async function findUnseenTopLevelSession(knownRootSessionIds: string[]): Promise
|
|
|
453
474
|
}
|
|
454
475
|
}
|
|
455
476
|
|
|
456
|
-
function
|
|
457
|
-
let
|
|
477
|
+
function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
|
|
478
|
+
let count = 0;
|
|
458
479
|
for (const message of messages) {
|
|
459
|
-
if (message.info?.role
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
480
|
+
if (message.info?.role !== 'user') continue;
|
|
481
|
+
for (const part of message.parts ?? []) {
|
|
482
|
+
if (
|
|
483
|
+
part.type === 'text' &&
|
|
484
|
+
part.metadata?.[OPENCODE_LAUNCH_CORRELATION_METADATA_KEY] === correlationIdentity
|
|
485
|
+
) {
|
|
486
|
+
count++;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
466
489
|
}
|
|
467
|
-
return
|
|
490
|
+
return count;
|
|
468
491
|
}
|
|
469
492
|
|
|
470
493
|
/**
|
|
471
494
|
* 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.
|
|
495
|
+
* server. It chooses the session containing exactly one hidden metadata match,
|
|
496
|
+
* never repeated prompt text, timestamps, or newest-session order. A fork is
|
|
497
|
+
* therefore allowed only when it received this launch's correlation metadata.
|
|
476
498
|
*/
|
|
477
|
-
async function findLaunchSession(
|
|
499
|
+
async function findLaunchSession(correlationIdentity: string): Promise<{
|
|
478
500
|
session: OCSession;
|
|
479
501
|
knownRootSessionIds: string[];
|
|
480
502
|
} | null> {
|
|
@@ -485,25 +507,17 @@ async function findLaunchSession(nonce: string): Promise<{
|
|
|
485
507
|
const knownRootSessionIds = sessions
|
|
486
508
|
.filter(isTopLevelSession)
|
|
487
509
|
.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 };
|
|
510
|
+
const candidates = await Promise.all(sessions.map(async (session) => ({
|
|
511
|
+
session,
|
|
512
|
+
matchCount: launchCorrelationMatchCount(
|
|
513
|
+
await listSessionMessages(session.id),
|
|
514
|
+
correlationIdentity,
|
|
515
|
+
),
|
|
516
|
+
})));
|
|
517
|
+
const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
|
|
518
|
+
if (totalMatches !== 1) return null;
|
|
519
|
+
const matched = candidates.find((candidate) => candidate.matchCount === 1);
|
|
520
|
+
return matched ? { session: matched.session, knownRootSessionIds } : null;
|
|
507
521
|
} catch {
|
|
508
522
|
return null;
|
|
509
523
|
}
|
|
@@ -615,6 +629,14 @@ async function deliverOpenCodeEntry(
|
|
|
615
629
|
): Promise<OpenCodeDeliveryOutcome> {
|
|
616
630
|
let target: OCSession | null = null;
|
|
617
631
|
|
|
632
|
+
const sourcePending = async (): Promise<boolean> => {
|
|
633
|
+
if (!delivery.isSourcePending) return true;
|
|
634
|
+
if (await delivery.isSourcePending()) return true;
|
|
635
|
+
delivery.settled = true;
|
|
636
|
+
settleOpenCodeEntry(delivery.sourceEntryId);
|
|
637
|
+
return false;
|
|
638
|
+
};
|
|
639
|
+
|
|
618
640
|
// Before the one allowed POST, retries are safe: no submission has happened.
|
|
619
641
|
// OpenCode must generate the message ID: its run loop treats IDs as
|
|
620
642
|
// lexicographically ordered, so arbitrary caller IDs can persist without
|
|
@@ -623,6 +645,7 @@ async function deliverOpenCodeEntry(
|
|
|
623
645
|
for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
|
|
624
646
|
if (delivery.settled) return 'delivered';
|
|
625
647
|
if (state !== owner || !owner.connected) return 'failed';
|
|
648
|
+
if (!(await sourcePending())) return 'delivered';
|
|
626
649
|
if (attempt > 0) {
|
|
627
650
|
delivery.state = 'retried';
|
|
628
651
|
owner.totalEntriesRetried++;
|
|
@@ -675,6 +698,7 @@ async function deliverOpenCodeEntry(
|
|
|
675
698
|
delivery.state = 'delivered-unconfirmed';
|
|
676
699
|
} else {
|
|
677
700
|
if (delivery.settled) return 'delivered';
|
|
701
|
+
if (!(await sourcePending())) return 'delivered';
|
|
678
702
|
owner.pendingSubmissions.set(delivery.entryId, {
|
|
679
703
|
sourceEntryId: delivery.sourceEntryId,
|
|
680
704
|
sessionId: target.id,
|
|
@@ -795,11 +819,15 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
|
|
|
795
819
|
|
|
796
820
|
/**
|
|
797
821
|
* 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.
|
|
822
|
+
* this launch's metadata-correlated `--prompt` kickoff. The binding survives
|
|
823
|
+
* the separate MCP-child process, which must never fall back to a newest-session heuristic.
|
|
800
824
|
*/
|
|
801
825
|
export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promise<boolean> {
|
|
802
826
|
if (!state?.connected) { log('kickoff: not connected'); return false; }
|
|
827
|
+
if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
|
|
828
|
+
log('kickoff: correlation identity missing or unverifiable');
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
803
831
|
|
|
804
832
|
try {
|
|
805
833
|
// Wait for the server.
|
|
@@ -815,9 +843,9 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
|
|
|
815
843
|
}
|
|
816
844
|
|
|
817
845
|
// Capture the launch-selected session, including explicit resume/fork
|
|
818
|
-
// targets. Unrelated sessions do not contain this launch's
|
|
846
|
+
// targets. Unrelated sessions do not contain this launch's metadata identity.
|
|
819
847
|
for (let i = 0; i < 30; i++) {
|
|
820
|
-
const binding = await findLaunchSession(launch.
|
|
848
|
+
const binding = await findLaunchSession(launch.correlationIdentity);
|
|
821
849
|
if (binding) {
|
|
822
850
|
saveBinding(binding.session, binding.knownRootSessionIds);
|
|
823
851
|
log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
|
|
@@ -847,6 +875,7 @@ export function injectOpenCodeEntry(
|
|
|
847
875
|
entryId: string = createHash('sha256').update(text).digest('hex'),
|
|
848
876
|
allowSubmit: boolean = true,
|
|
849
877
|
sourceEntryId: string = entryId,
|
|
878
|
+
isSourcePending?: () => Promise<boolean>,
|
|
850
879
|
): Promise<boolean> {
|
|
851
880
|
const owner = state;
|
|
852
881
|
if (!owner?.connected) {
|
|
@@ -865,7 +894,7 @@ export function injectOpenCodeEntry(
|
|
|
865
894
|
);
|
|
866
895
|
if (pendingSource) {
|
|
867
896
|
log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
|
|
868
|
-
return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId);
|
|
897
|
+
return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
|
|
869
898
|
}
|
|
870
899
|
for (const [deliveredEntryId, record] of owner.deliveredEntries) {
|
|
871
900
|
if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
|
|
@@ -950,6 +979,7 @@ export function injectOpenCodeEntry(
|
|
|
950
979
|
acceptedSubmission: false,
|
|
951
980
|
sessionId: null,
|
|
952
981
|
settled: false,
|
|
982
|
+
isSourcePending,
|
|
953
983
|
state: 'queued',
|
|
954
984
|
resolve: resolveDelivery,
|
|
955
985
|
promise,
|
|
@@ -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
|
`;
|
package/src/remote-client.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import {
|
|
18
18
|
createProtocolEnvelope,
|
|
19
|
+
decodeAppendLogResult,
|
|
19
20
|
decodeDeleteCubeResponse,
|
|
20
21
|
decodeDeleteRoleRequest,
|
|
21
22
|
decodeDeleteRoleResult,
|
|
@@ -90,7 +91,7 @@ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
|
|
|
90
91
|
// Replay is opt-in: most local requests are mutations or have ambiguous
|
|
91
92
|
// delivery, while unread-log reads carry an explicit cursor and are safe to
|
|
92
93
|
// repeat with the exact same request body.
|
|
93
|
-
type AuthedFetchRetryMode = 'unread-cursor';
|
|
94
|
+
type AuthedFetchRetryMode = 'unread-cursor' | 'append-log';
|
|
94
95
|
export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
95
96
|
// A typed auth-error envelope is tiny; anything larger is hostile and the
|
|
96
97
|
// bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
|
|
@@ -790,17 +791,17 @@ async function authedFetch(
|
|
|
790
791
|
return res;
|
|
791
792
|
};
|
|
792
793
|
|
|
793
|
-
let transportRetriesRemaining = retryMode === 'unread-cursor'
|
|
794
|
+
let transportRetriesRemaining = retryMode === 'unread-cursor' || retryMode === 'append-log'
|
|
794
795
|
? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
|
|
795
796
|
: 0;
|
|
796
797
|
const requestWithRetry = async (): Promise<Response> => {
|
|
797
798
|
try {
|
|
798
799
|
return await buildRequest(token);
|
|
799
800
|
} catch (error) {
|
|
800
|
-
if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
|
|
801
|
+
if ((retryMode !== 'unread-cursor' && retryMode !== 'append-log') || !isConnectionReset(error)) throw error;
|
|
801
802
|
if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
|
|
802
803
|
transportRetriesRemaining -= 1;
|
|
803
|
-
debugLog(
|
|
804
|
+
debugLog(`↻ retrying ${retryMode === 'append-log' ? 'log append' : 'unread log read'} after ECONNRESET`);
|
|
804
805
|
try {
|
|
805
806
|
return await buildRequest(token);
|
|
806
807
|
} catch (retryError) {
|
|
@@ -1334,31 +1335,14 @@ export async function appendLog(
|
|
|
1334
1335
|
to?: string[];
|
|
1335
1336
|
serverTrustIdentity?: string;
|
|
1336
1337
|
} = {}
|
|
1337
|
-
): Promise<{
|
|
1338
|
-
entry: {
|
|
1339
|
-
id: string;
|
|
1340
|
-
cube_id: string;
|
|
1341
|
-
drone_id: string;
|
|
1342
|
-
message: string;
|
|
1343
|
-
visibility: 'broadcast' | 'direct';
|
|
1344
|
-
created_at: string;
|
|
1345
|
-
};
|
|
1346
|
-
routing?: {
|
|
1347
|
-
class: string | null;
|
|
1348
|
-
recipients: string[];
|
|
1349
|
-
fellOpen: boolean;
|
|
1350
|
-
message: string | null;
|
|
1351
|
-
} | null;
|
|
1352
|
-
// gh#534: directed recipients currently unreachable via the wake path
|
|
1353
|
-
// (wake-path:deaf). Empty/absent for broadcast or all-reachable sends.
|
|
1354
|
-
unreachableRecipients?: { id: string; label: string }[];
|
|
1355
|
-
}> {
|
|
1338
|
+
): Promise<ReturnType<typeof decodeAppendLogResult>> {
|
|
1356
1339
|
if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
|
|
1357
1340
|
throw new Error(
|
|
1358
1341
|
"Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
|
|
1359
1342
|
'Remove visibility to direct to recipients, or remove to: to broadcast.',
|
|
1360
1343
|
);
|
|
1361
1344
|
}
|
|
1345
|
+
const postId = randomUUID();
|
|
1362
1346
|
const local = await localAuthorityContext(
|
|
1363
1347
|
sessionToken,
|
|
1364
1348
|
apiUrl,
|
|
@@ -1386,11 +1370,12 @@ export async function appendLog(
|
|
|
1386
1370
|
} else if (visibility === undefined && recipientDroneIds !== undefined) {
|
|
1387
1371
|
visibility = 'direct';
|
|
1388
1372
|
}
|
|
1389
|
-
const payload = await localServerRequest<
|
|
1373
|
+
const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
|
|
1390
1374
|
local,
|
|
1391
1375
|
`/api/cubes/${local.cubeId}/logs`,
|
|
1392
1376
|
'POST',
|
|
1393
1377
|
{
|
|
1378
|
+
post_id: postId,
|
|
1394
1379
|
message,
|
|
1395
1380
|
...(visibility ? { visibility } : {}),
|
|
1396
1381
|
...(visibility === 'direct' && recipientDroneIds
|
|
@@ -1401,9 +1386,10 @@ export async function appendLog(
|
|
|
1401
1386
|
// visibility/recipients override it (server resolveMessageRouting).
|
|
1402
1387
|
...(opts.class ? { class: opts.class } : {}),
|
|
1403
1388
|
},
|
|
1389
|
+
{ retryMode: 'append-log', decodePayload: decodeAppendLogResult },
|
|
1404
1390
|
);
|
|
1405
1391
|
if (!payload) throw new Error('Local Borg server returned an empty log response');
|
|
1406
|
-
return
|
|
1392
|
+
return payload;
|
|
1407
1393
|
}
|
|
1408
1394
|
|
|
1409
1395
|
/**
|
package/src/server-handshake.ts
CHANGED
|
@@ -211,10 +211,11 @@ export interface ServerAttachResult {
|
|
|
211
211
|
sessionId: string;
|
|
212
212
|
};
|
|
213
213
|
result: 'created' | 'reused';
|
|
214
|
+
initial_log_cursor: { id: string; created_at: string } | null;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
/**
|
|
217
|
-
* Attach an enrolled client principal to one granted cube/role over protocol
|
|
218
|
+
* Attach an enrolled client principal to one granted cube/role over protocol v9.
|
|
218
219
|
* The client CSPRNG-generates the session bearer and persists it PENDING in the
|
|
219
220
|
* OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
|
|
220
221
|
* interrupted/lost response is recovered by re-sending the exact same bearer —
|
|
@@ -237,6 +238,7 @@ export interface PreparedServerAttach {
|
|
|
237
238
|
drone: ServerAttachResult['drone'];
|
|
238
239
|
session: { sessionId: string };
|
|
239
240
|
result: 'created' | 'reused';
|
|
241
|
+
initialLogCursor: ServerAttachResult['initial_log_cursor'];
|
|
240
242
|
credentialRef: string;
|
|
241
243
|
pendingBearerDigest: string;
|
|
242
244
|
/** The single-store ATOMIC activate+bind (CR#2 collapse): given the worktree
|
|
@@ -391,6 +393,7 @@ export async function sendBorgServerAttach(
|
|
|
391
393
|
sessionId: decoded.session.id,
|
|
392
394
|
},
|
|
393
395
|
result: decoded.result,
|
|
396
|
+
initialLogCursor: decoded.initial_log_cursor,
|
|
394
397
|
credentialRef,
|
|
395
398
|
pendingBearerDigest,
|
|
396
399
|
// The single-store ATOMIC activate+bind — invoked by FINALIZE with the decided
|
package/src/setup-confirm.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
62
|
-
change: '
|
|
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;
|