cloudflare-mcp-smart-proxy 1.5.10 → 1.5.17

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-mcp-smart-proxy",
3
- "version": "1.5.10",
3
+ "version": "1.5.17",
4
4
  "description": "Smart proxy for Cloudflare MCP - routes tools to cloud or local execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 {
@@ -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(path.join(directory, 'contract.json'), preparingContract);
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
- throw new Error(`Local memory entry differs from CloudMCP: ${entry.id}`);
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?.pagination?.hasMore) break;
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(path.join(directory, 'contract.json'), publicContract);
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 = readJson(path.join(contract.directory, 'contract.json'));
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) {
@@ -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',
@@ -61,12 +61,11 @@ export async function discoverProjectIdentity(workspaceRoot = process.cwd()) {
61
61
  ]);
62
62
  const aliases = Array.from(new Set([
63
63
  gitRemote ? `git:${gitRemote}` : '',
64
- packageName ? `package:${packageName}` : '',
65
- `repo:${normalizeAlias(repoName)}`
64
+ packageName ? `package:${packageName}` : ''
66
65
  ].filter(Boolean)));
67
66
 
68
67
  return {
69
- canonicalId: aliases[0],
68
+ canonicalId: aliases[0] || '',
70
69
  aliases,
71
70
  repoName,
72
71
  gitRemote: gitRemote || null,
@@ -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: null,
125
+ gitRemote: projectIdentity.gitRemote,
124
126
  defaultBranch: 'main',
125
127
  languages,
126
128
  frameworkHints,
package/src/router.js CHANGED
@@ -66,6 +66,7 @@ export class SmartRouter {
66
66
  'execute_shell_command',
67
67
  'connector_bridge_status',
68
68
  'connector_sync_profile',
69
+ 'connector_sync_skills',
69
70
  'connector_report_status',
70
71
  'connector_discover_project_probe',
71
72
  'connector_report_project_probe',
@@ -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
+ }