borgmcp 3.0.0 → 3.0.2

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/src/cubes.ts CHANGED
@@ -84,6 +84,13 @@ interface LaunchFile {
84
84
  projects: Record<string, { cli: BorgCli }>;
85
85
  }
86
86
 
87
+ const UNREADABLE_STATE = Symbol('unreadable-state-file');
88
+ type StateFileRead<T> = T | null | typeof UNREADABLE_STATE;
89
+
90
+ function unreadableStateError(filePath: string): Error {
91
+ return new Error(`Borg state file is unreadable; refusing to overwrite it: ${filePath}`);
92
+ }
93
+
87
94
  export interface CodexWakeTargetRecord {
88
95
  threadId: string;
89
96
  socketPath: string;
@@ -225,7 +232,7 @@ function isLaunchFile(data: any): data is LaunchFile {
225
232
  );
226
233
  }
227
234
 
228
- async function readLaunchFile(): Promise<LaunchFile | null> {
235
+ async function readLaunchFile(): Promise<StateFileRead<LaunchFile>> {
229
236
  let raw: string;
230
237
  try {
231
238
  raw = await readFile(LAUNCH_FILE, 'utf8');
@@ -235,9 +242,9 @@ async function readLaunchFile(): Promise<LaunchFile | null> {
235
242
  }
236
243
  try {
237
244
  const parsed = JSON.parse(raw);
238
- return isLaunchFile(parsed) ? parsed : null;
245
+ return isLaunchFile(parsed) ? parsed : UNREADABLE_STATE;
239
246
  } catch {
240
- return null;
247
+ return UNREADABLE_STATE;
241
248
  }
242
249
  }
243
250
 
@@ -263,7 +270,7 @@ function isCodexWakeTargetsFile(data: any): data is CodexWakeTargetsFile {
263
270
  );
264
271
  }
265
272
 
266
- async function readCodexWakeTargetsFile(): Promise<CodexWakeTargetsFile | null> {
273
+ async function readCodexWakeTargetsFile(): Promise<StateFileRead<CodexWakeTargetsFile>> {
267
274
  let raw: string;
268
275
  try {
269
276
  raw = await readFile(CODEX_WAKE_TARGETS_FILE, 'utf8');
@@ -273,9 +280,9 @@ async function readCodexWakeTargetsFile(): Promise<CodexWakeTargetsFile | null>
273
280
  }
274
281
  try {
275
282
  const parsed = JSON.parse(raw);
276
- return isCodexWakeTargetsFile(parsed) ? parsed : null;
283
+ return isCodexWakeTargetsFile(parsed) ? parsed : UNREADABLE_STATE;
277
284
  } catch {
278
- return null;
285
+ return UNREADABLE_STATE;
279
286
  }
280
287
  }
281
288
 
@@ -574,6 +581,7 @@ export async function refreshActiveCubeMetadata(active: ActiveCubeInput): Promis
574
581
 
575
582
  export async function getProjectCliPreference(): Promise<BorgCli | null> {
576
583
  const data = await readLaunchFile();
584
+ if (data === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
577
585
  if (!data) return null;
578
586
  const entry = data.projects[findProjectRoot()];
579
587
  return entry?.cli === 'claude' || entry?.cli === 'codex' || entry?.cli === 'opencode' ? entry.cli : null;
@@ -586,6 +594,7 @@ export async function getProjectCliPreference(): Promise<BorgCli | null> {
586
594
  */
587
595
  export async function getProjectCliPreferenceForPath(dir: string): Promise<BorgCli | null> {
588
596
  const data = await readLaunchFile();
597
+ if (data === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
589
598
  if (!data) return null;
590
599
  const entry = data.projects[findProjectRoot(dir)];
591
600
  return entry?.cli === 'claude' || entry?.cli === 'codex' || entry?.cli === 'opencode' ? entry.cli : null;
@@ -617,9 +626,11 @@ export async function readAllProjectIdentities(): Promise<
617
626
  * sibling worktree but the process still began in the invoking checkout.
618
627
  */
619
628
  export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promise<void> {
620
- const existing = (await readLaunchFile()) ?? { projects: {} };
621
- existing.projects[findProjectRoot(dir)] = { cli };
622
- await writeLaunchFile(existing);
629
+ const existing = await readLaunchFile();
630
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
631
+ const next = existing ?? { projects: {} };
632
+ next.projects[findProjectRoot(dir)] = { cli };
633
+ await writeLaunchFile(next);
623
634
  }
624
635
 
625
636
  export async function setCodexWakeTarget(
@@ -627,12 +638,14 @@ export async function setCodexWakeTarget(
627
638
  droneId: string,
628
639
  target: Omit<CodexWakeTargetRecord, 'updatedAt'>
629
640
  ): Promise<void> {
630
- const existing = (await readCodexWakeTargetsFile()) ?? { targets: {} };
631
- existing.targets[codexWakeTargetKey(cubeId, droneId)] = {
641
+ const existing = await readCodexWakeTargetsFile();
642
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
643
+ const next = existing ?? { targets: {} };
644
+ next.targets[codexWakeTargetKey(cubeId, droneId)] = {
632
645
  ...target,
633
646
  updatedAt: new Date().toISOString(),
634
647
  };
635
- await writeCodexWakeTargetsFile(existing);
648
+ await writeCodexWakeTargetsFile(next);
636
649
  }
637
650
 
638
651
  export async function getCodexWakeTarget(
@@ -640,6 +653,7 @@ export async function getCodexWakeTarget(
640
653
  droneId: string
641
654
  ): Promise<CodexWakeTargetRecord | null> {
642
655
  const existing = await readCodexWakeTargetsFile();
656
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
643
657
  if (!existing) return null;
644
658
  const target = existing.targets[codexWakeTargetKey(cubeId, droneId)];
645
659
  if (!target || typeof target.threadId !== 'string' || typeof target.socketPath !== 'string') {
@@ -661,6 +675,7 @@ export async function pruneDeadCodexWakeTargets(
661
675
  socketLiveness: (socketPath: string) => boolean | null
662
676
  ): Promise<void> {
663
677
  const existing = await readCodexWakeTargetsFile();
678
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
664
679
  if (!existing) return;
665
680
  const { targets, changed } = pruneDeadWakeTargets(existing.targets, socketLiveness);
666
681
  if (changed) await writeCodexWakeTargetsFile({ ...existing, targets });
@@ -177,8 +177,11 @@ export async function offerFirstRunServerInstall(
177
177
  : decision === 'eof'
178
178
  ? 'Installation was cancelled because confirmation input ended.'
179
179
  : 'Installation was declined.';
180
+ const setupRecovery = options.initializeServer
181
+ ? ', and no agent configuration was written. Run `borg setup` again to configure your agents.'
182
+ : '.';
180
183
  deps.stderr(
181
- `${reason} No server package or server state was changed.\n` +
184
+ `${reason} No server package or server state was changed${setupRecovery}\n` +
182
185
  `To install it later, run \`${command}\`. Then run \`borg server setup\` and \`borg server start\`.\n`,
183
186
  );
184
187
  return { kind: 'declined' };
@@ -28,6 +28,15 @@ interface LifecycleStateFile {
28
28
  entries: Record<string, LifecycleStateEntry>;
29
29
  }
30
30
 
31
+ const UNREADABLE_STATE = Symbol('unreadable-lifecycle-state');
32
+ type LifecycleStateRead = LifecycleStateFile | typeof UNREADABLE_STATE;
33
+
34
+ function unreadableStateError(): Error {
35
+ return new Error(
36
+ `Lifecycle log state is unreadable; refusing to overwrite it: ${STATE_FILE}`,
37
+ );
38
+ }
39
+
31
40
  export function lifecycleSignalForMessage(message: string): LifecycleSignal | null {
32
41
  if (message.startsWith('ARRIVAL: ')) return 'arrival';
33
42
  if (
@@ -44,7 +53,7 @@ function stateKey(subject: LifecycleLogSubject): string {
44
53
  return `${subject.cubeId}:${subject.droneId}`;
45
54
  }
46
55
 
47
- async function readState(): Promise<LifecycleStateFile> {
56
+ async function readState(): Promise<LifecycleStateRead> {
48
57
  try {
49
58
  const raw = await readFile(STATE_FILE, 'utf8');
50
59
  const parsed = JSON.parse(raw);
@@ -58,9 +67,10 @@ async function readState(): Promise<LifecycleStateFile> {
58
67
  return parsed as LifecycleStateFile;
59
68
  }
60
69
  } catch (err: any) {
61
- if (err?.code !== 'ENOENT') throw err;
70
+ if (err?.code === 'ENOENT') return { entries: {} };
71
+ return UNREADABLE_STATE;
62
72
  }
63
- return { entries: {} };
73
+ return UNREADABLE_STATE;
64
74
  }
65
75
 
66
76
  async function writeState(state: LifecycleStateFile): Promise<void> {
@@ -100,6 +110,7 @@ export async function shouldSuppressLifecycleLog(
100
110
  message: string
101
111
  ): Promise<{ suppress: boolean; signal: LifecycleSignal | null }> {
102
112
  const state = await readState();
113
+ if (state === UNREADABLE_STATE) throw unreadableStateError();
103
114
  return shouldSuppressLifecycleLogFromState(
104
115
  message,
105
116
  state.entries[stateKey(subject)]
@@ -138,6 +149,7 @@ export async function recordLifecycleLog(
138
149
  message: string
139
150
  ): Promise<void> {
140
151
  const state = await readState();
152
+ if (state === UNREADABLE_STATE) throw unreadableStateError();
141
153
  const key = stateKey(subject);
142
154
  state.entries[key] = nextLifecycleStateAfterLog(message, state.entries[key]);
143
155
  await writeState(state);
@@ -95,16 +95,22 @@ export const LOCAL_SERVER_REQUEST_TIMEOUT_MS = 5_000;
95
95
  const LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE =
96
96
  'Local Borg server response exceeded the response limit';
97
97
 
98
+ export const SERVER_ADVISORY_MAX_CHARS = 512;
99
+ // Keep enough source headroom to remove control sequences that cross the
100
+ // visible 512-character boundary without letting regex work scale with the
101
+ // 32 MiB response-body limit.
102
+ const SERVER_MESSAGE_SANITIZE_MAX_CHARS = SERVER_ADVISORY_MAX_CHARS * 8;
103
+
98
104
  function sanitizeServerMessage(message: string): string {
99
- return message
100
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
101
- .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
105
+ const bounded = message.slice(0, SERVER_MESSAGE_SANITIZE_MAX_CHARS);
106
+
107
+ return bounded
108
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\|$)/g, '')
109
+ .replace(/\u001b\[[0-?]*[ -/]*(?:[@-~]|$)/g, '')
102
110
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
103
111
  .replace(/\\u(?:000[0-9a-f]|001[0-9a-f]|007f|008[0-9a-f]|009[0-9a-f])/gi, '');
104
112
  }
105
113
 
106
- export const SERVER_ADVISORY_MAX_CHARS = 512;
107
-
108
114
  export function sanitizeServerAdvisory(value: unknown): string | undefined {
109
115
  if (typeof value !== 'string') return undefined;
110
116
  const sanitized = sanitizeServerMessage(value).trim();