borgmcp 2.14.2 → 2.16.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 (124) 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 +5 -0
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +53 -14
  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 +19 -4
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/bare-launch-menu.d.ts +8 -0
  13. package/dist/bare-launch-menu.d.ts.map +1 -1
  14. package/dist/bare-launch-menu.js +12 -0
  15. package/dist/bare-launch-menu.js.map +1 -1
  16. package/dist/claude.d.ts.map +1 -1
  17. package/dist/claude.js +28 -40
  18. package/dist/claude.js.map +1 -1
  19. package/dist/cleanup-cmd.d.ts.map +1 -1
  20. package/dist/cleanup-cmd.js +2 -2
  21. package/dist/cleanup-cmd.js.map +1 -1
  22. package/dist/cli-tool-approval.d.ts.map +1 -1
  23. package/dist/cli-tool-approval.js +3 -2
  24. package/dist/cli-tool-approval.js.map +1 -1
  25. package/dist/codex-remote.d.ts.map +1 -1
  26. package/dist/codex-remote.js +4 -3
  27. package/dist/codex-remote.js.map +1 -1
  28. package/dist/config-utils.d.ts +16 -1
  29. package/dist/config-utils.d.ts.map +1 -1
  30. package/dist/config-utils.js +176 -17
  31. package/dist/config-utils.js.map +1 -1
  32. package/dist/credential-paths.d.ts.map +1 -1
  33. package/dist/credential-paths.js +2 -3
  34. package/dist/credential-paths.js.map +1 -1
  35. package/dist/cubes.d.ts +6 -1
  36. package/dist/cubes.d.ts.map +1 -1
  37. package/dist/cubes.js +9 -4
  38. package/dist/cubes.js.map +1 -1
  39. package/dist/foreign-path-reminder.d.ts +12 -0
  40. package/dist/foreign-path-reminder.d.ts.map +1 -0
  41. package/dist/foreign-path-reminder.js +122 -0
  42. package/dist/foreign-path-reminder.js.map +1 -0
  43. package/dist/launch-access.d.ts +22 -0
  44. package/dist/launch-access.d.ts.map +1 -0
  45. package/dist/launch-access.js +27 -0
  46. package/dist/launch-access.js.map +1 -0
  47. package/dist/launch-all-deps.d.ts.map +1 -1
  48. package/dist/launch-all-deps.js +2 -2
  49. package/dist/launch-all-deps.js.map +1 -1
  50. package/dist/lifecycle-log-guard.js +2 -2
  51. package/dist/lifecycle-log-guard.js.map +1 -1
  52. package/dist/local-server-cursor.js +2 -2
  53. package/dist/local-server-cursor.js.map +1 -1
  54. package/dist/opencode-plugin.js +2 -2
  55. package/dist/opencode-plugin.js.map +1 -1
  56. package/dist/parse-assimilate-args.d.ts.map +1 -1
  57. package/dist/parse-assimilate-args.js +6 -1
  58. package/dist/parse-assimilate-args.js.map +1 -1
  59. package/dist/private-root.d.ts +17 -0
  60. package/dist/private-root.d.ts.map +1 -1
  61. package/dist/private-root.js +62 -2
  62. package/dist/private-root.js.map +1 -1
  63. package/dist/regen-format.js +1 -1
  64. package/dist/regen-format.js.map +1 -1
  65. package/dist/resolved-cli-config.d.ts +16 -0
  66. package/dist/resolved-cli-config.d.ts.map +1 -0
  67. package/dist/resolved-cli-config.js +26 -0
  68. package/dist/resolved-cli-config.js.map +1 -0
  69. package/dist/seat-store.d.ts.map +1 -1
  70. package/dist/seat-store.js +4 -3
  71. package/dist/seat-store.js.map +1 -1
  72. package/dist/seats.d.ts.map +1 -1
  73. package/dist/seats.js +2 -2
  74. package/dist/seats.js.map +1 -1
  75. package/dist/self-path.d.ts +2 -0
  76. package/dist/self-path.d.ts.map +1 -1
  77. package/dist/self-path.js +4 -0
  78. package/dist/self-path.js.map +1 -1
  79. package/dist/server-trust.d.ts.map +1 -1
  80. package/dist/server-trust.js +3 -3
  81. package/dist/server-trust.js.map +1 -1
  82. package/dist/setup-confirm.d.ts +1 -1
  83. package/dist/setup-confirm.js +1 -1
  84. package/dist/setup-selection.d.ts +38 -0
  85. package/dist/setup-selection.d.ts.map +1 -0
  86. package/dist/setup-selection.js +51 -0
  87. package/dist/setup-selection.js.map +1 -0
  88. package/dist/setup.js +80 -21
  89. package/dist/setup.js.map +1 -1
  90. package/dist/stream-owner.js +2 -2
  91. package/dist/stream-owner.js.map +1 -1
  92. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  93. package/docs/LOCAL_SERVER.md +23 -0
  94. package/docs/RELEASING.md +130 -282
  95. package/package.json +9 -9
  96. package/src/agent-runtime.ts +17 -0
  97. package/src/assimilate-cmd.ts +73 -14
  98. package/src/assimilate-deps.ts +23 -3
  99. package/src/bare-launch-menu.ts +17 -0
  100. package/src/claude.ts +31 -37
  101. package/src/cleanup-cmd.ts +2 -2
  102. package/src/cli-tool-approval.ts +3 -2
  103. package/src/codex-remote.ts +4 -2
  104. package/src/config-utils.ts +186 -18
  105. package/src/credential-paths.ts +2 -3
  106. package/src/cubes.ts +9 -4
  107. package/src/foreign-path-reminder.ts +141 -0
  108. package/src/launch-access.ts +40 -0
  109. package/src/launch-all-deps.ts +2 -2
  110. package/src/lifecycle-log-guard.ts +2 -2
  111. package/src/local-server-cursor.ts +2 -2
  112. package/src/opencode-plugin.ts +2 -2
  113. package/src/parse-assimilate-args.ts +7 -1
  114. package/src/private-root.ts +62 -2
  115. package/src/regen-format.ts +1 -1
  116. package/src/resolved-cli-config.ts +40 -0
  117. package/src/seat-store.ts +4 -3
  118. package/src/seats.ts +2 -2
  119. package/src/self-path.ts +5 -0
  120. package/src/server-trust.ts +3 -3
  121. package/src/setup-confirm.ts +1 -1
  122. package/src/setup-selection.ts +87 -0
  123. package/src/setup.ts +98 -24
  124. package/src/stream-owner.ts +2 -2
