cloudflare-mcp-smart-proxy 1.3.0 → 1.4.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
@@ -61,7 +61,10 @@ node connector-cli.js install claude_code \
61
61
  ### Codex
62
62
 
63
63
  - 配置文件:`~/.codex/config.toml`
64
- - 写入方式:managed block,不覆盖用户其他配置
64
+ - 设备身份:`~/.codex/cloudmcp/device-identity.json`
65
+ - 写入方式:managed block 原子写入,不覆盖用户其他配置
66
+ - 激活方式:安装完成后自动调用 Codex app-server 官方配置热加载,并确认 `cloudmcp` 已发现工具;无需重启对话或人工结束进程
67
+ - 兼容迁移:首次安装会把已有 `~/.cloudmcp/device-identity.json` 安全迁移到 Codex 共享目录,避免不同运行视图生成不同设备身份
65
68
 
66
69
  ### Claude Code
67
70
 
@@ -78,6 +81,7 @@ node connector-cli.js install claude_code \
78
81
  - `CLOUDMCP_CONNECTOR_ID`
79
82
  - `CLOUDMCP_CONNECTOR_TYPE`
80
83
  - `CLOUDMCP_WORKSPACE_ID`
84
+ - `CLOUDMCP_DEVICE_IDENTITY_PATH`
81
85
 
82
86
  这些字段会让连接器启动后直接进入:
83
87
 
@@ -108,10 +112,21 @@ node connector-cli.js print codex \
108
112
  npm run smoke:connectors
109
113
  ```
110
114
 
115
+ ### 重新激活现有 Codex 配置
116
+
117
+ ```bash
118
+ node connector-cli.js reload codex
119
+ ```
120
+
121
+ 该命令不需要再次传入凭据。若 Codex app-server 正在运行,它会执行官方热加载并检查工具数量;若 app-server 尚未运行,配置会在下次启动时直接生效。运行中的 app-server 若加载失败或没有发现任何云端治理工具,命令会明确失败,不再把空工具集误报为安装成功。
122
+
111
123
  ## 验证
112
124
 
113
125
  ```bash
114
- node --test /home/coder/project/CLOUDMCP/tests/reference-connector-a3.test.js
126
+ node --test /home/coder/project/CLOUDMCP/tests/reference-connector-a3.test.js \
127
+ /home/coder/project/CLOUDMCP/tests/codex-app-server-reload.test.js \
128
+ /home/coder/project/CLOUDMCP/tests/local-proxy-device-identity.test.js \
129
+ /home/coder/project/CLOUDMCP/tests/local-proxy-tool-discovery.test.js
115
130
  ```
116
131
 
117
132
  ## 备注
package/connector-cli.js CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
+ import { reloadCodexMcpServer } from './src/codex-app-server.js';
5
6
  import { installReferenceConnector, printReferenceConnectorConfig } from './src/reference-connectors.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
9
10
 
10
11
  function printUsage() {
11
- console.error('Usage: cloudmcp-connector <install|print|smoke> <codex|claude_code> [options]');
12
+ console.error('Usage: cloudmcp-connector <install|print|smoke|reload> <codex|claude_code> [options]');
12
13
  console.error('');
13
- console.error('Required options:');
14
+ console.error('Required options for install, print, and smoke:');
14
15
  console.error(' --cloud-url <url>');
15
16
  console.error(' --api-key <key>');
16
17
  console.error(' --client-profile-id <id>');
@@ -23,7 +24,13 @@ function printUsage() {
23
24
  console.error(' --scope <user|project> Claude Code supports user/project; Codex is always user');
24
25
  console.error(' --server-name <name> Default: cloudmcp');
25
26
  console.error(' --runtime <npm|local> Default: npm');
27
+ console.error(' --codex-config-path <path> Shared Codex config source');
28
+ console.error(' --codex-device-identity-path <path> Shared CloudMCP device identity');
29
+ console.error(' --app-server-socket <path> Current runtime Codex app-server socket');
30
+ console.error(' --runtime-id <id> Current Codex runtime identity');
26
31
  console.error(' --dry-run Show output without writing');
32
+ console.error('');
33
+ console.error('The reload codex command uses the existing Codex config and requires no credentials.');
27
34
  }
28
35
 
29
36
  function parseArgs(argv) {
@@ -79,6 +86,10 @@ function buildInstallOptions(options) {
79
86
  scope: options.scope || 'user',
80
87
  serverName: options['server-name'] || 'cloudmcp',
81
88
  runtime: options.runtime || 'npm',
89
+ codexConfigPath: options['codex-config-path'] || process.env.CODEX_SHARED_CONFIG_PATH || '',
90
+ codexDeviceIdentityPath: options['codex-device-identity-path']
91
+ || process.env.CODEX_SHARED_DEVICE_IDENTITY_PATH
92
+ || '',
82
93
  packageRoot: __dirname,
83
94
  dryRun: options.dryRun === true
84
95
  };
@@ -90,6 +101,15 @@ function printInstallResult(result, { includeContent = false } = {}) {
90
101
  console.error(`target: ${result.targetFile}`);
91
102
  console.error(`server: ${result.serverName}`);
92
103
  console.error(`written: ${result.written ? 'yes' : 'no'}`);
104
+ if (result.deviceIdentityPath) {
105
+ console.error(`device identity: ${result.deviceIdentityPath}`);
106
+ }
107
+ if (result.activation) {
108
+ console.error(`activation: ${result.activation.status}`);
109
+ if (Number.isInteger(result.activation.toolCount)) {
110
+ console.error(`tools: ${result.activation.toolCount}`);
111
+ }
112
+ }
93
113
  if (includeContent) {
94
114
  process.stdout.write(`${result.renderedContent}\n`);
95
115
  }
@@ -113,7 +133,7 @@ async function main() {
113
133
  try {
114
134
  const options = parseArgs(process.argv.slice(2));
115
135
 
116
- if (!['install', 'print', 'smoke'].includes(options.command)) {
136
+ if (!['install', 'print', 'smoke', 'reload'].includes(options.command)) {
117
137
  printUsage();
118
138
  process.exit(1);
119
139
  }
@@ -124,10 +144,37 @@ async function main() {
124
144
 
125
145
  if (options.command === 'install') {
126
146
  const result = installReferenceConnector(buildInstallOptions(options));
147
+ if (options.ecosystem === 'codex' && !options.dryRun) {
148
+ result.activation = await reloadCodexMcpServer({
149
+ serverName: result.serverName,
150
+ socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
151
+ runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
152
+ });
153
+ }
127
154
  printInstallResult(result);
128
155
  return;
129
156
  }
130
157
 
158
+ if (options.command === 'reload') {
159
+ if (options.ecosystem !== 'codex') {
160
+ throw new Error('Runtime reload is currently supported only for Codex');
161
+ }
162
+ const activation = await reloadCodexMcpServer({
163
+ serverName: options['server-name'] || 'cloudmcp',
164
+ socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
165
+ runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
166
+ });
167
+ printInstallResult({
168
+ ecosystem: 'codex',
169
+ scope: 'user',
170
+ targetFile: activation.socketPath,
171
+ serverName: activation.serverName,
172
+ written: false,
173
+ activation
174
+ });
175
+ return;
176
+ }
177
+
131
178
  if (options.command === 'print') {
132
179
  const result = printReferenceConnectorConfig(buildInstallOptions(options));
133
180
  printInstallResult(result, { includeContent: true });
package/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  ListPromptsRequestSchema,
14
14
  GetPromptRequestSchema
15
15
  } from '@modelcontextprotocol/sdk/types.js';
16
- import { SmartRouter } from './src/router.js';
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
19
 
@@ -25,6 +25,7 @@ const CLIENT_PROFILE_ID = process.env.CLOUDMCP_CLIENT_PROFILE_ID || process.env.
25
25
  const CONNECTOR_ID = process.env.CLOUDMCP_CONNECTOR_ID || process.env.CONNECTOR_ID || '';
26
26
  const CONNECTOR_TYPE = process.env.CLOUDMCP_CONNECTOR_TYPE || process.env.CONNECTOR_TYPE || 'smart_proxy';
27
27
  const WORKSPACE_ID = process.env.CLOUDMCP_WORKSPACE_ID || process.env.WORKSPACE_ID || '';
28
+ const DEVICE_IDENTITY_PATH = process.env.CLOUDMCP_DEVICE_IDENTITY_PATH || '';
28
29
  const AUTO_SYNC_PROFILE = (process.env.CLOUDMCP_AUTO_SYNC_PROFILE || 'true') !== 'false';
29
30
  const AUTO_APPLY_BRAIN = (process.env.CLOUDMCP_AUTO_APPLY_BRAIN || 'true') !== 'false';
30
31
  const AUTO_REPORT_PROJECT_PROBE = (process.env.CLOUDMCP_AUTO_REPORT_PROJECT_PROBE || 'true') === 'true';
@@ -47,7 +48,8 @@ const connectorBridge = new ConnectorBridge({
47
48
  clientProfileId: CLIENT_PROFILE_ID,
48
49
  connectorId: CONNECTOR_ID,
49
50
  connectorType: CONNECTOR_TYPE,
50
- workspaceId: WORKSPACE_ID
51
+ workspaceId: WORKSPACE_ID,
52
+ deviceIdentityPath: DEVICE_IDENTITY_PATH
51
53
  });
52
54
  const localTools = new LocalToolExecutor(WORKSPACE_ROOT, connectorBridge);
53
55
  const router = new SmartRouter(CLOUD_URL, CLOUD_API_KEY, localTools, WORKSPACE_ROOT, connectorBridge.deviceIdentity);
@@ -72,8 +74,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
72
74
  const tools = await router.getAllTools();
73
75
  return { tools };
74
76
  } catch (error) {
75
- console.error('Error listing tools:', error);
76
- return { tools: [] };
77
+ const safeError = sanitizeProxyError(error);
78
+ console.error('Error listing tools:', safeError);
79
+ throw new Error(`CloudMCP tool discovery failed: ${safeError}`);
77
80
  }
78
81
  });
79
82
 
@@ -156,7 +159,7 @@ server.setRequestHandler(ListPromptsRequestSchema, async () => {
156
159
 
157
160
  return result.result || { prompts: [] };
158
161
  } catch (error) {
159
- console.error('Error listing prompts:', error);
162
+ console.error('Error listing prompts:', { error: sanitizeProxyError(error) });
160
163
  return { prompts: [] };
161
164
  }
162
165
  });
@@ -198,7 +201,9 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
198
201
  messages: []
199
202
  };
200
203
  } catch (error) {
201
- console.error(`Error getting prompt:`, error);
204
+ console.error('Error getting prompt:', {
205
+ error: sanitizeProxyError(error, request.params?.arguments || {})
206
+ });
202
207
  return {
203
208
  description: '',
204
209
  messages: []
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "cloudflare-mcp-smart-proxy",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Smart proxy for Cloudflare MCP - routes tools to cloud or local execution",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jobssteve164dev/CLOUDMCP.git",
8
+ "directory": "local-proxy"
9
+ },
5
10
  "type": "module",
6
11
  "packageManager": "pnpm@10.33.0",
7
12
  "main": "index.js",
@@ -24,7 +29,17 @@
24
29
  "author": "",
25
30
  "license": "MIT",
26
31
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.0.0"
32
+ "@modelcontextprotocol/sdk": "^1.30.0"
33
+ },
34
+ "pnpm": {
35
+ "overrides": {
36
+ "@hono/node-server": "2.0.12",
37
+ "ajv": "8.20.0",
38
+ "body-parser": "2.3.0",
39
+ "fast-uri": "3.1.4",
40
+ "path-to-regexp": "8.4.0",
41
+ "qs": "6.15.3"
42
+ }
28
43
  },
29
44
  "engines": {
30
45
  "node": ">=18.0.0"
@@ -27,14 +27,16 @@ export class CloudClient {
27
27
 
28
28
  async request(path, { method = 'GET', body, query, idempotencyKey } = {}) {
29
29
  const serializedBody = body == null ? '' : JSON.stringify(body);
30
+ const requestUrl = new URL(this.buildUrl(path, query));
30
31
  const signedHeaders = this.deviceIdentity
31
32
  ? await this.deviceIdentity.buildSignedHeaders({
32
33
  method,
33
- pathname: new URL(this.buildUrl(path, query)).pathname,
34
+ pathname: requestUrl.pathname,
35
+ search: requestUrl.search,
34
36
  body: serializedBody
35
37
  })
36
38
  : {};
37
- const response = await fetch(this.buildUrl(path, query), {
39
+ const response = await fetch(requestUrl, {
38
40
  method,
39
41
  headers: {
40
42
  'Authorization': `Bearer ${this.cloudApiKey}`,
@@ -0,0 +1,391 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 30_000;
8
+
9
+ function readText(filePath) {
10
+ try {
11
+ return fs.readFileSync(filePath, 'utf8');
12
+ } catch {
13
+ return '';
14
+ }
15
+ }
16
+
17
+ function readLink(filePath) {
18
+ try {
19
+ return fs.readlinkSync(filePath);
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ function describeRuntime(pid = process.pid) {
26
+ return {
27
+ pid,
28
+ runtimeId: (process.env.CODEX_RUNTIME_ID || '').trim() || 'unspecified',
29
+ mountNamespace: readLink(`/proc/${pid}/ns/mnt`),
30
+ cgroup: readText(`/proc/${pid}/cgroup`).trim().replace(/\s+/g, ' ')
31
+ };
32
+ }
33
+
34
+ function resolveSocketOwner(socketPath) {
35
+ if (process.platform !== 'linux') return null;
36
+ const socketRow = readText('/proc/net/unix')
37
+ .split('\n')
38
+ .map((line) => line.trim().split(/\s+/))
39
+ .find((fields) => fields.length >= 8 && fields.slice(7).join(' ') === socketPath);
40
+ const inode = socketRow?.[6];
41
+ if (!inode) return null;
42
+
43
+ let processEntries;
44
+ try {
45
+ processEntries = fs.readdirSync('/proc', { withFileTypes: true });
46
+ } catch {
47
+ return null;
48
+ }
49
+ for (const entry of processEntries) {
50
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
51
+ let fileDescriptors;
52
+ try {
53
+ fileDescriptors = fs.readdirSync(`/proc/${entry.name}/fd`);
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (!fileDescriptors.some((fd) => readLink(`/proc/${entry.name}/fd/${fd}`) === `socket:[${inode}]`)) {
58
+ continue;
59
+ }
60
+ return describeRuntime(Number(entry.name));
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function validateCodexAppServerRuntime({
66
+ socketPath,
67
+ runtimeId = '',
68
+ requireOwner = false,
69
+ ownerResolver = resolveSocketOwner
70
+ } = {}) {
71
+ if (!socketPath) throw new Error('Codex app-server socket path is required');
72
+ const expected = {
73
+ ...describeRuntime(),
74
+ runtimeId: runtimeId || (process.env.CODEX_RUNTIME_ID || '').trim() || 'unspecified'
75
+ };
76
+ const actual = ownerResolver(socketPath);
77
+ if (requireOwner && process.platform === 'linux' && !actual) {
78
+ throw new Error(
79
+ `Refusing Codex app-server socket with unknown runtime ownership: socket=${socketPath} `
80
+ + `expected_runtime=${expected.runtimeId} expected_mount_ns=${expected.mountNamespace} `
81
+ + `expected_cgroup=${expected.cgroup || 'unknown'}`
82
+ );
83
+ }
84
+ if (
85
+ actual?.mountNamespace
86
+ && expected.mountNamespace
87
+ && actual.mountNamespace !== expected.mountNamespace
88
+ ) {
89
+ throw new Error(
90
+ `Refusing Codex app-server socket from another runtime: socket=${socketPath} `
91
+ + `expected_runtime=${expected.runtimeId} expected_mount_ns=${expected.mountNamespace} `
92
+ + `expected_cgroup=${expected.cgroup || 'unknown'} actual_pid=${actual.pid || 'unknown'} `
93
+ + `actual_mount_ns=${actual.mountNamespace} actual_cgroup=${actual.cgroup || 'unknown'}`
94
+ );
95
+ }
96
+ return { expected, actual };
97
+ }
98
+
99
+ function encodeClientFrame(payload, opcode = 0x1) {
100
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
101
+ const mask = crypto.randomBytes(4);
102
+ let header;
103
+ if (body.length < 126) {
104
+ header = Buffer.alloc(2);
105
+ header[1] = 0x80 | body.length;
106
+ } else if (body.length <= 0xffff) {
107
+ header = Buffer.alloc(4);
108
+ header[1] = 0x80 | 126;
109
+ header.writeUInt16BE(body.length, 2);
110
+ } else {
111
+ header = Buffer.alloc(10);
112
+ header[1] = 0x80 | 127;
113
+ header.writeBigUInt64BE(BigInt(body.length), 2);
114
+ }
115
+ header[0] = 0x80 | opcode;
116
+ const masked = Buffer.alloc(body.length);
117
+ for (let index = 0; index < body.length; index += 1) {
118
+ masked[index] = body[index] ^ mask[index % mask.length];
119
+ }
120
+ return Buffer.concat([header, mask, masked]);
121
+ }
122
+
123
+ function readServerFrame(buffer) {
124
+ if (buffer.length < 2) return null;
125
+ const opcode = buffer[0] & 0x0f;
126
+ const masked = Boolean(buffer[1] & 0x80);
127
+ let payloadLength = buffer[1] & 0x7f;
128
+ let offset = 2;
129
+ if (payloadLength === 126) {
130
+ if (buffer.length < 4) return null;
131
+ payloadLength = buffer.readUInt16BE(2);
132
+ offset = 4;
133
+ } else if (payloadLength === 127) {
134
+ if (buffer.length < 10) return null;
135
+ const length = buffer.readBigUInt64BE(2);
136
+ if (length > BigInt(Number.MAX_SAFE_INTEGER)) {
137
+ throw new Error('Codex app-server response is too large');
138
+ }
139
+ payloadLength = Number(length);
140
+ offset = 10;
141
+ }
142
+ let mask = null;
143
+ if (masked) {
144
+ if (buffer.length < offset + 4) return null;
145
+ mask = buffer.subarray(offset, offset + 4);
146
+ offset += 4;
147
+ }
148
+ if (buffer.length < offset + payloadLength) return null;
149
+ let payload = buffer.subarray(offset, offset + payloadLength);
150
+ if (mask) {
151
+ payload = Buffer.from(payload);
152
+ for (let index = 0; index < payload.length; index += 1) {
153
+ payload[index] ^= mask[index % mask.length];
154
+ }
155
+ }
156
+ return {
157
+ opcode,
158
+ payload,
159
+ remaining: buffer.subarray(offset + payloadLength)
160
+ };
161
+ }
162
+
163
+ function createRpcError(response) {
164
+ return new Error(response?.error?.message || 'Codex app-server request failed');
165
+ }
166
+
167
+ export function resolveCodexAppServerSocket({
168
+ homeDir = os.homedir(),
169
+ codexHome = '',
170
+ socketPath = '',
171
+ runtimeDir = process.env.XDG_RUNTIME_DIR || ''
172
+ } = {}) {
173
+ const explicitSocketPath = socketPath || process.env.CODEX_APP_SERVER_SOCKET || '';
174
+ if (explicitSocketPath) return path.resolve(explicitSocketPath);
175
+ if (runtimeDir) return path.join(path.resolve(runtimeDir), 'codex', 'app-server.sock');
176
+ const resolvedCodexHome = codexHome || process.env.CODEX_HOME || path.join(homeDir, '.codex');
177
+ return path.join(resolvedCodexHome, 'app-server-control', 'app-server-control.sock');
178
+ }
179
+
180
+ function assertIsolatedSocketPath({ socketPath, homeDir, codexHome, isolationMode }) {
181
+ if (!isolationMode) return;
182
+ const resolvedCodexHome = codexHome || process.env.CODEX_HOME || path.join(homeDir, '.codex');
183
+ const legacySocketPath = path.join(path.resolve(resolvedCodexHome), 'app-server-control', 'app-server-control.sock');
184
+ if (path.resolve(socketPath) === legacySocketPath) {
185
+ throw new Error(
186
+ `Refusing shared Codex app-server socket in isolated runtime mode: socket=${socketPath}`
187
+ );
188
+ }
189
+ }
190
+
191
+ export async function connectCodexAppServer({ socketPath, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
192
+ if (!socketPath) throw new Error('Codex app-server socket path is required');
193
+ const socket = net.createConnection(socketPath);
194
+ const pending = new Map();
195
+ let nextId = 1;
196
+ let upgraded = false;
197
+ let buffer = Buffer.alloc(0);
198
+ let closed = false;
199
+
200
+ const failPending = (error) => {
201
+ for (const { reject } of pending.values()) reject(error);
202
+ pending.clear();
203
+ };
204
+
205
+ const parseFrames = () => {
206
+ while (true) {
207
+ const frame = readServerFrame(buffer);
208
+ if (!frame) return;
209
+ buffer = frame.remaining;
210
+ if (frame.opcode === 0x8) {
211
+ failPending(new Error('Codex app-server closed the connection'));
212
+ return;
213
+ }
214
+ if (frame.opcode === 0x9) {
215
+ socket.write(encodeClientFrame(frame.payload, 0x0a));
216
+ continue;
217
+ }
218
+ if (frame.opcode !== 0x1) continue;
219
+ let message;
220
+ try {
221
+ message = JSON.parse(frame.payload.toString('utf8'));
222
+ } catch {
223
+ continue;
224
+ }
225
+ if (message.id == null || !pending.has(message.id)) continue;
226
+ const request = pending.get(message.id);
227
+ pending.delete(message.id);
228
+ clearTimeout(request.timer);
229
+ if (message.error) request.reject(createRpcError(message));
230
+ else request.resolve(message.result);
231
+ }
232
+ };
233
+
234
+ const connected = new Promise((resolve, reject) => {
235
+ const timer = setTimeout(() => reject(new Error('Timed out connecting to Codex app-server')), timeoutMs);
236
+ socket.once('connect', () => {
237
+ const websocketKey = crypto.randomBytes(16).toString('base64');
238
+ socket.write([
239
+ 'GET / HTTP/1.1',
240
+ 'Host: localhost',
241
+ 'Upgrade: websocket',
242
+ 'Connection: Upgrade',
243
+ `Sec-WebSocket-Key: ${websocketKey}`,
244
+ 'Sec-WebSocket-Version: 13',
245
+ '',
246
+ ''
247
+ ].join('\r\n'));
248
+ });
249
+ socket.on('data', (chunk) => {
250
+ buffer = Buffer.concat([buffer, chunk]);
251
+ if (!upgraded) {
252
+ const headerEnd = buffer.indexOf('\r\n\r\n');
253
+ if (headerEnd < 0) return;
254
+ const header = buffer.subarray(0, headerEnd).toString('utf8');
255
+ if (!/^HTTP\/1\.1 101\b/m.test(header)) {
256
+ clearTimeout(timer);
257
+ reject(new Error('Codex app-server rejected the WebSocket upgrade'));
258
+ return;
259
+ }
260
+ buffer = buffer.subarray(headerEnd + 4);
261
+ upgraded = true;
262
+ clearTimeout(timer);
263
+ resolve();
264
+ }
265
+ try {
266
+ parseFrames();
267
+ } catch (error) {
268
+ failPending(error);
269
+ socket.destroy(error);
270
+ }
271
+ });
272
+ socket.once('error', (error) => {
273
+ clearTimeout(timer);
274
+ reject(error);
275
+ failPending(error);
276
+ });
277
+ socket.once('close', () => {
278
+ if (!closed) failPending(new Error('Codex app-server connection closed'));
279
+ });
280
+ });
281
+
282
+ await connected;
283
+
284
+ const request = (method, params = {}) => new Promise((resolve, reject) => {
285
+ const id = nextId;
286
+ nextId += 1;
287
+ const timer = setTimeout(() => {
288
+ pending.delete(id);
289
+ reject(new Error(`Timed out waiting for Codex app-server method ${method}`));
290
+ }, timeoutMs);
291
+ pending.set(id, { resolve, reject, timer });
292
+ socket.write(encodeClientFrame(JSON.stringify({ method, id, params })));
293
+ });
294
+
295
+ await request('initialize', {
296
+ clientInfo: {
297
+ name: 'cloudmcp_connector',
298
+ title: 'CloudMCP Connector',
299
+ version: '1.0.0'
300
+ }
301
+ });
302
+ socket.write(encodeClientFrame(JSON.stringify({ method: 'initialized' })));
303
+
304
+ return {
305
+ request,
306
+ close() {
307
+ if (closed) return;
308
+ closed = true;
309
+ socket.end(encodeClientFrame(Buffer.alloc(0), 0x8));
310
+ }
311
+ };
312
+ }
313
+
314
+ function normalizeServerRows(result) {
315
+ if (Array.isArray(result?.data)) return result.data;
316
+ if (Array.isArray(result?.items)) return result.items;
317
+ if (Array.isArray(result?.servers)) return result.servers;
318
+ return [];
319
+ }
320
+
321
+ export async function reloadCodexMcpServer({
322
+ serverName = 'cloudmcp',
323
+ homeDir = os.homedir(),
324
+ codexHome = '',
325
+ socketPath = '',
326
+ runtimeDir = process.env.XDG_RUNTIME_DIR || '',
327
+ runtimeId = process.env.CODEX_RUNTIME_ID || '',
328
+ isolationMode = Boolean(
329
+ process.env.CODEX_RUNTIME_ISOLATION === '1'
330
+ || process.env.CODEX_APP_SERVER_SOCKET
331
+ || process.env.CODEX_RUNTIME_ID
332
+ || process.env.XDG_RUNTIME_DIR
333
+ ),
334
+ timeoutMs = DEFAULT_TIMEOUT_MS,
335
+ rpcClient = null,
336
+ runtimeValidator = validateCodexAppServerRuntime
337
+ } = {}) {
338
+ const resolvedSocketPath = resolveCodexAppServerSocket({
339
+ homeDir,
340
+ codexHome,
341
+ socketPath,
342
+ runtimeDir
343
+ });
344
+ assertIsolatedSocketPath({
345
+ socketPath: resolvedSocketPath,
346
+ homeDir,
347
+ codexHome,
348
+ isolationMode
349
+ });
350
+ if (!rpcClient && !fs.existsSync(resolvedSocketPath)) {
351
+ return {
352
+ status: 'next_start',
353
+ serverName,
354
+ socketPath: resolvedSocketPath,
355
+ toolCount: null
356
+ };
357
+ }
358
+
359
+ let client = rpcClient;
360
+ try {
361
+ if (!rpcClient) {
362
+ runtimeValidator({
363
+ socketPath: resolvedSocketPath,
364
+ runtimeId,
365
+ requireOwner: isolationMode
366
+ });
367
+ }
368
+ client ||= await connectCodexAppServer({ socketPath: resolvedSocketPath, timeoutMs });
369
+ await client.request('config/mcpServer/reload', {});
370
+ const result = await client.request('mcpServerStatus/list', { cursor: null, limit: 100 });
371
+ const server = normalizeServerRows(result).find((item) => item?.name === serverName);
372
+ if (!server) {
373
+ throw new Error(`Codex did not load MCP server "${serverName}" after config reload`);
374
+ }
375
+ const toolCount = Array.isArray(server.tools)
376
+ ? server.tools.length
377
+ : (server.tools && typeof server.tools === 'object' ? Object.keys(server.tools).length : 0);
378
+ if (toolCount === 0) {
379
+ throw new Error(`Codex loaded MCP server "${serverName}" without any tools`);
380
+ }
381
+ return {
382
+ status: 'ready',
383
+ serverName,
384
+ socketPath: resolvedSocketPath,
385
+ toolCount,
386
+ authStatus: server.authStatus || server.auth_status || null
387
+ };
388
+ } finally {
389
+ if (!rpcClient) client?.close?.();
390
+ }
391
+ }
@@ -34,6 +34,7 @@ export class ConnectorBridge {
34
34
  connectorId,
35
35
  connectorType,
36
36
  workspaceId,
37
+ deviceIdentityPath = '',
37
38
  deviceIdentity = null,
38
39
  cloudClient = null
39
40
  }) {
@@ -42,7 +43,7 @@ export class ConnectorBridge {
42
43
  this.connectorId = normalizeString(connectorId, defaultConnectorId());
43
44
  this.connectorType = normalizeString(connectorType, 'smart_proxy');
44
45
  this.workspaceId = normalizeString(workspaceId, defaultWorkspaceId(this.workspaceRoot));
45
- this.deviceIdentity = deviceIdentity || new DeviceIdentity();
46
+ this.deviceIdentity = deviceIdentity || new DeviceIdentity({ identityPath: deviceIdentityPath });
46
47
  this.cloudClient = cloudClient || new CloudClient({
47
48
  cloudUrl,
48
49
  cloudApiKey,
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { webcrypto } from 'crypto';
5
5
 
6
6
  const subtle = webcrypto.subtle;
7
- const DEVICE_SIGNATURE_VERSION = 'device_sig_v1';
7
+ const DEVICE_SIGNATURE_VERSION = 'device_sig_v2';
8
8
 
9
9
  function bytesToBase64(bytes) {
10
10
  return Buffer.from(bytes).toString('base64');
@@ -15,8 +15,10 @@ async function sha256Hex(value) {
15
15
  return Buffer.from(digest).toString('hex');
16
16
  }
17
17
 
18
- function defaultIdentityPath() {
19
- return path.join(os.homedir(), '.cloudmcp', 'device-identity.json');
18
+ export function resolveDeviceIdentityPath({ identityPath = '', homeDir = os.homedir(), env = process.env } = {}) {
19
+ return identityPath
20
+ || env.CLOUDMCP_DEVICE_IDENTITY_PATH
21
+ || path.join(homeDir, '.cloudmcp', 'device-identity.json');
20
22
  }
21
23
 
22
24
  function ensureParent(filePath) {
@@ -24,8 +26,8 @@ function ensureParent(filePath) {
24
26
  }
25
27
 
26
28
  export class DeviceIdentity {
27
- constructor({ identityPath = defaultIdentityPath() } = {}) {
28
- this.identityPath = identityPath;
29
+ constructor({ identityPath = '' } = {}) {
30
+ this.identityPath = resolveDeviceIdentityPath({ identityPath });
29
31
  this.record = null;
30
32
  this.privateKey = null;
31
33
  }
@@ -40,15 +42,29 @@ export class DeviceIdentity {
40
42
  true,
41
43
  ['sign', 'verify']
42
44
  );
43
- this.record = {
45
+ const generatedRecord = {
44
46
  deviceId: `dev_${crypto.randomUUID()}`,
45
47
  publicKeyJwk: await subtle.exportKey('jwk', keyPair.publicKey),
46
48
  privateKeyJwk: await subtle.exportKey('jwk', keyPair.privateKey),
47
49
  createdAt: Date.now()
48
50
  };
49
51
  ensureParent(this.identityPath);
50
- fs.writeFileSync(this.identityPath, `${JSON.stringify(this.record, null, 2)}\n`, { mode: 0o600 });
52
+ try {
53
+ fs.writeFileSync(this.identityPath, `${JSON.stringify(generatedRecord, null, 2)}\n`, {
54
+ encoding: 'utf8',
55
+ flag: 'wx',
56
+ mode: 0o600
57
+ });
58
+ this.record = generatedRecord;
59
+ } catch (error) {
60
+ if (error?.code !== 'EEXIST') throw error;
61
+ this.record = JSON.parse(fs.readFileSync(this.identityPath, 'utf8'));
62
+ }
51
63
  }
64
+ if (!this.record?.deviceId || !this.record?.publicKeyJwk || !this.record?.privateKeyJwk) {
65
+ throw new Error(`Invalid CloudMCP device identity at ${this.identityPath}`);
66
+ }
67
+ fs.chmodSync(this.identityPath, 0o600);
52
68
  this.privateKey = await subtle.importKey(
53
69
  'jwk',
54
70
  this.record.privateKeyJwk,
@@ -59,7 +75,7 @@ export class DeviceIdentity {
59
75
  return this.record;
60
76
  }
61
77
 
62
- async buildSignedHeaders({ method, pathname, body = '' }) {
78
+ async buildSignedHeaders({ method, pathname, search = '', body = '' }) {
63
79
  const record = await this.loadOrCreate();
64
80
  const timestamp = String(Date.now());
65
81
  const nonce = crypto.randomUUID();
@@ -67,7 +83,7 @@ export class DeviceIdentity {
67
83
  const canonical = [
68
84
  DEVICE_SIGNATURE_VERSION,
69
85
  String(method || 'GET').toUpperCase(),
70
- pathname || '/',
86
+ `${pathname || '/'}${search || ''}`,
71
87
  bodyHash,
72
88
  timestamp,
73
89
  nonce
@@ -79,6 +95,7 @@ export class DeviceIdentity {
79
95
  );
80
96
  return {
81
97
  'X-CloudMCP-Device-ID': record.deviceId,
98
+ 'X-CloudMCP-Device-Signature-Version': DEVICE_SIGNATURE_VERSION,
82
99
  'X-CloudMCP-Device-Timestamp': timestamp,
83
100
  'X-CloudMCP-Device-Nonce': nonce,
84
101
  'X-CloudMCP-Device-Signature': bytesToBase64(signature)
@@ -16,7 +16,11 @@
16
16
  import fs from 'fs';
17
17
  import os from 'os';
18
18
  import path from 'path';
19
- import { mergeCodexConfig, mergeClaudeCodeConfig } from './reference-connectors.js';
19
+ import {
20
+ mergeCodexConfig,
21
+ mergeClaudeCodeConfig,
22
+ writeFileAtomically
23
+ } from './reference-connectors.js';
20
24
 
21
25
  // ── IDE detection ─────────────────────────────────────────────────────────────
22
26
 
@@ -130,8 +134,10 @@ function _applyCodex(snapshot, workspaceRoot) {
130
134
  const mcpServers = snapshot.mcpServers || {};
131
135
 
132
136
  if (Object.keys(mcpServers).length > 0) {
133
- const configDir = path.join(os.homedir(), '.codex');
134
- const configFile = path.join(configDir, 'config.toml');
137
+ const configFile = path.resolve(
138
+ (process.env.CODEX_SHARED_CONFIG_PATH || '').trim()
139
+ || path.join(os.homedir(), '.codex', 'config.toml')
140
+ );
135
141
  _ensureDir(configFile);
136
142
  let content = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
137
143
  for (const [serverName, serverDefinition] of Object.entries(mcpServers)) {
@@ -140,7 +146,7 @@ function _applyCodex(snapshot, workspaceRoot) {
140
146
  enabled: serverDefinition?.enabled !== false
141
147
  });
142
148
  }
143
- fs.writeFileSync(configFile, content, 'utf8');
149
+ writeFileAtomically(configFile, content);
144
150
  results.push({ action: 'merged_mcp', file: configFile, count: Object.keys(mcpServers).length });
145
151
  }
146
152
 
@@ -5,6 +5,7 @@ import path from 'path';
5
5
  const DEFAULT_SERVER_NAME = 'cloudmcp';
6
6
  const DEFAULT_PACKAGE_NAME = 'cloudflare-mcp-smart-proxy';
7
7
  const DEFAULT_PACKAGE_BIN = 'cloudflare-mcp-proxy';
8
+ const DEFAULT_CODEX_STARTUP_TIMEOUT_SEC = 30;
8
9
  const MANAGED_BLOCK_PREFIX = '# BEGIN CLOUDMCP MANAGED MCP SERVER';
9
10
  const MANAGED_BLOCK_SUFFIX = '# END CLOUDMCP MANAGED MCP SERVER';
10
11
 
@@ -43,6 +44,34 @@ function ensureDir(targetPath) {
43
44
  }
44
45
  }
45
46
 
47
+ function resolveAtomicWriteTarget(filePath) {
48
+ try {
49
+ if (fs.lstatSync(filePath).isSymbolicLink()) {
50
+ return fs.realpathSync(filePath);
51
+ }
52
+ } catch (error) {
53
+ if (error?.code !== 'ENOENT') throw error;
54
+ }
55
+ return filePath;
56
+ }
57
+
58
+ export function writeFileAtomically(filePath, content) {
59
+ const writeTarget = resolveAtomicWriteTarget(filePath);
60
+ ensureDir(writeTarget);
61
+ const temporaryPath = `${writeTarget}.${process.pid}.${Date.now()}.tmp`;
62
+ try {
63
+ fs.writeFileSync(temporaryPath, content, {
64
+ encoding: 'utf8',
65
+ flag: 'wx',
66
+ mode: 0o600
67
+ });
68
+ fs.renameSync(temporaryPath, writeTarget);
69
+ } catch (error) {
70
+ if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
71
+ throw error;
72
+ }
73
+ }
74
+
46
75
  function readJson(filePath, fallback) {
47
76
  try {
48
77
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -68,7 +97,9 @@ function toManagedEnv({
68
97
  clientProfileId,
69
98
  connectorId,
70
99
  connectorType,
71
- workspaceId
100
+ workspaceId,
101
+ deviceIdentityPath,
102
+ codexConfigPath
72
103
  }) {
73
104
  const env = {
74
105
  CLOUDFLARE_MCP_URL: normalizeEnvValue(cloudUrl),
@@ -77,7 +108,9 @@ function toManagedEnv({
77
108
  CLOUDMCP_CLIENT_PROFILE_ID: normalizeEnvValue(clientProfileId),
78
109
  CLOUDMCP_CONNECTOR_ID: normalizeEnvValue(connectorId),
79
110
  CLOUDMCP_CONNECTOR_TYPE: normalizeEnvValue(connectorType),
80
- CLOUDMCP_WORKSPACE_ID: normalizeEnvValue(workspaceId)
111
+ CLOUDMCP_WORKSPACE_ID: normalizeEnvValue(workspaceId),
112
+ CLOUDMCP_DEVICE_IDENTITY_PATH: normalizeEnvValue(deviceIdentityPath),
113
+ CODEX_SHARED_CONFIG_PATH: normalizeEnvValue(codexConfigPath)
81
114
  };
82
115
 
83
116
  return Object.fromEntries(
@@ -118,6 +151,8 @@ export function buildReferenceConnectorServer({
118
151
  connectorId = '',
119
152
  connectorType = '',
120
153
  workspaceId = '',
154
+ deviceIdentityPath = '',
155
+ codexConfigPath = '',
121
156
  packageRoot = null,
122
157
  runtime = 'npm'
123
158
  }) {
@@ -139,9 +174,12 @@ export function buildReferenceConnectorServer({
139
174
  clientProfileId,
140
175
  connectorId: resolvedConnectorId,
141
176
  connectorType: resolvedConnectorType,
142
- workspaceId: resolvedWorkspaceId
177
+ workspaceId: resolvedWorkspaceId,
178
+ deviceIdentityPath,
179
+ codexConfigPath: normalizedEcosystem === 'codex' ? codexConfigPath : ''
143
180
  }),
144
181
  enabled: true,
182
+ startupTimeoutSec: normalizedEcosystem === 'codex' ? DEFAULT_CODEX_STARTUP_TIMEOUT_SEC : null,
145
183
  connectorId: resolvedConnectorId,
146
184
  connectorType: resolvedConnectorType,
147
185
  workspaceId: resolvedWorkspaceId
@@ -152,7 +190,8 @@ export function getReferenceConnectorTarget({
152
190
  ecosystem,
153
191
  workspaceRoot = process.cwd(),
154
192
  scope = 'user',
155
- homeDir = null
193
+ homeDir = null,
194
+ codexConfigPath = ''
156
195
  }) {
157
196
  const normalizedEcosystem = normalizeString(ecosystem).toLowerCase();
158
197
  const normalizedScope = normalizeString(scope, 'user').toLowerCase();
@@ -164,7 +203,11 @@ export function getReferenceConnectorTarget({
164
203
  ecosystem: normalizedEcosystem,
165
204
  scope: 'user',
166
205
  format: 'toml',
167
- targetFile: path.join(resolvedHomeDir, '.codex', 'config.toml')
206
+ targetFile: path.resolve(
207
+ normalizeString(codexConfigPath)
208
+ || normalizeString(process.env.CODEX_SHARED_CONFIG_PATH)
209
+ || path.join(resolvedHomeDir, '.codex', 'config.toml')
210
+ )
168
211
  };
169
212
  }
170
213
 
@@ -197,6 +240,10 @@ export function buildCodexManagedBlock(serverName, serverDefinition) {
197
240
  `enabled = ${serverDefinition.enabled === false ? 'false' : 'true'}`
198
241
  ];
199
242
 
243
+ if (Number.isFinite(serverDefinition.startupTimeoutSec)) {
244
+ lines.push(`startup_timeout_sec = ${serverDefinition.startupTimeoutSec}`);
245
+ }
246
+
200
247
  const envEntries = Object.entries(serverDefinition.env || {});
201
248
  if (envEntries.length > 0) {
202
249
  lines.push('', `[mcp_servers.${serverName}.env]`);
@@ -254,6 +301,8 @@ export function installReferenceConnector({
254
301
  scope = 'user',
255
302
  serverName = DEFAULT_SERVER_NAME,
256
303
  homeDir = null,
304
+ codexConfigPath = '',
305
+ codexDeviceIdentityPath = '',
257
306
  dryRun = false
258
307
  }) {
259
308
  const normalizedServerName = sanitizeServerName(serverName);
@@ -261,8 +310,35 @@ export function installReferenceConnector({
261
310
  ecosystem,
262
311
  workspaceRoot,
263
312
  scope,
264
- homeDir
313
+ homeDir,
314
+ codexConfigPath
265
315
  });
316
+ const resolvedHomeDir = resolveHomeDir(homeDir);
317
+ const deviceIdentityPath = target.ecosystem === 'codex'
318
+ ? path.resolve(
319
+ normalizeString(codexDeviceIdentityPath)
320
+ || normalizeString(process.env.CODEX_SHARED_DEVICE_IDENTITY_PATH)
321
+ || path.join(resolvedHomeDir, '.codex', 'cloudmcp', 'device-identity.json')
322
+ )
323
+ : path.join(resolvedHomeDir, '.cloudmcp', 'device-identity.json');
324
+ const legacyDeviceIdentityPath = path.join(resolvedHomeDir, '.cloudmcp', 'device-identity.json');
325
+
326
+ let migratedDeviceIdentity = false;
327
+ if (
328
+ !dryRun
329
+ && target.ecosystem === 'codex'
330
+ && !fs.existsSync(deviceIdentityPath)
331
+ && fs.existsSync(legacyDeviceIdentityPath)
332
+ ) {
333
+ ensureDir(deviceIdentityPath);
334
+ try {
335
+ fs.copyFileSync(legacyDeviceIdentityPath, deviceIdentityPath, fs.constants.COPYFILE_EXCL);
336
+ fs.chmodSync(deviceIdentityPath, 0o600);
337
+ migratedDeviceIdentity = true;
338
+ } catch (error) {
339
+ if (error?.code !== 'EEXIST') throw error;
340
+ }
341
+ }
266
342
  const serverDefinition = buildReferenceConnectorServer({
267
343
  ecosystem,
268
344
  cloudUrl,
@@ -272,6 +348,8 @@ export function installReferenceConnector({
272
348
  connectorId,
273
349
  connectorType,
274
350
  workspaceId,
351
+ deviceIdentityPath,
352
+ codexConfigPath: target.ecosystem === 'codex' ? target.targetFile : '',
275
353
  packageRoot,
276
354
  runtime
277
355
  });
@@ -292,8 +370,7 @@ export function installReferenceConnector({
292
370
  }
293
371
 
294
372
  if (!dryRun) {
295
- ensureDir(target.targetFile);
296
- fs.writeFileSync(target.targetFile, renderedContent, 'utf8');
373
+ writeFileAtomically(target.targetFile, renderedContent);
297
374
  }
298
375
 
299
376
  return {
@@ -303,7 +380,9 @@ export function installReferenceConnector({
303
380
  serverName: normalizedServerName,
304
381
  serverDefinition,
305
382
  renderedContent,
306
- written: !dryRun
383
+ written: !dryRun,
384
+ deviceIdentityPath,
385
+ migratedDeviceIdentity
307
386
  };
308
387
  }
309
388
 
package/src/router.js CHANGED
@@ -2,6 +2,41 @@
2
2
  * Smart Router - 智能路由工具请求到云端或本地
3
3
  */
4
4
 
5
+ function collectArgumentStrings(value, output = new Set(), seen = new WeakSet()) {
6
+ if (typeof value === 'string') {
7
+ const normalized = value.trim();
8
+ if (normalized.length >= 2) output.add(normalized);
9
+ return output;
10
+ }
11
+ if (value == null || typeof value !== 'object' || seen.has(value)) return output;
12
+ seen.add(value);
13
+ for (const item of Array.isArray(value) ? value : Object.values(value)) {
14
+ collectArgumentStrings(item, output, seen);
15
+ }
16
+ return output;
17
+ }
18
+
19
+ export function sanitizeProxyError(error, params = {}) {
20
+ let message = typeof error === 'string'
21
+ ? error
22
+ : (error?.message || JSON.stringify(error || 'Unknown error'));
23
+ const values = Array.from(collectArgumentStrings(params))
24
+ .sort((left, right) => right.length - left.length);
25
+ for (const value of values) {
26
+ const variants = new Set([value]);
27
+ try {
28
+ variants.add(encodeURIComponent(value));
29
+ } catch {}
30
+ for (const variant of variants) {
31
+ message = message.split(variant).join('[REDACTED_PARAM]');
32
+ }
33
+ }
34
+ return String(message)
35
+ .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]')
36
+ .replace(/mcp_[A-Za-z0-9]{16,}/g, '[REDACTED]')
37
+ .slice(0, 512);
38
+ }
39
+
5
40
  export class SmartRouter {
6
41
  constructor(cloudUrl, cloudApiKey, localTools, workspaceRoot = null, deviceIdentity = null) {
7
42
  this.cloudUrl = cloudUrl.replace(/\/$/, ''); // 移除尾部斜杠
@@ -195,15 +230,13 @@ export class SmartRouter {
195
230
  errorMessage = errorMessage.message || JSON.stringify(errorMessage, null, 2);
196
231
  }
197
232
 
198
- // 记录详细错误信息用于调试
233
+ const safeErrorMessage = sanitizeProxyError(errorMessage, params);
199
234
  console.error(`[SmartRouter] Cloud tool error for ${toolName}:`, {
200
235
  code: result.error.code,
201
- message: result.error.message,
202
- data: result.error.data,
203
- fullError: result.error
236
+ message: safeErrorMessage
204
237
  });
205
238
 
206
- throw new Error(errorMessage);
239
+ throw new Error(safeErrorMessage);
207
240
  }
208
241
 
209
242
  // 提取结果内容
@@ -229,19 +262,17 @@ export class SmartRouter {
229
262
  return result;
230
263
  }
231
264
  } catch (error) {
232
- // 记录完整错误信息用于调试
265
+ const safeErrorMessage = sanitizeProxyError(error, params);
233
266
  console.error(`[SmartRouter] Error calling cloud tool ${toolName}:`, {
234
- error: error.message,
235
- stack: error.stack,
236
- params: params
267
+ error: safeErrorMessage
237
268
  });
238
269
 
239
270
  // 如果错误信息已经包含 "Cloud tool call failed",直接抛出
240
271
  // 否则添加前缀
241
- if (error.message && error.message.includes('Cloud tool call failed')) {
242
- throw error;
272
+ if (safeErrorMessage.includes('Cloud tool call failed')) {
273
+ throw new Error(safeErrorMessage);
243
274
  }
244
- throw new Error(`Cloud tool call failed: ${error.message}`);
275
+ throw new Error(`Cloud tool call failed: ${safeErrorMessage}`);
245
276
  }
246
277
  }
247
278
 
@@ -249,41 +280,35 @@ export class SmartRouter {
249
280
  * 获取所有工具列表(合并云端和本地)
250
281
  */
251
282
  async getAllTools() {
252
- try {
253
- const [cloudTools, localTools] = await Promise.all([
254
- this.getCloudTools(),
255
- this.localTools.listTools()
256
- ]);
257
-
258
- // 合并工具列表,添加 source 标识
259
- const allTools = [
260
- ...cloudTools.map(t => ({ ...t, source: 'cloud' })),
261
- ...localTools.map(t => ({ ...t, source: 'local' }))
262
- ];
283
+ const [cloudTools, localTools] = await Promise.all([
284
+ this.getCloudTools(),
285
+ this.localTools.listTools()
286
+ ]);
263
287
 
264
- return allTools;
265
- } catch (error) {
266
- console.error('Error getting all tools:', error);
267
- // 如果云端获取失败,至少返回本地工具
268
- return this.localTools.listTools().map(t => ({ ...t, source: 'local' }));
288
+ if (cloudTools.length === 0) {
289
+ throw new Error('CloudMCP returned no governed cloud tools');
269
290
  }
291
+
292
+ return [
293
+ ...cloudTools.map(t => ({ ...t, source: 'cloud' })),
294
+ ...localTools.map(t => ({ ...t, source: 'local' }))
295
+ ];
270
296
  }
271
297
 
272
298
  /**
273
299
  * 获取云端工具列表
274
300
  */
275
301
  async getCloudTools() {
276
- try {
277
- const body = JSON.stringify({
302
+ const body = JSON.stringify({
278
303
  jsonrpc: '2.0',
279
304
  id: Date.now(),
280
305
  method: 'tools/list',
281
306
  params: {}
282
307
  });
283
- const signedHeaders = this.deviceIdentity
284
- ? await this.deviceIdentity.buildSignedHeaders({ method: 'POST', pathname: '/mcp', body })
285
- : {};
286
- const response = await fetch(`${this.cloudUrl}/mcp`, {
308
+ const signedHeaders = this.deviceIdentity
309
+ ? await this.deviceIdentity.buildSignedHeaders({ method: 'POST', pathname: '/mcp', body })
310
+ : {};
311
+ const response = await fetch(`${this.cloudUrl}/mcp`, {
287
312
  method: 'POST',
288
313
  headers: {
289
314
  'Authorization': `Bearer ${this.cloudApiKey}`,
@@ -293,20 +318,14 @@ export class SmartRouter {
293
318
  body
294
319
  });
295
320
 
296
- if (!response.ok) {
297
- throw new Error(`Failed to fetch cloud tools: ${response.status}`);
298
- }
321
+ if (!response.ok) {
322
+ throw new Error(`Failed to fetch cloud tools: ${response.status}`);
323
+ }
299
324
 
300
- const result = await response.json();
301
-
302
- if (result.error) {
303
- throw new Error(result.error.message || 'Failed to fetch cloud tools');
304
- }
305
-
306
- return result.result?.tools || [];
307
- } catch (error) {
308
- console.error('Error fetching cloud tools:', error);
309
- return [];
325
+ const result = await response.json();
326
+ if (result.error) {
327
+ throw new Error(result.error.message || 'Failed to fetch cloud tools');
310
328
  }
329
+ return result.result?.tools || [];
311
330
  }
312
331
  }