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 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.9",
3
+ "version": "1.5.16",
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',
@@ -40,7 +40,12 @@ function normalizeStringArray(value, limit = 32) {
40
40
  }
41
41
 
42
42
  export function assertNoSensitiveMemory(input = {}) {
43
- const text = [input.title, input.content, ...(input.evidenceRefs || input.evidence_refs || [])]
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: null,
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 resolveAtomicWriteTarget(filePath) {
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(filePath).isSymbolicLink()) {
50
- return fs.realpathSync(filePath);
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 filePath;
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
- const workspaceWarnings = this.getWorkspaceWarnings();
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
- return await this.callCloudTool(toolName, params);
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
+ }