@@ -6,7 +6,6 @@
6
6
 
7
7
  import { execSync } from 'child_process';
8
8
  import fs from 'fs';
9
- import os from 'os';
10
9
  import path from 'path';
11
10
  import { fileURLToPath } from 'url';
12
11
  import { dirname } from 'path';
@@ -20,8 +19,11 @@ import {
20
19
  resolveRegenPath,
21
20
  resolveClearRewakePath,
22
21
  resolveLogAuditPath,
22
+ resolveForeignPathReminderPath,
23
23
  } from './self-path.js';
24
24
  import { shellEscape } from './shell-escape.js';
25
+ import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
26
+ import type { LaunchAccessPaths } from './launch-access.js';
25
27
 
26
28
  // Get __dirname equivalent in ESM
27
29
  const __filename = fileURLToPath(import.meta.url);
@@ -33,6 +35,7 @@ const __dirname = dirname(__filename);
33
35
  const HOOK_COMMAND = shellEscape(resolveRegenPath());
34
36
  const CLEAR_REWAKE_HOOK_COMMAND = shellEscape(resolveClearRewakePath());
35
37
  const AUDIT_HOOK_COMMAND = shellEscape(resolveLogAuditPath());
38
+ const FOREIGN_PATH_REMINDER_HOOK_COMMAND = shellEscape(resolveForeignPathReminderPath());
36
39
  const MCP_BINARY = resolveMcpBinaryPath();
37
40
 
38
41
  /**
@@ -44,14 +47,15 @@ const MCP_BINARY = resolveMcpBinaryPath();
44
47
  * `~/Library/Application Support/Claude/claude_desktop_config.json`,
45
48
  * which is the Claude Desktop app's config (different product).
46
49
  */
47
- const CLAUDE_CONFIG_PATH = path.join(os.homedir(), '.claude.json');
48
- const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
49
- const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
50
- const OPENCODE_CONFIG_PATH = path.join(os.homedir(), '.config', 'opencode', 'opencode.json');
50
+ const CONFIG_HOME = borgHomeRoot();
51
+ const CLAUDE_CONFIG_PATH = path.join(CONFIG_HOME, '.claude.json');
52
+ const CODEX_CONFIG_PATH = path.join(CONFIG_HOME, '.codex', 'config.toml');
53
+ const CODEX_HOOKS_PATH = path.join(CONFIG_HOME, '.codex', 'hooks.json');
54
+ const OPENCODE_CONFIG_PATH = path.join(CONFIG_HOME, '.config', 'opencode', 'opencode.json');
51
55
  const MCP_SERVER_NAME = 'borg';
52
56
 
53
57
  function settingsPath(): string {
54
- return path.join(os.homedir(), '.claude', 'settings.json');
58
+ return path.join(CONFIG_HOME, '.claude', 'settings.json');
55
59
  }
56
60
 
