borgmcp 5.5.0 → 5.7.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 (85) hide show
  1. package/README.md +7 -4
  2. package/dist/claude.d.ts.map +1 -1
  3. package/dist/claude.js +12 -0
  4. package/dist/claude.js.map +1 -1
  5. package/dist/cli-help.d.ts.map +1 -1
  6. package/dist/cli-help.js +16 -6
  7. package/dist/cli-help.js.map +1 -1
  8. package/dist/docs-sections.js +1 -1
  9. package/dist/docs-sections.js.map +1 -1
  10. package/dist/hermes-plugin-install.d.ts +19 -0
  11. package/dist/hermes-plugin-install.d.ts.map +1 -0
  12. package/dist/hermes-plugin-install.js +131 -0
  13. package/dist/hermes-plugin-install.js.map +1 -0
  14. package/dist/local-server-cursor.d.ts +9 -0
  15. package/dist/local-server-cursor.d.ts.map +1 -1
  16. package/dist/local-server-cursor.js +50 -1
  17. package/dist/local-server-cursor.js.map +1 -1
  18. package/dist/log-stream.d.ts +13 -0
  19. package/dist/log-stream.d.ts.map +1 -1
  20. package/dist/log-stream.js +45 -13
  21. package/dist/log-stream.js.map +1 -1
  22. package/dist/remote-client.d.ts +3 -0
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +7 -1
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/representative-cmd.d.ts +11 -0
  27. package/dist/representative-cmd.d.ts.map +1 -1
  28. package/dist/representative-cmd.js +48 -15
  29. package/dist/representative-cmd.js.map +1 -1
  30. package/dist/representative-core.d.ts +53 -7
  31. package/dist/representative-core.d.ts.map +1 -1
  32. package/dist/representative-core.js +229 -43
  33. package/dist/representative-core.js.map +1 -1
  34. package/dist/representative-delivery-store.d.ts +79 -0
  35. package/dist/representative-delivery-store.d.ts.map +1 -0
  36. package/dist/representative-delivery-store.js +233 -0
  37. package/dist/representative-delivery-store.js.map +1 -0
  38. package/dist/representative-listener-store.d.ts +46 -0
  39. package/dist/representative-listener-store.d.ts.map +1 -0
  40. package/dist/representative-listener-store.js +119 -0
  41. package/dist/representative-listener-store.js.map +1 -0
  42. package/dist/representative-listener.d.ts +32 -0
  43. package/dist/representative-listener.d.ts.map +1 -0
  44. package/dist/representative-listener.js +293 -0
  45. package/dist/representative-listener.js.map +1 -0
  46. package/dist/representative-mcp.d.ts +10 -3
  47. package/dist/representative-mcp.d.ts.map +1 -1
  48. package/dist/representative-mcp.js +48 -19
  49. package/dist/representative-mcp.js.map +1 -1
  50. package/dist/representative-store.d.ts +7 -0
  51. package/dist/representative-store.d.ts.map +1 -1
  52. package/dist/representative-store.js +17 -2
  53. package/dist/representative-store.js.map +1 -1
  54. package/dist/seat-probe.d.ts +1 -0
  55. package/dist/seat-probe.d.ts.map +1 -1
  56. package/dist/seat-probe.js +1 -1
  57. package/dist/seat-probe.js.map +1 -1
  58. package/dist/server-trust.d.ts +10 -0
  59. package/dist/server-trust.d.ts.map +1 -1
  60. package/dist/server-trust.js +23 -6
  61. package/dist/server-trust.js.map +1 -1
  62. package/dist/stream-owner.d.ts.map +1 -1
  63. package/dist/stream-owner.js +32 -3
  64. package/dist/stream-owner.js.map +1 -1
  65. package/docs/HUMAN_REPRESENTATIVE.md +310 -36
  66. package/hermes-plugin/borg-representative-push/__init__.py +743 -0
  67. package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
  68. package/package.json +3 -1
  69. package/src/claude.ts +12 -0
  70. package/src/cli-help.ts +16 -6
  71. package/src/docs-sections.ts +1 -1
  72. package/src/hermes-plugin-install.ts +147 -0
  73. package/src/local-server-cursor.ts +45 -1
  74. package/src/log-stream.ts +47 -14
  75. package/src/remote-client.ts +9 -1
  76. package/src/representative-cmd.ts +52 -20
  77. package/src/representative-core.ts +255 -46
  78. package/src/representative-delivery-store.ts +235 -0
  79. package/src/representative-listener-store.ts +115 -0
  80. package/src/representative-listener.ts +215 -0
  81. package/src/representative-mcp.ts +58 -17
  82. package/src/representative-store.ts +18 -2
  83. package/src/seat-probe.ts +1 -1
  84. package/src/server-trust.ts +25 -6
  85. package/src/stream-owner.ts +34 -2
