@intutic/sync-daemon 2.0.0 → 2.2.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/agentReporter.d.ts +5 -12
- package/dist/agentReporter.d.ts.map +1 -1
- package/dist/agentReporter.js +25 -4
- package/dist/agentReporter.js.map +1 -1
- package/dist/configReader.d.ts +44 -22
- package/dist/configReader.d.ts.map +1 -1
- package/dist/configReader.js +70 -44
- package/dist/configReader.js.map +1 -1
- package/dist/configWriter.d.ts +29 -41
- package/dist/configWriter.d.ts.map +1 -1
- package/dist/configWriter.js +28 -625
- package/dist/configWriter.js.map +1 -1
- package/dist/disconnect/harnesses.d.ts +31 -0
- package/dist/disconnect/harnesses.d.ts.map +1 -0
- package/dist/disconnect/harnesses.js +728 -0
- package/dist/disconnect/harnesses.js.map +1 -0
- package/dist/disconnect/index.d.ts +25 -0
- package/dist/disconnect/index.d.ts.map +1 -0
- package/dist/disconnect/index.js +82 -0
- package/dist/disconnect/index.js.map +1 -0
- package/dist/disconnect/mcp.d.ts +60 -0
- package/dist/disconnect/mcp.d.ts.map +1 -0
- package/dist/disconnect/mcp.js +283 -0
- package/dist/disconnect/mcp.js.map +1 -0
- package/dist/disconnect/originals.d.ts +88 -0
- package/dist/disconnect/originals.d.ts.map +1 -0
- package/dist/disconnect/originals.js +257 -0
- package/dist/disconnect/originals.js.map +1 -0
- package/dist/disconnect/plan.d.ts +175 -0
- package/dist/disconnect/plan.d.ts.map +1 -0
- package/dist/disconnect/plan.js +580 -0
- package/dist/disconnect/plan.js.map +1 -0
- package/dist/disconnect/recognise.d.ts +43 -0
- package/dist/disconnect/recognise.d.ts.map +1 -0
- package/dist/disconnect/recognise.js +98 -0
- package/dist/disconnect/recognise.js.map +1 -0
- package/dist/disconnect/tomlLines.d.ts +39 -0
- package/dist/disconnect/tomlLines.d.ts.map +1 -0
- package/dist/disconnect/tomlLines.js +105 -0
- package/dist/disconnect/tomlLines.js.map +1 -0
- package/dist/harness/aiderConfigMerger.d.ts +42 -5
- package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
- package/dist/harness/aiderConfigMerger.js +113 -86
- package/dist/harness/aiderConfigMerger.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts +13 -8
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +53 -39
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts +1 -2
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +61 -34
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/claudeDesktopHooks.d.ts +3 -0
- package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
- package/dist/harness/claudeDesktopHooks.js +3 -1
- package/dist/harness/claudeDesktopHooks.js.map +1 -1
- package/dist/harness/claudeProjectApproval.d.ts +53 -0
- package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
- package/dist/harness/claudeProjectApproval.js +159 -0
- package/dist/harness/claudeProjectApproval.js.map +1 -0
- package/dist/harness/clineHooks.d.ts +39 -9
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +142 -60
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexConfigMerger.d.ts +38 -0
- package/dist/harness/codexConfigMerger.d.ts.map +1 -0
- package/dist/harness/codexConfigMerger.js +105 -0
- package/dist/harness/codexConfigMerger.js.map +1 -0
- package/dist/harness/codexHooks.d.ts +4 -2
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +14 -11
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/continueConfigMerger.d.ts +46 -0
- package/dist/harness/continueConfigMerger.d.ts.map +1 -0
- package/dist/harness/continueConfigMerger.js +94 -0
- package/dist/harness/continueConfigMerger.js.map +1 -0
- package/dist/harness/continueHooks.d.ts +15 -25
- package/dist/harness/continueHooks.d.ts.map +1 -1
- package/dist/harness/continueHooks.js +25 -119
- package/dist/harness/continueHooks.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts +33 -16
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +69 -25
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/dshHooks.d.ts +59 -52
- package/dist/harness/dshHooks.d.ts.map +1 -1
- package/dist/harness/dshHooks.js +152 -147
- package/dist/harness/dshHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +11 -2
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +33 -6
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/gateKind.d.ts.map +1 -1
- package/dist/harness/gateKind.js +1 -0
- package/dist/harness/gateKind.js.map +1 -1
- package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
- package/dist/harness/githubCopilotHooks.js +5 -10
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts +15 -0
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +55 -43
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.d.ts.map +1 -1
- package/dist/harness/grokHooks.js +14 -12
- package/dist/harness/grokHooks.js.map +1 -1
- package/dist/harness/hermesHooks.d.ts +9 -0
- package/dist/harness/hermesHooks.d.ts.map +1 -1
- package/dist/harness/hermesHooks.js +39 -33
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/holdRedaction.d.ts +4 -1
- package/dist/harness/holdRedaction.d.ts.map +1 -1
- package/dist/harness/holdRedaction.js +32 -8
- package/dist/harness/holdRedaction.js.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
- package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.js +39 -18
- package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
- package/dist/harness/jsonMergeTarget.d.ts +23 -0
- package/dist/harness/jsonMergeTarget.d.ts.map +1 -0
- package/dist/harness/jsonMergeTarget.js +50 -0
- package/dist/harness/jsonMergeTarget.js.map +1 -0
- package/dist/harness/mcpAutoWrite.d.ts +64 -20
- package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
- package/dist/harness/mcpAutoWrite.js +429 -85
- package/dist/harness/mcpAutoWrite.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +6 -2
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/n8nHooks.d.ts.map +1 -1
- package/dist/harness/n8nHooks.js +6 -7
- package/dist/harness/n8nHooks.js.map +1 -1
- package/dist/harness/openCodeHooks.d.ts +37 -6
- package/dist/harness/openCodeHooks.d.ts.map +1 -1
- package/dist/harness/openCodeHooks.js +103 -13
- package/dist/harness/openCodeHooks.js.map +1 -1
- package/dist/harness/openWebuiHooks.d.ts.map +1 -1
- package/dist/harness/openWebuiHooks.js +10 -5
- package/dist/harness/openWebuiHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts +5 -0
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +3 -1
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/openhandsHooks.d.ts +26 -1
- package/dist/harness/openhandsHooks.d.ts.map +1 -1
- package/dist/harness/openhandsHooks.js +160 -40
- package/dist/harness/openhandsHooks.js.map +1 -1
- package/dist/harness/piHooks.d.ts +17 -0
- package/dist/harness/piHooks.d.ts.map +1 -1
- package/dist/harness/piHooks.js +31 -29
- package/dist/harness/piHooks.js.map +1 -1
- package/dist/harness/protectedPaths.d.ts +64 -6
- package/dist/harness/protectedPaths.d.ts.map +1 -1
- package/dist/harness/protectedPaths.js +161 -11
- package/dist/harness/protectedPaths.js.map +1 -1
- package/dist/harness/rooCodeHooks.d.ts.map +1 -1
- package/dist/harness/rooCodeHooks.js +5 -10
- package/dist/harness/rooCodeHooks.js.map +1 -1
- package/dist/harness/windsurfHooks.d.ts +21 -3
- package/dist/harness/windsurfHooks.d.ts.map +1 -1
- package/dist/harness/windsurfHooks.js +96 -44
- package/dist/harness/windsurfHooks.js.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts +10 -7
- package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.js +22 -7
- package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -10
- package/dist/index.js.map +1 -1
- package/dist/lib/decisionsDigest.d.ts +1 -1
- package/dist/lib/decisionsDigest.js +1 -1
- package/dist/lib/policySnapshot.d.ts +37 -0
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +170 -8
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/sessionReporter.d.ts +2 -2
- package/dist/sessionReporter.js +2 -2
- package/dist/syncCycle.d.ts +142 -0
- package/dist/syncCycle.d.ts.map +1 -0
- package/dist/syncCycle.js +315 -0
- package/dist/syncCycle.js.map +1 -0
- package/dist/trajectoryMonitor.d.ts +1 -1
- package/dist/trajectoryMonitor.d.ts.map +1 -1
- package/dist/trajectoryMonitor.js +1 -1
- package/dist/trajectoryMonitor.js.map +1 -1
- package/dist/watcher/settingsGuard.d.ts +4 -1
- package/dist/watcher/settingsGuard.d.ts.map +1 -1
- package/dist/watcher/settingsGuard.js +42 -18
- package/dist/watcher/settingsGuard.js.map +1 -1
- package/package.json +6 -6
- package/dist/hashReporter.d.ts +0 -33
- package/dist/hashReporter.d.ts.map +0 -1
- package/dist/hashReporter.js +0 -69
- package/dist/hashReporter.js.map +0 -1
- package/dist/integrityStore.d.ts +0 -35
- package/dist/integrityStore.d.ts.map +0 -1
- package/dist/integrityStore.js +0 -71
- package/dist/integrityStore.js.map +0 -1
- package/dist/lib/gitWorktrees.d.ts +0 -140
- package/dist/lib/gitWorktrees.d.ts.map +0 -1
- package/dist/lib/gitWorktrees.js +0 -191
- package/dist/lib/gitWorktrees.js.map +0 -1
- package/dist/syncLoop.d.ts +0 -172
- package/dist/syncLoop.d.ts.map +0 -1
- package/dist/syncLoop.js +0 -878
- package/dist/syncLoop.js.map +0 -1
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* MCP servers with the @intutic/mcp-governance-proxy.
|
|
4
4
|
*
|
|
5
5
|
* Injects and proxy-wraps MCP server entries in:
|
|
6
|
-
* - Claude Code: ~/.claude
|
|
6
|
+
* - Claude Code: ~/.claude.json (user-scope `mcpServers`, and the
|
|
7
|
+
* local-scope `projects[<workspaceRoot>].mcpServers`,
|
|
8
|
+
* where the approved servers of the project's
|
|
9
|
+
* `.mcp.json` also get wrapped shadows — the shared
|
|
10
|
+
* `.mcp.json` itself is only read)
|
|
7
11
|
* - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
8
12
|
* - Cursor (global): ~/Library/Application Support/Cursor/User/globalSettings.json
|
|
9
13
|
* - Cursor (project): <workspaceRoot>/.cursor/mcp.json
|
|
@@ -15,11 +19,14 @@
|
|
|
15
19
|
* - Muse Code: ~/.config/muse/settings.json (`mcp_servers` section)
|
|
16
20
|
* - Grok Build: ~/.grok/config.toml + <workspaceRoot>/.grok/config.toml
|
|
17
21
|
* ([mcp_servers.*] tables)
|
|
22
|
+
* - OpenCode: ~/.config/opencode/opencode.json + <workspaceRoot>/opencode.json
|
|
23
|
+
* (`mcp` block — TD-487; see `injectOpenCode`)
|
|
18
24
|
*
|
|
19
|
-
* That is
|
|
20
|
-
* Build each own two paths — global/project for Cursor
|
|
21
|
-
* Grok Build). `discoverMcpServers` below reads
|
|
22
|
-
*
|
|
25
|
+
* That is 14 config paths across 11 `HarnessType` values (Cursor, Grok
|
|
26
|
+
* Build and OpenCode each own two paths — global/project for Cursor and
|
|
27
|
+
* OpenCode, user/project for Grok Build). `discoverMcpServers` below reads
|
|
28
|
+
* all fourteen read-only, for reporting, plus Claude Code's project
|
|
29
|
+
* `.mcp.json`; the injectors above are the only thing that writes.
|
|
23
30
|
*
|
|
24
31
|
* # Grok Build's compat-path overlap — dedup, not a bug
|
|
25
32
|
*
|
|
@@ -65,15 +72,13 @@
|
|
|
65
72
|
* comment used to describe as "left untouched": the entry is rewritten to
|
|
66
73
|
* spawn the SAME proxy binary in bridge mode (`--remote-url`/
|
|
67
74
|
* `--remote-transport`, headers riding in `INTUTIC_REMOTE_HEADERS` env, not
|
|
68
|
-
* argv — `ps` visibility). `__intutic_original`
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* `intutic connect` (tools/cli) AND once per sync-loop iteration
|
|
76
|
-
* (services/sync-daemon/src/syncLoop.ts), so a server a user adds to a harness
|
|
75
|
+
* argv — `ps` visibility). `__intutic_original` keeps the whole pre-wrap
|
|
76
|
+
* entry, stdio or remote, so `classifyEntry` can report the TRUE transport
|
|
77
|
+
* of what got wrapped and `intutic disconnect` can put the entry back
|
|
78
|
+
* exactly.
|
|
79
|
+
*
|
|
80
|
+
* Continuous invariant, not one-shot: `intutic connect` (tools/cli) calls
|
|
81
|
+
* `injectMcpServer` on every sync cycle, so a server a user adds to a harness
|
|
77
82
|
* config after their first `connect` still gets wrapped on the next sync
|
|
78
83
|
* cycle rather than staying invisible to governance forever. Running this
|
|
79
84
|
* every ~30s only works because `writeJsonFile` below is write-if-changed —
|
|
@@ -88,41 +93,63 @@ import * as node_fs from 'node:fs/promises';
|
|
|
88
93
|
import * as node_path from 'node:path';
|
|
89
94
|
import * as node_os from 'node:os';
|
|
90
95
|
import { existsSync } from 'node:fs';
|
|
96
|
+
import { createRequire } from 'node:module';
|
|
91
97
|
import { isDeepStrictEqual } from 'node:util';
|
|
92
98
|
import { createLogger } from '@intutic/logger';
|
|
99
|
+
import { readJsonObjectForMerge } from './jsonMergeTarget.js';
|
|
100
|
+
import { projectServerApproval } from './claudeProjectApproval.js';
|
|
101
|
+
import { keepOriginal } from '../disconnect/originals.js';
|
|
93
102
|
import { parseDocument, isMap } from 'yaml';
|
|
94
103
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
95
104
|
const log = createLogger('sync-mcp-autowrite');
|
|
96
105
|
// ─── Proxy Binary Resolution ─────────────────────────────────────────────────
|
|
106
|
+
/** Resolves packages relative to this module — i.e. from wherever the
|
|
107
|
+
* daemon itself is installed, not from the user's project. */
|
|
108
|
+
const requireFromDaemon = createRequire(import.meta.url);
|
|
97
109
|
/**
|
|
98
|
-
* Resolve the path to the @intutic/mcp-governance-proxy
|
|
110
|
+
* Resolve the path to the @intutic/mcp-governance-proxy entry point.
|
|
99
111
|
*
|
|
100
112
|
* Resolution order:
|
|
101
|
-
* 1.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
113
|
+
* 1. The copy installed next to this daemon — a dependency of
|
|
114
|
+
* `@intutic/sync-daemon`, so a global `npm install -g @intutic/cli` has it
|
|
115
|
+
* in the CLI's own node_modules. This is the normal case: the user's
|
|
116
|
+
* project has no reason to depend on the proxy.
|
|
117
|
+
* 2. `<workspaceRoot>/node_modules/@intutic/mcp-governance-proxy/dist/index.js`
|
|
118
|
+
* and `<workspaceRoot>/packages/mcp-proxy/dist/index.js` — for a run from
|
|
119
|
+
* a bundled binary, whose bundled modules are not on disk for `node` to
|
|
120
|
+
* load (their paths start with `/snapshot/`), and for a source checkout
|
|
121
|
+
* that has not built the proxy yet.
|
|
105
122
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
123
|
+
* Synchronous on purpose — this runs while building config entries, not in a
|
|
124
|
+
* hot path.
|
|
108
125
|
*/
|
|
109
|
-
function resolveProxyBin(workspaceRoot) {
|
|
126
|
+
export function resolveProxyBin(workspaceRoot) {
|
|
127
|
+
try {
|
|
128
|
+
const installed = requireFromDaemon.resolve('@intutic/mcp-governance-proxy');
|
|
129
|
+
if (!installed.startsWith('/snapshot/') && existsSync(installed))
|
|
130
|
+
return installed;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Not installed alongside the daemon — fall through to the workspace.
|
|
134
|
+
}
|
|
110
135
|
const nmPath = node_path.join(workspaceRoot, 'node_modules', '@intutic', 'mcp-governance-proxy', 'dist', 'index.js');
|
|
111
136
|
if (existsSync(nmPath))
|
|
112
137
|
return nmPath;
|
|
113
|
-
|
|
114
|
-
const pkgPath = node_path.join(workspaceRoot, 'packages', 'mcp-proxy', 'dist', 'index.js');
|
|
115
|
-
return pkgPath;
|
|
138
|
+
return node_path.join(workspaceRoot, 'packages', 'mcp-proxy', 'dist', 'index.js');
|
|
116
139
|
}
|
|
117
140
|
// ─── Config Path Resolution ───────────────────────────────────────────────────
|
|
118
141
|
//
|
|
119
142
|
// Shared by both the injectors (which write) and discoverMcpServers (which
|
|
120
143
|
// only reads), so the two can never independently drift about where a
|
|
121
144
|
// harness keeps its config.
|
|
145
|
+
/** Claude Code keeps user- and local-scope MCP servers in `~/.claude.json`.
|
|
146
|
+
* Project-scope servers live in the repo's `.mcp.json`, which is shared with
|
|
147
|
+
* the team and never rewritten here; they are governed through local-scope
|
|
148
|
+
* shadows instead (see {@link shadowProjectServers}). */
|
|
122
149
|
function claudeCodeConfigPath() {
|
|
123
|
-
return node_path.join(node_os.homedir(), '.claude
|
|
150
|
+
return node_path.join(node_os.homedir(), '.claude.json');
|
|
124
151
|
}
|
|
125
|
-
function claudeDesktopConfigPath() {
|
|
152
|
+
export function claudeDesktopConfigPath() {
|
|
126
153
|
const home = node_os.homedir();
|
|
127
154
|
if (process.platform === 'darwin') {
|
|
128
155
|
return node_path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
@@ -132,7 +159,7 @@ function claudeDesktopConfigPath() {
|
|
|
132
159
|
}
|
|
133
160
|
return node_path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
134
161
|
}
|
|
135
|
-
function cursorGlobalConfigPath() {
|
|
162
|
+
export function cursorGlobalConfigPath() {
|
|
136
163
|
const home = node_os.homedir();
|
|
137
164
|
if (process.platform === 'darwin') {
|
|
138
165
|
return node_path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json');
|
|
@@ -148,27 +175,37 @@ function cursorProjectConfigPath(workspaceRoot) {
|
|
|
148
175
|
function clineConfigPath(workspaceRoot) {
|
|
149
176
|
return node_path.join(workspaceRoot, '.cline', 'mcp.json');
|
|
150
177
|
}
|
|
151
|
-
function windsurfConfigPath() {
|
|
178
|
+
export function windsurfConfigPath() {
|
|
152
179
|
return node_path.join(node_os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
|
|
153
180
|
}
|
|
154
|
-
function continueConfigPath() {
|
|
181
|
+
export function continueConfigPath() {
|
|
155
182
|
return node_path.join(node_os.homedir(), '.continue', 'config.json');
|
|
156
183
|
}
|
|
157
|
-
function gooseConfigPath() {
|
|
184
|
+
export function gooseConfigPath() {
|
|
158
185
|
return node_path.join(node_os.homedir(), '.config', 'goose', 'config.yaml');
|
|
159
186
|
}
|
|
160
187
|
function openHandsConfigPath(workspaceRoot) {
|
|
161
188
|
return node_path.join(workspaceRoot, '.openhands', 'mcp.json');
|
|
162
189
|
}
|
|
163
|
-
function museConfigPath() {
|
|
190
|
+
export function museConfigPath() {
|
|
164
191
|
return node_path.join(node_os.homedir(), '.config', 'muse', 'settings.json');
|
|
165
192
|
}
|
|
166
|
-
function grokUserConfigPath() {
|
|
193
|
+
export function grokUserConfigPath() {
|
|
167
194
|
return node_path.join(node_os.homedir(), '.grok', 'config.toml');
|
|
168
195
|
}
|
|
169
196
|
function grokProjectConfigPath(workspaceRoot) {
|
|
170
197
|
return node_path.join(workspaceRoot, '.grok', 'config.toml');
|
|
171
198
|
}
|
|
199
|
+
/** OpenCode's global config: `$XDG_CONFIG_HOME/opencode/opencode.json`, which
|
|
200
|
+
* is `~/.config/opencode/opencode.json` unless XDG_CONFIG_HOME is set (OpenCode
|
|
201
|
+
* resolves it through xdg-basedir). Only the `.json` spelling — see `injectOpenCode`. */
|
|
202
|
+
export function openCodeGlobalConfigPath() {
|
|
203
|
+
const base = process.env['XDG_CONFIG_HOME'] || node_path.join(node_os.homedir(), '.config');
|
|
204
|
+
return node_path.join(base, 'opencode', 'opencode.json');
|
|
205
|
+
}
|
|
206
|
+
function openCodeProjectConfigPath(workspaceRoot) {
|
|
207
|
+
return node_path.join(workspaceRoot, 'opencode.json');
|
|
208
|
+
}
|
|
172
209
|
// ─── Proxy Wrapping ───────────────────────────────────────────────────────────
|
|
173
210
|
/**
|
|
174
211
|
* Wrap a single MCP server entry with the governance proxy.
|
|
@@ -194,6 +231,7 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
|
|
|
194
231
|
if (entry.__intutic_wrapped)
|
|
195
232
|
return entry;
|
|
196
233
|
const proxyBin = resolveProxyBin(workspaceRoot);
|
|
234
|
+
const original = structuredClone(entry);
|
|
197
235
|
if (typeof entry.command === 'string') {
|
|
198
236
|
const originalArgs = entry.args ?? [];
|
|
199
237
|
return {
|
|
@@ -211,16 +249,12 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
|
|
|
211
249
|
INTUTIC_WORKSPACE_ID: workspaceId,
|
|
212
250
|
},
|
|
213
251
|
__intutic_wrapped: true,
|
|
252
|
+
__intutic_original: original,
|
|
214
253
|
};
|
|
215
254
|
}
|
|
216
255
|
if (typeof entry.url === 'string') {
|
|
217
256
|
const remoteTransport = entry.type?.toLowerCase() === 'sse' ? 'sse' : 'http';
|
|
218
257
|
const hasHeaders = entry.headers !== undefined && Object.keys(entry.headers).length > 0;
|
|
219
|
-
const original = { url: entry.url };
|
|
220
|
-
if (entry.type !== undefined)
|
|
221
|
-
original.type = entry.type;
|
|
222
|
-
if (entry.headers !== undefined)
|
|
223
|
-
original.headers = entry.headers;
|
|
224
258
|
const env = {
|
|
225
259
|
...(entry.env ?? {}),
|
|
226
260
|
INTUTIC_WORKSPACE_ID: workspaceId,
|
|
@@ -291,11 +325,23 @@ async function readJsonFile(filePath, fallback) {
|
|
|
291
325
|
return fallback;
|
|
292
326
|
}
|
|
293
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Read a harness config this module is about to write back. A missing file is
|
|
330
|
+
* an empty object; a file that is present but not a plain JSON object throws,
|
|
331
|
+
* so the caller's error handling skips the write instead of replacing the
|
|
332
|
+
* user's file with only the MCP entries (see jsonMergeTarget.ts).
|
|
333
|
+
*/
|
|
334
|
+
async function readJsonForWrite(filePath) {
|
|
335
|
+
const current = await readJsonObjectForMerge(filePath);
|
|
336
|
+
if (current === null)
|
|
337
|
+
throw new Error(`${filePath} is not a plain JSON object — left untouched`);
|
|
338
|
+
return current;
|
|
339
|
+
}
|
|
294
340
|
/**
|
|
295
341
|
* Write JSON to disk, but only if the content actually changed.
|
|
296
342
|
*
|
|
297
|
-
* `injectMcpServer`
|
|
298
|
-
*
|
|
343
|
+
* `injectMcpServer` runs every sync cycle (~every 30s, from `intutic
|
|
344
|
+
* connect`) instead of only once at startup — re-running the same
|
|
299
345
|
* wrap against an already-wrapped, unchanged config must not touch the file,
|
|
300
346
|
* or every cycle would churn the file's mtime and fire a spurious inotify /
|
|
301
347
|
* FSEvents event for every harness config on every developer machine.
|
|
@@ -304,8 +350,12 @@ async function readJsonFile(filePath, fallback) {
|
|
|
304
350
|
* serialized string — a naive string compare would report a false "changed"
|
|
305
351
|
* on nothing more than JSON.stringify key-ordering differences between two
|
|
306
352
|
* semantically identical objects.
|
|
353
|
+
*
|
|
354
|
+
* With `workspaceRoot`, the file's original is kept for `intutic disconnect`
|
|
355
|
+
* before the first write. `~/.claude.json` goes without: it is Claude Code's
|
|
356
|
+
* live state, never restored whole.
|
|
307
357
|
*/
|
|
308
|
-
async function writeJsonFile(filePath, data) {
|
|
358
|
+
async function writeJsonFile(filePath, data, workspaceRoot) {
|
|
309
359
|
let existing;
|
|
310
360
|
let hasExisting;
|
|
311
361
|
try {
|
|
@@ -318,16 +368,113 @@ async function writeJsonFile(filePath, data) {
|
|
|
318
368
|
if (hasExisting && isDeepStrictEqual(existing, data)) {
|
|
319
369
|
return;
|
|
320
370
|
}
|
|
371
|
+
if (workspaceRoot !== undefined)
|
|
372
|
+
await keepOriginal(filePath, workspaceRoot);
|
|
321
373
|
await node_fs.mkdir(node_path.dirname(filePath), { recursive: true });
|
|
322
374
|
await node_fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
323
375
|
}
|
|
324
|
-
// ─── Target: Claude Code ─────────────────────────────────────────────────────
|
|
325
376
|
async function injectClaudeCode(workspaceId, workspaceRoot) {
|
|
326
377
|
const configPath = claudeCodeConfigPath();
|
|
327
|
-
|
|
378
|
+
// ~/.claude.json is Claude Code's own state file; it exists once Claude
|
|
379
|
+
// Code has run. Never created here, and merged key by key: everything but
|
|
380
|
+
// the two `mcpServers` maps below is Claude Code's.
|
|
381
|
+
if (!existsSync(configPath)) {
|
|
382
|
+
log.debug({ action: 'claude_code_skip' }, 'Claude Code not set up (no ~/.claude.json) — skipping');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const current = await readJsonForWrite(configPath);
|
|
386
|
+
// User scope: available in every project.
|
|
328
387
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
388
|
+
// Local scope: servers added with `claude mcp add` in this project, then
|
|
389
|
+
// the shadows that govern its approved `.mcp.json` servers. A project Claude
|
|
390
|
+
// Code has never opened has no entry, and nothing approved to shadow.
|
|
391
|
+
const project = current.projects?.[workspaceRoot];
|
|
392
|
+
if (project) {
|
|
393
|
+
const local = wrapAllServers(project.mcpServers ?? {}, workspaceId, workspaceRoot);
|
|
394
|
+
const withShadows = shadowProjectServers(local, await readProjectScopeServers(workspaceRoot), await projectServerApproval(workspaceRoot, current), workspaceId, workspaceRoot);
|
|
395
|
+
if (project.mcpServers !== undefined || Object.keys(withShadows).length > 0)
|
|
396
|
+
project.mcpServers = withShadows;
|
|
397
|
+
}
|
|
329
398
|
await writeJsonFile(configPath, current);
|
|
330
|
-
log.info({ action: 'claude_code_mcp_injected' }, 'Claude Code ~/.claude
|
|
399
|
+
log.info({ action: 'claude_code_mcp_injected' }, 'Claude Code ~/.claude.json MCP servers updated');
|
|
400
|
+
}
|
|
401
|
+
/** Where Claude Code keeps a project's shared, committed servers. */
|
|
402
|
+
function claudeCodeProjectMcpPath(workspaceRoot) {
|
|
403
|
+
return node_path.join(workspaceRoot, '.mcp.json');
|
|
404
|
+
}
|
|
405
|
+
/** The project's `.mcp.json` servers; empty when the file is missing or not a JSON object. */
|
|
406
|
+
async function readProjectScopeServers(workspaceRoot) {
|
|
407
|
+
const parsed = await readJsonFile(claudeCodeProjectMcpPath(workspaceRoot), {});
|
|
408
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
409
|
+
return {};
|
|
410
|
+
const servers = parsed.mcpServers;
|
|
411
|
+
return servers !== null && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Why an approved project server cannot get a governed local-scope copy, or
|
|
415
|
+
* null when it can. Exported for discovery's reporting through
|
|
416
|
+
* {@link discoverMcpServers}.
|
|
417
|
+
*
|
|
418
|
+
* Expansion itself is not a reason: Claude Code expands `${VAR}` the same way
|
|
419
|
+
* at local scope as in `.mcp.json`, in `command`, `args`, `env`, `url` and
|
|
420
|
+
* `headers` (verified with Claude Code 2.1.233 — see the MCP governance
|
|
421
|
+
* guide), so a copy keeps references verbatim and writes no resolved value.
|
|
422
|
+
* A remote server is the exception. Its copy is a stdio entry that hands the
|
|
423
|
+
* url to the proxy as an argument and the headers through the environment,
|
|
424
|
+
* where Claude Code expands every variable; in a remote server's own `url`
|
|
425
|
+
* and `headers` it reads credential variables (its own API keys, cloud and
|
|
426
|
+
* proxy credentials) as empty, by a list it does not publish in full. A copy
|
|
427
|
+
* could therefore send a credential the original never would.
|
|
428
|
+
*/
|
|
429
|
+
function shadowBlocker(entry) {
|
|
430
|
+
if (entry === null || typeof entry !== 'object')
|
|
431
|
+
return 'an entry the proxy cannot front';
|
|
432
|
+
if (entry.__intutic_wrapped)
|
|
433
|
+
return 'the entry in .mcp.json is already wrapped';
|
|
434
|
+
if (typeof entry.command === 'string')
|
|
435
|
+
return null;
|
|
436
|
+
if (typeof entry.url === 'string') {
|
|
437
|
+
const refs = [entry.url, ...Object.values(entry.headers ?? {})].some((v) => typeof v === 'string' && v.includes('${'));
|
|
438
|
+
return refs
|
|
439
|
+
? 'its url or headers reference environment variables, which Claude Code expands with a credential filter a wrapped copy cannot keep'
|
|
440
|
+
: null;
|
|
441
|
+
}
|
|
442
|
+
return 'an entry the proxy cannot front';
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Governs a project's `.mcp.json` servers without touching the file.
|
|
446
|
+
*
|
|
447
|
+
* `.mcp.json` is committed and shared with the team: wrapping it in place
|
|
448
|
+
* would write this machine's proxy path and workspace id into everyone's
|
|
449
|
+
* checkout. Claude Code gives a local-scope server precedence over a
|
|
450
|
+
* project-scope one of the same name, so each approved project server gets a
|
|
451
|
+
* wrapped copy at local scope, in this project's `~/.claude.json` entry,
|
|
452
|
+
* marked `__intutic_shadow_of: 'project'`. The rules:
|
|
453
|
+
*
|
|
454
|
+
* - A server the user added at local scope keeps its name; it is wrapped by
|
|
455
|
+
* the local-scope pass already and never replaced by a shadow.
|
|
456
|
+
* - A shadow follows its source: rewritten when the project entry changes,
|
|
457
|
+
* removed when the project entry, or its approval, goes away. Deleting the
|
|
458
|
+
* marked entries returns the project to exactly what `.mcp.json` says.
|
|
459
|
+
* - An entry whose shape the proxy cannot wrap is left to Claude Code as is.
|
|
460
|
+
* - Re-running on unchanged input produces the same map, so the
|
|
461
|
+
* write-if-changed in `writeJsonFile` writes nothing.
|
|
462
|
+
*/
|
|
463
|
+
function shadowProjectServers(local, projectServers, approval, workspaceId, workspaceRoot) {
|
|
464
|
+
const result = {};
|
|
465
|
+
for (const [name, entry] of Object.entries(local)) {
|
|
466
|
+
if (entry.__intutic_shadow_of === 'project')
|
|
467
|
+
continue; // rebuilt below from the current source
|
|
468
|
+
result[name] = entry;
|
|
469
|
+
}
|
|
470
|
+
for (const [name, entry] of Object.entries(projectServers)) {
|
|
471
|
+
if (name === 'intutic' || name in result || !approval.approved(name))
|
|
472
|
+
continue;
|
|
473
|
+
if (shadowBlocker(entry) !== null)
|
|
474
|
+
continue;
|
|
475
|
+
result[name] = { ...wrapWithProxy(entry, workspaceId, workspaceRoot, name), __intutic_shadow_of: 'project' };
|
|
476
|
+
}
|
|
477
|
+
return result;
|
|
331
478
|
}
|
|
332
479
|
// ─── Target: Claude Desktop ───────────────────────────────────────────────────
|
|
333
480
|
async function injectClaudeDesktop(workspaceId, workspaceRoot) {
|
|
@@ -340,9 +487,9 @@ async function injectClaudeDesktop(workspaceId, workspaceRoot) {
|
|
|
340
487
|
log.debug({ action: 'claude_desktop_skip' }, 'Claude Desktop not installed — skipping');
|
|
341
488
|
return;
|
|
342
489
|
}
|
|
343
|
-
const current = await
|
|
490
|
+
const current = await readJsonForWrite(configPath);
|
|
344
491
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
345
|
-
await writeJsonFile(configPath, current);
|
|
492
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
346
493
|
log.info({ action: 'claude_desktop_mcp_injected' }, 'Claude Desktop config updated');
|
|
347
494
|
}
|
|
348
495
|
// ─── Target: Cursor (global + project) ───────────────────────────────────────
|
|
@@ -351,9 +498,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
|
|
|
351
498
|
const globalPath = cursorGlobalConfigPath();
|
|
352
499
|
try {
|
|
353
500
|
await node_fs.access(node_path.dirname(globalPath));
|
|
354
|
-
const current = await
|
|
501
|
+
const current = await readJsonForWrite(globalPath);
|
|
355
502
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
356
|
-
await writeJsonFile(globalPath, current);
|
|
503
|
+
await writeJsonFile(globalPath, current, workspaceRoot);
|
|
357
504
|
log.info({ action: 'cursor_global_mcp_injected' }, 'Cursor globalSettings.json updated');
|
|
358
505
|
}
|
|
359
506
|
catch {
|
|
@@ -362,9 +509,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
|
|
|
362
509
|
// Project-level .cursor/mcp.json
|
|
363
510
|
const projectPath = cursorProjectConfigPath(workspaceRoot);
|
|
364
511
|
try {
|
|
365
|
-
const current = await
|
|
512
|
+
const current = await readJsonForWrite(projectPath);
|
|
366
513
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
367
|
-
await writeJsonFile(projectPath, current);
|
|
514
|
+
await writeJsonFile(projectPath, current, workspaceRoot);
|
|
368
515
|
log.info({ action: 'cursor_project_mcp_injected' }, 'Cursor .cursor/mcp.json updated');
|
|
369
516
|
}
|
|
370
517
|
catch (err) {
|
|
@@ -375,9 +522,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
|
|
|
375
522
|
async function injectCline(workspaceId, workspaceRoot) {
|
|
376
523
|
const configPath = clineConfigPath(workspaceRoot);
|
|
377
524
|
try {
|
|
378
|
-
const current = await
|
|
525
|
+
const current = await readJsonForWrite(configPath);
|
|
379
526
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
380
|
-
await writeJsonFile(configPath, current);
|
|
527
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
381
528
|
log.info({ action: 'cline_mcp_injected' }, 'Cline .cline/mcp.json updated');
|
|
382
529
|
}
|
|
383
530
|
catch (err) {
|
|
@@ -389,9 +536,9 @@ async function injectWindsurf(workspaceId, workspaceRoot) {
|
|
|
389
536
|
const configPath = windsurfConfigPath();
|
|
390
537
|
try {
|
|
391
538
|
await node_fs.access(node_path.dirname(configPath));
|
|
392
|
-
const current = await
|
|
539
|
+
const current = await readJsonForWrite(configPath);
|
|
393
540
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
394
|
-
await writeJsonFile(configPath, current);
|
|
541
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
395
542
|
log.info({ action: 'windsurf_mcp_injected' }, 'Windsurf mcp_config.json updated');
|
|
396
543
|
}
|
|
397
544
|
catch {
|
|
@@ -402,7 +549,7 @@ async function injectContinue(workspaceId, workspaceRoot) {
|
|
|
402
549
|
const configPath = continueConfigPath();
|
|
403
550
|
try {
|
|
404
551
|
await node_fs.access(node_path.dirname(configPath));
|
|
405
|
-
const current = await
|
|
552
|
+
const current = await readJsonForWrite(configPath);
|
|
406
553
|
// Continue uses an array format for mcpServers
|
|
407
554
|
if (!Array.isArray(current.mcpServers)) {
|
|
408
555
|
current.mcpServers = [];
|
|
@@ -412,14 +559,16 @@ async function injectContinue(workspaceId, workspaceRoot) {
|
|
|
412
559
|
// Wrap existing non-intutic servers — routed through the same
|
|
413
560
|
// wrapWithProxy used by every other harness, so the stdio-only guard and
|
|
414
561
|
// --server-name threading stay in one place instead of two.
|
|
562
|
+
// The whole entry goes in, so the wrap's `__intutic_original` holds every
|
|
563
|
+
// key `intutic disconnect` has to put back.
|
|
415
564
|
current.mcpServers = current.mcpServers.map((s) => {
|
|
416
|
-
const
|
|
417
|
-
return { name
|
|
565
|
+
const { name, ...entry } = s;
|
|
566
|
+
return { name, ...wrapWithProxy(entry, workspaceId, workspaceRoot, name) };
|
|
418
567
|
});
|
|
419
568
|
// Add Intutic MCP server
|
|
420
569
|
const entry = buildIntuticMcpEntry(workspaceRoot);
|
|
421
570
|
current.mcpServers.unshift({ name: 'intutic', ...entry });
|
|
422
|
-
await writeJsonFile(configPath, current);
|
|
571
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
423
572
|
log.info({ action: 'continue_mcp_injected' }, 'Continue ~/.continue/config.json updated');
|
|
424
573
|
}
|
|
425
574
|
catch {
|
|
@@ -455,6 +604,7 @@ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, prox
|
|
|
455
604
|
// (see writeJsonFile's doc comment for why: this runs every sync cycle now).
|
|
456
605
|
return;
|
|
457
606
|
}
|
|
607
|
+
await keepOriginal(configPath, node_os.homedir());
|
|
458
608
|
await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
|
|
459
609
|
await node_fs.writeFile(configPath, text, 'utf-8');
|
|
460
610
|
log.info({ action: 'goose_mcp_injected', mode: 'append_only_fallback' }, 'Goose config.yaml updated (append-only fallback)');
|
|
@@ -471,7 +621,7 @@ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, prox
|
|
|
471
621
|
* stdio entries wrapped with `--`, remote (`url`-keyed) entries wrapped with
|
|
472
622
|
* `--remote-url`/`--remote-transport` — so a remote MCP server declared in
|
|
473
623
|
* Goose's config gets the identical bridge coverage a remote entry in
|
|
474
|
-
* `~/.claude
|
|
624
|
+
* `~/.claude.json` would get, not a second, divergent convention.
|
|
475
625
|
*
|
|
476
626
|
* Falls back to `injectGooseAppendOnly` (append-only text injection, the
|
|
477
627
|
* pre-existing behaviour) when the file does not parse as YAML at all —
|
|
@@ -546,6 +696,7 @@ async function injectGoose(workspaceId, workspaceRoot) {
|
|
|
546
696
|
// (see writeJsonFile's doc comment for why: this runs every sync cycle now).
|
|
547
697
|
return;
|
|
548
698
|
}
|
|
699
|
+
await keepOriginal(configPath, workspaceRoot);
|
|
549
700
|
await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
|
|
550
701
|
await node_fs.writeFile(configPath, doc.toString(), 'utf-8');
|
|
551
702
|
log.info({ action: 'goose_mcp_injected', mode: 'yaml' }, 'Goose config.yaml updated (structural YAML edit)');
|
|
@@ -554,9 +705,9 @@ async function injectGoose(workspaceId, workspaceRoot) {
|
|
|
554
705
|
async function injectOpenHands(workspaceId, workspaceRoot) {
|
|
555
706
|
const configPath = openHandsConfigPath(workspaceRoot);
|
|
556
707
|
try {
|
|
557
|
-
const current = await
|
|
708
|
+
const current = await readJsonForWrite(configPath);
|
|
558
709
|
current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
|
|
559
|
-
await writeJsonFile(configPath, current);
|
|
710
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
560
711
|
log.info({ action: 'openhands_mcp_injected' }, 'OpenHands .openhands/mcp.json updated');
|
|
561
712
|
}
|
|
562
713
|
catch (err) {
|
|
@@ -583,11 +734,11 @@ async function injectMuse(workspaceId, workspaceRoot) {
|
|
|
583
734
|
log.debug({ action: 'muse_skip' }, 'Muse Code not installed — skipping');
|
|
584
735
|
return;
|
|
585
736
|
}
|
|
586
|
-
const current = await
|
|
737
|
+
const current = await readJsonForWrite(configPath);
|
|
587
738
|
current.mcp_servers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcp_servers ?? {}) }, workspaceId, workspaceRoot);
|
|
588
739
|
if (current.schema_version === undefined)
|
|
589
740
|
current.schema_version = 1;
|
|
590
|
-
await writeJsonFile(configPath, current);
|
|
741
|
+
await writeJsonFile(configPath, current, workspaceRoot);
|
|
591
742
|
log.info({ action: 'muse_mcp_injected' }, 'Muse Code ~/.config/muse/settings.json mcp_servers updated');
|
|
592
743
|
}
|
|
593
744
|
/**
|
|
@@ -600,7 +751,7 @@ async function injectMuse(workspaceId, workspaceRoot) {
|
|
|
600
751
|
* honest limit `injectGooseAppendOnly`/`discoverGooseLineScanFallback`
|
|
601
752
|
* document for their own fallback.
|
|
602
753
|
*/
|
|
603
|
-
async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin) {
|
|
754
|
+
async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot) {
|
|
604
755
|
let text = existingToml;
|
|
605
756
|
if (!text.includes('[mcp_servers.intutic]') && !text.includes('[mcp_servers."intutic"]')) {
|
|
606
757
|
const block = [
|
|
@@ -612,6 +763,7 @@ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId,
|
|
|
612
763
|
}
|
|
613
764
|
if (text === existingToml)
|
|
614
765
|
return;
|
|
766
|
+
await keepOriginal(configPath, workspaceRoot);
|
|
615
767
|
await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
|
|
616
768
|
await node_fs.writeFile(configPath, text, 'utf-8');
|
|
617
769
|
log.info({ action: 'grok_mcp_injected', mode: 'append_only_fallback' }, 'Grok config.toml updated (append-only fallback)');
|
|
@@ -622,7 +774,7 @@ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId,
|
|
|
622
774
|
* SAME `wrapWithProxy` convention every other harness uses (stdio entries
|
|
623
775
|
* wrapped with `--`, remote/`url`-keyed entries wrapped with
|
|
624
776
|
* `--remote-url`/`--remote-transport`) so a remote MCP server declared for
|
|
625
|
-
* Grok Build gets identical bridge coverage to one in `~/.claude
|
|
777
|
+
* Grok Build gets identical bridge coverage to one in `~/.claude.json`,
|
|
626
778
|
* not a second, divergent convention.
|
|
627
779
|
*
|
|
628
780
|
* Unlike `injectGoose`'s `Document#setIn` (which preserves comments/
|
|
@@ -654,7 +806,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
|
|
|
654
806
|
}
|
|
655
807
|
catch (err) {
|
|
656
808
|
log.warn({ action: 'grok_toml_unparseable', path: configPath, err: err.message }, 'Grok config.toml did not parse as TOML — falling back to append-only text injection');
|
|
657
|
-
await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin);
|
|
809
|
+
await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot);
|
|
658
810
|
return;
|
|
659
811
|
}
|
|
660
812
|
const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' ? doc.mcp_servers : {};
|
|
@@ -679,6 +831,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
|
|
|
679
831
|
return;
|
|
680
832
|
}
|
|
681
833
|
doc.mcp_servers = servers;
|
|
834
|
+
await keepOriginal(configPath, workspaceRoot);
|
|
682
835
|
await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
|
|
683
836
|
const tmp = configPath + '.intutic-tmp';
|
|
684
837
|
await node_fs.writeFile(tmp, stringifyToml(doc), 'utf-8');
|
|
@@ -689,6 +842,129 @@ async function injectGrok(workspaceId, workspaceRoot) {
|
|
|
689
842
|
await injectGrokConfig(grokProjectConfigPath(workspaceRoot), workspaceId, workspaceRoot);
|
|
690
843
|
await injectGrokConfig(grokUserConfigPath(), workspaceId, workspaceRoot);
|
|
691
844
|
}
|
|
845
|
+
/** The proxy binary path `resolveProxyBin` returns, in either of its two forms. */
|
|
846
|
+
export const PROXY_BIN_PATTERN = /(?:^|[\\/])(?:@intutic[\\/]mcp-governance-proxy|packages[\\/]mcp-proxy)[\\/]dist[\\/]index\.js$/;
|
|
847
|
+
/**
|
|
848
|
+
* Whether an OpenCode entry is already fronted by the governance proxy, read
|
|
849
|
+
* from the command SHAPE — `["node", <proxy bin>, "--workspace-id", …]` —
|
|
850
|
+
* because an OpenCode entry cannot carry the `__intutic_wrapped` marker every
|
|
851
|
+
* other harness uses: OpenCode's config schema has no such key, and a key the
|
|
852
|
+
* schema does not declare is at best dropped and at worst refused.
|
|
853
|
+
*/
|
|
854
|
+
function isOpenCodeWrapped(entry) {
|
|
855
|
+
const c = entry.command;
|
|
856
|
+
return (Array.isArray(c) &&
|
|
857
|
+
c[0] === 'node' &&
|
|
858
|
+
typeof c[1] === 'string' &&
|
|
859
|
+
PROXY_BIN_PATTERN.test(c[1]) &&
|
|
860
|
+
c.includes('--workspace-id'));
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Wrap one OpenCode server. Translates the entry into the `command`/`args`/
|
|
864
|
+
* `env` (or `url`/`headers`) shape `wrapWithProxy` takes, so the proxy argv —
|
|
865
|
+
* `--workspace-id`, `--server-name`, then `--` and the real command, or
|
|
866
|
+
* `--remote-url`/`--remote-transport` for a remote server — is built in one
|
|
867
|
+
* place for every harness, then translates the result back into a `local`
|
|
868
|
+
* entry. Keys OpenCode defines for a local server (`cwd`, `enabled`,
|
|
869
|
+
* `timeout`, and anything unrecognised) are carried over unchanged.
|
|
870
|
+
*
|
|
871
|
+
* Left alone: an already-wrapped entry, an entry of neither type, a local
|
|
872
|
+
* entry with no command, and a remote entry with an `oauth` object. The
|
|
873
|
+
* proxy's remote bridge forwards static headers only, so wrapping a server
|
|
874
|
+
* that relies on OpenCode's own OAuth flow would silently stop it from
|
|
875
|
+
* authenticating; it stays ungoverned by the MCP proxy instead (the plugin
|
|
876
|
+
* gate still sees its calls).
|
|
877
|
+
*/
|
|
878
|
+
function wrapOpenCodeEntry(entry, workspaceId, workspaceRoot, serverName) {
|
|
879
|
+
if (isOpenCodeWrapped(entry))
|
|
880
|
+
return entry;
|
|
881
|
+
const { type, command, environment, url, headers, oauth, ...rest } = entry;
|
|
882
|
+
let wrapped;
|
|
883
|
+
if (type === 'local' && Array.isArray(command) && command.length > 0 && command.every((a) => typeof a === 'string')) {
|
|
884
|
+
const [cmd, ...args] = command;
|
|
885
|
+
wrapped = wrapWithProxy({ command: cmd, args, env: environment }, workspaceId, workspaceRoot, serverName);
|
|
886
|
+
}
|
|
887
|
+
else if (type === 'remote' && typeof url === 'string' && (oauth === undefined || oauth === false)) {
|
|
888
|
+
// OpenCode tries streamable HTTP first, then SSE; the bridge's `http` is
|
|
889
|
+
// the streamable transport. `type` is OpenCode's discriminator, not a
|
|
890
|
+
// transport hint, so it is not passed through.
|
|
891
|
+
wrapped = wrapWithProxy({ url, headers }, workspaceId, workspaceRoot, serverName);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
return entry;
|
|
895
|
+
}
|
|
896
|
+
// `rest` holds keys valid on a local entry (cwd/enabled/timeout/…); a
|
|
897
|
+
// remote entry's url/headers/oauth are now carried by the argv and env.
|
|
898
|
+
return {
|
|
899
|
+
type: 'local',
|
|
900
|
+
command: [wrapped.command, ...(wrapped.args ?? [])],
|
|
901
|
+
environment: wrapped.env,
|
|
902
|
+
...rest,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Merge the governance proxy into ONE OpenCode config file's `mcp` block.
|
|
907
|
+
*
|
|
908
|
+
* Narrow by design, like Muse's `settings.json`: `opencode.json` is the
|
|
909
|
+
* user's file (providers, permissions, agents, themes), so only `mcp` is
|
|
910
|
+
* rewritten and every other key is written back as it was parsed, in its
|
|
911
|
+
* original order, with the file's own indentation. The file is never created
|
|
912
|
+
* — a missing `opencode.json` means this scope has no OpenCode config to
|
|
913
|
+
* govern. OpenCode reads `opencode.json` as JSONC; a file that carries
|
|
914
|
+
* comments or trailing commas does not parse as JSON and is skipped
|
|
915
|
+
* (`opencode_config_unparseable`), never rewritten, since a rewrite would
|
|
916
|
+
* strip the comments. `opencode.jsonc` is not touched for the same reason.
|
|
917
|
+
* Write-if-changed and atomic, like every target here.
|
|
918
|
+
*/
|
|
919
|
+
async function injectOpenCodeConfig(configPath, workspaceId, workspaceRoot) {
|
|
920
|
+
let raw;
|
|
921
|
+
try {
|
|
922
|
+
raw = await node_fs.readFile(configPath, 'utf-8');
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
let doc;
|
|
928
|
+
try {
|
|
929
|
+
doc = JSON.parse(raw);
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
log.warn({ action: 'opencode_config_unparseable', path: configPath, err: err.message }, 'opencode.json is not plain JSON (comments?) — MCP servers in it are not proxy-wrapped');
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
936
|
+
return;
|
|
937
|
+
const config = doc;
|
|
938
|
+
const existing = config['mcp'] && typeof config['mcp'] === 'object' && !Array.isArray(config['mcp'])
|
|
939
|
+
? config['mcp']
|
|
940
|
+
: {};
|
|
941
|
+
const intutic = buildIntuticMcpEntry(workspaceRoot);
|
|
942
|
+
const servers = {
|
|
943
|
+
intutic: { type: 'local', command: [intutic.command, ...(intutic.args ?? [])], environment: intutic.env },
|
|
944
|
+
...existing,
|
|
945
|
+
};
|
|
946
|
+
const next = {};
|
|
947
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
948
|
+
next[name] =
|
|
949
|
+
name === 'intutic' || !entry || typeof entry !== 'object'
|
|
950
|
+
? entry
|
|
951
|
+
: wrapOpenCodeEntry(entry, workspaceId, workspaceRoot, name);
|
|
952
|
+
}
|
|
953
|
+
if (isDeepStrictEqual(next, config['mcp']))
|
|
954
|
+
return;
|
|
955
|
+
config['mcp'] = next;
|
|
956
|
+
const indent = raw.match(/^([ \t]+)\S/m)?.[1] ?? 2;
|
|
957
|
+
// OpenCode entries cannot carry a marker, so disconnect needs the file as it was.
|
|
958
|
+
await keepOriginal(configPath, workspaceRoot);
|
|
959
|
+
const tmp = configPath + '.intutic-tmp';
|
|
960
|
+
await node_fs.writeFile(tmp, JSON.stringify(config, null, indent) + '\n', 'utf-8');
|
|
961
|
+
await node_fs.rename(tmp, configPath);
|
|
962
|
+
log.info({ action: 'opencode_mcp_injected', path: configPath }, 'OpenCode opencode.json mcp block updated');
|
|
963
|
+
}
|
|
964
|
+
async function injectOpenCode(workspaceId, workspaceRoot) {
|
|
965
|
+
await injectOpenCodeConfig(openCodeProjectConfigPath(workspaceRoot), workspaceId, workspaceRoot);
|
|
966
|
+
await injectOpenCodeConfig(openCodeGlobalConfigPath(), workspaceId, workspaceRoot);
|
|
967
|
+
}
|
|
692
968
|
// ─── Discovery (read-only — writes nothing) ───────────────────────────────────
|
|
693
969
|
/** Classify a raw server entry's transport + wrapped status, tolerant of any shape. */
|
|
694
970
|
function classifyEntry(entry) {
|
|
@@ -732,6 +1008,32 @@ async function discoverJsonObjectHarness(harness, filePath) {
|
|
|
732
1008
|
}
|
|
733
1009
|
return out;
|
|
734
1010
|
}
|
|
1011
|
+
/** Claude Code's servers as Claude Code resolves them: user scope
|
|
1012
|
+
* (`~/.claude.json`), project scope (the repo's `.mcp.json`) and local scope
|
|
1013
|
+
* (this project's entry in `~/.claude.json`), each narrower scope winning a
|
|
1014
|
+
* name — so a project server governed by a local-scope shadow reports as
|
|
1015
|
+
* wrapped. */
|
|
1016
|
+
async function discoverClaudeCode(workspaceRoot) {
|
|
1017
|
+
const filePath = claudeCodeConfigPath();
|
|
1018
|
+
const current = existsSync(filePath) ? await readJsonFile(filePath, {}) : {};
|
|
1019
|
+
const projectServers = await readProjectScopeServers(workspaceRoot);
|
|
1020
|
+
const local = current.projects?.[workspaceRoot]?.mcpServers ?? {};
|
|
1021
|
+
const servers = { ...(current.mcpServers ?? {}), ...projectServers, ...local };
|
|
1022
|
+
const approval = Object.keys(projectServers).length > 0 ? await projectServerApproval(workspaceRoot, current) : null;
|
|
1023
|
+
return Object.entries(servers).map(([name, entry]) => {
|
|
1024
|
+
const found = { server: name, harness: 'claude-code', ...classifyEntry(entry) };
|
|
1025
|
+
// A project server Claude Code will start, with no copy of ours in front of it.
|
|
1026
|
+
if (approval && name in projectServers && !(name in local) && approval.approved(name)) {
|
|
1027
|
+
const reason = shadowBlocker(projectServers[name]);
|
|
1028
|
+
if (reason)
|
|
1029
|
+
found.ungovernedReason = `Claude Code project server not governed: ${reason}`;
|
|
1030
|
+
}
|
|
1031
|
+
if (approval?.blockedReason && name in projectServers && !(name in local)) {
|
|
1032
|
+
found.ungovernedReason = `Claude Code project server not governed: ${approval.blockedReason}`;
|
|
1033
|
+
}
|
|
1034
|
+
return found;
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
735
1037
|
/** Muse Code's `~/.config/muse/settings.json` keeps servers under `mcp_servers`,
|
|
736
1038
|
* not `mcpServers` — otherwise the same JSON-map shape `discoverJsonObjectHarness`
|
|
737
1039
|
* reads, so this is that function with one key renamed rather than a new format. */
|
|
@@ -885,9 +1187,45 @@ async function discoverGrok(workspaceRoot) {
|
|
|
885
1187
|
]);
|
|
886
1188
|
return [...project, ...user];
|
|
887
1189
|
}
|
|
1190
|
+
/** Classify an OpenCode `mcp` entry. A wrapped entry is a `local` one whose
|
|
1191
|
+
* argv names the proxy; its true transport is read back from that argv
|
|
1192
|
+
* (`--remote-url` / `--remote-transport`), since OpenCode entries cannot
|
|
1193
|
+
* carry `__intutic_original`. */
|
|
1194
|
+
function classifyOpenCodeEntry(entry) {
|
|
1195
|
+
if (!entry || typeof entry !== 'object')
|
|
1196
|
+
return { transport: 'unknown', wrapped: false };
|
|
1197
|
+
const e = entry;
|
|
1198
|
+
const wrapped = isOpenCodeWrapped(e);
|
|
1199
|
+
if (wrapped) {
|
|
1200
|
+
const argv = e.command;
|
|
1201
|
+
if (!argv.includes('--remote-url'))
|
|
1202
|
+
return { transport: 'stdio', wrapped };
|
|
1203
|
+
const t = argv[argv.indexOf('--remote-transport') + 1];
|
|
1204
|
+
return { transport: t === 'sse' ? 'sse' : 'http', wrapped };
|
|
1205
|
+
}
|
|
1206
|
+
if (e.type === 'local' && Array.isArray(e.command))
|
|
1207
|
+
return { transport: 'stdio', wrapped };
|
|
1208
|
+
if (e.type === 'remote' && typeof e.url === 'string')
|
|
1209
|
+
return { transport: 'http', wrapped };
|
|
1210
|
+
return { transport: 'unknown', wrapped };
|
|
1211
|
+
}
|
|
1212
|
+
async function discoverOpenCodeConfig(configPath) {
|
|
1213
|
+
if (!existsSync(configPath))
|
|
1214
|
+
return [];
|
|
1215
|
+
const current = await readJsonFile(configPath, {});
|
|
1216
|
+
const mcp = current.mcp && typeof current.mcp === 'object' ? current.mcp : {};
|
|
1217
|
+
return Object.entries(mcp).map(([name, entry]) => ({ server: name, harness: 'opencode', ...classifyOpenCodeEntry(entry) }));
|
|
1218
|
+
}
|
|
1219
|
+
async function discoverOpenCode(workspaceRoot) {
|
|
1220
|
+
const [project, global] = await Promise.all([
|
|
1221
|
+
discoverOpenCodeConfig(openCodeProjectConfigPath(workspaceRoot)),
|
|
1222
|
+
discoverOpenCodeConfig(openCodeGlobalConfigPath()),
|
|
1223
|
+
]);
|
|
1224
|
+
return [...project, ...global];
|
|
1225
|
+
}
|
|
888
1226
|
/**
|
|
889
1227
|
* Discover every MCP server declared in any harness config this daemon knows
|
|
890
|
-
* how to parse — the same
|
|
1228
|
+
* how to parse — the same 14 config paths / 11 harnesses `injectMcpServer`
|
|
891
1229
|
* wraps — without writing anything. Used for reporting (agentReporter's
|
|
892
1230
|
* `mcp_tools` facet) so visibility does not silently lag behind whatever
|
|
893
1231
|
* `injectMcpServer` was last run against.
|
|
@@ -906,7 +1244,7 @@ async function discoverGrok(workspaceRoot) {
|
|
|
906
1244
|
*/
|
|
907
1245
|
export async function discoverMcpServers(workspaceRoot) {
|
|
908
1246
|
const results = await Promise.all([
|
|
909
|
-
|
|
1247
|
+
discoverClaudeCode(workspaceRoot),
|
|
910
1248
|
discoverJsonObjectHarness('claude-desktop', claudeDesktopConfigPath()),
|
|
911
1249
|
discoverJsonObjectHarness('cursor', cursorGlobalConfigPath()),
|
|
912
1250
|
discoverJsonObjectHarness('cursor', cursorProjectConfigPath(workspaceRoot)),
|
|
@@ -917,6 +1255,7 @@ export async function discoverMcpServers(workspaceRoot) {
|
|
|
917
1255
|
discoverGoose(),
|
|
918
1256
|
discoverMuse(),
|
|
919
1257
|
discoverGrok(workspaceRoot),
|
|
1258
|
+
discoverOpenCode(workspaceRoot),
|
|
920
1259
|
]);
|
|
921
1260
|
return results.flat().filter((s) => s.server !== 'intutic');
|
|
922
1261
|
}
|
|
@@ -926,31 +1265,36 @@ export async function discoverMcpServers(workspaceRoot) {
|
|
|
926
1265
|
*
|
|
927
1266
|
* Non-fatal: a failure in one harness does not prevent other harnesses from being updated.
|
|
928
1267
|
*
|
|
929
|
-
* Called
|
|
930
|
-
*
|
|
931
|
-
* (services/sync-daemon/src/syncLoop.ts) so it is a continuous invariant
|
|
932
|
-
* rather than a one-shot — a server a user adds after their first `connect`
|
|
1268
|
+
* Called by `intutic connect` (tools/cli) on every sync cycle, so it is a
|
|
1269
|
+
* continuous invariant rather than a one-shot — a server a user adds after their first `connect`
|
|
933
1270
|
* still gets wrapped on the next cycle. Safe to call every cycle because
|
|
934
1271
|
* `writeJsonFile` is write-if-changed: an already-wrapped, unchanged config
|
|
935
1272
|
* writes zero bytes.
|
|
936
1273
|
*
|
|
937
1274
|
* @param workspaceId - The workspace ID for policy lookups and event attribution.
|
|
938
1275
|
* @param workspaceRoot - Absolute path to the project workspace root.
|
|
1276
|
+
* @param options.skip - Harness ids to leave alone: the ones `intutic
|
|
1277
|
+
* disconnect --harness` took out.
|
|
939
1278
|
*/
|
|
940
|
-
export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown') {
|
|
1279
|
+
export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown', options = {}) {
|
|
941
1280
|
log.info({ action: 'mcp_inject_start', workspaceRoot, workspaceId }, 'Starting MCP server injection');
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1281
|
+
const targets = [
|
|
1282
|
+
['claude-code', injectClaudeCode],
|
|
1283
|
+
['claude-desktop', injectClaudeDesktop],
|
|
1284
|
+
['cursor', injectCursor],
|
|
1285
|
+
['cline', injectCline],
|
|
1286
|
+
['windsurf', injectWindsurf],
|
|
1287
|
+
['continue', injectContinue],
|
|
1288
|
+
['goose', injectGoose],
|
|
1289
|
+
['openhands', injectOpenHands],
|
|
1290
|
+
['muse-code', injectMuse],
|
|
1291
|
+
['grok', injectGrok],
|
|
1292
|
+
['opencode', injectOpenCode],
|
|
1293
|
+
];
|
|
1294
|
+
const skip = new Set(options.skip ?? []);
|
|
1295
|
+
await Promise.allSettled(targets
|
|
1296
|
+
.filter(([target]) => !skip.has(target))
|
|
1297
|
+
.map(([target, inject]) => inject(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target }, 'MCP injection failed'))));
|
|
954
1298
|
log.info({ action: 'mcp_inject_complete', workspaceRoot }, 'MCP server injection complete');
|
|
955
1299
|
}
|
|
956
1300
|
//# sourceMappingURL=mcpAutoWrite.js.map
|