borgmcp 2.14.1 → 2.15.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 (101) hide show
  1. package/dist/agent-runtime.d.ts +5 -0
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +14 -0
  4. package/dist/agent-runtime.js.map +1 -1
  5. package/dist/assimilate-cmd.d.ts +3 -0
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +34 -8
  8. package/dist/assimilate-cmd.js.map +1 -1
  9. package/dist/assimilate-deps.d.ts.map +1 -1
  10. package/dist/assimilate-deps.js +17 -3
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/claude.d.ts.map +1 -1
  13. package/dist/claude.js +2 -1
  14. package/dist/claude.js.map +1 -1
  15. package/dist/cleanup-cmd.d.ts.map +1 -1
  16. package/dist/cleanup-cmd.js +2 -2
  17. package/dist/cleanup-cmd.js.map +1 -1
  18. package/dist/cli-tool-approval.d.ts.map +1 -1
  19. package/dist/cli-tool-approval.js +3 -2
  20. package/dist/cli-tool-approval.js.map +1 -1
  21. package/dist/codex-remote.d.ts.map +1 -1
  22. package/dist/codex-remote.js +4 -3
  23. package/dist/codex-remote.js.map +1 -1
  24. package/dist/config-utils.d.ts +16 -1
  25. package/dist/config-utils.d.ts.map +1 -1
  26. package/dist/config-utils.js +176 -17
  27. package/dist/config-utils.js.map +1 -1
  28. package/dist/credential-paths.d.ts.map +1 -1
  29. package/dist/credential-paths.js +2 -3
  30. package/dist/credential-paths.js.map +1 -1
  31. package/dist/cubes.js +2 -2
  32. package/dist/cubes.js.map +1 -1
  33. package/dist/foreign-path-reminder.d.ts +12 -0
  34. package/dist/foreign-path-reminder.d.ts.map +1 -0
  35. package/dist/foreign-path-reminder.js +122 -0
  36. package/dist/foreign-path-reminder.js.map +1 -0
  37. package/dist/launch-access.d.ts +22 -0
  38. package/dist/launch-access.d.ts.map +1 -0
  39. package/dist/launch-access.js +27 -0
  40. package/dist/launch-access.js.map +1 -0
  41. package/dist/launch-all-deps.d.ts.map +1 -1
  42. package/dist/launch-all-deps.js +2 -2
  43. package/dist/launch-all-deps.js.map +1 -1
  44. package/dist/lifecycle-log-guard.js +2 -2
  45. package/dist/lifecycle-log-guard.js.map +1 -1
  46. package/dist/local-server-cursor.js +2 -2
  47. package/dist/local-server-cursor.js.map +1 -1
  48. package/dist/opencode-plugin.js +2 -2
  49. package/dist/opencode-plugin.js.map +1 -1
  50. package/dist/private-root.d.ts +17 -0
  51. package/dist/private-root.d.ts.map +1 -1
  52. package/dist/private-root.js +62 -2
  53. package/dist/private-root.js.map +1 -1
  54. package/dist/remote-client.d.ts.map +1 -1
  55. package/dist/remote-client.js +66 -7
  56. package/dist/remote-client.js.map +1 -1
  57. package/dist/seat-store.d.ts.map +1 -1
  58. package/dist/seat-store.js +4 -3
  59. package/dist/seat-store.js.map +1 -1
  60. package/dist/seats.d.ts.map +1 -1
  61. package/dist/seats.js +2 -2
  62. package/dist/seats.js.map +1 -1
  63. package/dist/self-path.d.ts +2 -0
  64. package/dist/self-path.d.ts.map +1 -1
  65. package/dist/self-path.js +4 -0
  66. package/dist/self-path.js.map +1 -1
  67. package/dist/server-trust.d.ts.map +1 -1
  68. package/dist/server-trust.js +3 -3
  69. package/dist/server-trust.js.map +1 -1
  70. package/dist/setup-confirm.d.ts +1 -1
  71. package/dist/setup-confirm.js +1 -1
  72. package/dist/stream-owner.js +2 -2
  73. package/dist/stream-owner.js.map +1 -1
  74. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  75. package/docs/LOCAL_SERVER.md +23 -0
  76. package/docs/RELEASING.md +128 -1
  77. package/package.json +2 -1
  78. package/src/agent-runtime.ts +17 -0
  79. package/src/assimilate-cmd.ts +50 -8
  80. package/src/assimilate-deps.ts +21 -3
  81. package/src/claude.ts +2 -0
  82. package/src/cleanup-cmd.ts +2 -2
  83. package/src/cli-tool-approval.ts +3 -2
  84. package/src/codex-remote.ts +4 -2
  85. package/src/config-utils.ts +186 -18
  86. package/src/credential-paths.ts +2 -3
  87. package/src/cubes.ts +2 -2
  88. package/src/foreign-path-reminder.ts +141 -0
  89. package/src/launch-access.ts +40 -0
  90. package/src/launch-all-deps.ts +2 -2
  91. package/src/lifecycle-log-guard.ts +2 -2
  92. package/src/local-server-cursor.ts +2 -2
  93. package/src/opencode-plugin.ts +2 -2
  94. package/src/private-root.ts +62 -2
  95. package/src/remote-client.ts +75 -5
  96. package/src/seat-store.ts +4 -3
  97. package/src/seats.ts +2 -2
  98. package/src/self-path.ts +5 -0
  99. package/src/server-trust.ts +3 -3
  100. package/src/setup-confirm.ts +1 -1
  101. package/src/stream-owner.ts +2 -2