57
61
  function readSettings(): any {
@@ -106,6 +110,77 @@ export function addProjectSessionStartHook(projectRoot: string): boolean {
106
110
  return addSessionStartHookAt(projectSettingsPath(projectRoot), true);
107
111
  }
108
112
 
113
+ /**
114
+ * Pre-authorize the exact worktree + scratch paths for a Claude seat and add
115
+ * the native PreToolUse reminder. The permission layer remains authoritative;
116
+ * the reminder is deliberately advisory and cannot veto a tool call.
117
+ */
118
+ export function addClaudeLaunchAccess(
119
+ projectRoot: string,
120
+ paths: LaunchAccessPaths,
121
+ ): boolean {
122
+ const settingsFile = projectSettingsPath(projectRoot);
123
+ let settings: any;
124
+ try {
125
+ settings = readJsonFile(settingsFile);
126
+ } catch (err: any) {
127
+ throw new Error(`Could not parse ${settingsFile}: ${err.message}`);
128
+ }
129
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
130
+ throw new Error(`Claude settings ${settingsFile} is not an object`);
131
+ }
132
+
133
+ if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
134
+ if (settings.permissions !== undefined) {
135
+ throw new Error(`Claude settings permissions in ${settingsFile} are not an object`);
136
+ }
137
+ settings.permissions = {};
138
+ }
139
+ const additionalDirectories = settings.permissions.additionalDirectories;
140
+ if (additionalDirectories !== undefined && !Array.isArray(additionalDirectories)) {
141
+ throw new Error(`Claude settings permissions.additionalDirectories in ${settingsFile} is not an array`);
142
+ }
143
+
144
+ const directories = [paths.worktree, paths.scratch].map((value) => path.resolve(value));
145
+ const existingDirectories: unknown[] = Array.isArray(additionalDirectories)
146
+ ? additionalDirectories
147
+ : [];
148
+ let changed = false;
149
+ for (const directory of directories) {
150
+ if (!existingDirectories.includes(directory)) {
151
+ existingDirectories.push(directory);
152
+ changed = true;
153
+ }
154
+ }
155
+ if (settings.permissions.additionalDirectories !== existingDirectories) {
156
+ settings.permissions.additionalDirectories = existingDirectories;
157
+ changed = true;
158
+ }
159
+
160
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
161
+ if (settings.hooks !== undefined) {
162
+ throw new Error(`Claude settings hooks in ${settingsFile} are not an object`);
163
+ }
164
+ settings.hooks = {};
165
+ }
166
+ settings.hooks.PreToolUse ??= [];
167
+ if (!Array.isArray(settings.hooks.PreToolUse)) {
168
+ throw new Error(`Claude settings hooks.PreToolUse in ${settingsFile} is not an array`);
169
+ }
170
+ const entries = settings.hooks.PreToolUse;
171
+ changed = migrateAndDedupOwnedHooks(entries) || changed;
172
+ if (!hasCommandHook(entries, FOREIGN_PATH_REMINDER_HOOK_COMMAND)) {
173
+ entries.push({
174
+ matcher: '*',
175
+ hooks: [{ type: 'command', command: FOREIGN_PATH_REMINDER_HOOK_COMMAND }],
176
+ });
177
+ changed = true;
178
+ }
179
+
180
+ if (changed) writeJsonFile(settingsFile, settings);
181
+ return changed;
182
+ }
183
+
109
184
  /** Peek variant of addProjectSessionStartHook — no mutation. */
