cloudflare-mcp-smart-proxy 1.5.9 → 1.5.16
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/connector-cli.js +6 -0
- package/index.js +7 -0
- package/package.json +1 -1
- package/src/codex-app-server.js +127 -0
- package/src/connector-bridge.js +31 -0
- package/src/local-memory-coordinator.js +49 -8
- package/src/local-tools.js +13 -1
- package/src/memory-schema.js +6 -1
- package/src/project-probe-discovery.js +3 -1
- package/src/reference-connectors.js +36 -7
- package/src/router.js +13 -27
- package/src/skill-sync.js +187 -0
package/connector-cli.js
CHANGED
|
@@ -303,6 +303,12 @@ function printInstallResult(result, { includeContent = false, includeSecrets = f
|
|
|
303
303
|
if (Number.isInteger(result.activation.toolCount)) {
|
|
304
304
|
console.error(`tools: ${result.activation.toolCount}`);
|
|
305
305
|
}
|
|
306
|
+
if (result.activation.workspaceRoot) {
|
|
307
|
+
console.error(`workspace root: ${result.activation.workspaceRoot}`);
|
|
308
|
+
}
|
|
309
|
+
if (result.activation.workspaceId) {
|
|
310
|
+
console.error(`workspace: ${result.activation.workspaceId}`);
|
|
311
|
+
}
|
|
306
312
|
}
|
|
307
313
|
if (includeContent) {
|
|
308
314
|
const apiKey = result.serverDefinition?.env?.CLOUDFLARE_MCP_API_KEY || '';
|
package/index.js
CHANGED
|
@@ -49,6 +49,7 @@ const WORKSPACE_ID = FIXED_PROJECT_CONTEXT
|
|
|
49
49
|
: '';
|
|
50
50
|
const DEVICE_IDENTITY_PATH = process.env.CLOUDMCP_DEVICE_IDENTITY_PATH || '';
|
|
51
51
|
const AUTO_SYNC_PROFILE = (process.env.CLOUDMCP_AUTO_SYNC_PROFILE || 'true') !== 'false';
|
|
52
|
+
const AUTO_SYNC_SKILLS = (process.env.CLOUDMCP_AUTO_SYNC_SKILLS || 'true') !== 'false';
|
|
52
53
|
const AUTO_APPLY_BRAIN = (process.env.CLOUDMCP_AUTO_APPLY_BRAIN || 'true') !== 'false';
|
|
53
54
|
const AUTO_REPORT_PROJECT_PROBE = (process.env.CLOUDMCP_AUTO_REPORT_PROJECT_PROBE || 'true') === 'true';
|
|
54
55
|
const AUTO_GENERATE_CONTEXT_PACK = (process.env.CLOUDMCP_AUTO_GENERATE_CONTEXT_PACK || 'true') === 'true';
|
|
@@ -90,6 +91,7 @@ const router = new SmartRouter(
|
|
|
90
91
|
() => connectorBridge.getScopeHeaders()
|
|
91
92
|
);
|
|
92
93
|
localTools.setCloudToolCaller((tool, params) => router.callCloudTool(tool, params));
|
|
94
|
+
connectorBridge.setCloudToolCaller((tool, params, options) => router.callCloudTool(tool, params, options));
|
|
93
95
|
|
|
94
96
|
// 创建 MCP 服务器
|
|
95
97
|
const server = new Server(
|
|
@@ -287,6 +289,7 @@ async function main() {
|
|
|
287
289
|
bridgeStatus = await connectorBridge.initialize({
|
|
288
290
|
autoApplyBrain: AUTO_APPLY_BRAIN,
|
|
289
291
|
autoSyncProfile: AUTO_SYNC_PROFILE,
|
|
292
|
+
autoSyncSkills: AUTO_SYNC_SKILLS,
|
|
290
293
|
autoReportProjectProbe: AUTO_REPORT_PROJECT_PROBE,
|
|
291
294
|
autoGenerateContextPack: AUTO_GENERATE_CONTEXT_PACK
|
|
292
295
|
});
|
|
@@ -305,6 +308,10 @@ async function main() {
|
|
|
305
308
|
console.error(`Brain applied for IDE: ${applyResult.ide} (${applyResult.applied?.length || 0} files written)`);
|
|
306
309
|
}
|
|
307
310
|
console.error(`Connector bridge auto probe: ${AUTO_REPORT_PROJECT_PROBE ? 'enabled' : 'disabled'}`);
|
|
311
|
+
const skillSync = bridgeStatus.state?.lastSkillSync;
|
|
312
|
+
if (skillSync) {
|
|
313
|
+
console.error(`Global Skill sync: ${skillSync.success ? `${skillSync.synchronized?.length || 0} synchronized` : `failed (${skillSync.error})`}`);
|
|
314
|
+
}
|
|
308
315
|
} else {
|
|
309
316
|
console.error('Connector bridge profile: not configured');
|
|
310
317
|
}
|
package/package.json
CHANGED
package/src/codex-app-server.js
CHANGED
|
@@ -3,9 +3,117 @@ import fs from 'node:fs';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
8
|
|
|
7
9
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
10
|
|
|
11
|
+
function parseTomlValue(value) {
|
|
12
|
+
const normalized = String(value || '').trim();
|
|
13
|
+
if (!normalized) return '';
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(normalized);
|
|
16
|
+
} catch {
|
|
17
|
+
return normalized.replace(/^"|"$/g, '');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readCodexMcpServerConfig(configPath, serverName = 'cloudmcp') {
|
|
22
|
+
const content = readText(configPath);
|
|
23
|
+
const serverSection = `[mcp_servers.${serverName}]`;
|
|
24
|
+
const envSection = `[mcp_servers.${serverName}.env]`;
|
|
25
|
+
let section = '';
|
|
26
|
+
const server = { env: {} };
|
|
27
|
+
for (const rawLine of content.split('\n')) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
if (!line || line.startsWith('#')) continue;
|
|
30
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
31
|
+
section = line;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (section !== serverSection && section !== envSection) continue;
|
|
35
|
+
const separator = line.indexOf('=');
|
|
36
|
+
if (separator < 1) continue;
|
|
37
|
+
const key = line.slice(0, separator).trim();
|
|
38
|
+
const value = parseTomlValue(line.slice(separator + 1));
|
|
39
|
+
if (section === envSection) server.env[key] = String(value);
|
|
40
|
+
else server[key] = value;
|
|
41
|
+
}
|
|
42
|
+
if (!server.command) {
|
|
43
|
+
throw new Error(`Codex MCP server "${serverName}" is missing from ${configPath}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
command: String(server.command),
|
|
47
|
+
args: Array.isArray(server.args) ? server.args.map(String) : [],
|
|
48
|
+
env: server.env
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function probeCodexMcpServer({
|
|
53
|
+
configPath,
|
|
54
|
+
serverName = 'cloudmcp',
|
|
55
|
+
cwd = process.cwd(),
|
|
56
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
57
|
+
} = {}) {
|
|
58
|
+
const configured = readCodexMcpServerConfig(configPath, serverName);
|
|
59
|
+
const transport = new StdioClientTransport({
|
|
60
|
+
command: configured.command,
|
|
61
|
+
args: configured.args,
|
|
62
|
+
cwd,
|
|
63
|
+
env: { ...process.env, ...configured.env },
|
|
64
|
+
stderr: 'pipe'
|
|
65
|
+
});
|
|
66
|
+
const client = new Client({ name: 'cloudmcp-reload-verifier', version: '1.0.0' });
|
|
67
|
+
const withTimeout = async (operation) => {
|
|
68
|
+
let timer;
|
|
69
|
+
try {
|
|
70
|
+
return await Promise.race([
|
|
71
|
+
operation,
|
|
72
|
+
new Promise((_, reject) => {
|
|
73
|
+
timer = setTimeout(() => reject(new Error(`Timed out probing MCP server "${serverName}"`)), timeoutMs);
|
|
74
|
+
timer.unref?.();
|
|
75
|
+
})
|
|
76
|
+
]);
|
|
77
|
+
} finally {
|
|
78
|
+
if (timer) clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
await withTimeout(client.connect(transport));
|
|
83
|
+
const result = await withTimeout(client.listTools());
|
|
84
|
+
const tools = Array.isArray(result?.tools) ? result.tools : [];
|
|
85
|
+
if (tools.length === 0) {
|
|
86
|
+
throw new Error(`Codex MCP server "${serverName}" returned no tools during a fresh probe`);
|
|
87
|
+
}
|
|
88
|
+
const statusResult = await withTimeout(client.callTool({
|
|
89
|
+
name: 'connector_bridge_status',
|
|
90
|
+
arguments: {}
|
|
91
|
+
}));
|
|
92
|
+
const statusText = (Array.isArray(statusResult?.content) ? statusResult.content : [])
|
|
93
|
+
.find((entry) => entry?.type === 'text')?.text;
|
|
94
|
+
let status;
|
|
95
|
+
try {
|
|
96
|
+
status = JSON.parse(statusText || '{}');
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`Codex MCP server "${serverName}" returned an invalid connector workspace status`);
|
|
99
|
+
}
|
|
100
|
+
const expectedWorkspaceRoot = path.resolve(cwd);
|
|
101
|
+
const actualWorkspaceRoot = status?.workspaceRoot ? path.resolve(status.workspaceRoot) : '';
|
|
102
|
+
const workspaceId = String(status?.workspaceId || '').trim();
|
|
103
|
+
const workspaceResolution = String(status?.state?.workspaceResolution?.status || '').trim();
|
|
104
|
+
if (actualWorkspaceRoot !== expectedWorkspaceRoot || !workspaceId || workspaceResolution !== 'resolved') {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Codex MCP server "${serverName}" resolved the wrong project workspace: `
|
|
107
|
+
+ `expected_root=${expectedWorkspaceRoot} actual_root=${actualWorkspaceRoot || 'missing'} `
|
|
108
|
+
+ `workspace_id=${workspaceId || 'missing'} resolution=${workspaceResolution || 'missing'}`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { toolCount: tools.length, workspaceId, workspaceRoot: actualWorkspaceRoot, workspaceResolution };
|
|
112
|
+
} finally {
|
|
113
|
+
await client.close().catch(() => {});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
9
117
|
function readText(filePath) {
|
|
10
118
|
try {
|
|
11
119
|
return fs.readFileSync(filePath, 'utf8');
|
|
@@ -333,6 +441,8 @@ export async function reloadCodexMcpServer({
|
|
|
333
441
|
),
|
|
334
442
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
335
443
|
rpcClient = null,
|
|
444
|
+
configPath = process.env.CODEX_SHARED_CONFIG_PATH || '',
|
|
445
|
+
toolProbe = null,
|
|
336
446
|
runtimeValidator = validateCodexAppServerRuntime
|
|
337
447
|
} = {}) {
|
|
338
448
|
const resolvedSocketPath = resolveCodexAppServerSocket({
|
|
@@ -378,11 +488,28 @@ export async function reloadCodexMcpServer({
|
|
|
378
488
|
if (toolCount === 0) {
|
|
379
489
|
throw new Error(`Codex loaded MCP server "${serverName}" without any tools`);
|
|
380
490
|
}
|
|
491
|
+
const probeResult = toolProbe
|
|
492
|
+
? await toolProbe({ serverName, configPath, timeoutMs })
|
|
493
|
+
: (!rpcClient
|
|
494
|
+
? await probeCodexMcpServer({
|
|
495
|
+
configPath: configPath || path.join(path.resolve(codexHome || path.join(homeDir, '.codex')), 'config.toml'),
|
|
496
|
+
serverName,
|
|
497
|
+
timeoutMs
|
|
498
|
+
})
|
|
499
|
+
: { toolCount });
|
|
500
|
+
if (Number(probeResult?.toolCount) !== toolCount) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`Codex MCP server "${serverName}" reload is stale: app_server_tools=${toolCount} fresh_probe_tools=${Number(probeResult?.toolCount) || 0}`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
381
505
|
return {
|
|
382
506
|
status: 'ready',
|
|
383
507
|
serverName,
|
|
384
508
|
socketPath: resolvedSocketPath,
|
|
385
509
|
toolCount,
|
|
510
|
+
workspaceId: probeResult?.workspaceId || null,
|
|
511
|
+
workspaceRoot: probeResult?.workspaceRoot || null,
|
|
512
|
+
workspaceResolution: probeResult?.workspaceResolution || null,
|
|
386
513
|
authStatus: server.authStatus || server.auth_status || null
|
|
387
514
|
};
|
|
388
515
|
} finally {
|
package/src/connector-bridge.js
CHANGED
|
@@ -5,6 +5,7 @@ import { DeviceIdentity } from './device-identity.js';
|
|
|
5
5
|
import { discoverProjectProbe } from './project-probe-discovery.js';
|
|
6
6
|
import { detectIde, applyBrainSnapshot } from './ide-configurator.js';
|
|
7
7
|
import { discoverProjectIdentity } from './project-identity.js';
|
|
8
|
+
import { syncGlobalSkills } from './skill-sync.js';
|
|
8
9
|
|
|
9
10
|
function normalizeString(value, fallback = '') {
|
|
10
11
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
@@ -56,8 +57,14 @@ export class ConnectorBridge {
|
|
|
56
57
|
lastGeneratedContextPack: null,
|
|
57
58
|
lastSyncAt: null,
|
|
58
59
|
lastProjectProbeReportAt: null,
|
|
60
|
+
lastSkillSync: null,
|
|
59
61
|
lastError: null
|
|
60
62
|
};
|
|
63
|
+
this.cloudToolCaller = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setCloudToolCaller(caller) {
|
|
67
|
+
this.cloudToolCaller = typeof caller === 'function' ? caller : null;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
isConfigured() {
|
|
@@ -112,6 +119,7 @@ export class ConnectorBridge {
|
|
|
112
119
|
async initialize({
|
|
113
120
|
autoSyncProfile = true,
|
|
114
121
|
autoApplyBrain = true,
|
|
122
|
+
autoSyncSkills = true,
|
|
115
123
|
autoReportProjectProbe = false,
|
|
116
124
|
autoGenerateContextPack = false
|
|
117
125
|
} = {}) {
|
|
@@ -119,6 +127,17 @@ export class ConnectorBridge {
|
|
|
119
127
|
return this.getBridgeStatus();
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
if (autoSyncSkills) {
|
|
131
|
+
try {
|
|
132
|
+
await this.syncSkills();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
this.state.lastSkillSync = {
|
|
135
|
+
success: false,
|
|
136
|
+
error: error.message,
|
|
137
|
+
synchronized_at: Date.now()
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
122
141
|
const workspaceResolution = await this.resolveCurrentWorkspace();
|
|
123
142
|
if (!workspaceResolution.workspaceId) return this.getBridgeStatus();
|
|
124
143
|
try {
|
|
@@ -237,6 +256,18 @@ export class ConnectorBridge {
|
|
|
237
256
|
};
|
|
238
257
|
}
|
|
239
258
|
|
|
259
|
+
async syncSkills(options = {}) {
|
|
260
|
+
if (typeof this.cloudToolCaller !== 'function') {
|
|
261
|
+
throw new Error('Cloud Skill synchronization is not configured');
|
|
262
|
+
}
|
|
263
|
+
const result = await syncGlobalSkills({
|
|
264
|
+
callCloudTool: this.cloudToolCaller,
|
|
265
|
+
...options
|
|
266
|
+
});
|
|
267
|
+
this.state.lastSkillSync = result;
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
240
271
|
async reportStatus({
|
|
241
272
|
status = 'succeeded',
|
|
242
273
|
summary = {},
|
|
@@ -31,6 +31,38 @@ function fingerprint(parts) {
|
|
|
31
31
|
return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 24);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function targetMarkerPath(directory, targetId) {
|
|
35
|
+
return path.join(directory, '.cloudmcp-targets', `${targetId}.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readTargetMarker(contract) {
|
|
39
|
+
const markerPath = targetMarkerPath(contract.directory, contract.targetId);
|
|
40
|
+
if (fs.existsSync(markerPath)) return readJson(markerPath);
|
|
41
|
+
|
|
42
|
+
const legacyMarkerPath = path.join(contract.directory, 'contract.json');
|
|
43
|
+
if (!fs.existsSync(legacyMarkerPath)) return null;
|
|
44
|
+
const legacyMarker = readJson(legacyMarkerPath);
|
|
45
|
+
return legacyMarker.targetId === contract.targetId ? legacyMarker : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function conflictEntryPath(directory, entry) {
|
|
49
|
+
const hash = String(entry.canonicalHash || '').replace(/[^A-Za-z0-9_-]/g, '-');
|
|
50
|
+
return path.join(directory, '.cloudmcp-conflicts', `${entry.id}.${hash}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function preserveLocalConflict(directory, entry) {
|
|
54
|
+
const conflictPath = conflictEntryPath(directory, entry);
|
|
55
|
+
if (fs.existsSync(conflictPath)) {
|
|
56
|
+
const preserved = assertMemoryEntryIntegrity(readJson(conflictPath));
|
|
57
|
+
if (canonicalMemoryJson(preserved) !== canonicalMemoryJson(entry)) {
|
|
58
|
+
throw new Error(`Local memory conflict evidence differs for ${entry.id}`);
|
|
59
|
+
}
|
|
60
|
+
return conflictPath;
|
|
61
|
+
}
|
|
62
|
+
writeJsonAtomically(conflictPath, entry);
|
|
63
|
+
return conflictPath;
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
function resolveConfiguredDirectory(directory, workspaceRoot) {
|
|
35
67
|
const raw = typeof directory === 'string' ? directory.trim() : '';
|
|
36
68
|
if (!raw) throw new Error('directory is required');
|
|
@@ -132,10 +164,11 @@ export class LocalMemoryCoordinator {
|
|
|
132
164
|
cloudScopeIds,
|
|
133
165
|
status: 'preparing'
|
|
134
166
|
};
|
|
135
|
-
writeJsonAtomically(
|
|
167
|
+
writeJsonAtomically(targetMarkerPath(directory, targetId), preparingContract);
|
|
136
168
|
writeJsonAtomically(this.contractPath, { ...preparingContract, directory });
|
|
137
169
|
|
|
138
170
|
let hydratedEntries = 0;
|
|
171
|
+
let reconciledConflicts = 0;
|
|
139
172
|
for (const scopeId of cloudScopeIds) {
|
|
140
173
|
let offset = 0;
|
|
141
174
|
while (true) {
|
|
@@ -145,6 +178,10 @@ export class LocalMemoryCoordinator {
|
|
|
145
178
|
limit: 50,
|
|
146
179
|
offset
|
|
147
180
|
});
|
|
181
|
+
if (loaded?.pagination?.complete !== true) {
|
|
182
|
+
const reason = loaded?.pagination?.incomplete_reason || 'pagination.complete was not true';
|
|
183
|
+
throw new Error(`Cloud memory hydration is incomplete for ${scopeId}: ${reason}`);
|
|
184
|
+
}
|
|
148
185
|
const entries = Array.isArray(loaded?.entries) ? loaded.entries : [];
|
|
149
186
|
for (const entry of entries) {
|
|
150
187
|
assertMemoryEntryIntegrity(entry);
|
|
@@ -152,29 +189,33 @@ export class LocalMemoryCoordinator {
|
|
|
152
189
|
if (fs.existsSync(entryPath)) {
|
|
153
190
|
const current = assertMemoryEntryIntegrity(readJson(entryPath));
|
|
154
191
|
if (current.canonicalHash !== entry.canonicalHash) {
|
|
155
|
-
|
|
192
|
+
preserveLocalConflict(directory, current);
|
|
193
|
+
writeJsonAtomically(entryPath, entry);
|
|
194
|
+
reconciledConflicts += 1;
|
|
156
195
|
}
|
|
157
196
|
} else {
|
|
158
197
|
writeJsonAtomically(entryPath, entry);
|
|
159
198
|
}
|
|
160
199
|
hydratedEntries += 1;
|
|
161
200
|
}
|
|
162
|
-
if (!loaded
|
|
201
|
+
if (!loaded.pagination.hasMore) break;
|
|
202
|
+
if (entries.length === 0) {
|
|
203
|
+
throw new Error(`Cloud memory hydration returned an empty non-terminal page for ${scopeId}`);
|
|
204
|
+
}
|
|
163
205
|
offset += entries.length;
|
|
164
|
-
if (entries.length === 0) break;
|
|
165
206
|
}
|
|
166
207
|
}
|
|
167
208
|
const publicContract = { ...preparingContract, status: 'active' };
|
|
168
|
-
writeJsonAtomically(
|
|
209
|
+
writeJsonAtomically(targetMarkerPath(directory, targetId), publicContract);
|
|
169
210
|
writeJsonAtomically(this.contractPath, { ...publicContract, directory });
|
|
170
|
-
return { configured: true, verified: true, contract: publicContract, hydratedEntries };
|
|
211
|
+
return { configured: true, verified: true, contract: publicContract, hydratedEntries, reconciledConflicts };
|
|
171
212
|
}
|
|
172
213
|
|
|
173
214
|
verify(params = {}) {
|
|
174
215
|
const contract = this.getContract();
|
|
175
216
|
if (!contract) throw new Error('Local memory target is not configured');
|
|
176
|
-
const marker =
|
|
177
|
-
if (marker.targetId !== contract.targetId || marker.schemaVersion !== 1 || marker.status !== 'active') {
|
|
217
|
+
const marker = readTargetMarker(contract);
|
|
218
|
+
if (!marker || marker.targetId !== contract.targetId || marker.schemaVersion !== 1 || marker.status !== 'active') {
|
|
178
219
|
throw new Error('Local memory target contract verification failed');
|
|
179
220
|
}
|
|
180
221
|
if (params.memory_id) {
|
package/src/local-tools.js
CHANGED
|
@@ -70,6 +70,10 @@ export class LocalToolExecutor {
|
|
|
70
70
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
71
71
|
return await this.connectorBridge.syncProfile();
|
|
72
72
|
|
|
73
|
+
case 'connector_sync_skills':
|
|
74
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
75
|
+
return await this.connectorBridge.syncSkills(params || {});
|
|
76
|
+
|
|
73
77
|
case 'connector_report_status':
|
|
74
78
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
75
79
|
return await this.connectorBridge.reportStatus(params || {});
|
|
@@ -144,7 +148,7 @@ export class LocalToolExecutor {
|
|
|
144
148
|
},
|
|
145
149
|
{
|
|
146
150
|
name: 'capture_memory',
|
|
147
|
-
description: 'Validate one governed memory and commit the same canonical JSON entry to the configured local target and CloudMCP scope.',
|
|
151
|
+
description: 'Validate one governed memory, reject secret-shaped values from every semantic field including tags, and commit the same canonical JSON entry to the configured local target and CloudMCP scope.',
|
|
148
152
|
inputSchema: {
|
|
149
153
|
type: 'object',
|
|
150
154
|
properties: {
|
|
@@ -462,6 +466,14 @@ export class LocalToolExecutor {
|
|
|
462
466
|
properties: {}
|
|
463
467
|
}
|
|
464
468
|
},
|
|
469
|
+
{
|
|
470
|
+
name: 'connector_sync_skills',
|
|
471
|
+
description: 'Synchronize verified global CloudMCP Skills into the current user Codex Skill directory',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {}
|
|
475
|
+
}
|
|
476
|
+
},
|
|
465
477
|
{
|
|
466
478
|
name: 'connector_report_status',
|
|
467
479
|
description: 'Report local connector apply status back to CloudMCP',
|
package/src/memory-schema.js
CHANGED
|
@@ -40,7 +40,12 @@ function normalizeStringArray(value, limit = 32) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function assertNoSensitiveMemory(input = {}) {
|
|
43
|
-
const text = [
|
|
43
|
+
const text = [
|
|
44
|
+
input.title,
|
|
45
|
+
input.content,
|
|
46
|
+
...(input.tags || []),
|
|
47
|
+
...(input.evidenceRefs || input.evidence_refs || [])
|
|
48
|
+
]
|
|
44
49
|
.filter(Boolean).join('\n');
|
|
45
50
|
if (SENSITIVE_MEMORY_PATTERNS.some((pattern) => pattern.test(text))) {
|
|
46
51
|
throw new Error('Memory content appears to contain a secret or approval credential');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { discoverProjectIdentity } from './project-identity.js';
|
|
3
4
|
|
|
4
5
|
async function pathExists(target) {
|
|
5
6
|
try {
|
|
@@ -67,6 +68,7 @@ export async function discoverProjectProbe({
|
|
|
67
68
|
}) {
|
|
68
69
|
const repoRoot = path.resolve(workspaceRoot || process.cwd());
|
|
69
70
|
const repoName = path.basename(repoRoot);
|
|
71
|
+
const projectIdentity = await discoverProjectIdentity(repoRoot);
|
|
70
72
|
const topLevel = await safeReadDir(repoRoot);
|
|
71
73
|
const dirNames = topLevel.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
72
74
|
const fileNames = topLevel.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
@@ -120,7 +122,7 @@ export async function discoverProjectProbe({
|
|
|
120
122
|
workspaceId: workspaceId || null,
|
|
121
123
|
repoName,
|
|
122
124
|
repoRoot,
|
|
123
|
-
gitRemote:
|
|
125
|
+
gitRemote: projectIdentity.gitRemote,
|
|
124
126
|
defaultBranch: 'main',
|
|
125
127
|
languages,
|
|
126
128
|
frameworkHints,
|
|
@@ -44,19 +44,40 @@ function ensureDir(targetPath) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
47
|
+
function isPathWithin(rootPath, candidatePath) {
|
|
48
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
49
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveAtomicWriteTarget(filePath, { allowedRoot = null, followFinalSymlink = true } = {}) {
|
|
53
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
54
|
+
const resolvedAllowedRoot = allowedRoot ? fs.realpathSync(path.resolve(allowedRoot)) : null;
|
|
55
|
+
if (resolvedAllowedRoot) {
|
|
56
|
+
const physicalParent = fs.realpathSync(path.dirname(resolvedFilePath));
|
|
57
|
+
if (!isPathWithin(resolvedAllowedRoot, physicalParent)) {
|
|
58
|
+
throw new Error(`Connector configuration target escapes the selected workspace: ${filePath}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
try {
|
|
49
|
-
if (fs.lstatSync(
|
|
50
|
-
|
|
63
|
+
if (fs.lstatSync(resolvedFilePath).isSymbolicLink()) {
|
|
64
|
+
if (!followFinalSymlink) {
|
|
65
|
+
throw new Error(`Connector configuration target must not be a symbolic link: ${filePath}`);
|
|
66
|
+
}
|
|
67
|
+
const physicalTarget = fs.realpathSync(resolvedFilePath);
|
|
68
|
+
if (resolvedAllowedRoot && !isPathWithin(resolvedAllowedRoot, physicalTarget)) {
|
|
69
|
+
throw new Error(`Connector configuration target escapes the selected workspace: ${filePath}`);
|
|
70
|
+
}
|
|
71
|
+
return physicalTarget;
|
|
51
72
|
}
|
|
52
73
|
} catch (error) {
|
|
53
74
|
if (error?.code !== 'ENOENT') throw error;
|
|
54
75
|
}
|
|
55
|
-
return
|
|
76
|
+
return resolvedFilePath;
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
export function writeFileAtomically(filePath, content) {
|
|
59
|
-
const writeTarget = resolveAtomicWriteTarget(filePath);
|
|
79
|
+
export function writeFileAtomically(filePath, content, options = {}) {
|
|
80
|
+
const writeTarget = resolveAtomicWriteTarget(filePath, options);
|
|
60
81
|
ensureDir(writeTarget);
|
|
61
82
|
const temporaryPath = `${writeTarget}.${process.pid}.${Date.now()}.tmp`;
|
|
62
83
|
try {
|
|
@@ -65,6 +86,7 @@ export function writeFileAtomically(filePath, content) {
|
|
|
65
86
|
flag: 'wx',
|
|
66
87
|
mode: 0o600
|
|
67
88
|
});
|
|
89
|
+
resolveAtomicWriteTarget(filePath, options);
|
|
68
90
|
fs.renameSync(temporaryPath, writeTarget);
|
|
69
91
|
} catch (error) {
|
|
70
92
|
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
|
|
@@ -367,6 +389,13 @@ export function installReferenceConnector({
|
|
|
367
389
|
runtime
|
|
368
390
|
});
|
|
369
391
|
|
|
392
|
+
const writeOptions = target.scope === 'project'
|
|
393
|
+
? { allowedRoot: workspaceRoot, followFinalSymlink: false }
|
|
394
|
+
: {};
|
|
395
|
+
if (target.scope === 'project') {
|
|
396
|
+
resolveAtomicWriteTarget(target.targetFile, writeOptions);
|
|
397
|
+
}
|
|
398
|
+
|
|
370
399
|
let renderedContent = '';
|
|
371
400
|
if (target.format === 'toml') {
|
|
372
401
|
const existingContent = fs.existsSync(target.targetFile)
|
|
@@ -383,7 +412,7 @@ export function installReferenceConnector({
|
|
|
383
412
|
}
|
|
384
413
|
|
|
385
414
|
if (!dryRun) {
|
|
386
|
-
writeFileAtomically(target.targetFile, renderedContent);
|
|
415
|
+
writeFileAtomically(target.targetFile, renderedContent, writeOptions);
|
|
387
416
|
}
|
|
388
417
|
|
|
389
418
|
return {
|
package/src/router.js
CHANGED
|
@@ -45,7 +45,6 @@ export class SmartRouter {
|
|
|
45
45
|
this.workspaceRoot = workspaceRoot || process.cwd();
|
|
46
46
|
this.deviceIdentity = deviceIdentity;
|
|
47
47
|
this.getScopeHeaders = typeof getScopeHeaders === 'function' ? getScopeHeaders : () => ({});
|
|
48
|
-
|
|
49
48
|
// 工具路由规则
|
|
50
49
|
this.routingRules = {
|
|
51
50
|
// 本地工具(需要文件系统访问)
|
|
@@ -67,6 +66,7 @@ export class SmartRouter {
|
|
|
67
66
|
'execute_shell_command',
|
|
68
67
|
'connector_bridge_status',
|
|
69
68
|
'connector_sync_profile',
|
|
69
|
+
'connector_sync_skills',
|
|
70
70
|
'connector_report_status',
|
|
71
71
|
'connector_discover_project_probe',
|
|
72
72
|
'connector_report_project_probe',
|
|
@@ -136,30 +136,22 @@ export class SmartRouter {
|
|
|
136
136
|
return 'cloud';
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
getWorkspaceWarnings() {
|
|
140
|
-
const resolution = this.localTools?.connectorBridge?.state?.workspaceResolution;
|
|
141
|
-
if (resolution?.workspaceId) return [];
|
|
142
|
-
return Array.isArray(resolution?.warnings) ? resolution.warnings : [];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
139
|
/**
|
|
146
140
|
* 执行工具调用
|
|
147
141
|
*/
|
|
148
142
|
async executeTool(toolName, params) {
|
|
149
143
|
const route = this.routeTool(toolName);
|
|
150
|
-
|
|
151
|
-
if (route === 'cloud' && workspaceWarnings.length) {
|
|
152
|
-
return {
|
|
153
|
-
objectType: 'connector_workspace_warning',
|
|
154
|
-
executed: false,
|
|
155
|
-
warnings: workspaceWarnings
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
144
|
+
|
|
159
145
|
if (route === 'local') {
|
|
160
146
|
return await this.localTools.execute(toolName, params);
|
|
161
147
|
} else {
|
|
162
|
-
|
|
148
|
+
const recoveryParams = toolName === 'request_project_workspace_registration'
|
|
149
|
+
? {
|
|
150
|
+
...params,
|
|
151
|
+
projectIdentity: this.localTools?.connectorBridge?.state?.projectIdentity
|
|
152
|
+
}
|
|
153
|
+
: params;
|
|
154
|
+
return await this.callCloudTool(toolName, recoveryParams);
|
|
163
155
|
}
|
|
164
156
|
}
|
|
165
157
|
|
|
@@ -187,7 +179,7 @@ export class SmartRouter {
|
|
|
187
179
|
/**
|
|
188
180
|
* 调用云端工具
|
|
189
181
|
*/
|
|
190
|
-
async callCloudTool(toolName, params) {
|
|
182
|
+
async callCloudTool(toolName, params, { workspaceId = null } = {}) {
|
|
191
183
|
// 对于 skill_* 工具,自动注入项目根路径信息
|
|
192
184
|
if (toolName.startsWith('skill_')) {
|
|
193
185
|
// 如果参数中没有 project_root 且没有 paths,自动添加 project_root
|
|
@@ -231,6 +223,7 @@ export class SmartRouter {
|
|
|
231
223
|
'Authorization': `Bearer ${this.cloudApiKey}`,
|
|
232
224
|
'Content-Type': 'application/json',
|
|
233
225
|
...this.getScopeHeaders(),
|
|
226
|
+
...(workspaceId ? { 'X-CloudMCP-Workspace-ID': workspaceId } : {}),
|
|
234
227
|
...signedHeaders
|
|
235
228
|
},
|
|
236
229
|
body
|
|
@@ -302,14 +295,6 @@ export class SmartRouter {
|
|
|
302
295
|
* 获取所有工具列表(合并云端和本地)
|
|
303
296
|
*/
|
|
304
297
|
async getAllTools() {
|
|
305
|
-
const workspaceWarnings = this.getWorkspaceWarnings();
|
|
306
|
-
if (workspaceWarnings.length) {
|
|
307
|
-
const localTools = await this.localTools.listTools();
|
|
308
|
-
const recoveryTools = new Set(['connector_bridge_status', 'connector_discover_project_probe']);
|
|
309
|
-
return localTools
|
|
310
|
-
.filter((tool) => recoveryTools.has(tool.name))
|
|
311
|
-
.map((tool) => ({ ...tool, source: 'local' }));
|
|
312
|
-
}
|
|
313
298
|
const [cloudTools, localTools] = await Promise.all([
|
|
314
299
|
this.getCloudTools(),
|
|
315
300
|
this.localTools.listTools()
|
|
@@ -334,7 +319,7 @@ export class SmartRouter {
|
|
|
334
319
|
/**
|
|
335
320
|
* 获取云端工具列表
|
|
336
321
|
*/
|
|
337
|
-
async getCloudTools() {
|
|
322
|
+
async getCloudTools({ workspaceId = null } = {}) {
|
|
338
323
|
const body = JSON.stringify({
|
|
339
324
|
jsonrpc: '2.0',
|
|
340
325
|
id: Date.now(),
|
|
@@ -350,6 +335,7 @@ export class SmartRouter {
|
|
|
350
335
|
'Authorization': `Bearer ${this.cloudApiKey}`,
|
|
351
336
|
'Content-Type': 'application/json',
|
|
352
337
|
...this.getScopeHeaders(),
|
|
338
|
+
...(workspaceId ? { 'X-CloudMCP-Workspace-ID': workspaceId } : {}),
|
|
353
339
|
...signedHeaders
|
|
354
340
|
},
|
|
355
341
|
body
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const GLOBAL_SKILL_COLLECTION_ID = 'skill_collection:global';
|
|
7
|
+
const PORTABLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
const PORTABLE_VERSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
9
|
+
|
|
10
|
+
function normalizeString(value) {
|
|
11
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sortValue(value) {
|
|
15
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
16
|
+
if (!value || typeof value !== 'object') return value;
|
|
17
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256(value) {
|
|
21
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertPortableId(value, label) {
|
|
25
|
+
const normalized = normalizeString(value);
|
|
26
|
+
if (!PORTABLE_ID_PATTERN.test(normalized)) throw new Error(`${label} is not portable: ${normalized || '(empty)'}`);
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertSafeRelativePath(value) {
|
|
31
|
+
const normalized = normalizeString(value);
|
|
32
|
+
if (!normalized || path.isAbsolute(normalized) || normalized.includes('\\')) {
|
|
33
|
+
throw new Error(`Skill file path is unsafe: ${normalized || '(empty)'}`);
|
|
34
|
+
}
|
|
35
|
+
if (normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
36
|
+
throw new Error(`Skill file path is unsafe: ${normalized}`);
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function pathState(target) {
|
|
42
|
+
try {
|
|
43
|
+
const stat = await fs.lstat(target);
|
|
44
|
+
return { exists: true, stat };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === 'ENOENT') return { exists: false, stat: null };
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateBundle(response) {
|
|
52
|
+
const bundle = response?.bundle;
|
|
53
|
+
const manifest = bundle?.manifest;
|
|
54
|
+
if (!manifest || manifest.schemaVersion !== 'cloudmcp.skill.v1') {
|
|
55
|
+
throw new Error('CloudMCP returned an unsupported Skill bundle');
|
|
56
|
+
}
|
|
57
|
+
const skillId = assertPortableId(manifest.skillId, 'skill_id');
|
|
58
|
+
const versionId = normalizeString(response?.version_id);
|
|
59
|
+
if (!PORTABLE_VERSION_ID_PATTERN.test(versionId)) throw new Error(`version_id is not portable: ${versionId || '(empty)'}`);
|
|
60
|
+
if (response?.skill_id !== skillId) throw new Error(`Skill identity mismatch for ${skillId}`);
|
|
61
|
+
if (!Array.isArray(manifest.files) || !Array.isArray(bundle.files)) {
|
|
62
|
+
throw new Error(`Skill bundle file inventory is missing for ${skillId}`);
|
|
63
|
+
}
|
|
64
|
+
const unsignedManifest = { ...manifest };
|
|
65
|
+
delete unsignedManifest.packageHash;
|
|
66
|
+
const expectedPackageHash = sha256(JSON.stringify(sortValue(unsignedManifest)));
|
|
67
|
+
if (manifest.packageHash !== expectedPackageHash) throw new Error(`Skill package hash mismatch for ${skillId}`);
|
|
68
|
+
|
|
69
|
+
const contentByPath = new Map(bundle.files.map((file) => [assertSafeRelativePath(file?.path), file?.content]));
|
|
70
|
+
const files = manifest.files.map((descriptor) => {
|
|
71
|
+
const filePath = assertSafeRelativePath(descriptor?.path);
|
|
72
|
+
const content = contentByPath.get(filePath);
|
|
73
|
+
if (typeof content !== 'string') throw new Error(`Skill file content is missing: ${skillId}/${filePath}`);
|
|
74
|
+
if (descriptor.executable === true || descriptor.role === 'script' || filePath.startsWith('scripts/')) {
|
|
75
|
+
throw new Error(`Executable Skill content is not supported: ${skillId}/${filePath}`);
|
|
76
|
+
}
|
|
77
|
+
if (!/^(?:text\/|application\/(?:json|yaml|x-yaml)$)/i.test(descriptor.mediaType || '')) {
|
|
78
|
+
throw new Error(`Binary Skill content is not supported: ${skillId}/${filePath}`);
|
|
79
|
+
}
|
|
80
|
+
if (Buffer.byteLength(content) !== descriptor.size || sha256(content) !== descriptor.sha256) {
|
|
81
|
+
throw new Error(`Skill file verification failed: ${skillId}/${filePath}`);
|
|
82
|
+
}
|
|
83
|
+
return { path: filePath, content };
|
|
84
|
+
});
|
|
85
|
+
if (!files.some((file) => file.path === 'SKILL.md')) throw new Error(`SKILL.md is missing for ${skillId}`);
|
|
86
|
+
if (contentByPath.size !== files.length) throw new Error(`Skill bundle inventory mismatch for ${skillId}`);
|
|
87
|
+
return { skillId, versionId, manifest, files };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeVerifiedFile(target, content) {
|
|
91
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
92
|
+
const existing = await pathState(target);
|
|
93
|
+
if (existing.exists) {
|
|
94
|
+
if (!existing.stat.isFile()) throw new Error(`Managed Skill path is not a file: ${target}`);
|
|
95
|
+
const current = await fs.readFile(target, 'utf8');
|
|
96
|
+
if (current !== content) throw new Error(`Managed Skill cache conflict: ${target}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
await fs.writeFile(target, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function materializeVersion(cacheRoot, verified) {
|
|
104
|
+
const versionDir = path.join(cacheRoot, verified.skillId, verified.versionId);
|
|
105
|
+
await fs.mkdir(versionDir, { recursive: true, mode: 0o700 });
|
|
106
|
+
for (const file of verified.files) {
|
|
107
|
+
const target = path.resolve(versionDir, ...file.path.split('/'));
|
|
108
|
+
if (!target.startsWith(`${path.resolve(versionDir)}${path.sep}`)) throw new Error(`Skill path escaped cache: ${file.path}`);
|
|
109
|
+
await writeVerifiedFile(target, file.content);
|
|
110
|
+
}
|
|
111
|
+
await writeVerifiedFile(
|
|
112
|
+
path.join(versionDir, '.cloudmcp-manifest.json'),
|
|
113
|
+
`${JSON.stringify(verified.manifest, null, 2)}\n`
|
|
114
|
+
);
|
|
115
|
+
return versionDir;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function activateVersion(skillsRoot, cacheRoot, skillId, versionDir) {
|
|
119
|
+
await fs.mkdir(skillsRoot, { recursive: true, mode: 0o700 });
|
|
120
|
+
const activePath = path.join(skillsRoot, skillId);
|
|
121
|
+
const existing = await pathState(activePath);
|
|
122
|
+
if (existing.exists) {
|
|
123
|
+
if (!existing.stat.isSymbolicLink()) {
|
|
124
|
+
throw new Error(`Refusing to overwrite unmanaged local Skill: ${activePath}`);
|
|
125
|
+
}
|
|
126
|
+
const currentTarget = path.resolve(path.dirname(activePath), await fs.readlink(activePath));
|
|
127
|
+
const managedRoot = `${path.resolve(cacheRoot)}${path.sep}`;
|
|
128
|
+
if (!currentTarget.startsWith(managedRoot)) {
|
|
129
|
+
throw new Error(`Refusing to replace unmanaged Skill symlink: ${activePath}`);
|
|
130
|
+
}
|
|
131
|
+
if (currentTarget === path.resolve(versionDir)) return { activePath, changed: false };
|
|
132
|
+
const historyRoot = path.join(cacheRoot, '.activation-history');
|
|
133
|
+
await fs.mkdir(historyRoot, { recursive: true, mode: 0o700 });
|
|
134
|
+
await fs.rename(activePath, path.join(historyRoot, `${skillId}.${Date.now()}.${process.pid}`));
|
|
135
|
+
}
|
|
136
|
+
await fs.symlink(versionDir, activePath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
137
|
+
return { activePath, changed: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function syncGlobalSkills({
|
|
141
|
+
callCloudTool,
|
|
142
|
+
homeDir = os.homedir(),
|
|
143
|
+
cacheRoot = process.env.CLOUDMCP_SKILL_CACHE_ROOT || path.join(homeDir, '.cloudmcp', 'skills'),
|
|
144
|
+
skillsRoot = process.env.CLOUDMCP_USER_SKILLS_ROOT || path.join(homeDir, '.agents', 'skills')
|
|
145
|
+
} = {}) {
|
|
146
|
+
if (typeof callCloudTool !== 'function') throw new Error('Cloud Skill tool caller is not configured');
|
|
147
|
+
const skills = [];
|
|
148
|
+
let cursor = '';
|
|
149
|
+
do {
|
|
150
|
+
const searchResult = await callCloudTool('search_skills', {
|
|
151
|
+
collection_id: GLOBAL_SKILL_COLLECTION_ID,
|
|
152
|
+
query: '',
|
|
153
|
+
limit: 100,
|
|
154
|
+
...(cursor ? { cursor } : {})
|
|
155
|
+
}, { workspaceId: 'workspace.global' });
|
|
156
|
+
skills.push(...(Array.isArray(searchResult?.skills) ? searchResult.skills : []));
|
|
157
|
+
cursor = normalizeString(searchResult?.next_cursor);
|
|
158
|
+
if (searchResult?.complete !== false) cursor = '';
|
|
159
|
+
if (searchResult?.complete === false && !cursor) throw new Error('CloudMCP returned an incomplete Skill catalog without a cursor');
|
|
160
|
+
} while (cursor);
|
|
161
|
+
const synchronized = [];
|
|
162
|
+
for (const skill of skills) {
|
|
163
|
+
const skillId = assertPortableId(skill?.skill_id, 'skill_id');
|
|
164
|
+
const response = await callCloudTool('get_skill_bundle', {
|
|
165
|
+
skill_id: skillId,
|
|
166
|
+
version_id: skill.active_version_id
|
|
167
|
+
}, { workspaceId: 'workspace.global' });
|
|
168
|
+
const verified = validateBundle(response);
|
|
169
|
+
const versionDir = await materializeVersion(cacheRoot, verified);
|
|
170
|
+
const activation = await activateVersion(skillsRoot, cacheRoot, skillId, versionDir);
|
|
171
|
+
synchronized.push({
|
|
172
|
+
skill_id: skillId,
|
|
173
|
+
version_id: verified.versionId,
|
|
174
|
+
package_hash: verified.manifest.packageHash,
|
|
175
|
+
cache_path: versionDir,
|
|
176
|
+
active_path: activation.activePath,
|
|
177
|
+
changed: activation.changed
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
success: true,
|
|
182
|
+
collection_id: GLOBAL_SKILL_COLLECTION_ID,
|
|
183
|
+
skills_root: skillsRoot,
|
|
184
|
+
synchronized,
|
|
185
|
+
synchronized_at: Date.now()
|
|
186
|
+
};
|
|
187
|
+
}
|