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.
Files changed (43) hide show
  1. package/README.md +96 -230
  2. package/dist/drone-lifecycle.d.ts +8 -0
  3. package/dist/drone-lifecycle.d.ts.map +1 -1
  4. package/dist/drone-lifecycle.js +19 -0
  5. package/dist/drone-lifecycle.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +14 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/log-stream.d.ts +4 -0
  10. package/dist/log-stream.d.ts.map +1 -1
  11. package/dist/log-stream.js +28 -4
  12. package/dist/log-stream.js.map +1 -1
  13. package/dist/remote-client.d.ts +1 -1
  14. package/dist/remote-client.d.ts.map +1 -1
  15. package/dist/remote-client.js +42 -7
  16. package/dist/remote-client.js.map +1 -1
  17. package/dist/server-errors.d.ts +8 -0
  18. package/dist/server-errors.d.ts.map +1 -1
  19. package/dist/server-errors.js +19 -0
  20. package/dist/server-errors.js.map +1 -1
  21. package/dist/server-handshake.d.ts +1 -1
  22. package/dist/server-trust.d.ts.map +1 -1
  23. package/dist/server-trust.js +10 -0
  24. package/dist/server-trust.js.map +1 -1
  25. package/dist/tool-manifest.d.ts.map +1 -1
  26. package/dist/tool-manifest.js +3 -2
  27. package/dist/tool-manifest.js.map +1 -1
  28. package/dist/update-cmd.d.ts.map +1 -1
  29. package/dist/update-cmd.js +122 -16
  30. package/dist/update-cmd.js.map +1 -1
  31. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  32. package/docs/LOCAL_SERVER.md +3 -3
  33. package/docs/RELEASING.md +11 -6
  34. package/package.json +2 -2
  35. package/src/drone-lifecycle.ts +25 -0
  36. package/src/index.ts +15 -1
  37. package/src/log-stream.ts +26 -5
  38. package/src/remote-client.ts +56 -5
  39. package/src/server-errors.ts +24 -0
  40. package/src/server-handshake.ts +1 -1
  41. package/src/server-trust.ts +11 -0
  42. package/src/tool-manifest.ts +3 -2
  43. 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
- await deleteCube(cubeId);
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
- `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
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
 
@@ -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 { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
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
- void cubeId;
1321
- localUnsupported('cube deletion');
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
  /**
@@ -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'
@@ -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 v6.
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 —
@@ -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:',
@@ -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 confirm with the user before invoking unless the cube is clearly disposable.',
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
- `${errorMessage(error, 'Update preflight failed')}\n` +
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
- `${errorMessage(error, 'Update preflight failed')}\n` +
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 (npm registry):\n` +
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 (state === 'running') await deps.verifyRunningProtocol(status.endpoint!);
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
- ? `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}: prepared; still stopped.\n`
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 or final verification failed: ${errorMessage(error, 'unknown failure')}.\n` +
828
+ `Server update failed during ${failureStage}: ${errorMessage(error, 'unknown failure')}.\n` +
722
829
  renderServerState(client, server, observedStatus, observedUpdate) +
723
- `Retry with: borg update --yes\n`,
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(`server ${command} exited ${result.code}`);
1198
+ throw new Error(
1199
+ `server ${command} exited ${result.code}${serverCommandStderr(result.stderr)}`,
1200
+ );
1072
1201
  }
1073
1202
  return parsed;
1074
1203
  },