110
185
  export function isProjectSessionStartHookRegistered(projectRoot: string): boolean {
111
186
  return sessionStartHookRegisteredAt(projectSettingsPath(projectRoot), true);
@@ -190,12 +265,14 @@ function ownedCanonical(command: string): string | null {
190
265
  if (stripped === BARE_BORG_REGEN) return HOOK_COMMAND;
191
266
  if (stripped === BARE_CLEAR_REWAKE) return CLEAR_REWAKE_HOOK_COMMAND;
192
267
  if (stripped === BARE_LOG_AUDIT) return AUDIT_HOOK_COMMAND;
268
+ if (stripped === BARE_FOREIGN_PATH_REMINDER) return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
193
269
 
194
270
  // (b) Exact match of THIS installation's canonical command (raw or escaped)
195
271
  // — always owned, no marker check required
196
272
  if (command === HOOK_COMMAND || stripped === resolveRegenPath()) return HOOK_COMMAND;
197
273
  if (command === CLEAR_REWAKE_HOOK_COMMAND || stripped === resolveClearRewakePath()) return CLEAR_REWAKE_HOOK_COMMAND;
198
274
  if (command === AUDIT_HOOK_COMMAND || stripped === resolveLogAuditPath()) return AUDIT_HOOK_COMMAND;
275
+ if (command === FOREIGN_PATH_REMINDER_HOOK_COMMAND || stripped === resolveForeignPathReminderPath()) return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
199
276
 
200
277
  // (c) Foreign-install heuristic: absolute path + owned basename + borg marker
201
278
  if (stripped.startsWith('/') && (stripped.includes('borgmcp') || stripped.includes('borg-mcp'))) {
@@ -203,6 +280,7 @@ function ownedCanonical(command: string): string | null {
203
280
  if (name === 'regen.js') return HOOK_COMMAND;
204
281
  if (name === 'clear-rewake.js') return CLEAR_REWAKE_HOOK_COMMAND;
205
282
  if (name === 'log-audit.js') return AUDIT_HOOK_COMMAND;
283
+ if (name === 'foreign-path-reminder.js') return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
206
284
  }
207
285
 
208
286
  return null;
@@ -272,6 +350,7 @@ function isCanonicalCommand(entryCommand: string, canonical: string): boolean {
272
350
  const BARE_BORG_REGEN = 'borg-regen';
273
351
  const BARE_CLEAR_REWAKE = 'borg-clear-rewake';
274
352
  const BARE_LOG_AUDIT = 'borg-log-audit';
353
+ const BARE_FOREIGN_PATH_REMINDER = 'borg-foreign-path-reminder';
275
354
 
276
355
  function hasCommandHook(entries: any[], command: string): boolean {
277
356
  return entries.some((entry: any) =>
@@ -282,7 +361,7 @@ function hasCommandHook(entries: any[], command: string): boolean {
282
361
  // maps to the SAME canonical as the target. This correctly distinguishes
283
362
  // regen from clear-rewake from audit — a clear-rewake hook does NOT
284
363
  // satisfy the dedup check for a regen target, and vice versa.
285
- if (command === HOOK_COMMAND || command === CLEAR_REWAKE_HOOK_COMMAND || command === AUDIT_HOOK_COMMAND) {
364
+ if (command === HOOK_COMMAND || command === CLEAR_REWAKE_HOOK_COMMAND || command === AUDIT_HOOK_COMMAND || command === FOREIGN_PATH_REMINDER_HOOK_COMMAND) {
286
365
  return ownedCanonical(h.command) === command;
287
366
  }
288
367
  return h.command === command;
@@ -616,9 +695,10 @@ export function getBinaryPath(): string {
616
695
  */
617
696
  export function addMcpServer(): void {
618
697
  try {
698
+ const agentConfigEnv = borgAgentConfigEnv(process.env);
619
699
  // First, remove any existing borg configuration (ignore errors if not found)
620
700
  try {
621
- execSync('claude mcp remove --scope user borg', { stdio: 'ignore' });
701
+ execSync('claude mcp remove --scope user borg', { stdio: 'ignore', env: agentConfigEnv });
622
702
  } catch {
623
703
  // Ignore - server might not exist yet
624
704
  }
@@ -631,7 +711,7 @@ export function addMcpServer(): void {
631
711
  stdio: 'inherit', // Show output to user
632
712
  // No hosted-URL injection: BORG_API_URL passes through from the
633
713
  // environment only when the operator has explicitly set it.
634
- env: process.env,
714
+ env: agentConfigEnv,
635
715
  });
636
716
  } catch (error: any) {
637
717
  if (error.message?.includes('command not found')) {
@@ -643,8 +723,9 @@ export function addMcpServer(): void {
643
723
 
644
724
  export function addCodexMcpServer(): void {
645
725
  try {
726
+ const codexConfigEnv = withAgentRuntimeEnv(borgAgentConfigEnv(process.env), 'codex');
646
727
  try {
647
- execSync('codex mcp remove borg', { stdio: 'ignore' });
728
+ execSync('codex mcp remove borg', { stdio: 'ignore', env: codexConfigEnv });
648
729
  } catch {
649
730
  // Ignore - server might not exist yet.
650
731
  }
@@ -656,10 +737,14 @@ export function addCodexMcpServer(): void {
656
737
  // transport capability. Do not persist a transport marker here: a future
657
738
  // Codex child may launch without a live --remote socket.
658
739
  // gh#client#18: use absolute path to THIS installation's binary.
659
- const codexConfigEnv = withAgentRuntimeEnv(process.env, 'codex');
660
740
  const apiUrlEnvArg = apiUrl ? ` --env BORG_API_URL=${shellQuote(apiUrl)}` : '';
741
+ const stateRoot = process.env[BORG_STATE_ROOT_ENV];
742
+ const stateRootEnvArg = stateRoot
743
+ ? ` --env ${BORG_STATE_ROOT_ENV}=${shellQuote(stateRoot)}`
744
+ : '';
661
745
  execSync('codex mcp add borg' +
662
746
  apiUrlEnvArg +
747
+ stateRootEnvArg +
663
748
  ` --env ${BORG_AGENT_KIND_ENV}=codex` +
664
749
  ` -- ${shellQuote(MCP_BINARY)}`, {
665
750
  stdio: 'inherit',
@@ -677,12 +762,17 @@ function shellQuote(value: string): string {
677
762
  return `'${value.replace(/'/g, `'\\''`)}'`;
678
763
  }
679
764
 
680
- function addCodexHook(eventName: 'SessionStart' | 'UserPromptSubmit', command: string, options: { matcher?: string; timeout?: number } = {}): boolean {
765
+ function addCodexHook(
766
+ eventName: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse',
767
+ command: string,
768
+ options: { matcher?: string; timeout?: number } = {},
769
+ hooksPath: string = CODEX_HOOKS_PATH,
770
+ ): boolean {
681
771
  let hooksFile: any;
682
772
  try {
683
- hooksFile = readJsonFile(CODEX_HOOKS_PATH);
773
+ hooksFile = readJsonFile(hooksPath);
684
774
  } catch (err: any) {
685
- console.error(`⚠ Could not parse ${CODEX_HOOKS_PATH}: ${err.message}. Skipping Codex hook registration.`);
775
+ console.error(`⚠ Could not parse ${hooksPath}: ${err.message}. Skipping Codex hook registration.`);
686
776
  return false;
687
777
  }
688
778
 
@@ -705,7 +795,7 @@ function addCodexHook(eventName: 'SessionStart' | 'UserPromptSubmit', command: s
705
795
  entries.push(entry);
706
796
  changed = true;
707
797
  }
708
- if (changed) writeJsonFile(CODEX_HOOKS_PATH, hooksFile);
798
+ if (changed) writeJsonFile(hooksPath, hooksFile);
709
799
  return changed;
710
800
  }
711
801
 
@@ -717,8 +807,13 @@ export function addCodexUserPromptSubmitHook(): boolean {
717
807
  return addCodexHook('UserPromptSubmit', AUDIT_HOOK_COMMAND, { timeout: 10 });
718
808
  }
719
809
 
810
+ /** Register the advisory foreign-path reminder on Codex's native hook surface. */
811
+ export function addCodexForeignPathReminderHook(hooksPath: string = CODEX_HOOKS_PATH): boolean {
812
+ return addCodexHook('PreToolUse', FOREIGN_PATH_REMINDER_HOOK_COMMAND, {}, hooksPath);
813
+ }
814
+
720
815
  export function isCodexHookRegistered(
721
- eventName: 'SessionStart' | 'UserPromptSubmit' | 'Stop',
816
+ eventName: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'Stop',
722
817
  command: string,
723
818
  hooksPath: string = CODEX_HOOKS_PATH
724
819
  ): boolean {
@@ -783,6 +878,75 @@ export function isOpenCodeMcpServerConfigured(
783
878
  }
784
879
  }
785
880
 
881
+ /**
882
+ * Pre-authorize the exact worktree + scratch paths in the launch-root
883
+ * OpenCode config. This intentionally writes only the project-local
884
+ * `.opencode/opencode.json`; the user-global config is shared by every seat.
885
+ */
886
+ export function addOpenCodeLaunchAccess(
887
+ projectRoot: string,
888
+ paths: LaunchAccessPaths,
889
+ ): boolean {
890
+ const configPath = path.join(projectRoot, '.opencode', 'opencode.json');
891
+ let config: any;
892
+ try {
893
+ config = readJsonFile(configPath);
894
+ } catch (err: any) {
895
+ throw new Error(`Could not parse ${configPath}: ${err.message}`);
896
+ }
897
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
898
+ throw new Error(`OpenCode config ${configPath} is not an object`);
899
+ }
900
+
901
+ const before = JSON.stringify(config);
902
+ const permission = config.permission;
903
+ let permissionObject: Record<string, unknown>;
904
+ if (permission === undefined) {
905
+ permissionObject = {};
906
+ } else if (permission === 'allow' || permission === 'ask' || permission === 'deny') {
907
+ permissionObject = { '*': permission };
908
+ } else if (permission && typeof permission === 'object' && !Array.isArray(permission)) {
909
+ permissionObject = { ...(permission as Record<string, unknown>) };
910
+ } else {
911
+ throw new Error(`OpenCode permission in ${configPath} has an unsupported shape`);
912
+ }
913
+
914
+ const existingExternal = permissionObject.external_directory;
915
+ let external: Record<string, unknown>;
916
+ if (existingExternal === undefined) {
917
+ external = {};
918
+ } else if (existingExternal === 'allow' || existingExternal === 'ask' || existingExternal === 'deny') {
919
+ external = { '*': existingExternal };
920
+ } else if (existingExternal && typeof existingExternal === 'object' && !Array.isArray(existingExternal)) {
921
+ external = { ...(existingExternal as Record<string, unknown>) };
922
+ } else {
923
+ throw new Error(`OpenCode permission.external_directory in ${configPath} has an unsupported shape`);
924
+ }
925
+
926
+ for (const directory of [paths.worktree, paths.scratch].map((value) => path.resolve(value))) {
927
+ // OpenCode matches external_directory patterns against the requested path,
928
+ // so a literal directory key does not authorize files below that directory.
929
+ // The exact `allow` shape is the literal form written by the predecessor;
930
+ // migrate only that owned key. Never replace an existing subtree decision
931
+ // or exact decision supplied by the operator.
932
+ const subtree = `${directory}/**`;
933
+ const hasExact = Object.prototype.hasOwnProperty.call(external, directory);
934
+ const hasSubtree = Object.prototype.hasOwnProperty.call(external, subtree);
935
+ if (external[directory] === 'allow') {
936
+ delete external[directory];
937
+ if (!hasSubtree) external[subtree] = 'allow';
938
+ } else if (!hasExact && !hasSubtree) {
939
+ external[subtree] = 'allow';
940
+ }
941
+ }
942
+ permissionObject.external_directory = external;
943
+ config.permission = permissionObject;
944
+
945
+ const changed = JSON.stringify(config) !== before;
946
+ if (changed) writeJsonFile(configPath, config);
947
+ return changed;
948
+ }
949
+
786
950
  /**
787
951
  * Add borg MCP server to OpenCode using `opencode mcp add` CLI.
788
952
  * Pins BORG_SESSION=1, BORG_AGENT_KIND=opencode, the legacy BORG_OPENCODE=1,
@@ -797,10 +961,14 @@ export function addOpenCodeMcpServer(): void {
797
961
  // No hosted-URL fallback: only forward BORG_API_URL when explicitly set.
798
962
  const apiUrl = process.env.BORG_API_URL;
799
963
  const apiUrlEnvArg = apiUrl ? ` --env BORG_API_URL=${shellQuote(apiUrl)}` : '';
964
+ const stateRoot = process.env[BORG_STATE_ROOT_ENV];
965
+ const stateRootEnvArg = stateRoot
966
+ ? ` --env ${BORG_STATE_ROOT_ENV}=${shellQuote(stateRoot)}`
967
+ : '';
800
968
  // gh#client#18: use absolute path to THIS installation's binary.
801
969
  execSync(
802
- `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg} -- ${shellQuote(MCP_BINARY)}`,
803
- { stdio: 'inherit' }
970
+ `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg} -- ${shellQuote(MCP_BINARY)}`,
971
+ { stdio: 'inherit', env: borgAgentConfigEnv(process.env) }
804
972
  );
805
973
  } catch (error: any) {
806
974
  if (error.message?.includes('command not found')) {
@@ -1,8 +1,7 @@
1
- import { realpathSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
1
  import { join } from 'node:path';
2
+ import { borgHomeRoot } from './private-root.js';
4
3
 
5
- const canonicalHome = realpathSync(homedir());
4
+ const canonicalHome = borgHomeRoot();
6
5
 
7
6
  export const BORG_USER_ROOT = join(canonicalHome, '.borg');
8
7
  export const SERVER_CREDENTIALS_FILE = join(BORG_USER_ROOT, 'credentials');
package/src/cubes.ts CHANGED
@@ -17,9 +17,9 @@
17
17
 
18
18
  import { existsSync } from 'node:fs';
19
19
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
20
- import { homedir } from 'node:os';
21
20
  import { dirname, join, resolve } from 'node:path';
22
21
  import { pruneDeadWakeTargets } from './codex-wake-resolve.js';
22
+ import { borgConfigRoot } from './private-root.js';
23
23
  import {
24
24
  getActiveSeatCredential,
25
25
  getActiveSeatForWorktree,
@@ -44,7 +44,7 @@ import {
44
44
  /** Re-exported from seats.ts for call-site parity (the retired cross-store name). */
45
45
  export type { SeatExpectation as ExpectedBinding } from './seats.js';
46
46
 
47
- const CUBES_DIR = join(homedir(), '.config', 'borgmcp');
47
+ const CUBES_DIR = borgConfigRoot();
48
48
  const LAUNCH_FILE = join(CUBES_DIR, 'launch.json');
49
49
  const CODEX_WAKE_TARGETS_FILE = join(CUBES_DIR, 'codex-wake-targets.json');
50
50
  const INBOX_DIR = join(CUBES_DIR, 'inboxes');
@@ -611,9 +611,14 @@ export async function readAllProjectIdentities(): Promise<
611
611
  );
612
612
  }
613
613
 
614
- export async function setProjectCliPreference(cli: BorgCli): Promise<void> {
614
+ /**
615
+ * Save the CLI preference for the current project, or for an explicitly named
616
+ * worktree. The explicit path is used when assimilate has just created a
617
+ * sibling worktree but the process still began in the invoking checkout.
618
+ */
619
+ export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promise<void> {
615
620
  const existing = (await readLaunchFile()) ?? { projects: {} };
616
- existing.projects[findProjectRoot()] = { cli };
621
+ existing.projects[findProjectRoot(dir)] = { cli };
617
622
  await writeLaunchFile(existing);
618
623
  }
619
624
 
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Non-blocking pre-tool reminder for a Borg-launched seat.
5
+ *
6
+ * Claude Code and Codex pass a JSON hook payload on stdin. The hook only
7
+ * emits a reminder when the payload names a working directory or target path
8
+ * outside the two paths Borg granted to this seat. It never returns a deny
9
+ * decision and exits successfully for malformed, missing, or unconfigured
10
+ * input so the harness permission layer remains the enforcement point.
11
+ */
12
+
13
+ import { isAbsolute, relative, resolve } from 'node:path';
14
+ import {
15
+ BORG_LAUNCH_CLI_ENV,
16
+ BORG_LAUNCH_SCRATCH_ENV,
17
+ BORG_LAUNCH_WORKTREE_ENV,
18
+ } from './launch-access.js';
19
+
20
+ const REMINDER = 'Reminder: this seat is scoped to its own worktree and scratch root; coordinate before working on a foreign path.';
21
+
22
+ const PATH_KEYS = new Set([
23
+ 'cwd',
24
+ 'workdir',
25
+ 'working_directory',
26
+ 'directory',
27
+ 'dir',
28
+ 'path',
29
+ 'file_path',
30
+ 'target_path',
31
+ 'source_path',
32
+ 'destination_path',
33
+ 'old_path',
34
+ 'new_path',
35
+ ]);
36
+
37
+ const COMMAND_KEYS = new Set(['command', 'cmd', 'shell_command']);
38
+
39
+ interface HookPayload {
40
+ cwd?: unknown;
41
+ tool_input?: unknown;
42
+ input?: unknown;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ function readStdin(): Promise<string> {
47
+ if (process.stdin.isTTY) return Promise.resolve('');
48
+ return new Promise((resolveInput) => {
49
+ const chunks: Buffer[] = [];
50
+ process.stdin.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
51
+ process.stdin.on('end', () => resolveInput(Buffer.concat(chunks).toString('utf8')));
52
+ process.stdin.on('error', () => resolveInput(''));
53
+ });
54
+ }
55
+
56
+ function configuredRoots(): string[] {
57
+ return [process.env[BORG_LAUNCH_WORKTREE_ENV], process.env[BORG_LAUNCH_SCRATCH_ENV]]
58
+ .filter((root): root is string => typeof root === 'string' && isAbsolute(root))
59
+ .map((root) => resolve(root));
60
+ }
61
+
62
+ function isInsideAnyRoot(candidate: string, roots: string[]): boolean {
63
+ const absolute = resolve(candidate);
64
+ return roots.some((root) => {
65
+ const suffix = relative(root, absolute);
66
+ return suffix === '' || (!suffix.startsWith('..') && !isAbsolute(suffix));
67
+ });
68
+ }
69
+
70
+ function pathFromValue(value: string, baseCwd: string): string {
71
+ return isAbsolute(value) ? resolve(value) : resolve(baseCwd, value);
72
+ }
73
+
74
+ function absolutePathsInCommand(command: string): string[] {
75
+ const paths: string[] = [];
76
+ const pattern = /(?:^|[\s"'=])((?:\/|[A-Za-z]:[\\/])[^\s"'`;&|<>]*)/g;
77
+ for (const match of command.matchAll(pattern)) {
78
+ const value = match[1]?.replace(/[),.]+$/, '');
79
+ if (value) paths.push(value);
80
+ }
81
+ return paths;
82
+ }
83
+
84
+ function containsForeignTarget(value: unknown, key: string, baseCwd: string, roots: string[]): boolean {
85
+ if (typeof value === 'string') {
86
+ if (PATH_KEYS.has(key)) return !isInsideAnyRoot(pathFromValue(value, baseCwd), roots);
87
+ if (COMMAND_KEYS.has(key)) {
88
+ return absolutePathsInCommand(value).some((path) => !isInsideAnyRoot(path, roots));
89
+ }
90
+ return false;
91
+ }
92
+ if (Array.isArray(value)) {
93
+ return value.some((item) => containsForeignTarget(item, key, baseCwd, roots));
94
+ }
95
+ if (!value || typeof value !== 'object') return false;
96
+ return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
97
+ containsForeignTarget(childValue, childKey, baseCwd, roots)
98
+ );
99
+ }
100
+
101
+ function shouldRemind(payload: HookPayload, roots: string[]): boolean {
102
+ const baseCwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
103
+ ? resolve(payload.cwd)
104
+ : roots[0];
105
+ if (!isInsideAnyRoot(baseCwd, roots)) return true;
106
+ return containsForeignTarget(payload.tool_input ?? payload.input ?? payload, 'input', baseCwd, roots);
107
+ }
108
+
109
+ async function main(): Promise<void> {
110
+ const roots = configuredRoots();
111
+ if (roots.length === 0) return;
112
+
113
+ const raw = await readStdin();
114
+ if (!raw.trim()) return;
115
+ let payload: HookPayload;
116
+ try {
117
+ payload = JSON.parse(raw) as HookPayload;
118
+ } catch {
119
+ return;
120
+ }
121
+ if (!shouldRemind(payload, roots)) return;
122
+
123
+ // Claude Code consumes both fields: systemMessage displays the advisory and
124
+ // hookSpecificOutput.additionalContext delivers it to the model. Codex's
125
+ // PreToolUse parser accepts systemMessage but rejects the Claude-only nested
126
+ // additionalContext field, so keep that branch deliberately narrower.
127
+ const output = process.env[BORG_LAUNCH_CLI_ENV] === 'codex'
128
+ ? { systemMessage: REMINDER }
129
+ : {
130
+ systemMessage: REMINDER,
131
+ hookSpecificOutput: {
132
+ hookEventName: 'PreToolUse',
133
+ additionalContext: REMINDER,
134
+ },
135
+ };
136
+ process.stdout.write(`${JSON.stringify(output)}\n`);
137
+ }
138
+
139
+ main().catch(() => {
140
+ // A reminder hook is advisory only and must never block a tool call.
141
+ });
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { join, resolve } from 'node:path';
3
+ import { validateName } from './name-validator.js';
4
+
5
+ /** Environment names consumed by the optional foreign-path reminder hooks. */
6
+ export const BORG_LAUNCH_WORKTREE_ENV = 'BORG_LAUNCH_WORKTREE';
7
+ export const BORG_LAUNCH_SCRATCH_ENV = 'BORG_LAUNCH_SCRATCH';
8
+ export const BORG_LAUNCH_CLI_ENV = 'BORG_LAUNCH_CLI';
9
+
10
+ export interface LaunchAccessPaths {
11
+ /** The repository root whose subtree is granted to the launched harness. */
12
+ worktree: string;
13
+ /** The disposable scratch root reserved for this seat. */
14
+ scratch: string;
15
+ }
16
+
17
+ /**
18
+ * Resolve the canonical per-seat scratch root.
19
+ *
20
+ * Drone labels are server-derived path components in the normal flow. Keep a
21
+ * defensive fallback for malformed labels anyway: a server value must never
22
+ * be allowed to escape the scratch parent, and the drone id gives a stable
23
+ * collision-resistant directory name when the label is unusable.
24
+ */
25
+ export function scratchRootForSeat(
26
+ homeDir: string,
27
+ droneLabel: string,
28
+ droneId: string,
29
+ ): string {
30
+ const label = validateName(droneLabel).ok
31
+ ? droneLabel
32
+ : `seat-${createHash('sha256').update(`${droneLabel}\0${droneId}`).digest('hex').slice(0, 24)}`;
33
+ return join(resolve(homeDir), '.borg', 'scratch', label);
34
+ }
35
+
36
+ /** Build Codex's native launch-time additional-directory flags. */
37
+ export function codexLaunchDirectoryArgs(paths: LaunchAccessPaths): string[] {
38
+ const directories = [...new Set([paths.worktree, paths.scratch].map((path) => resolve(path)))];
39
+ return directories.flatMap((directory) => ['--add-dir', directory]);
40
+ }
@@ -15,7 +15,6 @@ import {
15
15
  statSync,
16
16
  readdirSync,
17
17
  } from 'node:fs';
18
- import { homedir as osHomedir } from 'node:os';
19
18
  import { createInterface } from 'node:readline/promises';
20
19
  import type { ActiveCube } from './cubes.js';
21
20
  import {
@@ -26,6 +25,7 @@ import {
26
25
  } from './cubes.js';
27
26
  import { getRoster, getCube } from './remote-client.js';
28
27
  import { defaultProbeSeat, type SeatStatus } from './seat-probe.js';
28
+ import { borgHomeRoot } from './private-root.js';
29
29
 
30
30
  /** Subprocess runner — sync, returns stdout, THROWS on non-zero exit or ENOENT. */
31
31
  export type RunSyncFn = (cmd: string, args: string[]) => string;
@@ -122,7 +122,7 @@ export function buildDefaultLaunchAllDeps(): LaunchAllDeps {
122
122
  },
123
123
  cwd: () => process.cwd(),
124
124
  pathExists: (p) => existsSync(p),
125
- homedir: () => osHomedir(),
125
+ homedir: () => borgHomeRoot(),
126
126
  mkdirp: (dir) => {
127
127
  mkdirSync(dir, { recursive: true });
128
128
  },
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { dirname, join } from 'node:path';
3
+ import { borgConfigRoot } from './private-root.js';
4
4
 
5
- const STATE_FILE = join(homedir(), '.config', 'borgmcp', 'lifecycle-log-state.json');
5
+ const STATE_FILE = join(borgConfigRoot(), 'lifecycle-log-state.json');
6
6
  const ARRIVAL_DUPLICATE_WINDOW_MS = 10 * 60 * 1000;
7
7
 
8
8
  export type LifecycleSignal = 'arrival' | 'ready';
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
4
+ import { borgConfigRoot } from './private-root.js';
5
5
 
6
- const CURSOR_FILE = join(homedir(), '.config', 'borgmcp', 'local-server-cursors.json');
6
+ const CURSOR_FILE = join(borgConfigRoot(), 'local-server-cursors.json');
7
7
  const CURSOR_LOCK = `${CURSOR_FILE}.lock`;
8
8
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
9
 
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
3
+ import { borgHomeRoot } from './private-root.js';
4
4
 
5
5
  /**
6
6
  * Plugin source that preserves borg cube context across session compaction.
@@ -24,7 +24,7 @@ export default function () {
24
24
  const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
25
25
 
26
26
  export function installBorgPlugin(): void {
27
- const pluginPath = path.join(os.homedir(), PLUGIN_REL_PATH);
27
+ const pluginPath = path.join(borgHomeRoot(), PLUGIN_REL_PATH);
28
28
  try {
29
29
  if (fs.existsSync(pluginPath)) {
30
30
  const existing = fs.readFileSync(pluginPath, 'utf-8');
@@ -115,7 +115,13 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
115
115
  if (enrollmentRequested) {
116
116
  return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };
117
117
  }
118
- return { ok: false, error: `unexpected extra argument: ${arg} (already have role "${role}")` };
118
+ return {
119
+ ok: false,
120
+ error:
121
+ `unexpected extra argument: ${arg} (already have role "${role}"). ` +
122
+ 'Multi-word role names must be quoted (for example, `borg assimilate "Code Reviewer"`) ' +
123
+ 'or written with hyphens.',
124
+ };
119
125
  }
120
126
  if (enrollmentRequested && !validateName(arg).ok) {
121
127
  return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };