cloudflare-mcp-smart-proxy 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,25 +37,23 @@ node index.js
37
37
 
38
38
  当前已发布包名与可执行名不同,因此正式入口需要显式指定包与命令。仓库内入口只保留给本地开发调试,通过 `--runtime local` 显式选择。
39
39
 
40
- ### 2. 作为参考连接器安装器运行
40
+ ### 2. 经过审批后自动安装(正式主线)
41
41
 
42
42
  ```bash
43
- cd /home/coder/project/CLOUDMCP/local-proxy
44
- node connector-cli.js install codex \
45
- --cloud-url https://your-cloudmcp.example.com \
46
- --api-key your_api_key \
47
- --client-profile-id client_profile.codex.default
43
+ npx -y -p cloudflare-mcp-smart-proxy cloudmcp-connector activate codex \
44
+ --cloud-url https://your-cloudmcp.example.com
48
45
  ```
49
46
 
47
+ 第一次运行会生成设备密钥并发起接入申请,不会取得任何租户配置或凭证。管理员在 CloudMCP 后台的“生态连接中心”选择精确生态与工作区并批准后,将一次性审批密令回传给智能体。智能体随后运行:
48
+
50
49
  ```bash
51
- cd /home/coder/project/CLOUDMCP/local-proxy
52
- node connector-cli.js install claude_code \
53
- --scope project \
54
- --cloud-url https://your-cloudmcp.example.com \
55
- --api-key your_api_key \
56
- --client-profile-id client_profile.claude_code.default
50
+ CLOUDMCP_APPROVAL_CODE='<一次性审批密令>' \
51
+ npx -y -p cloudflare-mcp-smart-proxy cloudmcp-connector activate codex \
52
+ --cloud-url https://your-cloudmcp.example.com
57
53
  ```
58
54
 
55
+ 审批密令 10 分钟内有效、只能成功领取一次,并且只能由原申请设备签名领取。连接凭证写入权限为 `0600` 的独立文件,IDE 配置只保存该文件路径。CloudMCP 的机器可发现入口为 `/.well-known/cloudmcp`。
56
+
59
57
  ## 目标配置文件
60
58
 
61
59
  ### Codex
@@ -74,8 +72,7 @@ node connector-cli.js install claude_code \
74
72
 
75
73
  ## 默认注入环境变量
76
74
 
77
- - `CLOUDFLARE_MCP_URL`
78
- - `CLOUDFLARE_MCP_API_KEY`
75
+ - `CLOUDMCP_CREDENTIAL_PATH`
79
76
  - `WORKSPACE_ROOT`
80
77
  - `CLOUDMCP_CLIENT_PROFILE_ID`
81
78
  - `CLOUDMCP_CONNECTOR_ID`
@@ -83,7 +80,7 @@ node connector-cli.js install claude_code \
83
80
  - `CLOUDMCP_WORKSPACE_ID`
84
81
  - `CLOUDMCP_DEVICE_IDENTITY_PATH`
85
82
 
86
- 这些字段会让连接器启动后直接进入:
83
+ 凭证文件保存服务 URL、设备绑定 API Key 和批准后的精确范围;这些字段会让连接器启动后直接进入:
87
84
 
88
85
  - `PUT /connectors/workspace-binding`
89
86
  - `GET /connectors/install-plan`
@@ -131,5 +128,6 @@ node --test /home/coder/project/CLOUDMCP/tests/reference-connector-a3.test.js \
131
128
 
132
129
  ## 备注
133
130
 
131
+ - `install --api-key` 只保留给已经绑定设备的旧连接器迁移;未绑定 API Key 无法注册新设备。
134
132
  - 旧的 Cursor / Claude Desktop 入口仍保留兼容,但它们不再是 A3 主线。
135
133
  - A3 没有把 `systemPrompt` 直接写进项目根级 `AGENTS.md` 或 `CLAUDE.md`,以避免覆盖用户已有 agent 规范文件。
package/connector-cli.js CHANGED
@@ -1,17 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'fs';
4
+ import os from 'os';
3
5
  import path from 'path';
6
+ import { createHash } from 'crypto';
4
7
  import { fileURLToPath } from 'url';