@@ -1,8 +1,68 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
1
2
  import { chmod, lstat, mkdir } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
- import { isAbsolute, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
5
 
5
- export const borgConfigRoot = (): string => join(homedir(), '.config', 'borgmcp');
6
+ /**
7
+ * Optional alternate home root for isolated client runs. The value is the
8
+ * replacement home directory, not the `.config/borgmcp` directory itself, so
9
+ * every client-owned path (credentials, seats, worktrees, and agent config)
10
+ * stays under one root.
11
+ */
12
+ export const BORG_STATE_ROOT_ENV = 'BORG_STATE_ROOT';
13
+
14
+ function invalidStateRoot(): Error {
15
+ return new Error(`${BORG_STATE_ROOT_ENV} must be an absolute canonical path`);
16
+ }
17
+
18
+ /** Return whether a path and every existing ancestor are free of symlinks. */
19
+ export function isCanonicalPath(root: string): boolean {
20
+ if (!isAbsolute(root) || resolve(root) !== root) return false;
21
+ let candidate = root;
22
+ while (true) {
23
+ try {
24
+ const metadata = lstatSync(candidate);
25
+ return !metadata.isSymbolicLink() && realpathSync(candidate) === candidate;
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
28
+ const parent = dirname(candidate);
29
+ if (parent === candidate) return false;
30
+ candidate = parent;
31
+ }
32
+ }
33
+ }
34
+
35
+ function configuredStateRoot(env: NodeJS.ProcessEnv = process.env): string | null {
36
+ const configured = env[BORG_STATE_ROOT_ENV];
37
+ if (configured === undefined) return null;
38
+ if (configured.length === 0 || !isCanonicalPath(configured)) {
39
+ throw invalidStateRoot();
40
+ }
41
+ return configured;
42
+ }
43
+
44
+ /** Resolve the effective home root used by all Borg-owned local state. */
45
+ export function borgHomeRoot(env: NodeJS.ProcessEnv = process.env): string {
46
+ return configuredStateRoot(env) ?? realpathSync(homedir());
47
+ }
48
+
49
+ export const borgConfigRoot = (): string => join(borgHomeRoot(), '.config', 'borgmcp');
50
+
51
+ /**
52
+ * Environment used when a native agent CLI registers Borg. The CLI must write
53
+ * its own config under the same effective root that config-utils reads; the
54
+ * eventual MCP child receives BORG_STATE_ROOT separately via its registration.
55
+ */
56
+ export function borgAgentConfigEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
57
+ if (env[BORG_STATE_ROOT_ENV] === undefined) return { ...env };
58
+ const root = borgHomeRoot(env);
59
+ return {
60
+ ...env,
61
+ HOME: root,
62
+ CODEX_HOME: join(root, '.codex'),
63
+ XDG_CONFIG_HOME: join(root, '.config'),
64
+ };
65
+ }
6
66
 
