borgmcp 5.1.1 → 5.2.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.
@@ -331,6 +331,7 @@ export interface AssimilateDeps {
331
331
  trustIdentity: string;
332
332
  serverCapabilities?: readonly string[];
333
333
  }>;
334
+ listServerCredentialOrigins?: (origin: string) => Promise<string[]>;
334
335
  resumeServerEnrollment: (
335
336
  apiUrl: string,
336
337
  onPending?: () => void,
@@ -533,13 +534,26 @@ function localAssimilateCliCommand(apiUrl: string, cli: BorgCli): string {
533
534
  return `\`borg assimilate --host ${apiUrl} --cli ${cli}\``;
534
535
  }
535
536
 
536
- function reportServerFailure(
537
- deps: Pick<AssimilateDeps, 'stderr'>,
537
+ function isLoopbackHostname(hostname: string): boolean {
538
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
539
+ }
540
+
541
+ function isSiblingEndpoint(reachedOrigin: string, enrolledOrigin: string): boolean {
542
+ const reached = new URL(reachedOrigin);
543
+ const enrolled = new URL(enrolledOrigin);
544
+ return reached.hostname === enrolled.hostname
545
+ || (reached.port === enrolled.port
546
+ && isLoopbackHostname(reached.hostname)
547
+ && isLoopbackHostname(enrolled.hostname));
548
+ }
549
+
550
+ async function reportServerFailure(
551
+ deps: Pick<AssimilateDeps, 'stderr' | 'listServerCredentialOrigins'>,
538
552
  apiUrl: string,
539
553
  error: unknown,
540
554
  enroll = false,
541
555
  mode: 'assimilate' | 'cube-init' = 'assimilate',
542
- ): number {
556
+ ): Promise<number> {
543
557
  const message = error instanceof Error ? error.message : String(error);
544
558
  const retryCommand = localAssimilateCommand(apiUrl, enroll, mode);
545
559
  if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
@@ -551,6 +565,42 @@ function reportServerFailure(
551
565
  return 1;
552
566
  }
553
567
  if (error instanceof BorgServerError && error.code === 'NOT_ENROLLED') {
568
+ let enrolledOrigins: string[] = [];
569
+ try {
570
+ enrolledOrigins = (await deps.listServerCredentialOrigins?.(apiUrl) ?? []).filter((origin) => {
571
+ try {
572
+ const parsed = new URL(origin);
573
+ return parsed.protocol === 'https:' && parsed.origin === origin && origin !== apiUrl;
574
+ } catch {
575
+ return false;
576
+ }
577
+ });
578
+ } catch {
579
+ // Preserve the existing recovery when the optional diagnostic lookup is unavailable.
580
+ }
581
+ const siblingOrigins = enrolledOrigins.filter((origin) => isSiblingEndpoint(apiUrl, origin));
582
+ if (siblingOrigins.length > 0) {
583
+ const enrollmentLabel = siblingOrigins.length === 1 ? 'a saved enrollment' : 'saved enrollments';
584
+ const commandLabel = siblingOrigins.length === 1 ? 'the enrolled endpoint' : 'one of the enrolled endpoints';
585
+ const commands = siblingOrigins
586
+ .map((origin) => localAssimilateCommand(origin, false, mode))
587
+ .join(' or ');
588
+ deps.stderr(
589
+ `Borg found ${enrollmentLabel} for ${siblingOrigins.join(', ')}, but this command is reaching ${apiUrl}. ` +
590
+ `Use ${commandLabel} instead: ${commands}.\n`,
591
+ );
592
+ return 1;
593
+ }
594
+ if (enrolledOrigins.length > 0) {
595
+ deps.stderr(
596
+ `This client is enrolled against ${enrolledOrigins.join(', ')}, not ${apiUrl}. ` +
597
+ `Confirm that the host, port, and IPv4 or IPv6 loopback ` +
598
+ `form in ${apiUrl} match the endpoint used during enrollment. If this client has never ` +
599
+ `enrolled with that server, run ${localAssimilateCommand(apiUrl, true, mode)} from the ` +
600
+ `operator’s terminal.\n`,
601
+ );
602
+ return 1;
603
+ }
554
604
  deps.stderr(
555
605
  `Borg could not find a saved enrollment for ${apiUrl}. ` +
556
606
  `This can mean that this client has not enrolled with the server, or that its enrollment ` +
@@ -1267,7 +1317,7 @@ export async function prepareAssimilationSeat(
1267
1317
  return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'superseded') };
1268
1318
  }
1269
1319
  }
1270
- return { kind: 'stop', code: reportServerFailure(deps, apiUrl, error) };
1320
+ return { kind: 'stop', code: await reportServerFailure(deps, apiUrl, error) };
1271
1321
  }
1272
1322
 
1273
1323
  if (result.prepareAborted) {
@@ -1281,7 +1331,7 @@ export async function prepareAssimilationSeat(
1281
1331
  if (result.local_session === undefined) {
1282
1332
  return {
1283
1333
  kind: 'stop',
1284
- code: reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
1334
+ code: await reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
1285
1335
  };
1286
1336
  }
1287
1337
  const assignedRole = cubeDetail.roles.find((role) => role.id === result.role_id) ?? resolvedRole;
@@ -1592,7 +1642,7 @@ export async function resolveAssimilationAuthority(
1592
1642
  hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
1593
1643
  } catch (error) {
1594
1644
  if (error instanceof LegacySessionCredentialCollisionError) {
1595
- return { kind: 'stop', code: reportServerFailure(deps, error.origin, error, false, mode) };
1645
+ return { kind: 'stop', code: await reportServerFailure(deps, error.origin, error, false, mode) };
1596
1646
  }
1597
1647
  localSeatReadError = error;
1598
1648
  }
@@ -1601,7 +1651,7 @@ export async function resolveAssimilationAuthority(
1601
1651
  if (!selectedAuthority) return { kind: 'stop', code: 1 };
1602
1652
  let authority = selectedAuthority;
1603
1653
  if (localSeatReadError !== undefined) {
1604
- return { kind: 'stop', code: reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
1654
+ return { kind: 'stop', code: await reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
1605
1655
  }
1606
1656
 
1607
1657
  const projectRoot = repositoryContext.root;
@@ -1704,7 +1754,7 @@ export async function resolveAssimilationAuthority(
1704
1754
  } catch (error) {
1705
1755
  return {
1706
1756
  kind: 'stop',
1707
- code: reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
1757
+ code: await reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
1708
1758
  };
1709
1759
  }
1710
1760
 
@@ -1861,7 +1911,7 @@ export async function runAssimilate(
1861
1911
  return 1;
1862
1912
  }
1863
1913
  if (error instanceof BorgServerError) {
1864
- return reportServerFailure(deps, auth.apiUrl, error, false, mode);
1914
+ return await reportServerFailure(deps, auth.apiUrl, error, false, mode);
1865
1915
  }
1866
1916
  deps.stderr(
1867
1917
  'Repository cube initialization failed.\n' +
@@ -67,7 +67,7 @@ import {
67
67
  addProjectSessionStartHook,
68
68
  provisionLaunchAccess,
69
69
  } from './config-utils.js';
70
- import { findPendingServerEnrollment } from './config.js';
70
+ import { findPendingServerEnrollment, listServerCredentialOrigins } from './config.js';
71
71
  import { setTerminalTitle as setTitle } from './terminal-title.js';
72
72
  import { defaultCliChoiceDeps, resolveCliChoice } from './cli-platform.js';
73
73
  import { prepareCodexRemoteLaunch, defaultCodexRemoteDeps } from './codex-remote.js';
@@ -268,6 +268,7 @@ export function buildDefaultAssimilateDeps(
268
268
  }
269
269
  return connectLocalBorgServer(apiUrl);
270
270
  },
271
+ listServerCredentialOrigins,
271
272
  resumeServerEnrollment: async (apiUrl, onPending) =>
272
273
  resumeLocalBorgServerEnrollment(apiUrl, {
273
274
  ...(onPending === undefined ? {} : { onPending }),
package/src/config.ts CHANGED
@@ -566,6 +566,31 @@ export async function hasServerCredentialForOrigin(origin: string): Promise<bool
566
566
  }), { processShared: processSharedEnrollmentLock() });
567
567
  }
568
568
 
569
+ export async function listServerCredentialOrigins(origin: string): Promise<string[]> {
570
+ const backend = await getServerCredentialBackend();
571
+ if (!backend.entries) return [];
572
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
573
+ const accounts = await backend.entries!();
574
+ const origins = new Set<string>();
575
+ for (const [account, value] of Object.entries(accounts)) {
576
+ try {
577
+ const candidate = JSON.parse(value) as { origin?: unknown; trustIdentity?: unknown };
578
+ if (typeof candidate.origin !== 'string' || typeof candidate.trustIdentity !== 'string') continue;
579
+ const record = decodeActiveServerCredentialRecord(
580
+ value,
581
+ candidate.origin,
582
+ candidate.trustIdentity,
583
+ );
584
+ if (account !== serverCredentialAccount(record.origin, record.trustIdentity)) continue;
585
+ origins.add(record.origin);
586
+ } catch {
587
+ // Ignore unrelated or malformed backend entries.
588
+ }
589
+ }
590
+ return [...origins].sort();
591
+ }), { processShared: processSharedEnrollmentLock() });
592
+ }
593
+
569
594
  function decodePendingServerEnrollment(
570
595
  stored: string,
571
596
  origin: string,
package/src/index.ts CHANGED
@@ -606,7 +606,9 @@ export async function main() {
606
606
  drone: displayedResult.drone,
607
607
  role: displayedResult.role,
608
608
  behind_by: typeof displayedResult.behind_by === 'number' ? displayedResult.behind_by : null,
609
- decision_topics: (displayedResult.decisions ?? []).map((d: any) => d.topic),
609
+ ...(Array.isArray(displayedResult.decisions)
610
+ ? { decision_topics: displayedResult.decisions.map((d) => d.topic) }
611
+ : {}),
610
612
  running_version: getPackageVersion(),
611
613
  on_disk_version: onDiskVersion,
612
614
  wake_path_healthy: inboxMonitorHealthy,
@@ -22,6 +22,7 @@ import { shellEscape } from './shell-escape.js';
22
22
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
23
23
  import { isBorgSession } from './launch-gate.js';
24
24
  import type { AgentKind } from './agent-runtime.js';
25
+ import type { Decision } from 'borgmcp-shared/protocol';
25
26
 
26
27
  export type HandoverMode = 'origin' | 'local';
27
28
 
@@ -385,6 +386,14 @@ Any time you make a factual claim that could be verified — "this shipped as ve
385
386
  - Cube log state → \`borg_read-log unread_only=true\` for wake triage, draining until \`behind_by=0\`; don't cite from memory or from another drone's summary
386
387
  - Ratified cube decision → \`borg_decisions {topic}\` — cite the registry's active decision by topic; NEVER restate a ratified decision from memory (a memory restatement drifts on the axis). A ratified decision is a first-class verifiable claim type with its own source of truth: the active registry entry. Recording one is \`borg_decide\`: Coordinator/Queen are workflow-eligible to ratify, but role labels grant no server permission; the selected local client needs a live cube-manage grant.
387
388
 
389
+ **Durable layers:**
390
+ 1. Decision registry (\`borg_decide\` / \`borg_decisions\`): choices between alternatives that could be revisited, cited by topic, served into every drone's context, capped at 16,384 active bytes per cube.
391
+ 2. Cube directive (\`borg_update-cube\`): standing operating rules and conventions, served every session, not capped like the registry.
392
+ 3. Cube documents (\`borg_put-document\` / \`borg_get-document\`): large or detailed material — contracts, designs, evidence — cited by id, never inlined.
393
+ 4. Repository \`AGENTS.md\`: rules specific to one repository, read only by seats working there.
394
+
395
+ Rules: a registry entry that records a rule rather than a choice belongs in the directive — move it and remove the registry copy; on a cap refusal the order is relocate rules → supersede stale choices → remove obsolete; never archive playbook prose in the registry; detail goes to a document and is cited.
396
+
388
397
  **The discipline is universal to reviewer-class actions** (Code Reviewer formal gates + Security Auditor SR gates + PM-courtesy verifications + UX-courtesy reviews + any drone making a verification-worthy factual claim in their cube-log post). It lives in this universal playbook rather than any one role's text because it applies to ALL reviewers.
389
398
 
390
399
  **Four-surface propagation:**
@@ -554,7 +563,8 @@ export function formatRegenMarkdown(
554
563
  // gh#740: active ratified decisions for the cube. Rendered in the
555
564
  // always-shown band (lite + full) so the source of truth is in context at
556
565
  // mid-session restatement moments. Absent on a pre-gh#740 worker → omitted.
557
- decisions?: any[];
566
+ decisions?: Decision[];
567
+ decisions_error?: string;
558
568
  },
559
569
  opts: { mode?: RegenMode; handoverMode?: HandoverMode } = {}
560
570
  ): string {
@@ -612,16 +622,28 @@ export function formatRegenMarkdown(
612
622
  const taxonomyTip = nullTaxonomyTip(result.cube.message_taxonomy);
613
623
 
614
624
  // gh#740: render active ratified decisions concisely (one line each), capped
615
- // with an elision footer. Section omitted when there are none (or the field
616
- // is absent on a pre-gh#740 worker mixed-client safe). Lives in the
625
+ // with an elision footer. Empty and failed reads are explicit and distinct;
626
+ // an absent field from a pre-gh#740 worker is omitted. Lives in the
617
627
  // always-shown band below so it surfaces on LITE wakes (the mid-session
618
628
  // restatement moment), not just the session-start full regen (PM F1).
619
629
  const RATIFIED_DECISIONS_CAP = 12;
620
- const activeDecisions = Array.isArray(result.decisions) ? result.decisions : [];
621
630
  const decisionsSection = (() => {
622
- if (activeDecisions.length === 0) return '';
631
+ if (result.decisions_error !== undefined) {
632
+ const errorClass = /^[A-Za-z][A-Za-z0-9]*Error$/.test(result.decisions_error)
633
+ ? result.decisions_error
634
+ : 'UnknownError';
635
+ return [
636
+ '## Ratified decisions',
637
+ `The decision registry could not be read (${errorClass}). Active decisions are unavailable.`,
638
+ ].join('\n');
639
+ }
640
+ if (!Array.isArray(result.decisions)) return '';
641
+ const activeDecisions = result.decisions;
642
+ if (activeDecisions.length === 0) {
643
+ return ['## Ratified decisions', 'No active decisions are recorded.'].join('\n');
644
+ }
623
645
  const shown = activeDecisions.slice(0, RATIFIED_DECISIONS_CAP);
624
- const lines = shown.map((d: any) => `- **${d.topic}:** ${d.decision}`);
646
+ const lines = shown.map((d) => `- **${d.topic}:** ${d.decision}`);
625
647
  const remaining = activeDecisions.length - shown.length;
626
648
  if (remaining > 0) lines.push(`- _+${remaining} more — \`borg_decisions\`_`);
627
649
  return ['## Ratified decisions', 'Cite these by topic — do NOT restate a ratified decision from memory.', ...lines].join('\n');
@@ -57,6 +57,7 @@ import {
57
57
  type ListDocumentsResult,
58
58
  type RemoveDocumentResult,
59
59
  type CreateCubeRepository,
60
+ type Decision,
60
61
  } from 'borgmcp-shared/protocol';
61
62
  import { Buffer } from 'node:buffer';
62
63
  import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
@@ -1261,9 +1262,9 @@ export async function listDecisions(
1261
1262
  apiUrl: string,
1262
1263
  topic?: string,
1263
1264
  serverTrustIdentity?: string,
1264
- ): Promise<{ decisions: any[] }> {
1265
+ ): Promise<{ decisions: Decision[] }> {
1265
1266
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
1266
- const payload = await localServerRequest<{ decisions: any[] }>(
1267
+ const payload = await localServerRequest<{ decisions: Decision[] }>(
1267
1268
  local,
1268
1269
  `/api/cubes/${local.cubeId}/decisions`,
1269
1270
  'PUT',
@@ -1352,7 +1353,8 @@ export async function regen(
1352
1353
  behind_by?: number;
1353
1354
  // gh#740: active ratified decisions for the cube, rendered by regen-format.
1354
1355
  // Local regen composes these via listDecisions.
1355
- decisions?: any[];
1356
+ decisions?: Decision[];
1357
+ decisions_error?: string;
1356
1358
  }> {
1357
1359
  const local = await localAuthorityContext(
1358
1360
  sessionToken,
@@ -1382,7 +1384,8 @@ export async function regen(
1382
1384
  ? await getLocalServerCursor(localCursorBinding(local))
1383
1385
  : await resolveLocalLogCursor(local, opts.since);
1384
1386
  const page = await localReadLogPage(local, { cursor, limit: 1 });
1385
- let decisions: any[] = [];
1387
+ let decisions: Decision[] | undefined;
1388
+ let decisionsError: string | undefined;
1386
1389
  try {
1387
1390
  decisions = (await listDecisions(
1388
1391
  sessionToken,
@@ -1391,8 +1394,12 @@ export async function regen(
1391
1394
  opts.serverTrustIdentity,
1392
1395
  )).decisions;
1393
1396
  } catch (error) {
1397
+ const constructorName = error instanceof Error ? error.constructor.name : '';
1398
+ decisionsError = /^[A-Za-z][A-Za-z0-9]*Error$/.test(constructorName)
1399
+ ? constructorName
1400
+ : 'UnknownError';
1394
1401
  console.warn(
1395
- `Local regen: failed to fetch ratified decisions (${error instanceof Error ? error.message : String(error)}); continuing without them.`,
1402
+ `Local regen: failed to fetch ratified decisions (${decisionsError}); continuing without them.`,
1396
1403
  );
1397
1404
  }
1398
1405
  return {
@@ -1403,7 +1410,8 @@ export async function regen(
1403
1410
  drones: composed.drones,
1404
1411
  recentLog: [],
1405
1412
  behind_by: page.entries.length + page.behind_by,
1406
- decisions,
1413
+ ...(decisions === undefined ? {} : { decisions }),
1414
+ ...(decisionsError === undefined ? {} : { decisions_error: decisionsError }),
1407
1415
  };
1408
1416
  }
1409
1417