5
8
  import { reloadCodexMcpServer } from './src/codex-app-server.js';
6
9
  import { installReferenceConnector, printReferenceConnectorConfig } from './src/reference-connectors.js';
10
+ import { DeviceIdentity } from './src/device-identity.js';
11
+ import { writeFileAtomically } from './src/reference-connectors.js';
7
12
 
8
13
  const __filename = fileURLToPath(import.meta.url);
9
14
  const __dirname = path.dirname(__filename);
10
15
 
11
16
  function printUsage() {
12
- console.error('Usage: cloudmcp-connector <install|print|smoke|reload> <codex|claude_code> [options]');
17
+ console.error('Usage: cloudmcp-connector <activate|install|print|smoke|reload> <codex|claude_code> [options]');
13
18
  console.error('');
14
- console.error('Required options for install, print, and smoke:');
19
+ console.error('Required option for activate:');
20
+ console.error(' --cloud-url <url>');
21
+ console.error(' First run creates an approval request; rerun with --approval-code after admin approval.');
22
+ console.error('');
23
+ console.error('Required options for legacy install, print, and smoke:');
15
24
  console.error(' --cloud-url <url>');
16
25
  console.error(' --api-key <key>');
17
26
  console.error(' --client-profile-id <id>');
@@ -29,6 +38,8 @@ function printUsage() {
29
38
  console.error(' --app-server-socket <path> Current runtime Codex app-server socket');
30
39
  console.error(' --runtime-id <id> Current Codex runtime identity');
31
40
  console.error(' --dry-run Show output without writing');
41
+ console.error(' --show-secrets Explicitly include credentials in print output');
42
+ console.error(' --approval-code <code> One-time code shown by the CloudMCP administrator');
32
43
  console.error('');
33
44
  console.error('The reload codex command uses the existing Codex config and requires no credentials.');
34
45
  }
@@ -47,8 +58,8 @@ function parseArgs(argv) {
47
58
  }
48
59
 
49
60
  const key = token.slice(2);
50
- if (key === 'dry-run') {
51
- options.dryRun = true;
61
+ if (key === 'dry-run' || key === 'show-secrets') {
62
+ options[key === 'dry-run' ? 'dryRun' : key] = true;
52
63
  continue;
53
64
  }
54
65
 
@@ -95,7 +106,163 @@ function buildInstallOptions(options) {
95
106
  };
96
107
  }
97
108
 
98
- function printInstallResult(result, { includeContent = false } = {}) {
109
+ function activationPaths(options) {
110
+ const homeDir = os.homedir();
111
+ const workspaceRoot = path.resolve(options['workspace-root'] || process.cwd());
112
+ const cloudUrl = resolveRequiredOption(options, 'cloud-url', ['CLOUDFLARE_MCP_URL', 'MCP_URL']).replace(/\/$/, '');
113
+ const fingerprint = createHash('sha256')
114
+ .update(`${cloudUrl}\n${options.ecosystem}\n${workspaceRoot}`)
115
+ .digest('hex')
116
+ .slice(0, 20);
117
+ const deviceIdentityPath = options['codex-device-identity-path']
118
+ || (options.ecosystem === 'codex'
119
+ ? path.join(homeDir, '.codex', 'cloudmcp', 'device-identity.json')
120
+ : path.join(homeDir, '.cloudmcp', 'device-identity.json'));
121
+ return {
122
+ cloudUrl,
123
+ workspaceRoot,
124
+ deviceIdentityPath,
125
+ pendingPath: path.join(homeDir, '.cloudmcp', 'activation-requests', `${fingerprint}.json`),
126
+ credentialPath: path.join(homeDir, '.cloudmcp', 'credentials', `${fingerprint}.json`)
127
+ };
128
+ }
129
+
130
+ function defaultConnectorId(ecosystem, workspaceRoot) {
131
+ const workspaceName = path.basename(workspaceRoot).replace(/[^a-zA-Z0-9_.-]/g, '_');
132
+ return `connector.${ecosystem}.${workspaceName}`;
133
+ }
134
+
135
+ async function activationFetch(url, options = {}) {
136
+ const response = await fetch(url, options);
137
+ const data = await response.json().catch(() => ({}));
138
+ if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`);
139
+ return data;
140
+ }
141
+
142
+ async function runActivation(options) {
143
+ const paths = activationPaths(options);
144
+ const identity = new DeviceIdentity({ identityPath: paths.deviceIdentityPath });
145
+ const device = await identity.loadOrCreate();
146
+ const approvalCode = options['approval-code'] || process.env.CLOUDMCP_APPROVAL_CODE || '';
147
+ let pending = null;
148
+ try {
149
+ pending = JSON.parse(fs.readFileSync(paths.pendingPath, 'utf8'));
150
+ } catch (error) {
151
+ if (error?.code !== 'ENOENT') throw error;
152
+ }
153
+
154
+ const installClaimedConnection = async (connection) => {
155
+ const result = installReferenceConnector({
156
+ ecosystem: options.ecosystem,
157
+ cloudUrl: connection.cloudUrl,
158
+ cloudApiKey: '',
159
+ credentialPath: paths.credentialPath,
160
+ clientProfileId: connection.clientProfileId,
161
+ workspaceId: connection.workspaceId,
162
+ connectorId: connection.connectorId,
163
+ connectorType: connection.connectorType,
164
+ workspaceRoot: paths.workspaceRoot,
165
+ scope: options.scope || 'user',
166
+ serverName: options['server-name'] || 'cloudmcp',
167
+ runtime: options.runtime || 'npm',
168
+ codexConfigPath: options['codex-config-path'] || process.env.CODEX_SHARED_CONFIG_PATH || '',
169
+ codexDeviceIdentityPath: paths.deviceIdentityPath,
170
+ packageRoot: __dirname,
171
+ dryRun: options.dryRun === true
172
+ });
173
+ if (options.ecosystem === 'codex' && !options.dryRun) {
174
+ result.activation = await reloadCodexMcpServer({
175
+ serverName: result.serverName,
176
+ socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
177
+ runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
178
+ });
179
+ }
180
+ if (!options.dryRun) fs.chmodSync(paths.credentialPath, 0o600);
181
+ if (!options.dryRun && pending) {
182
+ writeFileAtomically(paths.pendingPath, `${JSON.stringify({
183
+ requestId: pending.requestId,
184
+ cloudUrl: pending.cloudUrl,
185
+ ecosystem: pending.ecosystem,
186
+ workspaceRoot: pending.workspaceRoot,
187
+ status: 'claimed',
188
+ claimedAt: Date.now()
189
+ }, null, 2)}\n`);
190
+ }
191
+ printInstallResult(result);
192
+ console.error(`credential: ${paths.credentialPath}`);
193
+ };
194
+
195
+ if (fs.existsSync(paths.credentialPath)) {
196
+ const connection = JSON.parse(fs.readFileSync(paths.credentialPath, 'utf8'));
197
+ await installClaimedConnection(connection);
198
+ return;
199
+ }
200
+
201
+ if (!pending) {
202
+ if (approvalCode) throw new Error('No local activation request matches this CloudMCP URL and workspace');
203
+ const requested = await activationFetch(`${paths.cloudUrl}/connectors/activation-requests`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({
207
+ ecosystem: options.ecosystem,
208
+ deviceId: device.deviceId,
209
+ publicKeyJwk: device.publicKeyJwk,
210
+ deviceLabel: options['device-label'] || os.hostname(),
211
+ workspaceLabel: path.basename(paths.workspaceRoot),
212
+ connectorId: options['connector-id'] || defaultConnectorId(options.ecosystem, paths.workspaceRoot)
213
+ })
214
+ });
215
+ pending = {
216
+ requestId: requested.activationRequest.id,
217
+ requestToken: requested.requestToken,
218
+ cloudUrl: paths.cloudUrl,
219
+ ecosystem: options.ecosystem,
220
+ workspaceRoot: paths.workspaceRoot,
221
+ createdAt: Date.now()
222
+ };
223
+ writeFileAtomically(paths.pendingPath, `${JSON.stringify(pending, null, 2)}\n`);
224
+ console.error(`approval request: ${pending.requestId}`);
225
+ console.error('status: pending administrator approval');
226
+ console.error('After approval, rerun this command with --approval-code <code>.');
227
+ return;
228
+ }
229
+
230
+ if (!approvalCode) {
231
+ const status = await activationFetch(
232
+ `${paths.cloudUrl}/connectors/activation-requests/${encodeURIComponent(pending.requestId)}`,
233
+ { headers: { Authorization: `Activation ${pending.requestToken}` } }
234
+ );
235
+ console.error(`approval request: ${pending.requestId}`);
236
+ console.error(`status: ${status.activationRequest.status}`);
237
+ console.error('When approved, rerun with --approval-code <code>.');
238
+ return;
239
+ }
240
+
241
+ const claimUrl = new URL(`${paths.cloudUrl}/connectors/activation-requests/${encodeURIComponent(pending.requestId)}/claim`);
242
+ const claimBody = JSON.stringify({ approvalCode });
243
+ const signedHeaders = await identity.buildSignedHeaders({
244
+ method: 'POST',
245
+ pathname: claimUrl.pathname,
246
+ search: claimUrl.search,
247
+ body: claimBody
248
+ });
249
+ const claimed = await activationFetch(
250
+ claimUrl.toString(),
251
+ {
252
+ method: 'POST',
253
+ headers: {
254
+ Authorization: `Activation ${pending.requestToken}`,
255
+ 'Content-Type': 'application/json',
256
+ ...signedHeaders
257
+ },
258
+ body: claimBody
259
+ }
260
+ );
261
+ writeFileAtomically(paths.credentialPath, `${JSON.stringify(claimed.connection, null, 2)}\n`);
262
+ await installClaimedConnection(claimed.connection);
263
+ }
264
+
265
+ function printInstallResult(result, { includeContent = false, includeSecrets = false } = {}) {
99
266
  console.error(`ecosystem: ${result.ecosystem}`);
100
267
  console.error(`scope: ${result.scope}`);
101
268
  console.error(`target: ${result.targetFile}`);
@@ -111,7 +278,11 @@ function printInstallResult(result, { includeContent = false } = {}) {
111
278
  }
112
279
  }
113
280
  if (includeContent) {
114
- process.stdout.write(`${result.renderedContent}\n`);
281
+ const apiKey = result.serverDefinition?.env?.CLOUDFLARE_MCP_API_KEY || '';
282
+ const renderedContent = includeSecrets
283
+ ? result.renderedContent
284
+ : (apiKey ? result.renderedContent.split(apiKey).join('[REDACTED]') : result.renderedContent);
285
+ process.stdout.write(`${renderedContent}\n`);
115
286
  }
116
287
  }
117
288
 
@@ -133,7 +304,7 @@ async function main() {
133
304
  try {
134
305
  const options = parseArgs(process.argv.slice(2));
135
306
 
136
- if (!['install', 'print', 'smoke', 'reload'].includes(options.command)) {
307
+ if (!['activate', 'install', 'print', 'smoke', 'reload'].includes(options.command)) {
137
308
  printUsage();
138
309
  process.exit(1);
139
310
  }
@@ -142,6 +313,11 @@ async function main() {
142
313
  process.exit(1);
143
314
  }
144
315
 
316
+ if (options.command === 'activate') {
317
+ await runActivation(options);
318
+ return;
319
+ }
320
+
145
321
  if (options.command === 'install') {
146
322
  const result = installReferenceConnector(buildInstallOptions(options));
147
323
  if (options.ecosystem === 'codex' && !options.dryRun) {
@@ -177,7 +353,7 @@ async function main() {
177
353
 
178
354
  if (options.command === 'print') {
179
355
  const result = printReferenceConnectorConfig(buildInstallOptions(options));
180
- printInstallResult(result, { includeContent: true });
356
+ printInstallResult(result, { includeContent: true, includeSecrets: options['show-secrets'] === true });
181
357
  return;
182
358
  }
183
359
 
package/index.js CHANGED
@@ -16,15 +16,32 @@ import {
16
16
  import { sanitizeProxyError, SmartRouter } from './src/router.js';
17
17
  import { LocalToolExecutor } from './src/local-tools.js';
18
18
  import { ConnectorBridge } from './src/connector-bridge.js';
19
+ import fs from 'fs';
19
20
 
20
21
  // 从环境变量读取配置
21
- const CLOUD_URL = process.env.CLOUDFLARE_MCP_URL || process.env.MCP_URL;
22
- const CLOUD_API_KEY = process.env.CLOUDFLARE_MCP_API_KEY || process.env.MCP_API_KEY;
22
+ function readManagedCredential() {
23
+ const credentialPath = process.env.CLOUDMCP_CREDENTIAL_PATH || '';
24
+ if (!credentialPath) return null;
25
+ try {
26
+ const stat = fs.statSync(credentialPath);
27
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
28
+ throw new Error('credential file permissions must be 0600');
29
+ }
30
+ return JSON.parse(fs.readFileSync(credentialPath, 'utf8'));
31
+ } catch (error) {
32
+ console.error(`Error: unable to read CloudMCP credential file: ${error.message}`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ const MANAGED_CREDENTIAL = readManagedCredential();
38
+ const CLOUD_URL = MANAGED_CREDENTIAL?.cloudUrl || process.env.CLOUDFLARE_MCP_URL || process.env.MCP_URL;
39
+ const CLOUD_API_KEY = MANAGED_CREDENTIAL?.apiKey || process.env.CLOUDFLARE_MCP_API_KEY || process.env.MCP_API_KEY;
23
40
  const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || process.cwd();
24
- const CLIENT_PROFILE_ID = process.env.CLOUDMCP_CLIENT_PROFILE_ID || process.env.CLIENT_PROFILE_ID || '';
25
- const CONNECTOR_ID = process.env.CLOUDMCP_CONNECTOR_ID || process.env.CONNECTOR_ID || '';
26
- const CONNECTOR_TYPE = process.env.CLOUDMCP_CONNECTOR_TYPE || process.env.CONNECTOR_TYPE || 'smart_proxy';
27
- const WORKSPACE_ID = process.env.CLOUDMCP_WORKSPACE_ID || process.env.WORKSPACE_ID || '';
41
+ const CLIENT_PROFILE_ID = MANAGED_CREDENTIAL?.clientProfileId || process.env.CLOUDMCP_CLIENT_PROFILE_ID || process.env.CLIENT_PROFILE_ID || '';
42
+ const CONNECTOR_ID = MANAGED_CREDENTIAL?.connectorId || process.env.CLOUDMCP_CONNECTOR_ID || process.env.CONNECTOR_ID || '';
43
+ const CONNECTOR_TYPE = MANAGED_CREDENTIAL?.connectorType || process.env.CLOUDMCP_CONNECTOR_TYPE || process.env.CONNECTOR_TYPE || 'smart_proxy';
44
+ const WORKSPACE_ID = MANAGED_CREDENTIAL?.workspaceId || process.env.CLOUDMCP_WORKSPACE_ID || process.env.WORKSPACE_ID || '';
28
45
  const DEVICE_IDENTITY_PATH = process.env.CLOUDMCP_DEVICE_IDENTITY_PATH || '';
29
46
  const AUTO_SYNC_PROFILE = (process.env.CLOUDMCP_AUTO_SYNC_PROFILE || 'true') !== 'false';
30
47
  const AUTO_APPLY_BRAIN = (process.env.CLOUDMCP_AUTO_APPLY_BRAIN || 'true') !== 'false';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-mcp-smart-proxy",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Smart proxy for Cloudflare MCP - routes tools to cloud or local execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,7 +36,9 @@
36
36
  "@hono/node-server": "2.0.12",
37
37
  "ajv": "8.20.0",
38
38
  "body-parser": "2.3.0",
39
- "fast-uri": "3.1.4",
39
+ "fast-uri": "3.1.5",
40
+ "hono": "4.12.34",
41
+ "ip-address": "10.4.0",
40
42
  "path-to-regexp": "8.4.0",
41
43
  "qs": "6.15.3"
42
44
  }
@@ -214,13 +214,13 @@ export class LocalToolExecutor {
214
214
  },
215
215
  {
216
216
  name: 'execute_command',
217
- description: 'Execute a shell command locally',
217
+ description: 'Execute a local process without shell interpretation',
218
218
  inputSchema: {
219
219
  type: 'object',
220
220
  properties: {
221
221
  command: {
222
222
  type: 'string',
223
- description: 'Shell command to execute'
223
+ description: 'Executable and arguments to run'
224
224
  },
225
225
  cwd: {
226
226
  type: 'string',
@@ -93,6 +93,7 @@ function formatTomlArray(values) {
93
93
  function toManagedEnv({
94
94
  cloudUrl,
95
95
  cloudApiKey,
96
+ credentialPath,
96
97
  workspaceRoot,
97
98
  clientProfileId,
98
99
  connectorId,
@@ -104,6 +105,7 @@ function toManagedEnv({
104
105
  const env = {
105
106
  CLOUDFLARE_MCP_URL: normalizeEnvValue(cloudUrl),
106
107
  CLOUDFLARE_MCP_API_KEY: normalizeEnvValue(cloudApiKey),
108
+ CLOUDMCP_CREDENTIAL_PATH: normalizeEnvValue(credentialPath),
107
109
  WORKSPACE_ROOT: normalizeEnvValue(workspaceRoot),
108
110
  CLOUDMCP_CLIENT_PROFILE_ID: normalizeEnvValue(clientProfileId),
109
111
  CLOUDMCP_CONNECTOR_ID: normalizeEnvValue(connectorId),
@@ -146,6 +148,7 @@ export function buildReferenceConnectorServer({
146
148
  ecosystem,
147
149
  cloudUrl,
148
150
  cloudApiKey,
151
+ credentialPath = '',
149
152
  workspaceRoot = process.cwd(),
150
153
  clientProfileId,
151
154
  connectorId = '',
@@ -170,6 +173,7 @@ export function buildReferenceConnectorServer({
170
173
  env: toManagedEnv({
171
174
  cloudUrl,
172
175
  cloudApiKey,
176
+ credentialPath,
173
177
  workspaceRoot: resolvedWorkspaceRoot,
174
178
  clientProfileId,
175
179
  connectorId: resolvedConnectorId,
@@ -292,6 +296,7 @@ export function installReferenceConnector({
292
296
  ecosystem,
293
297
  cloudUrl,
294
298
  cloudApiKey,
299
+ credentialPath = '',
295
300
  workspaceRoot = process.cwd(),
296
301
  clientProfileId,
297
302
  connectorId = '',
@@ -345,6 +350,7 @@ export function installReferenceConnector({
345
350
  ecosystem,
346
351
  cloudUrl,
347
352
  cloudApiKey,
353
+ credentialPath,
348
354
  workspaceRoot,
349
355
  clientProfileId,
350
356
  connectorId,
@@ -4,6 +4,17 @@
4
4
 
5
5
  import { spawn } from 'child_process';
6
6
  import path from 'path';
7
+ import { FileOperations } from './file-operations.js';
8
+
9
+ function parseCommand(command) {
10
+ const tokens = [];
11
+ const pattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|([^\s]+)/g;
12
+ let match;
13
+ while ((match = pattern.exec(command)) !== null) {
14
+ tokens.push((match[1] ?? match[2] ?? match[3]).replace(/\\([\\"])/g, '$1'));
15
+ }
16
+ return tokens;
17
+ }
7
18
 
8
19
  export class CommandExecutor {
9
20
  /**
@@ -16,17 +27,21 @@ export class CommandExecutor {
16
27
  throw new Error('command parameter is required');
17
28
  }
18
29
 
19
- const workingDir = cwd ? path.resolve(workspaceRoot, cwd) : workspaceRoot;
30
+ const workingDir = cwd ? FileOperations.validatePath(cwd, workspaceRoot) : FileOperations.validatePath('.', workspaceRoot);
20
31
 
21
32
  return new Promise((resolve, reject) => {
22
33
  // 解析命令和参数
23
- const parts = command.trim().split(/\s+/);
34
+ const parts = parseCommand(command.trim());
24
35
  const cmd = parts[0];
25
36
  const args = parts.slice(1);
37
+ if (!cmd) {
38
+ reject(new Error('command parameter must include an executable'));
39
+ return;
40
+ }
26
41
 
27
42
  // 安全限制:禁止某些危险命令
28
- const dangerousCommands = ['rm', 'del', 'format', 'shutdown', 'reboot'];
29
- if (dangerousCommands.some(danger => cmd.includes(danger))) {
43
+ const dangerousCommands = new Set(['rm', 'del', 'format', 'shutdown', 'reboot']);
44
+ if (dangerousCommands.has(path.basename(cmd).toLowerCase())) {
30
45
  reject(new Error(`Command "${cmd}" is not allowed for security reasons`));
31
46
  return;
32
47
  }
@@ -38,9 +53,13 @@ export class CommandExecutor {
38
53
  // 执行命令
39
54
  const proc = spawn(cmd, args, {
40
55
  cwd: workingDir,
41
- shell: true,
56
+ shell: false,
42
57
  stdio: ['ignore', 'pipe', 'pipe']
43
58
  });
59
+ const timeout = setTimeout(() => {
60
+ proc.kill();
61
+ reject(new Error('Command execution timeout (30s)'));
62
+ }, 30000);
44
63
 
45
64
  // 收集输出
46
65
  proc.stdout.on('data', (data) => {
@@ -53,6 +72,7 @@ export class CommandExecutor {
53
72
 
54
73
  // 处理完成
55
74
  proc.on('close', (code) => {
75
+ clearTimeout(timeout);
56
76
  const duration = Date.now() - startTime;
57
77
 
58
78
  resolve({
@@ -68,15 +88,9 @@ export class CommandExecutor {
68
88
 
69
89
  // 处理错误
70
90
  proc.on('error', (error) => {
91
+ clearTimeout(timeout);
71
92
  reject(new Error(`Command execution failed: ${error.message}`));
72
93
  });
73
-
74
- // 超时保护(30秒)
75
- setTimeout(() => {
76
- proc.kill();
77
- reject(new Error('Command execution timeout (30s)'));
78
- }, 30000);
79
94
  });
80
95
  }
81
96
  }
82
-
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import fs from 'fs/promises';
6
+ import fsSync from 'fs';
6
7
  import path from 'path';
7
8
 
8
9
  export class FileOperations {
@@ -16,14 +17,28 @@ export class FileOperations {
16
17
  }
17
18
 
18
19
  // 限制在工作目录内
19
- const resolvedPath = path.resolve(workspaceRoot, filePath);
20
- const resolvedRoot = path.resolve(workspaceRoot);
20
+ const resolvedRoot = fsSync.realpathSync(path.resolve(workspaceRoot));
21
+ const resolvedPath = path.resolve(resolvedRoot, filePath);
22
+ const relativePath = path.relative(resolvedRoot, resolvedPath);
21
23
 
22
- if (!resolvedPath.startsWith(resolvedRoot)) {
24
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
23
25
  throw new Error('File path outside workspace root');
24
26
  }
25
-
26
- return resolvedPath;
27
+
28
+ let existingAncestor = resolvedPath;
29
+ while (!fsSync.existsSync(existingAncestor)) {
30
+ const parent = path.dirname(existingAncestor);
31
+ if (parent === existingAncestor) break;
32
+ existingAncestor = parent;
33
+ }
34
+ const realAncestor = fsSync.realpathSync(existingAncestor);
35
+ const realTarget = path.resolve(realAncestor, path.relative(existingAncestor, resolvedPath));
36
+ const realRelativePath = path.relative(resolvedRoot, realTarget);
37
+ if (realRelativePath.startsWith('..') || path.isAbsolute(realRelativePath)) {
38
+ throw new Error('File path resolves outside workspace root');
39
+ }
40
+
41
+ return realTarget;
27
42
  }
28
43
 
29
44
  /**
@@ -208,4 +223,3 @@ export class FileOperations {
208
223
  }
209
224
  }
210
225
  }
211
-