@@ -2,8 +2,9 @@
2
2
  * Restricted stdio MCP facade for the human representative.
3
3
  *
4
4
  * A Borg server speaks pinned-TLS HTTPS, not MCP, so a generic MCP host reaches
5
- * it through this local stdio process. The surface is four tools: status, send
6
- * (to the ONE bound Coordinator), read (that Coordinator's replies) and ack.
5
+ * it through this local stdio process. The surface is five tools: status, send
6
+ * (to the ONE bound Coordinator), read (that Coordinator's replies), deliver
7
+ * (the host's durable delivery checkpoint) and ack.
7
8
  * There is deliberately no log/broadcast/dispatch, roster-management, grant,
8
9
  * evict, release, regen or server-lifecycle tool, and no dispatcher escape hatch.
9
10
  *
@@ -21,18 +22,22 @@ import {
21
22
  REPRESENTATIVE_MESSAGE_LIMIT_BYTES,
22
23
  RepresentativeError,
23
24
  ackRepresentativeReply,
25
+ deliverRepresentativeReplies,
24
26
  readRepresentativeReplies,
27
+ serializeRepresentativeResult,
25
28
  representativeStatus,
26
29
  sendRepresentativeMessage,
27
30
  type RepresentativeContext,
28
31
  } from './representative-core.js';
29
- import { RepresentativeStoreError } from './representative-store.js';
32
+ import { RepresentativeStoreError, bindingFingerprint } from './representative-store.js';
33
+ import { createDeliveryStore } from './representative-delivery-store.js';
30
34
  import { createRepresentativeOwner } from './representative-owner.js';
31
35
 
32
36
  export const REPRESENTATIVE_TOOL_NAMES = [
33
37
  'borg_representative-status',
34
38
  'borg_representative-send',
35
39
  'borg_representative-read',
40
+ 'borg_representative-deliver',
36
41
  'borg_representative-ack',
37
42
  ] as const;
38
43
 
@@ -83,29 +88,47 @@ const TOOLS = [
83
88
  {
84
89
  name: 'borg_representative-read',
85
90
  description:
86
- 'Read unread replies from the bound Coordinator addressed to this representative. Replies arrive only when this tool ' +
87
- 'is called; there is no background wake. Each call DRAINS the whole fetched unread page for this drone — returned ' +
88
- 'replies and ignored entries alike (other drones\' entries are counted, never returned) — so they will not appear ' +
89
- 'unread again: persist the result, then route each reply by in_reply_to to its conversation or hold it for the human. If the host stops before persisting, the reply ' +
90
- 'is no longer in the unread view.',
91
+ 'Read the bound Coordinator\'s replies addressed to this representative that come after your delivered checkpoint, ' +
92
+ 'oldest first. Reading changes nothing: the same replies return on every call until you call ' +
93
+ 'borg_representative-deliver. Persist each reply durably, route it by in_reply_to or hold it for the human, then ' +
94
+ 'deliver through the last one you persisted. At most `limit` replies and `max_bytes` of serialized result; a reply ' +
95
+ 'larger than max_bytes is returned whole and alone with oversize:true, and the result never exceeds ' +
96
+ 'max(max_bytes, 16384) bytes (oversize citations reduced to ids, documents_reduced:true). has_more means more ' +
97
+ 'replies follow the window; an empty page never has has_more. ' +
98
+ 'Stop routing if binding_fingerprint differs from the value you persisted at binding time.',
91
99
  inputSchema: {
92
100
  type: 'object',
93
101
  properties: {
94
102
  include_broadcast: { type: 'boolean', description: 'Also return the Coordinator\'s cube-wide broadcasts (marked as such).' },
95
- limit: {
96
- type: 'integer', minimum: 1, maximum: 200,
97
- description: 'Page-size hint, not a hard cap: a large unread backlog may return (and drain) more entries.',
103
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Hard cap on returned replies. Default 10.' },
104
+ max_bytes: {
105
+ type: 'integer', minimum: 4096, maximum: 60000,
106
+ description: 'Hard cap on the serialized result size in bytes. Default 32768.',
98
107
  },
99
108
  },
100
109
  additionalProperties: false,
101
110
  },
102
111
  },
112
+ {
113
+ name: 'borg_representative-deliver',
114
+ description:
115
+ 'Record that every reply up to and including `through` is durably persisted by the host. Only this call moves the ' +
116
+ 'read window. `through` must be a reply borg_representative-read returned; the same or an older id is a no-op ' +
117
+ '(advanced:false). Call it only after durable persistence: a delivered reply is not returned again. It does not ' +
118
+ 'notify the Coordinator; that is borg_representative-ack.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: { through: { type: 'string', description: 'entry_id of the last reply you persisted.' } },
122
+ required: ['through'],
123
+ additionalProperties: false,
124
+ },
125
+ },
103
126
  {
104
127
  name: 'borg_representative-ack',
105
128
  description:
106
129
  'Signal to the Coordinator that one of its direct replies (entry_id from borg_representative-read) was received. ' +
107
- 'It is only that signal: it does not make delivery reliable, does not move or restore the unread cursor, and is ' +
108
- 'not needed for reading.',
130
+ 'It is only that signal: it is not delivery, does not move the read window (that is borg_representative-deliver), ' +
131
+ 'and is not needed for reading.',
109
132
  inputSchema: {
110
133
  type: 'object',
111
134
  properties: { entry_id: { type: 'string' } },
@@ -130,13 +153,19 @@ function errorBody(error: unknown): { error: { code: string; message: string; de
130
153
  }
131
154
 
132
155
  const toolResult = (body: unknown, isError = false) => ({
133
- content: [{ type: 'text' as const, text: JSON.stringify(body, null, 2) }],
156
+ content: [{ type: 'text' as const, text: serializeRepresentativeResult(body) }],
134
157
  ...(isError ? { isError: true } : {}),
135
158
  });
136
159
 
137
160
  export interface ServeRepresentativeOptions {
138
161
  /** Resolved on every call; a throw fails that call closed. */
139
162
  context: () => Promise<RepresentativeContext>;
163
+ /**
164
+ * The binding generation this process started with. Every call except status
165
+ * refuses with BINDING_MISMATCH, before any lease, ledger, checkpoint, cursor
166
+ * or network activity, once the saved binding's fingerprint differs.
167
+ */
168
+ pinnedFingerprint?: string;
140
169
  version: string;
141
170
  stdin?: Readable;
142
171
  stdout?: Writable;
@@ -163,16 +192,26 @@ export async function serveRepresentativeMcp(
163
192
  throw new RepresentativeError(ErrorCode.INVALID_INPUT, `Unknown tool ${JSON.stringify(name)}; this connection exposes only the representative tools.`);
164
193
  }
165
194
  const ctx = await options.context();
195
+ const pinned = options.pinnedFingerprint ? { pinned_binding_fingerprint: options.pinnedFingerprint } : {};
166
196
  if (name === 'borg_representative-status') {
167
- return toolResult({ ...await representativeStatus(ctx), ownership: await owner.snapshot(ctx.binding) });
197
+ return toolResult({ ...await representativeStatus(ctx), ...pinned, ownership: await owner.snapshot(ctx.binding) });
198
+ }
199
+ if (options.pinnedFingerprint && bindingFingerprint(ctx.binding) !== options.pinnedFingerprint) {
200
+ throw new RepresentativeError(
201
+ 'BINDING_MISMATCH',
202
+ 'The operator rebound this connection (a new binding generation) while it was running. Restart the MCP server to use the new binding.',
203
+ );
168
204
  }
205
+ // An untrustworthy delivery checkpoint stops every effect, not only read
206
+ // and deliver, so the refusal reaches the human. Read-only; status stays.
207
+ await createDeliveryStore(ctx.binding).load();
169
208
  await owner.ensure(ctx.binding);
170
209
  // Recheck at each network boundary, not just at tool dispatch: a process
171
210
  // may have paused or lost its lease while awaiting live verification.
172
211
  const backend = ctx.backend;
173
- const guarded = { ...ctx, backend: Object.fromEntries(['whoami', 'roster', 'append', 'readUnread', 'readEntry', 'ack'].map((key) => [key, async (...args: unknown[]) => {
212
+ const guarded = { ...ctx, guard: () => owner.ensure(ctx.binding), backend: Object.fromEntries(['whoami', 'roster', 'append', 'readAfter', 'unreadCursor', 'readEntry', 'ack'].map((key) => [key, async (...args: unknown[]) => {
174
213
  await owner.ensure(ctx.binding);
175
- if (key === 'readUnread') args[1] = () => owner.ensure(ctx.binding);
214
+ if (key === 'readAfter') args[2] = () => owner.ensure(ctx.binding);
176
215
  const result = await (backend[key as keyof typeof backend] as (...args: unknown[]) => Promise<unknown>).apply(backend, args);
177
216
  await owner.ensure(ctx.binding);
178
217
  return result;
@@ -184,6 +223,8 @@ export async function serveRepresentativeMcp(
184
223
  }
185
224
  case 'borg_representative-read':
186
225
  return toolResult(await readRepresentativeReplies(guarded, args));
226
+ case 'borg_representative-deliver':
227
+ return toolResult(await deliverRepresentativeReplies(guarded, args));
187
228
  default:
188
229
  return toolResult(await ackRepresentativeReply(guarded, args));
189
230
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { decodeUuid } from 'borgmcp-shared/protocol';
15
+ import { createHash } from 'node:crypto';
15
16
  import { join } from 'node:path';
16
17
  import { borgConfigRoot } from './private-root.js';
17
18
  import { shellEscape } from './shell-escape.js';
@@ -22,6 +23,19 @@ export function isRepresentativeUuid(value: unknown): value is string {
22
23
  }
23
24
  const SETTLED_REQUEST_LIMIT = 200;
24
25
 
26
+ /**
27
+ * Host fence for one binding generation: hex SHA-256 of the canonical JSON array
28
+ * [origin, trustIdentity, cubeId, representativeDroneId, coordinatorDroneId,
29
+ * boundAt]. It changes on every rebind (boundAt) and trust change, and carries
30
+ * no path or credential.
31
+ */
32
+ export function bindingFingerprint(binding: RepresentativeBinding): string {
33
+ return createHash('sha256').update(JSON.stringify([
34
+ binding.origin, binding.trustIdentity, binding.cubeId,
35
+ binding.representativeDroneId, binding.coordinatorDroneId, binding.boundAt,
36
+ ])).digest('hex');
37
+ }
38
+
25
39
  export interface RepresentativeBinding {
26
40
  /** Canonical worktree holding the dedicated representative seat. */
27
41
  worktree: string;
@@ -177,7 +191,9 @@ export function createRepresentativeStore(storePath: string = representativeStor
177
191
  throw new Error('Refusing to save an invalid representative binding');
178
192
  }
179
193
  const existing = txn.data.bindings[binding.worktree];
180
- if (existing && sameSelection(existing, binding)) return 'unchanged' as const;
194
+ // An explicit rebind always starts a new generation (new boundAt, so a new
195
+ // binding_fingerprint), even for the same selection.
196
+ if (existing && sameSelection(existing, binding) && !options.rebind) return 'unchanged' as const;
181
197
  if (existing && !options.rebind) {
182
198
  throw new RepresentativeStoreError(
183
199
  'BINDING_CONFLICT',
@@ -188,7 +204,7 @@ export function createRepresentativeStore(storePath: string = representativeStor
188
204
  txn.data.bindings[binding.worktree] = binding;
189
205
  // A different selection invalidates the old ledger: its post ids belong to
190
206
  // another cube/Coordinator conversation.
191
- if (existing) delete txn.data.requests[binding.worktree];
207
+ if (existing && !sameSelection(existing, binding)) delete txn.data.requests[binding.worktree];
192
208
  await txn.commit();
193
209
  return existing ? 'rebound' as const : 'created' as const;
194
210
  }),
package/src/seat-probe.ts CHANGED
@@ -68,7 +68,7 @@ const TRANSPORT_ERRNOS = new Set([
68
68
  'ABORT_ERR',
69
69
  ]);
70
70
 
71
- function isTransportFailure(err: unknown): boolean {
71
+ export function isTransportFailure(err: unknown): boolean {
72
72
  if (err instanceof BorgServerUnreachableError) return true;
73
73
  const e = err as { name?: string; code?: string; cause?: { code?: string } };
74
74
  if (e?.name === 'AbortError') return true;
@@ -457,12 +457,7 @@ export async function loadBorgServerTrust(
457
457
  let pending = trustCache.get(key);
458
458
  if (!pending) {
459
459
  pending = (async () => {
460
- const [certificate, configText] = await Promise.all([
461
- readTrustFile(join(directory, 'ca.crt')),
462
- readTrustFile(join(directory, 'server.json')),
463
- ]);
464
- const config = decodeTrustConfig(configText);
465
- const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
460
+ const { certificate, identity } = await readLocalAuthorityTrust(directory);
466
461
  return { identity, fetchImpl: createPinnedServerFetch(origin, certificate) };
467
462
  })();
468
463
  trustCache.set(key, pending);
@@ -494,6 +489,30 @@ export async function loadBorgServerTrust(
494
489
  });
495
490
  }
496
491
 
492
+ async function readLocalAuthorityTrust(directory: string): Promise<{ certificate: string; identity: string }> {
493
+ const [certificate, configText] = await Promise.all([
494
+ readTrustFile(join(directory, 'ca.crt')),
495
+ readTrustFile(join(directory, 'server.json')),
496
+ ]);
497
+ return { certificate, identity: verifyCaIdentity(certificate, decodeTrustConfig(configText).ca_spki_sha256) };
498
+ }
499
+
500
+ /**
501
+ * The current trust identity for `origin`, read from disk on every call with the
502
+ * same source selection and file checks as loadBorgServerTrust. Its local-authority
503
+ * branch caches for the process lifetime, which a continuation guard on a
504
+ * long-lived connection must not rely on; the enrollment branch already rereads
505
+ * its pointer per call. It returns the identity only and grants no trust. The
506
+ * local-authority branch builds no fetch; the enrollment delegation may construct
507
+ * the loader's pinned fetch on a cache miss, but this function sends no request.
508
+ */
509
+ export async function readBorgServerTrustIdentity(origin: string): Promise<string> {
510
+ if (!await remoteTrustStateExists(origin) && await trustFilesExist(serverDataDirectory())) {
511
+ return (await readLocalAuthorityTrust(serverDataDirectory())).identity;
512
+ }
513
+ return (await loadBorgServerTrust(origin)).identity;
514
+ }
515
+
497
516
  function pemCertificate(raw: Buffer): string {
498
517
  const body = raw.toString('base64').match(/.{1,64}/g)?.join('\n') ?? '';
499
518
  return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
@@ -139,8 +139,37 @@ export async function readOwnershipSnapshot(
139
139
  deps: StreamOwnerDeps = {}
140
140
  ): Promise<StreamOwnershipSnapshot> {
141
141
  const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
142
- const inspected = await readBoundOwner(lockPath, deps);
143
- if (!inspected) return { state: 'unowned', lockPath };
142
+ // A refresh renames the lock to `<lock>.takeover` while it rewrites the record
143
+ // and then restores it. Read-only: report an intact, fresh, live in-flight
144
+ // owner there; a stale, future-dated, dead or malformed leftover stays
145
+ // `unowned`. A missing directory or owner leaf is re-resolved from the lock
146
+ // path, so one refresh overlapping any step cannot hide the owner; genuine
147
+ // initialization still reports `initializing` after the bounded attempts.
148
+ let initializing: StreamOwnershipSnapshot | undefined;
149
+ for (let attempt = 0; attempt < 3; attempt += 1) {
150
+ const inspected = await readBoundOwner(lockPath, deps);
151
+ if (inspected) {
152
+ const snapshot = snapshotFromOwner(lockPath, inspected, deps);
153
+ if (inspected.raw !== null) return snapshot;
154
+ initializing = snapshot;
155
+ continue;
156
+ }
157
+ const claimed = await readBoundOwner(takeoverPath(lockPath), deps);
158
+ if (!claimed || claimed.raw === null) continue;
159
+ const snapshot = snapshotFromOwner(lockPath, claimed, deps);
160
+ const ageMs = snapshot.ageMs ?? Infinity;
161
+ const live = snapshot.pid !== undefined && ageMs >= 0 && ageMs <= STREAM_OWNER_STALE_MS &&
162
+ isPidAlive(snapshot.pid, deps);
163
+ return live ? snapshot : { state: 'unowned', lockPath };
164
+ }
165
+ return initializing ?? { state: 'unowned', lockPath };
166
+ }
167
+
168
+ function snapshotFromOwner(
169
+ lockPath: string,
170
+ inspected: { raw: string | null; stat: { dev: number; ino: number; mtimeMs: number } },
171
+ deps: StreamOwnerDeps,
172
+ ): StreamOwnershipSnapshot {
144
173
  const { raw, stat: lockStat } = inspected;
145
174
  if (raw === null) {
146
175
  const now = (deps.now ?? (() => new Date()))();
@@ -666,6 +695,9 @@ async function readBoundOwner(lockPath: string, deps: StreamOwnerDeps = {}): Pro
666
695
  ? await readStoreFile(path.join(lockPath, OWNER_FILE), { secureRoot: lockPath, createRoot: false })
667
696
  : await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
668
697
  } catch (error: any) {
698
+ // A concurrent refresh atomically replaces the owner file; like other
699
+ // lock inspection, that identity drift is turnover and is re-read.
700
+ if (error?.code === 'STORE_FILE_IDENTITY_CHANGED') continue;
669
701
  if (error?.code !== 'ENOENT') throw error;
670
702
  raw = null;
671
703
  }