log-llm-config-staging 1.3.93 → 1.3.96
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/log_config_files/collection/claude_desktop_extensions_collector.js +145 -0
- package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
- package/dist/log_config_files/collection/config_collector.js +4 -1
- package/dist/log_config_files/collection/cursor_project_mcp_collector.js +198 -0
- package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
- package/dist/log_config_files/collection/mcp_tool_collector.js +8 -1
- package/dist/log_config_files/collection/plugin_collector.js +42 -1
- package/dist/log_config_files/runtime/compliance_check.js +22 -0
- package/dist/log_config_files/runtime/main_runner.js +40 -2
- package/dist/log_config_files/runtime/remediation_sync.js +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readJSONFile } from '../readers/file_readers.js';
|
|
5
|
+
function claudeAppSupportDir(home) {
|
|
6
|
+
if (process.platform === 'win32') {
|
|
7
|
+
const appData = process.env.APPDATA?.trim();
|
|
8
|
+
if (!appData)
|
|
9
|
+
return '';
|
|
10
|
+
return join(appData, 'Claude');
|
|
11
|
+
}
|
|
12
|
+
return join(home, 'Library', 'Application Support', 'Claude');
|
|
13
|
+
}
|
|
14
|
+
function claudeDesktopExtensionsDir(home) {
|
|
15
|
+
const base = claudeAppSupportDir(home);
|
|
16
|
+
return base ? join(base, 'Claude Extensions') : '';
|
|
17
|
+
}
|
|
18
|
+
function extensionsInstallationsPath(home) {
|
|
19
|
+
const base = claudeAppSupportDir(home);
|
|
20
|
+
return base ? join(base, 'extensions-installations.json') : '';
|
|
21
|
+
}
|
|
22
|
+
function normalizePathForMatch(path) {
|
|
23
|
+
return path.replace(/\\/g, '/');
|
|
24
|
+
}
|
|
25
|
+
function isExtensionsInstallationsUpload(entry) {
|
|
26
|
+
if (entry.file_type !== 'claude_extensions_installations')
|
|
27
|
+
return false;
|
|
28
|
+
return normalizePathForMatch(entry.file_path).includes('extensions-installations.json');
|
|
29
|
+
}
|
|
30
|
+
function registryExtensionIds(home) {
|
|
31
|
+
const path = extensionsInstallationsPath(home);
|
|
32
|
+
if (!path || !existsSync(path))
|
|
33
|
+
return new Set();
|
|
34
|
+
const raw = readJSONFile(path);
|
|
35
|
+
if (!raw || typeof raw !== 'object')
|
|
36
|
+
return new Set();
|
|
37
|
+
const extensions = raw.extensions;
|
|
38
|
+
if (!extensions || typeof extensions !== 'object')
|
|
39
|
+
return new Set();
|
|
40
|
+
return new Set(Object.keys(extensions));
|
|
41
|
+
}
|
|
42
|
+
function manifestHasMcpConfig(manifest) {
|
|
43
|
+
const server = manifest.server;
|
|
44
|
+
if (!server || typeof server !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const mcpConfig = server.mcp_config;
|
|
47
|
+
return mcpConfig !== null && typeof mcpConfig === 'object';
|
|
48
|
+
}
|
|
49
|
+
function collectDiskOnlyClaudeDesktopExtensions(home, skipIds) {
|
|
50
|
+
const extensionsDir = claudeDesktopExtensionsDir(home);
|
|
51
|
+
if (!extensionsDir || !existsSync(extensionsDir))
|
|
52
|
+
return {};
|
|
53
|
+
const out = {};
|
|
54
|
+
let dirs;
|
|
55
|
+
try {
|
|
56
|
+
dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
for (const dirent of dirs) {
|
|
62
|
+
const extId = dirent.name;
|
|
63
|
+
if (skipIds.has(extId))
|
|
64
|
+
continue;
|
|
65
|
+
const manifestPath = join(extensionsDir, extId, 'manifest.json');
|
|
66
|
+
if (!existsSync(manifestPath))
|
|
67
|
+
continue;
|
|
68
|
+
const manifest = readJSONFile(manifestPath);
|
|
69
|
+
if (!manifest || typeof manifest !== 'object')
|
|
70
|
+
continue;
|
|
71
|
+
const manifestObj = manifest;
|
|
72
|
+
if (!manifestHasMcpConfig(manifestObj))
|
|
73
|
+
continue;
|
|
74
|
+
const version = typeof manifestObj.version === 'string' || typeof manifestObj.version === 'number'
|
|
75
|
+
? String(manifestObj.version)
|
|
76
|
+
: undefined;
|
|
77
|
+
out[extId] = {
|
|
78
|
+
id: extId,
|
|
79
|
+
version,
|
|
80
|
+
manifest: manifestObj,
|
|
81
|
+
source: 'claude_extensions_directory',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Merge disk-only Desktop extensions into the extensions-installations.json upload
|
|
88
|
+
* so MCP inventory uses the same ingest path as registry-listed extensions (e.g. iMessage).
|
|
89
|
+
*/
|
|
90
|
+
function enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, home = homedir()) {
|
|
91
|
+
const skipIds = new Set();
|
|
92
|
+
for (const id of registryExtensionIds(home))
|
|
93
|
+
skipIds.add(id);
|
|
94
|
+
const registryUpload = configFiles.find(isExtensionsInstallationsUpload);
|
|
95
|
+
if (registryUpload?.raw_content && typeof registryUpload.raw_content === 'object') {
|
|
96
|
+
const extensions = registryUpload.raw_content.extensions;
|
|
97
|
+
if (extensions && typeof extensions === 'object') {
|
|
98
|
+
for (const id of Object.keys(extensions))
|
|
99
|
+
skipIds.add(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const diskOnly = collectDiskOnlyClaudeDesktopExtensions(home, skipIds);
|
|
103
|
+
const diskOnlyIds = Object.keys(diskOnly);
|
|
104
|
+
if (diskOnlyIds.length === 0) {
|
|
105
|
+
return { mergedCount: 0, hadRegistryUpload: Boolean(registryUpload) };
|
|
106
|
+
}
|
|
107
|
+
if (registryUpload) {
|
|
108
|
+
const raw = registryUpload.raw_content;
|
|
109
|
+
if (!raw.extensions || typeof raw.extensions !== 'object')
|
|
110
|
+
raw.extensions = {};
|
|
111
|
+
Object.assign(raw.extensions, diskOnly);
|
|
112
|
+
return { mergedCount: diskOnlyIds.length, hadRegistryUpload: true };
|
|
113
|
+
}
|
|
114
|
+
const registryPath = extensionsInstallationsPath(home);
|
|
115
|
+
if (!registryPath)
|
|
116
|
+
return { mergedCount: 0, hadRegistryUpload: false };
|
|
117
|
+
configFiles.push({
|
|
118
|
+
file_type: 'claude_extensions_installations',
|
|
119
|
+
file_path: registryPath,
|
|
120
|
+
raw_content: { extensions: diskOnly },
|
|
121
|
+
});
|
|
122
|
+
return { mergedCount: diskOnlyIds.length, hadRegistryUpload: false };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fallback when extensions-installations.json is not part of the upload batch: one synthetic
|
|
126
|
+
* claude_extensions_installations payload per disk-only manifest path.
|
|
127
|
+
*/
|
|
128
|
+
function collectClaudeDesktopExtensionManifests(home = homedir()) {
|
|
129
|
+
const extensionsDir = claudeDesktopExtensionsDir(home);
|
|
130
|
+
if (!extensionsDir || !existsSync(extensionsDir))
|
|
131
|
+
return [];
|
|
132
|
+
const inRegistry = registryExtensionIds(home);
|
|
133
|
+
const results = [];
|
|
134
|
+
const diskOnly = collectDiskOnlyClaudeDesktopExtensions(home, inRegistry);
|
|
135
|
+
for (const [extId, entry] of Object.entries(diskOnly)) {
|
|
136
|
+
const manifestPath = join(extensionsDir, extId, 'manifest.json');
|
|
137
|
+
results.push({
|
|
138
|
+
file_type: 'claude_extensions_installations',
|
|
139
|
+
file_path: manifestPath,
|
|
140
|
+
raw_content: { extensions: { [extId]: entry } },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
export { claudeAppSupportDir, claudeDesktopExtensionsDir, collectClaudeDesktopExtensionManifests, collectDiskOnlyClaudeDesktopExtensions, enrichClaudeDesktopExtensionsInstallationsUpload, };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readJSONFile } from '../readers/file_readers.js';
|
|
5
|
+
/**
|
|
6
|
+
* Collect project-level `.mcp.json` for each path listed under `projects` in ~/.claude.json.
|
|
7
|
+
* Claude Code records known project directories there; MCP config lives at `<project>/.mcp.json`.
|
|
8
|
+
*/
|
|
9
|
+
function collectMcpFromClaudeJsonProjects(home = homedir()) {
|
|
10
|
+
const claudeJsonPath = join(home, '.claude.json');
|
|
11
|
+
if (!existsSync(claudeJsonPath))
|
|
12
|
+
return [];
|
|
13
|
+
const raw = readJSONFile(claudeJsonPath);
|
|
14
|
+
if (!raw || typeof raw !== 'object')
|
|
15
|
+
return [];
|
|
16
|
+
const projects = raw.projects;
|
|
17
|
+
if (!projects || typeof projects !== 'object')
|
|
18
|
+
return [];
|
|
19
|
+
const seenPaths = new Set();
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const projectPath of Object.keys(projects)) {
|
|
22
|
+
if (!projectPath || typeof projectPath !== 'string')
|
|
23
|
+
continue;
|
|
24
|
+
const trimmed = projectPath.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
continue;
|
|
27
|
+
const mcpPath = join(trimmed, '.mcp.json');
|
|
28
|
+
if (seenPaths.has(mcpPath) || !existsSync(mcpPath))
|
|
29
|
+
continue;
|
|
30
|
+
const content = readJSONFile(mcpPath);
|
|
31
|
+
if (content === null)
|
|
32
|
+
continue;
|
|
33
|
+
seenPaths.add(mcpPath);
|
|
34
|
+
results.push({
|
|
35
|
+
file_type: 'claude_plugin_mcp',
|
|
36
|
+
file_path: mcpPath,
|
|
37
|
+
raw_content: typeof content === 'object' ? content : { content, source: 'file' },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
export { collectMcpFromClaudeJsonProjects };
|
|
@@ -153,7 +153,10 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
|
|
|
153
153
|
// ─── Re-exports (barrel for public API) ──────────────────────────────────────
|
|
154
154
|
export { collectConfigFilesFromPatterns };
|
|
155
155
|
export { collectMcpToolFiles } from './mcp_tool_collector.js';
|
|
156
|
-
export { collectConfigFilesFromInstalledPlugins } from './plugin_collector.js';
|
|
156
|
+
export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
|
|
157
|
+
export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
|
|
158
|
+
export { collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, } from './claude_desktop_extensions_collector.js';
|
|
159
|
+
export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
|
|
157
160
|
export { determineFileTypeFromPath } from './file_type_rules.js';
|
|
158
161
|
export { enrichRawFromRecipe } from './enrichment_helpers.js';
|
|
159
162
|
export { pushDerivedFilesFromRecipe } from './openclaw_helpers.js';
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readJSONFile } from '../readers/file_readers.js';
|
|
5
|
+
import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
6
|
+
import { getCursorProjectsPath } from '../paths/path_constants_helpers.js';
|
|
7
|
+
import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
|
|
8
|
+
import { isStaleCursorProjectWorkspace } from './cursor_project_workspace_activity.js';
|
|
9
|
+
/** Synthetic manifest path segment — not repo `.cursor/mcp.json`. */
|
|
10
|
+
export const CURSOR_WORKSPACE_MCP_MANIFEST = 'cursor-workspace-mcp-servers.json';
|
|
11
|
+
function fileUriToPath(uri) {
|
|
12
|
+
const trimmed = uri.trim();
|
|
13
|
+
if (!trimmed.startsWith('file://'))
|
|
14
|
+
return null;
|
|
15
|
+
let p = trimmed.slice('file://'.length);
|
|
16
|
+
if (!p.startsWith('/'))
|
|
17
|
+
p = `/${p}`;
|
|
18
|
+
try {
|
|
19
|
+
return decodeURIComponent(p);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function normalizeSlugPart(s) {
|
|
26
|
+
return s.replace(/\\/g, '/').replace(/_/g, '-').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
/** Best-effort: map a repo absolute path to Cursor's ~/.cursor/projects/<slug> dir name. */
|
|
29
|
+
function cursorProjectSlugFromRepoPath(repoPath, home) {
|
|
30
|
+
const normHome = home.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
31
|
+
const normPath = repoPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
32
|
+
if (!normPath.startsWith(`${normHome}/`))
|
|
33
|
+
return '';
|
|
34
|
+
const homeSegs = normHome.split('/').filter(Boolean);
|
|
35
|
+
const tail = normPath.slice(normHome.length + 1);
|
|
36
|
+
const prefix = homeSegs.join('-');
|
|
37
|
+
const tailSlug = tail.split('/').join('-').replace(/_/g, '-');
|
|
38
|
+
return tailSlug ? `${prefix}-${tailSlug}` : prefix;
|
|
39
|
+
}
|
|
40
|
+
function loadWorkspaceMetadataEntries(spec, home) {
|
|
41
|
+
if (!spec?.global_vscdb_path_segments?.length || !spec.global_workspace_list_key)
|
|
42
|
+
return [];
|
|
43
|
+
const globalDbPath = join(home, ...spec.global_vscdb_path_segments);
|
|
44
|
+
if (!existsSync(globalDbPath))
|
|
45
|
+
return [];
|
|
46
|
+
const globalResult = readVscdbItemTableJson(globalDbPath, spec.global_workspace_list_key);
|
|
47
|
+
if (!globalResult)
|
|
48
|
+
return [];
|
|
49
|
+
const rawValue = globalResult[spec.global_workspace_list_key];
|
|
50
|
+
const entries = Array.isArray(rawValue)
|
|
51
|
+
? rawValue
|
|
52
|
+
: Array.isArray(rawValue?.entries)
|
|
53
|
+
? rawValue.entries
|
|
54
|
+
: [];
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!entry || typeof entry !== 'object')
|
|
58
|
+
continue;
|
|
59
|
+
const e = entry;
|
|
60
|
+
const row = {};
|
|
61
|
+
if (typeof e.displayPath === 'string')
|
|
62
|
+
row.displayPath = e.displayPath;
|
|
63
|
+
if (typeof e.folderUri === 'string')
|
|
64
|
+
row.folderUri = e.folderUri;
|
|
65
|
+
if (typeof e.workspaceId === 'string')
|
|
66
|
+
row.workspaceId = e.workspaceId;
|
|
67
|
+
out.push(row);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function workspaceMetaForSlug(slug, metadataEntries, home) {
|
|
72
|
+
const slugNorm = normalizeSlugPart(slug);
|
|
73
|
+
for (const entry of metadataEntries) {
|
|
74
|
+
if (entry.folderUri) {
|
|
75
|
+
const repoPath = fileUriToPath(entry.folderUri);
|
|
76
|
+
if (repoPath && normalizeSlugPart(cursorProjectSlugFromRepoPath(repoPath, home)) === slugNorm) {
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (entry.displayPath) {
|
|
81
|
+
const expanded = entry.displayPath.startsWith('~/')
|
|
82
|
+
? join(home, entry.displayPath.slice(2))
|
|
83
|
+
: entry.displayPath;
|
|
84
|
+
if (expanded && normalizeSlugPart(cursorProjectSlugFromRepoPath(expanded, home)) === slugNorm) {
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function mcpServerLabelFromMcpsDir(serverDirName, meta) {
|
|
92
|
+
const fromMeta = (typeof meta?.serverName === 'string' && meta.serverName) ||
|
|
93
|
+
(typeof meta?.serverIdentifier === 'string' && meta.serverIdentifier) ||
|
|
94
|
+
serverDirName;
|
|
95
|
+
if (fromMeta.startsWith('user-'))
|
|
96
|
+
return fromMeta.slice('user-'.length);
|
|
97
|
+
return fromMeta;
|
|
98
|
+
}
|
|
99
|
+
function isEphemeralCursorProjectDir(name) {
|
|
100
|
+
if (/^\d+$/.test(name))
|
|
101
|
+
return true;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
/** Cursor uses this slug when no repo folder is open; mcps/ there are app-built-in only. */
|
|
105
|
+
function isInternalCursorWorkspaceSlug(slug) {
|
|
106
|
+
return normalizeSlugPart(slug) === 'empty-window';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Cursor stores per-workspace MCP state under ~/.cursor/projects/<slug>/ (mcp-cache.json, mcps/*).
|
|
110
|
+
* This is not the repo file at <repo>/.cursor/mcp.json. Upload one mcp_config per slug with
|
|
111
|
+
* normalized mcpServers for inventory plus cursor_workspace metadata for scope display.
|
|
112
|
+
*/
|
|
113
|
+
function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constants, workspaceVscdbSpec, home = homedir()) {
|
|
114
|
+
const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
|
|
115
|
+
const results = [];
|
|
116
|
+
const cursorProjectsPath = getCursorProjectsPath(home, constants);
|
|
117
|
+
const metadataEntries = loadWorkspaceMetadataEntries(workspaceVscdbSpec, home);
|
|
118
|
+
if (!existsSync(cursorProjectsPath))
|
|
119
|
+
return results;
|
|
120
|
+
let projectDirs;
|
|
121
|
+
try {
|
|
122
|
+
projectDirs = readdirSync(cursorProjectsPath, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
for (const projectDir of projectDirs) {
|
|
128
|
+
if (skipPrefixes.some((p) => projectDir.name.startsWith(p)))
|
|
129
|
+
continue;
|
|
130
|
+
if (isEphemeralCursorProjectDir(projectDir.name))
|
|
131
|
+
continue;
|
|
132
|
+
if (isInternalCursorWorkspaceSlug(projectDir.name))
|
|
133
|
+
continue;
|
|
134
|
+
const projectPath = join(cursorProjectsPath, projectDir.name);
|
|
135
|
+
if (isStaleCursorProjectWorkspace(projectPath))
|
|
136
|
+
continue;
|
|
137
|
+
const mcpServers = {};
|
|
138
|
+
const cachePath = join(projectPath, 'mcp-cache.json');
|
|
139
|
+
if (existsSync(cachePath)) {
|
|
140
|
+
const cache = readJSONFile(cachePath);
|
|
141
|
+
if (cache && typeof cache === 'object' && !Array.isArray(cache)) {
|
|
142
|
+
for (const [name, val] of Object.entries(cache)) {
|
|
143
|
+
if (!name.trim())
|
|
144
|
+
continue;
|
|
145
|
+
if (val && typeof val === 'object' && Array.isArray(val.tools)) {
|
|
146
|
+
mcpServers[name] = { cursor_source: 'mcp-cache.json' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const mcpsPath = join(projectPath, 'mcps');
|
|
152
|
+
if (existsSync(mcpsPath)) {
|
|
153
|
+
try {
|
|
154
|
+
for (const serverDir of readdirSync(mcpsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
|
|
155
|
+
const metaPath = join(mcpsPath, serverDir.name, 'SERVER_METADATA.json');
|
|
156
|
+
const meta = existsSync(metaPath) ? readJSONFile(metaPath) : null;
|
|
157
|
+
const metaObj = meta && typeof meta === 'object' ? meta : null;
|
|
158
|
+
const label = mcpServerLabelFromMcpsDir(serverDir.name, metaObj);
|
|
159
|
+
if (!label)
|
|
160
|
+
continue;
|
|
161
|
+
if (!(label in mcpServers)) {
|
|
162
|
+
mcpServers[label] = {
|
|
163
|
+
cursor_source: 'mcps',
|
|
164
|
+
server_identifier: serverDir.name,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(mcpServers).length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
const linked = workspaceMetaForSlug(projectDir.name, metadataEntries, home);
|
|
176
|
+
const linkedRepoPath = linked?.folderUri ? fileUriToPath(linked.folderUri) : null;
|
|
177
|
+
const manifestPath = existsSync(cachePath)
|
|
178
|
+
? cachePath
|
|
179
|
+
: join(projectPath, CURSOR_WORKSPACE_MCP_MANIFEST);
|
|
180
|
+
results.push({
|
|
181
|
+
file_type: 'mcp_config',
|
|
182
|
+
file_path: manifestPath,
|
|
183
|
+
raw_content: {
|
|
184
|
+
mcpServers,
|
|
185
|
+
cursor_workspace: {
|
|
186
|
+
slug: projectDir.name,
|
|
187
|
+
collection_root: projectPath,
|
|
188
|
+
display_path: linked?.displayPath,
|
|
189
|
+
folder_uri: linked?.folderUri,
|
|
190
|
+
linked_repo_path: linkedRepoPath ?? undefined,
|
|
191
|
+
note: 'Cursor ~/.cursor/projects workspace MCP; not repo .cursor/mcp.json',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
export { collectCursorProjectWorkspaceMcpConfigs, cursorProjectSlugFromRepoPath, fileUriToPath, isInternalCursorWorkspaceSlug, workspaceMetaForSlug, };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Align with backend `AGENT_ACTIVITY_RECENCY_DAYS` — skip workspace MCP when idle longer. */
|
|
4
|
+
export const CURSOR_WORKSPACE_MCP_RECENCY_DAYS = 7;
|
|
5
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
6
|
+
function maxMtimeInTree(root, maxDepth) {
|
|
7
|
+
if (!existsSync(root))
|
|
8
|
+
return null;
|
|
9
|
+
let best = null;
|
|
10
|
+
const visit = (dir, depth) => {
|
|
11
|
+
if (depth > maxDepth)
|
|
12
|
+
return;
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const fullPath = join(dir, entry.name);
|
|
22
|
+
try {
|
|
23
|
+
const st = statSync(fullPath);
|
|
24
|
+
const ms = st.mtimeMs;
|
|
25
|
+
if (best === null || ms > best)
|
|
26
|
+
best = ms;
|
|
27
|
+
if (entry.isDirectory())
|
|
28
|
+
visit(fullPath, depth + 1);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
visit(root, 0);
|
|
36
|
+
return best;
|
|
37
|
+
}
|
|
38
|
+
function pushMtime(candidates, path) {
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
candidates.push(statSync(path).mtimeMs);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Latest filesystem activity under a Cursor ~/.cursor/projects/<slug> directory.
|
|
50
|
+
* Uses mcp-cache, mcps/, agent-tools/, terminals/, and the project root — not hook ingest time.
|
|
51
|
+
*/
|
|
52
|
+
function cursorProjectWorkspaceLatestActivityMs(projectPath) {
|
|
53
|
+
const candidates = [];
|
|
54
|
+
pushMtime(candidates, projectPath);
|
|
55
|
+
pushMtime(candidates, join(projectPath, 'mcp-cache.json'));
|
|
56
|
+
pushMtime(candidates, join(projectPath, 'terminals'));
|
|
57
|
+
for (const subdir of ['mcps', 'agent-tools']) {
|
|
58
|
+
const nested = maxMtimeInTree(join(projectPath, subdir), 5);
|
|
59
|
+
if (nested !== null)
|
|
60
|
+
candidates.push(nested);
|
|
61
|
+
}
|
|
62
|
+
if (candidates.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
return Math.max(...candidates);
|
|
65
|
+
}
|
|
66
|
+
function isStaleCursorProjectWorkspace(projectPath, recencyDays = CURSOR_WORKSPACE_MCP_RECENCY_DAYS, nowMs = Date.now()) {
|
|
67
|
+
const latest = cursorProjectWorkspaceLatestActivityMs(projectPath);
|
|
68
|
+
if (latest === null)
|
|
69
|
+
return true;
|
|
70
|
+
const cutoff = nowMs - recencyDays * MS_PER_DAY;
|
|
71
|
+
return latest < cutoff;
|
|
72
|
+
}
|
|
73
|
+
export { cursorProjectWorkspaceLatestActivityMs, isStaleCursorProjectWorkspace, maxMtimeInTree, };
|
|
@@ -4,6 +4,8 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { readJSONFile } from '../readers/file_readers.js';
|
|
5
5
|
import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
6
6
|
import { getCursorProjectsPath } from '../paths/path_constants_helpers.js';
|
|
7
|
+
import { isInternalCursorWorkspaceSlug } from './cursor_project_mcp_collector.js';
|
|
8
|
+
import { isStaleCursorProjectWorkspace } from './cursor_project_workspace_activity.js';
|
|
7
9
|
import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
|
|
8
10
|
/**
|
|
9
11
|
* Read workspaceMetadata.entries from the Cursor global state.vscdb and emit one
|
|
@@ -76,7 +78,12 @@ function collectMcpToolFiles(pathSkipPrefixes = [], constants) {
|
|
|
76
78
|
for (const projectDir of readdirSync(cursorProjectsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
|
|
77
79
|
if (skipPrefixes.some((p) => projectDir.name.startsWith(p)))
|
|
78
80
|
continue;
|
|
79
|
-
|
|
81
|
+
if (isInternalCursorWorkspaceSlug(projectDir.name))
|
|
82
|
+
continue;
|
|
83
|
+
const projectPath = join(cursorProjectsPath, projectDir.name);
|
|
84
|
+
if (isStaleCursorProjectWorkspace(projectPath))
|
|
85
|
+
continue;
|
|
86
|
+
const mcpsPath = join(projectPath, 'mcps');
|
|
80
87
|
if (!existsSync(mcpsPath))
|
|
81
88
|
continue;
|
|
82
89
|
for (const serverDir of readdirSync(mcpsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
|
|
@@ -60,6 +60,47 @@ function collectPluginInstallEntries(pluginKey, entries, results, constants) {
|
|
|
60
60
|
collectMcpFile(installPath, results, constants);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
const PLUGIN_CACHE_MCP_FILENAME = '.mcp.json';
|
|
64
|
+
function walkPluginCacheForMcp(dir, results, seenMcpPaths, constants, depth) {
|
|
65
|
+
if (depth > 12)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
for (const ent of readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() || d.isFile())) {
|
|
69
|
+
const full = join(dir, ent.name);
|
|
70
|
+
if (ent.isDirectory()) {
|
|
71
|
+
walkPluginCacheForMcp(full, results, seenMcpPaths, constants, depth + 1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!ent.isFile() || ent.name !== PLUGIN_CACHE_MCP_FILENAME)
|
|
75
|
+
continue;
|
|
76
|
+
if (seenMcpPaths.has(full))
|
|
77
|
+
continue;
|
|
78
|
+
const content = readJSONFile(full);
|
|
79
|
+
if (content === null)
|
|
80
|
+
continue;
|
|
81
|
+
seenMcpPaths.add(full);
|
|
82
|
+
const ver = versionFromPluginCachePath(full, constants);
|
|
83
|
+
results.push({
|
|
84
|
+
file_type: 'claude_plugin_mcp',
|
|
85
|
+
file_path: full,
|
|
86
|
+
raw_content: typeof content === 'object' ? { ...content, ...(ver ? { version: ver } : {}) } : content,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** All plugin MCP manifests under ~/.claude/plugins/cache, whether or not the plugin is enabled. */
|
|
95
|
+
function collectPluginCacheMcpFiles(constants, home = homedir()) {
|
|
96
|
+
const cacheRoot = join(home, '.claude', 'plugins', 'cache');
|
|
97
|
+
if (!existsSync(cacheRoot))
|
|
98
|
+
return [];
|
|
99
|
+
const results = [];
|
|
100
|
+
const seenMcpPaths = new Set();
|
|
101
|
+
walkPluginCacheForMcp(cacheRoot, results, seenMcpPaths, constants, 0);
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
63
104
|
function collectConfigFilesFromInstalledPlugins(constants) {
|
|
64
105
|
const home = homedir();
|
|
65
106
|
const installedPluginsPath = getInstalledPluginsPath(home, constants);
|
|
@@ -86,4 +127,4 @@ function collectConfigFilesFromInstalledPlugins(constants) {
|
|
|
86
127
|
}
|
|
87
128
|
return results;
|
|
88
129
|
}
|
|
89
|
-
export { collectConfigFilesFromInstalledPlugins };
|
|
130
|
+
export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles };
|
|
@@ -165,6 +165,21 @@ function deepEqual(a, b) {
|
|
|
165
165
|
function isStringArray(v) {
|
|
166
166
|
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
167
167
|
}
|
|
168
|
+
function escapeRegExp(s) {
|
|
169
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
170
|
+
}
|
|
171
|
+
/** Match Cursor yolo allowlist entries (same boundary idea as server filter_yolo_allowlist_entries). */
|
|
172
|
+
function allowlistEntryMatchesRemoveToken(entry, token) {
|
|
173
|
+
const valStr = String(entry).toLowerCase();
|
|
174
|
+
const t = token.toLowerCase().trim();
|
|
175
|
+
if (!t)
|
|
176
|
+
return false;
|
|
177
|
+
const first = valStr.trim().split(/\s+/)[0] ?? '';
|
|
178
|
+
if (first === t || valStr === t)
|
|
179
|
+
return true;
|
|
180
|
+
const pattern = new RegExp(`(^|[^a-z0-9_|])${escapeRegExp(t)}([^a-z0-9_|]|$)`);
|
|
181
|
+
return pattern.test(valStr);
|
|
182
|
+
}
|
|
168
183
|
function verifyOpsApplied(configJson, settingPath, ops) {
|
|
169
184
|
const parts = settingPath.split('.');
|
|
170
185
|
const leafKey = parts[parts.length - 1] ?? '';
|
|
@@ -186,7 +201,14 @@ function verifyOpsApplied(configJson, settingPath, ops) {
|
|
|
186
201
|
const curArr = Array.isArray(cur) ? cur : [];
|
|
187
202
|
const toAdd = add[k] ?? [];
|
|
188
203
|
const toRemove = remove[k] ?? [];
|
|
204
|
+
const isYoloAllowlist = leafKey === 'yoloCommandAllowlist' || targetPath.endsWith('.yoloCommandAllowlist');
|
|
189
205
|
for (const item of toRemove) {
|
|
206
|
+
if (isYoloAllowlist) {
|
|
207
|
+
if (curArr.some((entry) => allowlistEntryMatchesRemoveToken(entry, String(item)))) {
|
|
208
|
+
return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
190
212
|
if (curArr.some((curItem) => deepEqual(curItem, item))) {
|
|
191
213
|
return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
|
|
192
214
|
}
|
|
@@ -12,8 +12,9 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
|
|
|
12
12
|
import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
|
|
13
13
|
import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
|
|
14
14
|
import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
|
|
15
|
-
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, determineFileTypeFromPath } from '../collection/config_collector.js';
|
|
15
|
+
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
|
|
16
16
|
import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
|
|
17
|
+
import { collectCursorProjectWorkspaceMcpConfigs } from '../collection/cursor_project_mcp_collector.js';
|
|
17
18
|
import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
18
19
|
import { sendConfigFile, sendConfigFilesBatch, sendHookRequestCreate, sendHookRequestUpdateManifest, sendIngestSessionStart, sendIngestSessionFinish, BATCH_CHUNK_SIZE } from '../sender/batch_sender.js';
|
|
19
20
|
import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js';
|
|
@@ -55,6 +56,14 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
55
56
|
configFiles.push(m);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
hookRunLog(`scanning Cursor project workspace MCP configs (~/.cursor/projects/*/mcp-cache.json)`);
|
|
60
|
+
for (const m of collectCursorProjectWorkspaceMcpConfigs(mcpToolSkipPrefixes, patternsResponse.client_path_constants, patternsResponse.workspace_vscdb_spec, HOME_DIR)) {
|
|
61
|
+
const key = `${m.file_type}\t${m.file_path}`;
|
|
62
|
+
if (!existingPaths.has(key)) {
|
|
63
|
+
existingPaths.add(key);
|
|
64
|
+
configFiles.push(m);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
58
67
|
if (patternsResponse.workspace_vscdb_spec) {
|
|
59
68
|
hookRunLog(`scanning Cursor workspace vscdb files`);
|
|
60
69
|
for (const m of collectWorkspaceVscdbs(patternsResponse.workspace_vscdb_spec)) {
|
|
@@ -69,6 +78,35 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
69
78
|
if (!patternsResponse.client_path_constants)
|
|
70
79
|
throw new Error('client_path_constants required from API response');
|
|
71
80
|
configFiles.push(...collectConfigFilesFromInstalledPlugins(patternsResponse.client_path_constants));
|
|
81
|
+
hookRunLog(`scanning Claude plugin cache MCP manifests`);
|
|
82
|
+
for (const entry of collectPluginCacheMcpFiles(patternsResponse.client_path_constants, HOME_DIR)) {
|
|
83
|
+
const key = `${entry.file_type}\t${entry.file_path}`;
|
|
84
|
+
if (!existingPaths.has(key)) {
|
|
85
|
+
existingPaths.add(key);
|
|
86
|
+
configFiles.push(entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
hookRunLog(`scanning Claude known projects from ~/.claude.json`);
|
|
90
|
+
for (const entry of collectMcpFromClaudeJsonProjects(HOME_DIR)) {
|
|
91
|
+
const key = `${entry.file_type}\t${entry.file_path}`;
|
|
92
|
+
if (!existingPaths.has(key)) {
|
|
93
|
+
existingPaths.add(key);
|
|
94
|
+
configFiles.push(entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
|
|
98
|
+
const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
|
|
99
|
+
hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
|
|
100
|
+
if (!diskExtMerge.hadRegistryUpload && diskExtMerge.mergedCount > 0) {
|
|
101
|
+
hookRunLog(`scanning Claude Desktop extension manifests on disk (no registry upload in batch)`);
|
|
102
|
+
for (const entry of collectClaudeDesktopExtensionManifests(HOME_DIR)) {
|
|
103
|
+
const key = `${entry.file_type}\t${entry.file_path}`;
|
|
104
|
+
if (!existingPaths.has(key)) {
|
|
105
|
+
existingPaths.add(key);
|
|
106
|
+
configFiles.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
72
110
|
const worktreeReport = scanActiveWorktrees(PROJECT_ROOT, HOME_DIR);
|
|
73
111
|
const activeRoots = activeWorktreeRootSet(worktreeReport);
|
|
74
112
|
const beforeFilter = configFiles.length;
|
|
@@ -108,7 +146,7 @@ async function sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, aut
|
|
|
108
146
|
const ok = await sendHookRequestUpdateManifest(hardwareUuid, authKey, hookRequestId, manifest);
|
|
109
147
|
hookRunLog(`hook-request update manifest result=${ok ? 'ok' : 'fail'}`);
|
|
110
148
|
}
|
|
111
|
-
// Finish ingest session:
|
|
149
|
+
// Finish ingest session: failed_uploads hold set, worktree prune, config prune-on-absence, and scans.
|
|
112
150
|
// Run finish whenever a session was started — even if every upload failed — so worktree
|
|
113
151
|
// cleanup still runs for machines with only stale worktrees on disk.
|
|
114
152
|
if (ingestSessionId) {
|
|
@@ -428,6 +428,23 @@ function applyCheck(configJson, check) {
|
|
|
428
428
|
}
|
|
429
429
|
setByPath(configJson, check.setting_path, value);
|
|
430
430
|
}
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// SQLite remediation support
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
function escapeRegExpForAllowlist(s) {
|
|
435
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
436
|
+
}
|
|
437
|
+
function allowlistEntryMatchesRemoveToken(entry, token) {
|
|
438
|
+
const valStr = String(entry).toLowerCase();
|
|
439
|
+
const t = token.toLowerCase().trim();
|
|
440
|
+
if (!t)
|
|
441
|
+
return false;
|
|
442
|
+
const first = valStr.trim().split(/\s+/)[0] ?? '';
|
|
443
|
+
if (first === t || valStr === t)
|
|
444
|
+
return true;
|
|
445
|
+
const pattern = new RegExp(`(^|[^a-z0-9_|])${escapeRegExpForAllowlist(t)}([^a-z0-9_|]|$)`);
|
|
446
|
+
return pattern.test(valStr);
|
|
447
|
+
}
|
|
431
448
|
function mergePostApplyUploadIntoPayload(payload, hint) {
|
|
432
449
|
if (!hint)
|
|
433
450
|
return;
|
|
@@ -674,6 +691,30 @@ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
|
|
|
674
691
|
container[arrayKey] = existing.filter((x) => !removeSet.has(String(x)));
|
|
675
692
|
return;
|
|
676
693
|
}
|
|
694
|
+
if (sqliteOp.remove_commands?.length &&
|
|
695
|
+
(sqliteOp.json_path ?? '').includes('yoloCommandAllowlist') &&
|
|
696
|
+
!sqliteOp.array_remove?.length) {
|
|
697
|
+
const jp = (sqliteOp.json_path ?? '').trim();
|
|
698
|
+
const parts = jp.split('.').filter(Boolean);
|
|
699
|
+
if (parts.length === 0)
|
|
700
|
+
return;
|
|
701
|
+
let node = currentJson;
|
|
702
|
+
for (const p of parts.slice(0, -1)) {
|
|
703
|
+
if (node == null || typeof node !== 'object' || Array.isArray(node))
|
|
704
|
+
return;
|
|
705
|
+
node = node[p];
|
|
706
|
+
}
|
|
707
|
+
if (node == null || typeof node !== 'object' || Array.isArray(node))
|
|
708
|
+
return;
|
|
709
|
+
const container = node;
|
|
710
|
+
const arrayKey = parts[parts.length - 1];
|
|
711
|
+
const existing = container[arrayKey];
|
|
712
|
+
if (!Array.isArray(existing))
|
|
713
|
+
return;
|
|
714
|
+
const tokens = sqliteOp.remove_commands.map((t) => String(t).trim()).filter(Boolean);
|
|
715
|
+
container[arrayKey] = existing.filter((entry) => !tokens.some((t) => allowlistEntryMatchesRemoveToken(entry, t)));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
677
718
|
const where = sqliteOp.array_item_where;
|
|
678
719
|
const jp = sqliteOp.json_path ?? '';
|
|
679
720
|
if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
|