borgmcp 2.7.3 → 2.8.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/README.md +96 -230
- package/dist/drone-lifecycle.d.ts +8 -0
- package/dist/drone-lifecycle.d.ts.map +1 -1
- package/dist/drone-lifecycle.js +19 -0
- package/dist/drone-lifecycle.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +4 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +28 -4
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +42 -7
- package/dist/remote-client.js.map +1 -1
- package/dist/server-errors.d.ts +8 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +19 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-handshake.d.ts +1 -1
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +10 -0
- package/dist/server-trust.js.map +1 -1
- package/dist/tool-manifest.d.ts.map +1 -1
- package/dist/tool-manifest.js +3 -2
- package/dist/tool-manifest.js.map +1 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +122 -16
- package/dist/update-cmd.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +7 -7
- package/docs/LOCAL_SERVER.md +3 -3
- package/docs/RELEASING.md +11 -6
- package/package.json +2 -2
- package/src/drone-lifecycle.ts +25 -0
- package/src/index.ts +15 -1
- package/src/log-stream.ts +26 -5
- package/src/remote-client.ts +56 -5
- package/src/server-errors.ts +24 -0
- package/src/server-handshake.ts +1 -1
- package/src/server-trust.ts +11 -0
- package/src/tool-manifest.ts +3 -2
- package/src/update-cmd.ts +145 -16
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
21
21
|
|
|
22
22
|
import { assertRoleMatches } from './role-match.js';
|
|
23
|
+
import { CubeDeletionConfirmationError } from './server-errors.js';
|
|
23
24
|
|
|
24
25
|
import {
|
|
25
26
|
getCubeInfo,
|
|
@@ -103,7 +104,9 @@ import {
|
|
|
103
104
|
} from './roster-render.js';
|
|
104
105
|
import { resolveWorkingRepo } from './working-repo.js';
|
|
105
106
|
import {
|
|
107
|
+
CubeDeletedError,
|
|
106
108
|
DroneEvictedError,
|
|
109
|
+
formatCubeDeletedErrorToolResult,
|
|
107
110
|
formatEvictedToolResult,
|
|
108
111
|
} from './drone-lifecycle.js';
|
|
109
112
|
import {
|
|
@@ -1048,7 +1051,11 @@ export async function main() {
|
|
|
1048
1051
|
case 'borg_delete-cube': {
|
|
1049
1052
|
const cubeId = args?.cube_id as string;
|
|
1050
1053
|
if (!cubeId) throw new Error('cube_id is required');
|
|
1051
|
-
|
|
1054
|
+
const confirmCubeId = args?.confirm_cube_id as string | undefined;
|
|
1055
|
+
if (confirmCubeId !== cubeId) {
|
|
1056
|
+
throw new CubeDeletionConfirmationError(cubeId, confirmCubeId);
|
|
1057
|
+
}
|
|
1058
|
+
await deleteCube(cubeId, confirmCubeId);
|
|
1052
1059
|
return { content: [{ type: 'text', text: `Deleted cube ${cubeId} (and all its roles, drones, log entries).` }] };
|
|
1053
1060
|
}
|
|
1054
1061
|
|
|
@@ -1261,6 +1268,13 @@ export async function main() {
|
|
|
1261
1268
|
isError: true,
|
|
1262
1269
|
};
|
|
1263
1270
|
}
|
|
1271
|
+
if (error instanceof CubeDeletedError) {
|
|
1272
|
+
const active = await getActiveCube();
|
|
1273
|
+
return {
|
|
1274
|
+
content: [{ type: 'text', text: formatCubeDeletedErrorToolResult(error, active?.name) }],
|
|
1275
|
+
isError: true,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1264
1278
|
|
|
1265
1279
|
const localManageResult = formatLocalManageToolResult(error);
|
|
1266
1280
|
if (localManageResult) return localManageResult;
|
package/src/log-stream.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
type LocalServerCursor,
|
|
41
41
|
} from './local-server-cursor.js';
|
|
42
42
|
import {
|
|
43
|
+
CubeDeletedError,
|
|
44
|
+
CUBE_DELETED_CODE,
|
|
43
45
|
DroneEvictedError,
|
|
44
46
|
DRONE_EVICTED_CODE,
|
|
45
47
|
EVICTED_RESULT_MARKER,
|
|
@@ -573,7 +575,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
573
575
|
// loop quiesces cleanly. The agent's graceful shutdown (TaskStop Monitor,
|
|
574
576
|
// no /loop reschedule) is driven separately by the EVICTED tool-result it
|
|
575
577
|
// already received on the authed call that produced this verdict.
|
|
576
|
-
if (err instanceof DroneEvictedError || err instanceof BorgServerError) {
|
|
578
|
+
if (err instanceof CubeDeletedError || err instanceof DroneEvictedError || err instanceof BorgServerError) {
|
|
577
579
|
if (active.localSessionCredentialRef) {
|
|
578
580
|
markSeatRejected(active.localSessionCredentialRef);
|
|
579
581
|
}
|
|
@@ -591,15 +593,15 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
591
593
|
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
592
594
|
continue;
|
|
593
595
|
}
|
|
594
|
-
if (err instanceof DroneEvictedError) {
|
|
596
|
+
if (err instanceof DroneEvictedError || err instanceof CubeDeletedError) {
|
|
595
597
|
if (lease) await lease.release().catch(() => {});
|
|
596
598
|
lease = null;
|
|
597
599
|
leaseKey = null;
|
|
598
600
|
streamState.connected = false;
|
|
599
601
|
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
600
|
-
process.stderr.write(
|
|
601
|
-
|
|
602
|
-
|
|
602
|
+
process.stderr.write(err instanceof CubeDeletedError
|
|
603
|
+
? '[borg-mcp log stream] cube deleted — stream terminated (no reconnect).\n'
|
|
604
|
+
: '[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n');
|
|
603
605
|
}
|
|
604
606
|
throw new TerminalStreamError();
|
|
605
607
|
}
|
|
@@ -939,6 +941,10 @@ export async function streamOnce(
|
|
|
939
941
|
if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
|
|
940
942
|
throw new DroneEvictedError();
|
|
941
943
|
}
|
|
944
|
+
if (code === CUBE_DELETED_CODE) {
|
|
945
|
+
if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
|
|
946
|
+
throw new CubeDeletedError();
|
|
947
|
+
}
|
|
942
948
|
// client#42: an expired resume cursor is RECOVERABLE, not terminal. The
|
|
943
949
|
// pointed-at entry was pruned server-side, so retrying the SAME cursor
|
|
944
950
|
// 410s forever (a wedged, silently-dead wake path). Reset the stream's
|
|
@@ -1010,6 +1016,13 @@ export async function streamOnce(
|
|
|
1010
1016
|
break;
|
|
1011
1017
|
}
|
|
1012
1018
|
|
|
1019
|
+
if (event.type === 'error') {
|
|
1020
|
+
if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
|
|
1021
|
+
if (event.code === CUBE_DELETED_CODE) throw new CubeDeletedError();
|
|
1022
|
+
if (event.code === DRONE_EVICTED_CODE) throw new DroneEvictedError();
|
|
1023
|
+
throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server terminated the stream');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1013
1026
|
if (event.type === 'heartbeat') {
|
|
1014
1027
|
streamState.lastHeartbeatAt = nowIso;
|
|
1015
1028
|
// First/baseline heartbeat absorb: until this session has seen
|
|
@@ -1198,6 +1211,7 @@ export type ParsedEvent =
|
|
|
1198
1211
|
| { type: 'bookmark'; as_of: string | null }
|
|
1199
1212
|
// gh#877 Path-A: terminal eviction control frame (wake hint, zero authority).
|
|
1200
1213
|
| { type: 'eviction'; id: string | null; cube_id: string | null; reason: string | null }
|
|
1214
|
+
| { type: 'error'; code: ErrorCode }
|
|
1201
1215
|
| { type: 'unknown'; raw: string };
|
|
1202
1216
|
|
|
1203
1217
|
/**
|
|
@@ -1357,6 +1371,13 @@ function parseEventBlock(block: string): ParsedEvent | null {
|
|
|
1357
1371
|
}
|
|
1358
1372
|
return { type: 'eviction', id: id || null, cube_id, reason };
|
|
1359
1373
|
}
|
|
1374
|
+
if (eventName === 'error') {
|
|
1375
|
+
try {
|
|
1376
|
+
return { type: 'error', code: decodeProtocolErrorEnvelope(JSON.parse(dataStr)).error.code };
|
|
1377
|
+
} catch {
|
|
1378
|
+
return { type: 'unknown', raw: block };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1360
1381
|
return { type: 'unknown', raw: block };
|
|
1361
1382
|
}
|
|
1362
1383
|
|
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
|
+
decodeDeleteCubeResponse,
|
|
19
20
|
decodeDroneRuntimeMetadataState,
|
|
20
21
|
decodeEvictDroneResult,
|
|
21
22
|
decodeProtocolEnvelope,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
decodeReassignDroneResult,
|
|
24
25
|
decodeUpdateDroneRuntimeMetadataResponse,
|
|
25
26
|
ErrorCode,
|
|
27
|
+
ProtocolContractError,
|
|
26
28
|
type AgentKind,
|
|
27
29
|
type EvictDroneResult,
|
|
28
30
|
type ReassignDroneResult,
|
|
@@ -30,7 +32,12 @@ import {
|
|
|
30
32
|
import { consolePrefix } from './console-prefix.js';
|
|
31
33
|
import { debugLog } from './debug.js';
|
|
32
34
|
import { assertUuidShape } from './evict-drone.js';
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
CubeDeletedError,
|
|
37
|
+
CUBE_DELETED_CODE,
|
|
38
|
+
DroneEvictedError,
|
|
39
|
+
DRONE_EVICTED_CODE,
|
|
40
|
+
} from './drone-lifecycle.js';
|
|
34
41
|
import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
|
|
35
42
|
import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
|
|
36
43
|
import { parseRoleSections } from 'borgmcp-shared/role-section';
|
|
@@ -41,8 +48,10 @@ import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
|
|
|
41
48
|
import {
|
|
42
49
|
BorgServerError,
|
|
43
50
|
BorgServerHttpError,
|
|
51
|
+
BorgProtocolMismatchError,
|
|
44
52
|
BorgServerTrustError,
|
|
45
53
|
BorgServerUnreachableError,
|
|
54
|
+
CubeDeletionConfirmationError,
|
|
46
55
|
LocalManageCredentialUnavailableError,
|
|
47
56
|
LocalManageRequiredError,
|
|
48
57
|
} from './server-errors.js';
|
|
@@ -226,6 +235,12 @@ async function decodeLocalProtocolResponse<T>(
|
|
|
226
235
|
// CR5: a TYPED transport-timeout verdict (message kept for call-site parity).
|
|
227
236
|
throw new BorgServerUnreachableError('Local Borg server request timed out');
|
|
228
237
|
}
|
|
238
|
+
if (
|
|
239
|
+
error instanceof ProtocolContractError &&
|
|
240
|
+
error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
241
|
+
) {
|
|
242
|
+
throw new BorgProtocolMismatchError();
|
|
243
|
+
}
|
|
229
244
|
throw error;
|
|
230
245
|
} finally {
|
|
231
246
|
clearTimeout(timeout);
|
|
@@ -329,6 +344,9 @@ async function localManageRequest<T>(
|
|
|
329
344
|
}),
|
|
330
345
|
}), true, decodePayload);
|
|
331
346
|
} catch (error) {
|
|
347
|
+
if (error instanceof CubeDeletedError) {
|
|
348
|
+
throw new CubeDeletedError(operation.cubeName);
|
|
349
|
+
}
|
|
332
350
|
if (
|
|
333
351
|
error instanceof BorgServerHttpError &&
|
|
334
352
|
error.status === 403 &&
|
|
@@ -704,6 +722,7 @@ async function authedFetch(
|
|
|
704
722
|
// terminal controls. Decode only the bounded protocol error code for typed
|
|
705
723
|
// branching; never surface the server-provided message or details.
|
|
706
724
|
let code: ErrorCode | undefined;
|
|
725
|
+
let protocolMismatch = false;
|
|
707
726
|
try {
|
|
708
727
|
const body = await readBoundedResponseBody(
|
|
709
728
|
response,
|
|
@@ -713,7 +732,13 @@ async function authedFetch(
|
|
|
713
732
|
const parsed = JSON.parse(body);
|
|
714
733
|
try {
|
|
715
734
|
code = decodeProtocolErrorEnvelope(parsed).error.code;
|
|
716
|
-
} catch {
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (
|
|
737
|
+
error instanceof ProtocolContractError &&
|
|
738
|
+
error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
739
|
+
) {
|
|
740
|
+
protocolMismatch = true;
|
|
741
|
+
}
|
|
717
742
|
if (
|
|
718
743
|
parsed !== null && typeof parsed === 'object' &&
|
|
719
744
|
parsed.error !== null && typeof parsed.error === 'object' &&
|
|
@@ -738,10 +763,15 @@ async function authedFetch(
|
|
|
738
763
|
code = undefined;
|
|
739
764
|
}
|
|
740
765
|
debugLog(`✗ ${response.status} ${method} ${path}`);
|
|
766
|
+
if (protocolMismatch) throw new BorgProtocolMismatchError();
|
|
741
767
|
if (droneSession !== undefined && response.status === 410 && code === DRONE_EVICTED_CODE) {
|
|
742
768
|
if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
|
|
743
769
|
throw new DroneEvictedError();
|
|
744
770
|
}
|
|
771
|
+
if (response.status === 410 && code === CUBE_DELETED_CODE) {
|
|
772
|
+
if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
|
|
773
|
+
throw new CubeDeletedError();
|
|
774
|
+
}
|
|
745
775
|
throw new BorgServerHttpError(
|
|
746
776
|
response.status,
|
|
747
777
|
`Borg server request failed (HTTP ${response.status})`,
|
|
@@ -1316,9 +1346,30 @@ export async function patchTaxonomyClass(
|
|
|
1316
1346
|
* Delete a cube. Cascade-deletes all roles, drones, and log entries.
|
|
1317
1347
|
* Requires a live cube-manage grant on the selected local client.
|
|
1318
1348
|
*/
|
|
1319
|
-
export async function deleteCube(cubeId: string): Promise<void> {
|
|
1320
|
-
|
|
1321
|
-
|
|
1349
|
+
export async function deleteCube(cubeId: string, confirmCubeId: string): Promise<void> {
|
|
1350
|
+
if (confirmCubeId !== cubeId) {
|
|
1351
|
+
throw new CubeDeletionConfirmationError(cubeId, confirmCubeId);
|
|
1352
|
+
}
|
|
1353
|
+
assertUuidShape(cubeId, 'cube_id');
|
|
1354
|
+
const active = await getActiveCube();
|
|
1355
|
+
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1356
|
+
const cubeName = cubeId === active.cubeId ? active.name : cubeId;
|
|
1357
|
+
const result = await localManageRequest(
|
|
1358
|
+
active,
|
|
1359
|
+
`/api/cubes/${cubeId}`,
|
|
1360
|
+
'DELETE',
|
|
1361
|
+
{
|
|
1362
|
+
operation: `delete cube ${manageCopyValue(cubeName)}`,
|
|
1363
|
+
cubeName,
|
|
1364
|
+
noMutation: 'The cube was not deleted.',
|
|
1365
|
+
},
|
|
1366
|
+
{},
|
|
1367
|
+
decodeDeleteCubeResponse,
|
|
1368
|
+
);
|
|
1369
|
+
if (!result) throw new Error('Local Borg server returned an empty cube deletion response');
|
|
1370
|
+
if (result.cube_id !== cubeId) {
|
|
1371
|
+
throw new Error('Local Borg server returned a deletion response for an unexpected cube');
|
|
1372
|
+
}
|
|
1322
1373
|
}
|
|
1323
1374
|
|
|
1324
1375
|
/**
|
package/src/server-errors.ts
CHANGED
|
@@ -35,6 +35,16 @@ export class BorgServerHttpError extends Error {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export class BorgProtocolMismatchError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super(
|
|
41
|
+
'This client and the selected Borg server use different protocol versions. ' +
|
|
42
|
+
'Update `borgmcp-server` and `borgmcp` to matching releases, server first and then client.',
|
|
43
|
+
);
|
|
44
|
+
this.name = 'BorgProtocolMismatchError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export class LocalManageRequiredError extends Error {
|
|
39
49
|
constructor(
|
|
40
50
|
public readonly operation: string,
|
|
@@ -106,6 +116,20 @@ export class CubeCreationConfirmationError extends Error {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
export class CubeDeletionConfirmationError extends Error {
|
|
120
|
+
constructor(
|
|
121
|
+
public readonly cubeId: string,
|
|
122
|
+
public readonly confirmCubeId: string | undefined,
|
|
123
|
+
) {
|
|
124
|
+
const supplied = confirmCubeId === undefined ? '(missing)' : `"${confirmCubeId}"`;
|
|
125
|
+
super(
|
|
126
|
+
`Cube deletion is irreversible. The confirmation cube ID ${supplied} must exactly match ` +
|
|
127
|
+
`the requested cube ID "${cubeId}". No cube was deleted.`,
|
|
128
|
+
);
|
|
129
|
+
this.name = 'CubeDeletionConfirmationError';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
109
133
|
export type RepositoryAssociationFailure =
|
|
110
134
|
| 'repository-already-associated'
|
|
111
135
|
| 'cube-already-associated'
|
package/src/server-handshake.ts
CHANGED
|
@@ -196,7 +196,7 @@ export interface ServerAttachResult {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Attach an enrolled client principal to one granted cube/role over protocol
|
|
199
|
+
* Attach an enrolled client principal to one granted cube/role over protocol v7.
|
|
200
200
|
* The client CSPRNG-generates the session bearer and persists it PENDING in the
|
|
201
201
|
* OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
|
|
202
202
|
* interrupted/lost response is recovered by re-sending the exact same bearer —
|
package/src/server-trust.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
1
2
|
import { createHash, timingSafeEqual, X509Certificate } from 'node:crypto';
|
|
2
3
|
import { constants } from 'node:fs';
|
|
3
4
|
import { open } from 'node:fs/promises';
|
|
@@ -157,6 +158,16 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
|
|
|
157
158
|
|
|
158
159
|
const body = requestBody(init.body);
|
|
159
160
|
const headers = new Headers(init.headers);
|
|
161
|
+
if (body !== undefined) {
|
|
162
|
+
const contentLength = String(
|
|
163
|
+
typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.byteLength,
|
|
164
|
+
);
|
|
165
|
+
const declaredLength = headers.get('Content-Length');
|
|
166
|
+
if (declaredLength !== null && declaredLength !== contentLength) {
|
|
167
|
+
throw new Error('Borg server transport refused an inconsistent Content-Length');
|
|
168
|
+
}
|
|
169
|
+
if (declaredLength === null) headers.set('Content-Length', contentLength);
|
|
170
|
+
}
|
|
160
171
|
return await new Promise<Response>((resolvePromise, rejectPromise) => {
|
|
161
172
|
const request = httpsRequest({
|
|
162
173
|
protocol: 'https:',
|
package/src/tool-manifest.ts
CHANGED
|
@@ -419,13 +419,14 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
|
|
|
419
419
|
},
|
|
420
420
|
{
|
|
421
421
|
name: 'borg_delete-cube',
|
|
422
|
-
description: 'Delete a cube and all its roles, drones, and log entries. Irreversible
|
|
422
|
+
description: 'Delete a cube and all its roles, drones, and log entries. Irreversible; requires the exact cube UUID again after explicit user confirmation.',
|
|
423
423
|
inputSchema: {
|
|
424
424
|
type: 'object',
|
|
425
425
|
properties: {
|
|
426
426
|
cube_id: { type: 'string', description: 'UUID of the cube to delete.' },
|
|
427
|
+
confirm_cube_id: { type: 'string', description: 'Explicit user confirmation: repeat the exact cube UUID to confirm this irreversible deletion.' },
|
|
427
428
|
},
|
|
428
|
-
required: ['cube_id'],
|
|
429
|
+
required: ['cube_id', 'confirm_cube_id'],
|
|
429
430
|
},
|
|
430
431
|
},
|
|
431
432
|
{
|
package/src/update-cmd.ts
CHANGED
|
@@ -7,6 +7,7 @@ import which from 'which';
|
|
|
7
7
|
import { updateHelpText } from './cli-help.js';
|
|
8
8
|
import { preflightBorgServerTag } from './server-handshake.js';
|
|
9
9
|
import { loadBorgServerTrust } from './server-trust.js';
|
|
10
|
+
import { shellEscape } from './shell-escape.js';
|
|
10
11
|
|
|
11
12
|
const CLIENT_PACKAGE = 'borgmcp';
|
|
12
13
|
const SERVER_PACKAGE = 'borgmcp-server';
|
|
@@ -81,6 +82,7 @@ interface ServerStatus {
|
|
|
81
82
|
endpoint: string | null;
|
|
82
83
|
mode: 'foreground' | 'managed' | 'legacy' | 'stopped';
|
|
83
84
|
serviceAdapter: 'launchd' | 'systemd' | null;
|
|
85
|
+
serviceRecovery: { command: string[] } | null;
|
|
84
86
|
dataIdentity: 'available' | 'unavailable';
|
|
85
87
|
nextAction: string | null;
|
|
86
88
|
}
|
|
@@ -103,6 +105,15 @@ interface ServerUpdateFailure {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
type ServerUpdateResult = ServerUpdateSuccess | ServerUpdateFailure;
|
|
108
|
+
type ServerUpdateFailureStage =
|
|
109
|
+
| 'initial server status check'
|
|
110
|
+
| 'server controller identity check'
|
|
111
|
+
| 'server runtime activation'
|
|
112
|
+
| 'post-update server status check'
|
|
113
|
+
| 'final server state verification'
|
|
114
|
+
| 'final package verification'
|
|
115
|
+
| 'managed service continuity check'
|
|
116
|
+
| 'running server protocol verification';
|
|
106
117
|
|
|
107
118
|
interface NpmContext {
|
|
108
119
|
commandPath: string;
|
|
@@ -119,9 +130,13 @@ function errorMessage(error: unknown, fallback: string): string {
|
|
|
119
130
|
return error instanceof Error ? error.message : fallback;
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function hasErrorCode(error: unknown, code: string): boolean {
|
|
134
|
+
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
function renderReentryPreflightFailure(error: unknown, target: UpdateTarget): string {
|
|
123
138
|
return (
|
|
124
|
-
|
|
139
|
+
`Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
|
|
125
140
|
`Observed update state:\n` +
|
|
126
141
|
` client: ${CLIENT_PACKAGE}@${target.clientVersion} installed and verified before re-entry\n` +
|
|
127
142
|
` server controller: not changed by this continuation\n` +
|
|
@@ -346,6 +361,18 @@ function isNextAction(value: unknown): value is string | null {
|
|
|
346
361
|
);
|
|
347
362
|
}
|
|
348
363
|
|
|
364
|
+
function decodeManagedServiceRecovery(value: unknown): { command: string[] } | null {
|
|
365
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
366
|
+
const record = value as Record<string, unknown>;
|
|
367
|
+
if (record.kind !== 'run-platform-command' || !Array.isArray(record.command) ||
|
|
368
|
+
record.command.length === 0 || record.command.length > 32 ||
|
|
369
|
+
record.command.some((arg) => typeof arg !== 'string' || arg.length === 0 ||
|
|
370
|
+
Array.from(arg).length > 1024 || /\p{Cc}/u.test(arg))) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
return { command: record.command as string[] };
|
|
374
|
+
}
|
|
375
|
+
|
|
349
376
|
function decodeServerStatus(value: unknown): ServerStatus {
|
|
350
377
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
351
378
|
throw new Error('server returned invalid JSON status');
|
|
@@ -379,13 +406,19 @@ function decodeServerStatus(value: unknown): ServerStatus {
|
|
|
379
406
|
record.running_integrity !== null ||
|
|
380
407
|
record.build_identity !== null ||
|
|
381
408
|
record.endpoint !== null ||
|
|
382
|
-
record.mode !== 'stopped'
|
|
383
|
-
record.service_adapter !== null
|
|
409
|
+
record.mode !== 'stopped'
|
|
384
410
|
)) ||
|
|
385
411
|
(record.status === 'running' && record.mode === 'stopped')
|
|
386
412
|
) {
|
|
387
413
|
throw new Error('server returned inconsistent JSON status');
|
|
388
414
|
}
|
|
415
|
+
const managedRecovery = record.status === 'stopped' && record.service_adapter !== null
|
|
416
|
+
? decodeManagedServiceRecovery(record.service_recovery)
|
|
417
|
+
: null;
|
|
418
|
+
if (record.status === 'stopped' && record.service_adapter !== null &&
|
|
419
|
+
(record.service_state !== 'inactive' || managedRecovery === null)) {
|
|
420
|
+
throw new Error('server returned invalid managed-service recovery status');
|
|
421
|
+
}
|
|
389
422
|
return {
|
|
390
423
|
state: record.status,
|
|
391
424
|
installedController: record.installed_controller,
|
|
@@ -397,6 +430,7 @@ function decodeServerStatus(value: unknown): ServerStatus {
|
|
|
397
430
|
endpoint: record.endpoint,
|
|
398
431
|
mode: record.mode as ServerStatus['mode'],
|
|
399
432
|
serviceAdapter: record.service_adapter,
|
|
433
|
+
serviceRecovery: managedRecovery,
|
|
400
434
|
dataIdentity: record.data_identity,
|
|
401
435
|
nextAction: record.next_action,
|
|
402
436
|
};
|
|
@@ -472,7 +506,6 @@ function verifyServerStatus(status: ServerStatus, target: PublishedPackage): 'ru
|
|
|
472
506
|
status.buildIdentity !== null ||
|
|
473
507
|
status.endpoint !== null ||
|
|
474
508
|
status.mode !== 'stopped' ||
|
|
475
|
-
status.serviceAdapter !== null ||
|
|
476
509
|
status.dataIdentity !== 'available'
|
|
477
510
|
) {
|
|
478
511
|
throw new Error('final server verification failed: stopped server reported a running runtime');
|
|
@@ -492,6 +525,37 @@ function verifyServerStatus(status: ServerStatus, target: PublishedPackage): 'ru
|
|
|
492
525
|
return 'running';
|
|
493
526
|
}
|
|
494
527
|
|
|
528
|
+
function renderServerFailureRecovery(
|
|
529
|
+
status: ServerStatus | null,
|
|
530
|
+
updateAttempted: boolean,
|
|
531
|
+
retryCommand: 'borg update --yes' | 'borg server status' | 'borg server update' | 'borg server start',
|
|
532
|
+
): string {
|
|
533
|
+
let text = '';
|
|
534
|
+
if (status?.state === 'stopped') {
|
|
535
|
+
text += renderStoppedServiceRecovery(status);
|
|
536
|
+
} else if (updateAttempted && status === null) {
|
|
537
|
+
text += (
|
|
538
|
+
`Local server service state could not be verified; it may be stopped.\n` +
|
|
539
|
+
`Check it with: borg server status\n` +
|
|
540
|
+
`If it is stopped, run the recovery command reported by borg server status.\n`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (retryCommand !== 'borg server start') {
|
|
544
|
+
text += status?.state === 'stopped'
|
|
545
|
+
? `Then retry the failed stage with: ${retryCommand}\n`
|
|
546
|
+
: `Next: ${retryCommand}\n`;
|
|
547
|
+
}
|
|
548
|
+
return text;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderStoppedServiceRecovery(status: ServerStatus): string {
|
|
552
|
+
if (status.serviceRecovery !== null) {
|
|
553
|
+
const command = status.serviceRecovery.command.map(shellEscape).join(' ');
|
|
554
|
+
return `Local server service is stopped.\nRestart it with: ${command}\n`;
|
|
555
|
+
}
|
|
556
|
+
return `Local server service is stopped.\nStart it with: borg server start\n`;
|
|
557
|
+
}
|
|
558
|
+
|
|
495
559
|
export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promise<number> {
|
|
496
560
|
if (options.help) {
|
|
497
561
|
deps.stdout(updateHelpText(''));
|
|
@@ -512,7 +576,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
512
576
|
deps.stderr(options.target
|
|
513
577
|
? renderReentryPreflightFailure(error, options.target)
|
|
514
578
|
: (
|
|
515
|
-
|
|
579
|
+
`Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
|
|
516
580
|
`Observed update state:\n` +
|
|
517
581
|
` client: not inspected (registry preflight incomplete)\n` +
|
|
518
582
|
` server controller: not inspected (registry preflight incomplete)\n` +
|
|
@@ -540,7 +604,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
540
604
|
}
|
|
541
605
|
|
|
542
606
|
deps.stdout(
|
|
543
|
-
`Published update plan (
|
|
607
|
+
`Published update plan (${CANONICAL_NPM_REGISTRY}):\n` +
|
|
544
608
|
` client: ${CLIENT_PACKAGE}@${client.version} -> ${CLIENT_PACKAGE}@${pair.client.version}\n` +
|
|
545
609
|
` target integrity: ${pair.client.integrity}\n` +
|
|
546
610
|
` server: ${discoveredServer ? `${SERVER_PACKAGE}@${discoveredServer.version}` : 'not installed'} -> ${SERVER_PACKAGE}@${pair.server.version}\n` +
|
|
@@ -624,7 +688,8 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
624
688
|
: 'not installed'}\n` +
|
|
625
689
|
` prepared runtime: not inspected\n` +
|
|
626
690
|
` running runtime: not inspected\n` +
|
|
627
|
-
`Server mutation was not attempted.\n
|
|
691
|
+
`Server mutation was not attempted.\n` +
|
|
692
|
+
`Next: reinstall ${CLIENT_PACKAGE}@${pair.client.version} from ${CANONICAL_NPM_REGISTRY}, then rerun borg update --yes.\n`,
|
|
628
693
|
);
|
|
629
694
|
return interrupted ?? 1;
|
|
630
695
|
}
|
|
@@ -671,19 +736,39 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
671
736
|
|
|
672
737
|
let observedStatus: ServerStatus | null = null;
|
|
673
738
|
let observedUpdate: ServerUpdateResult | null = null;
|
|
739
|
+
let initialServerState: ServerStatus['state'] | null = null;
|
|
740
|
+
let updateAttempted = false;
|
|
741
|
+
let recoveryStatusAttempted = false;
|
|
742
|
+
let failureStage: ServerUpdateFailureStage = 'initial server status check';
|
|
743
|
+
let retryCommand: Parameters<typeof renderServerFailureRecovery>[2] = 'borg server status';
|
|
744
|
+
const observeStatusAfterFailure = async (): Promise<void> => {
|
|
745
|
+
recoveryStatusAttempted = true;
|
|
746
|
+
try {
|
|
747
|
+
observedStatus = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
|
|
748
|
+
} catch {
|
|
749
|
+
observedStatus = null;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
674
752
|
try {
|
|
675
753
|
let status = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
|
|
676
754
|
observedStatus = status;
|
|
755
|
+
initialServerState = status.state;
|
|
677
756
|
if (status.installedController !== exactServerIdentity(pair.server.version)) {
|
|
757
|
+
failureStage = 'server controller identity check';
|
|
758
|
+
retryCommand = 'borg update --yes';
|
|
678
759
|
throw new Error('server status contradicted the verified controller identity');
|
|
679
760
|
}
|
|
680
761
|
try {
|
|
681
762
|
verifyServerStatus(status, pair.server);
|
|
682
763
|
} catch {
|
|
683
764
|
observedStatus = null;
|
|
765
|
+
updateAttempted = true;
|
|
766
|
+
failureStage = 'server runtime activation';
|
|
767
|
+
retryCommand = 'borg server update';
|
|
684
768
|
const update = decodeServerUpdate(await deps.serverJson(server.binPath, 'update'));
|
|
685
769
|
observedUpdate = update;
|
|
686
770
|
if (update.status === 'failed') {
|
|
771
|
+
await observeStatusAfterFailure();
|
|
687
772
|
throw new Error(`server update failed: ${update.errorCode} (${update.recovery})`);
|
|
688
773
|
}
|
|
689
774
|
const serverIdentity = exactServerIdentity(pair.server.version);
|
|
@@ -696,10 +781,17 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
696
781
|
) {
|
|
697
782
|
throw new Error('server update result did not reach the target artifact');
|
|
698
783
|
}
|
|
784
|
+
failureStage = 'post-update server status check';
|
|
785
|
+
retryCommand = 'borg server status';
|
|
786
|
+
recoveryStatusAttempted = true;
|
|
699
787
|
status = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
|
|
700
788
|
observedStatus = status;
|
|
701
789
|
}
|
|
790
|
+
failureStage = 'final server state verification';
|
|
791
|
+
retryCommand = 'borg server update';
|
|
702
792
|
const state = verifyServerStatus(status, pair.server);
|
|
793
|
+
failureStage = 'final package verification';
|
|
794
|
+
retryCommand = 'borg update --yes';
|
|
703
795
|
const [finalClient, finalServer] = await Promise.all([
|
|
704
796
|
deps.currentClient(),
|
|
705
797
|
deps.currentServer(),
|
|
@@ -707,20 +799,35 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
707
799
|
assertInstalled(finalClient, pair.client);
|
|
708
800
|
if (!finalServer) throw new Error('server controller disappeared during final verification');
|
|
709
801
|
assertInstalled(finalServer, pair.server);
|
|
710
|
-
if (
|
|
802
|
+
if (initialServerState === 'running' && state === 'stopped') {
|
|
803
|
+
failureStage = 'managed service continuity check';
|
|
804
|
+
retryCommand = 'borg server start';
|
|
805
|
+
throw new Error('a previously running local server is now stopped');
|
|
806
|
+
}
|
|
807
|
+
if (state === 'running') {
|
|
808
|
+
failureStage = 'running server protocol verification';
|
|
809
|
+
retryCommand = 'borg server status';
|
|
810
|
+
await deps.verifyRunningProtocol(status.endpoint!);
|
|
811
|
+
}
|
|
711
812
|
deps.stdout(
|
|
712
813
|
state === 'stopped'
|
|
713
|
-
?
|
|
814
|
+
? (
|
|
815
|
+
`Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}: prepared.\n` +
|
|
816
|
+
renderStoppedServiceRecovery(status)
|
|
817
|
+
)
|
|
714
818
|
: `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}; running identities and protocol verified.\n`,
|
|
715
819
|
);
|
|
716
820
|
deps.stdout('Restart active agent sessions to load the updated client.\n');
|
|
717
821
|
return 0;
|
|
718
822
|
} catch (error) {
|
|
719
823
|
const interrupted = signalExitCode(error);
|
|
824
|
+
if (updateAttempted && !recoveryStatusAttempted && observedStatus === null) {
|
|
825
|
+
await observeStatusAfterFailure();
|
|
826
|
+
}
|
|
720
827
|
deps.stderr(
|
|
721
|
-
`Server update
|
|
828
|
+
`Server update failed during ${failureStage}: ${errorMessage(error, 'unknown failure')}.\n` +
|
|
722
829
|
renderServerState(client, server, observedStatus, observedUpdate) +
|
|
723
|
-
|
|
830
|
+
renderServerFailureRecovery(observedStatus, updateAttempted, retryCommand),
|
|
724
831
|
);
|
|
725
832
|
return interrupted ?? 1;
|
|
726
833
|
}
|
|
@@ -735,8 +842,8 @@ interface CommandResult {
|
|
|
735
842
|
class CommandSignalError extends Error {
|
|
736
843
|
readonly exitCode: number;
|
|
737
844
|
|
|
738
|
-
constructor(signal: NodeJS.Signals) {
|
|
739
|
-
super(`command stopped by ${signal}`);
|
|
845
|
+
constructor(signal: NodeJS.Signals, stderr = '') {
|
|
846
|
+
super(`command stopped by ${signal}${serverCommandStderr(stderr)}`);
|
|
740
847
|
this.name = 'CommandSignalError';
|
|
741
848
|
this.exitCode = 128 + (constants.signals[signal] ?? 1);
|
|
742
849
|
}
|
|
@@ -775,7 +882,7 @@ function runCommand(
|
|
|
775
882
|
child.once('exit', (code, signal) => {
|
|
776
883
|
if (settled) return;
|
|
777
884
|
if (signal) {
|
|
778
|
-
fail(new CommandSignalError(signal));
|
|
885
|
+
fail(new CommandSignalError(signal, stderr));
|
|
779
886
|
return;
|
|
780
887
|
}
|
|
781
888
|
settled = true;
|
|
@@ -800,6 +907,14 @@ function singleLine(text: string, label: string): string {
|
|
|
800
907
|
return value;
|
|
801
908
|
}
|
|
802
909
|
|
|
910
|
+
function serverCommandStderr(stderr: string): string {
|
|
911
|
+
const detail = stderr.trim();
|
|
912
|
+
if (detail === '') return '';
|
|
913
|
+
const bounded = Array.from(detail).slice(-4096).join('')
|
|
914
|
+
.replace(/\p{Cc}/gu, (character) => character === '\n' || character === '\t' ? character : '?');
|
|
915
|
+
return `\nServer command stderr:\n${bounded}`;
|
|
916
|
+
}
|
|
917
|
+
|
|
803
918
|
async function npmText(commandPath: string, args: readonly string[], label: string): Promise<string> {
|
|
804
919
|
const result = await runCommand(commandPath, args);
|
|
805
920
|
if (result.code !== 0) throw new Error(`npm ${label} lookup failed`);
|
|
@@ -925,6 +1040,18 @@ async function inspectNpmPackage(
|
|
|
925
1040
|
if (!required) return null;
|
|
926
1041
|
throw new Error(`${binName} is not available on PATH`);
|
|
927
1042
|
}
|
|
1043
|
+
try {
|
|
1044
|
+
await realpath(join(context.root, name));
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (hasErrorCode(error, 'ENOENT')) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`${binName} is on PATH from a different npm global prefix. ` +
|
|
1049
|
+
`borg update only manages packages under the active npm prefix. ` +
|
|
1050
|
+
`Run npm prefix --global to inspect it, then update the other installation with its package manager.`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
throw error;
|
|
1054
|
+
}
|
|
928
1055
|
return inspectNpmPackageAt({
|
|
929
1056
|
name,
|
|
930
1057
|
binName,
|
|
@@ -1065,10 +1192,12 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
|
|
|
1065
1192
|
try {
|
|
1066
1193
|
parsed = JSON.parse(result.stdout);
|
|
1067
1194
|
} catch {
|
|
1068
|
-
throw new Error(`server ${command} returned invalid JSON`);
|
|
1195
|
+
throw new Error(`server ${command} returned invalid JSON${serverCommandStderr(result.stderr)}`);
|
|
1069
1196
|
}
|
|
1070
1197
|
if (result.code !== 0 && command !== 'update') {
|
|
1071
|
-
throw new Error(
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
`server ${command} exited ${result.code}${serverCommandStderr(result.stderr)}`,
|
|
1200
|
+
);
|
|
1072
1201
|
}
|
|
1073
1202
|
return parsed;
|
|
1074
1203
|
},
|