borgmcp 2.14.2 → 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.
- package/dist/agent-runtime.d.ts +5 -0
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +14 -0
- package/dist/agent-runtime.js.map +1 -1
- package/dist/assimilate-cmd.d.ts +3 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +33 -7
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +17 -3
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +2 -1
- package/dist/claude.js.map +1 -1
- package/dist/cleanup-cmd.d.ts.map +1 -1
- package/dist/cleanup-cmd.js +2 -2
- package/dist/cleanup-cmd.js.map +1 -1
- package/dist/cli-tool-approval.d.ts.map +1 -1
- package/dist/cli-tool-approval.js +3 -2
- package/dist/cli-tool-approval.js.map +1 -1
- package/dist/codex-remote.d.ts.map +1 -1
- package/dist/codex-remote.js +4 -3
- package/dist/codex-remote.js.map +1 -1
- package/dist/config-utils.d.ts +16 -1
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +176 -17
- package/dist/config-utils.js.map +1 -1
- package/dist/credential-paths.d.ts.map +1 -1
- package/dist/credential-paths.js +2 -3
- package/dist/credential-paths.js.map +1 -1
- package/dist/cubes.js +2 -2
- package/dist/cubes.js.map +1 -1
- package/dist/foreign-path-reminder.d.ts +12 -0
- package/dist/foreign-path-reminder.d.ts.map +1 -0
- package/dist/foreign-path-reminder.js +122 -0
- package/dist/foreign-path-reminder.js.map +1 -0
- package/dist/launch-access.d.ts +22 -0
- package/dist/launch-access.d.ts.map +1 -0
- package/dist/launch-access.js +27 -0
- package/dist/launch-access.js.map +1 -0
- package/dist/launch-all-deps.d.ts.map +1 -1
- package/dist/launch-all-deps.js +2 -2
- package/dist/launch-all-deps.js.map +1 -1
- package/dist/lifecycle-log-guard.js +2 -2
- package/dist/lifecycle-log-guard.js.map +1 -1
- package/dist/local-server-cursor.js +2 -2
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/opencode-plugin.js +2 -2
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/private-root.d.ts +17 -0
- package/dist/private-root.d.ts.map +1 -1
- package/dist/private-root.js +62 -2
- package/dist/private-root.js.map +1 -1
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +4 -3
- package/dist/seat-store.js.map +1 -1
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +2 -2
- package/dist/seats.js.map +1 -1
- package/dist/self-path.d.ts +2 -0
- package/dist/self-path.d.ts.map +1 -1
- package/dist/self-path.js +4 -0
- package/dist/self-path.js.map +1 -1
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +3 -3
- package/dist/server-trust.js.map +1 -1
- package/dist/setup-confirm.d.ts +1 -1
- package/dist/setup-confirm.js +1 -1
- package/dist/stream-owner.js +2 -2
- package/dist/stream-owner.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +2 -2
- package/docs/LOCAL_SERVER.md +23 -0
- package/docs/RELEASING.md +123 -1
- package/package.json +2 -1
- package/src/agent-runtime.ts +17 -0
- package/src/assimilate-cmd.ts +49 -7
- package/src/assimilate-deps.ts +21 -3
- package/src/claude.ts +2 -0
- package/src/cleanup-cmd.ts +2 -2
- package/src/cli-tool-approval.ts +3 -2
- package/src/codex-remote.ts +4 -2
- package/src/config-utils.ts +186 -18
- package/src/credential-paths.ts +2 -3
- package/src/cubes.ts +2 -2
- package/src/foreign-path-reminder.ts +141 -0
- package/src/launch-access.ts +40 -0
- package/src/launch-all-deps.ts +2 -2
- package/src/lifecycle-log-guard.ts +2 -2
- package/src/local-server-cursor.ts +2 -2
- package/src/opencode-plugin.ts +2 -2
- package/src/private-root.ts +62 -2
- package/src/seat-store.ts +4 -3
- package/src/seats.ts +2 -2
- package/src/self-path.ts +5 -0
- package/src/server-trust.ts +3 -3
- package/src/setup-confirm.ts +1 -1
- package/src/stream-owner.ts +2 -2
package/src/config-utils.ts
CHANGED
|
@@ -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
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
773
|
+
hooksFile = readJsonFile(hooksPath);
|
|
684
774
|
} catch (err: any) {
|
|
685
|
-
console.error(`⚠ Could not parse ${
|
|
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(
|
|
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')) {
|
package/src/credential-paths.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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');
|
|
@@ -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
|
+
}
|
package/src/launch-all-deps.ts
CHANGED
|
@@ -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: () =>
|
|
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(
|
|
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(
|
|
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
|
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -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(
|
|
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');
|