7
67
  /** Ensure Borg's local state root exists with owner-only directory permissions. */
8
68
  export async function ensurePrivateBorgConfigRoot(root = borgConfigRoot()): Promise<void> {
@@ -80,6 +80,11 @@ export interface RemoteConnection {
80
80
  // retryAfter can't wedge the call.
81
81
  const RATE_LIMIT_MAX_RETRIES = 3;
82
82
  const RATE_LIMIT_MAX_WAIT_MS = 60_000; // cap a single Retry-After honor
83
+ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
84
+ // Replay is opt-in: most local requests are mutations or have ambiguous
85
+ // delivery, while unread-log reads carry an explicit cursor and are safe to
86
+ // repeat with the exact same request body.
87
+ type AuthedFetchRetryMode = 'unread-cursor';
83
88
  export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
84
89
  // A typed auth-error envelope is tiny; anything larger is hostile and the
85
90
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
@@ -179,6 +184,25 @@ export async function retryOn429(
179
184
  return response;
180
185
  }
181
186
 
187
+ function isConnectionReset(error: unknown): boolean {
188
+ let candidate: unknown = error;
189
+ for (let depth = 0; depth < 2; depth += 1) {
190
+ if (candidate === null || typeof candidate !== 'object') return false;
191
+ const typed = candidate as { code?: unknown; cause?: unknown };
192
+ if (typed.code === 'ECONNRESET') return true;
193
+ candidate = typed.cause;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ function unreadLogTransportFailure(cause: unknown): BorgServerUnreachableError {
199
+ return new BorgServerUnreachableError(
200
+ 'Borg could not complete the unread log read after one automatic retry. ' +
201
+ 'The request may have reached the server; repeat `borg_read-log unread_only=true` until caught up.',
202
+ { cause },
203
+ );
204
+ }
205
+
182
206
  async function localAuthorityContext(
183
207
  sessionToken: string,
184
208
  apiUrl: string,
@@ -271,6 +295,7 @@ async function localServerRequest<T>(
271
295
  path: string,
272
296
  method: 'GET' | 'POST' | 'PUT' | 'PATCH',
273
297
  payload?: Record<string, unknown>,
298
+ options: { retryMode?: AuthedFetchRetryMode } = {},
274
299
  ): Promise<T | null> {
275
300
  return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
276
301
  method,
@@ -286,6 +311,7 @@ async function localServerRequest<T>(
286
311
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
287
312
  body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
288
313
  }),
314
+ retryMode: options.retryMode,
289
315
  }), true);
290
316
  }
291
317
 
@@ -492,7 +518,11 @@ function localCursorBinding(active: ActiveCube) {
492
518
 
493
519
  async function localReadLogPage(
494
520
  active: ActiveCube,
495
- opts: { cursor?: LocalServerCursor | null; limit?: number } = {},
521
+ opts: {
522
+ cursor?: LocalServerCursor | null;
523
+ limit?: number;
524
+ retryMode?: AuthedFetchRetryMode;
525
+ } = {},
496
526
  ): Promise<any> {
497
527
  const payload = await localServerRequest<any>(
498
528
  active,
@@ -502,6 +532,7 @@ async function localReadLogPage(
502
532
  cursor: opts.cursor ?? null,
503
533
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
504
534
  },
535
+ { retryMode: opts.retryMode },
505
536
  );
506
537
  if (!payload) throw new Error('Local Borg server returned an empty log response');
507
538
  return payload;
@@ -627,6 +658,7 @@ async function authedFetch(
627
658
  authToken?: string;
628
659
  serverTrustIdentity?: string;
629
660
  localSessionCredentialRef?: string;
661
+ retryMode?: AuthedFetchRetryMode;
630
662
  } = {}
631
663
  ): Promise<Response> {
632
664
  const {
@@ -635,6 +667,7 @@ async function authedFetch(
635
667
  authToken,
636
668
  serverTrustIdentity: suppliedTrustIdentity,
637
669
  localSessionCredentialRef,
670
+ retryMode,
638
671
  headers,
639
672
  ...rest
640
673
  } = init;
@@ -697,7 +730,35 @@ async function authedFetch(
697
730
  return res;
698
731
  };
699
732
 
700
- const response = await buildRequest(token);
733
+ let transportRetriesRemaining = retryMode === 'unread-cursor'
734
+ ? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
735
+ : 0;
736
+ const requestWithRetry = async (): Promise<Response> => {
737
+ try {
738
+ return await buildRequest(token);
739
+ } catch (error) {
740
+ if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
741
+ if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
742
+ transportRetriesRemaining -= 1;
743
+ debugLog('↻ retrying unread log read after ECONNRESET');
744
+ try {
745
+ return await buildRequest(token);
746
+ } catch (retryError) {
747
+ if (isConnectionReset(retryError)) throw unreadLogTransportFailure(retryError);
748
+ throw retryError;
749
+ }
750
+ }
751
+ };
752
+
753
+ let response = await requestWithRetry();
754
+ let rateLimitRetryExhausted = false;
755
+ if (retryMode === 'unread-cursor') {
756
+ response = await retryOn429(response, requestWithRetry, {
757
+ sleep,
758
+ log: debugLog,
759
+ });
760
+ rateLimitRetryExhausted = response.status === 429;
761
+ }
701
762
 
702
763
  if (response.status === 401) {
703
764
  // Reached only after pinned-TLS trust is verified (localAuthorityContext
@@ -805,11 +866,14 @@ async function authedFetch(
805
866
  if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
806
867
  throw new CubeDeletedError();
807
868
  }
869
+ const retryGuidance = rateLimitRetryExhausted
870
+ ? ' Repeat `borg_read-log unread_only=true` until caught up.'
871
+ : '';
808
872
  throw new BorgServerHttpError(
809
873
  response.status,
810
874
  serverMessage
811
- ? `Borg server request failed (HTTP ${response.status}): ${serverMessage}`
812
- : `Borg server request failed (HTTP ${response.status})`,
875
+ ? `Borg server request failed (HTTP ${response.status}): ${serverMessage}${retryGuidance}`
876
+ : `Borg server request failed (HTTP ${response.status})${retryGuidance}`,
813
877
  code,
814
878
  );
815
879
  }
@@ -954,7 +1018,13 @@ export async function readLog(
954
1018
  let cursor: LocalServerCursor | null = null;
955
1019
  if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
956
1020
  if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
957
- const page = await localReadLogPage(local, { cursor, limit: opts.limit });
1021
+ const page = await localReadLogPage(local, {
1022
+ cursor,
1023
+ limit: opts.limit,
1024
+ // Keep the cursor payload stable across a lost response; do not re-read or
1025
+ // advance local state until one response has been decoded successfully.
1026
+ ...(opts.unreadOnly && opts.since === undefined ? { retryMode: 'unread-cursor' as const } : {}),
1027
+ });
958
1028
  if (opts.unreadOnly && page.cursor) {
959
1029
  await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
960
1030
  }
package/src/seat-store.ts CHANGED
@@ -32,9 +32,10 @@
32
32
  */
33
33
 
34
34
  import { constants } from 'node:fs';
35
- import { open, link, lstat, mkdir, readFile, realpath, rename, stat, unlink } from 'node:fs/promises';
35
+ import { open, link, lstat, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
36
36
  import { dirname, isAbsolute, resolve } from 'node:path';
37
37
  import { randomBytes } from 'node:crypto';
38
+ import { isCanonicalPath } from './private-root.js';
38
39
 
39
40
  const LOCK_WAIT_MS = 10;
40
41
  const LOCK_ATTEMPTS = 500;
@@ -58,7 +59,7 @@ async function assertSecureRoot(
58
59
  root: string,
59
60
  rootMode: SecureStoreOptions['rootMode'] = 'private',
60
61
  ): Promise<void> {
61
- if (!isAbsolute(root) || resolve(root) !== root) {
62
+ if (!isCanonicalPath(root)) {
62
63
  throw new Error(`Borg credential store path ${root} is not canonical`);
63
64
  }
64
65
  let metadata: Awaited<ReturnType<typeof lstat>>;
@@ -83,7 +84,7 @@ async function assertSecureRoot(
83
84
  if (uid !== null && metadata.uid !== uid) {
84
85
  throw new Error(`Borg credential store root ${root} is not owned by the current user`);
85
86
  }
86
- if (await realpath(root) !== root) {
87
+ if (!isCanonicalPath(root)) {
87
88
  throw new Error(`Borg credential store root ${root} is not canonical or contains a symlink`);
88
89
  }
89
90
  }
package/src/seats.ts CHANGED
@@ -17,13 +17,13 @@
17
17
  * hydration reader; every other observation is digest-only.
18
18
  */
19
19
 
20
- import os from 'os';
21
20
  import path from 'path';
22
21
  import { createHash } from 'node:crypto';
23
22
  import { readStoreFile, withStore } from './seat-store.js';
24
23
  import { LegacySessionCredentialCollisionError } from './server-errors.js';
24
+ import { borgConfigRoot } from './private-root.js';
25
25
 
26
- export const SEATS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'seats.json');
26
+ export const SEATS_FILE = path.join(borgConfigRoot(), 'seats.json');
27
27
  const SEATS_VERSION = 1 as const;
28
28
  const REF_RE = /^borg-server-session:[a-f0-9]{64}$/;
29
29
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
package/src/self-path.ts CHANGED
@@ -49,3 +49,8 @@ export function resolveClearRewakePath(): string {
49
49
  export function resolveLogAuditPath(): string {
50
50
  return resolveSelfBinPath('log-audit.js');
51
51
  }
52
+
53
+ /** Absolute path to the optional pre-tool foreign-path reminder hook. */
54
+ export function resolveForeignPathReminderPath(): string {
55
+ return resolveSelfBinPath('foreign-path-reminder.js');
56
+ }
@@ -4,7 +4,6 @@ import { constants } from 'node:fs';
4
4
  import { access, lstat, mkdir, open, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
5
  import { request as httpsRequest } from 'node:https';
6
6
  import { connect as tlsConnect } from 'node:tls';
7
- import { homedir } from 'node:os';
8
7
  import { join, resolve } from 'node:path';
9
8
  import { Readable } from 'node:stream';
10
9
  import { BorgServerTrustError } from './server-errors.js';
@@ -27,6 +26,7 @@ import type {
27
26
  EnrollmentArtifactBinding,
28
27
  EnrollmentTrustPointer,
29
28
  } from './enrollment-types.js';
29
+ import { borgHomeRoot } from './private-root.js';
30
30
 
31
31
  // CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
32
32
  // CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
@@ -102,12 +102,12 @@ function invalidateOriginTrustCache(origin: string): void {
102
102
  }
103
103
 
104
104
  function serverDataDirectory(): string {
105
- return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
105
+ return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(borgHomeRoot(), '.borg', 'server'));
106
106
  }
107
107
 
108
108
  function remoteTrustDirectory(origin: string): string {
109
109
  const key = createHash('sha256').update(origin).digest('hex');
110
- return join(homedir(), '.borg', 'server-trust', key);
110
+ return join(borgHomeRoot(), '.borg', 'server-trust', key);
111
111
  }
112
112
 
113
113
  function trustGenerationsDirectory(origin: string): string {
@@ -4,7 +4,7 @@
4
4
  * Step-1 of the setup wizard writes the user's GLOBAL agent config
5
5
  * (registers the borg MCP server + hooks). Before gh#818 this happened
6
6
  * silently on first run. This module adds informed-consent disclosure:
7
- * it lists WHICH files will be written (well-known `os.homedir()` paths)
7
+ * it lists WHICH files will be written (well-known home-relative paths)
8
8
  * and asks to continue before the first mutation.
9
9
  *
10
10
  * Pure / dep-injected (mirrors `resolveCliChoice` + `setup-action.ts`) so
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { promises as fs } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import path from 'node:path';
4
+ import { borgConfigRoot } from './private-root.js';
5
5
 
6
6
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
- const STREAM_LOCKS_DIR = path.join(homedir(), '.config', 'borgmcp', 'stream-locks');
7
+ const STREAM_LOCKS_DIR = path.join(borgConfigRoot(), 'stream-locks');
8
8
  const OWNER_FILE = 'owner.json';
9
9
  const TAKEOVER_FILE = 'takeover.json';
10
10
  const SCHEMA_VERSION = 1;