cloudflare-mcp-smart-proxy 1.3.1 → 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
@@ -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
 
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "cloudflare-mcp-smart-proxy",
3
- "version": "1.3.1",
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",
@@ -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,
@@ -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,
@@ -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
@@ -280,41 +280,35 @@ export class SmartRouter {
280
280
  * 获取所有工具列表(合并云端和本地)
281
281
  */
282
282
  async getAllTools() {
283
- try {
284
- const [cloudTools, localTools] = await Promise.all([
285
- this.getCloudTools(),
286
- this.localTools.listTools()
287
- ]);
288
-
289
- // 合并工具列表,添加 source 标识
290
- const allTools = [
291
- ...cloudTools.map(t => ({ ...t, source: 'cloud' })),
292
- ...localTools.map(t => ({ ...t, source: 'local' }))
293
- ];
283
+ const [cloudTools, localTools] = await Promise.all([
284
+ this.getCloudTools(),
285
+ this.localTools.listTools()
286
+ ]);
294
287
 
295
- return allTools;
296
- } catch (error) {
297
- console.error('Error getting all tools:', error);
298
- // 如果云端获取失败,至少返回本地工具
299
- return this.localTools.listTools().map(t => ({ ...t, source: 'local' }));
288
+ if (cloudTools.length === 0) {
289
+ throw new Error('CloudMCP returned no governed cloud tools');
300
290
  }
291
+
292
+ return [
293
+ ...cloudTools.map(t => ({ ...t, source: 'cloud' })),
294
+ ...localTools.map(t => ({ ...t, source: 'local' }))
295
+ ];
301
296
  }
302
297
 
303
298
  /**
304
299
  * 获取云端工具列表
305
300
  */
306
301
  async getCloudTools() {
307
- try {
308
- const body = JSON.stringify({
302
+ const body = JSON.stringify({
309
303
  jsonrpc: '2.0',
310
304
  id: Date.now(),
311
305
  method: 'tools/list',
312
306
  params: {}
313
307
  });
314
- const signedHeaders = this.deviceIdentity
315
- ? await this.deviceIdentity.buildSignedHeaders({ method: 'POST', pathname: '/mcp', body })
316
- : {};
317
- 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`, {
318
312
  method: 'POST',
319
313
  headers: {
320
314
  'Authorization': `Bearer ${this.cloudApiKey}`,
@@ -324,20 +318,14 @@ export class SmartRouter {
324
318
  body
325
319
  });
326
320
 
327
- if (!response.ok) {
328
- throw new Error(`Failed to fetch cloud tools: ${response.status}`);
329
- }
321
+ if (!response.ok) {
322
+ throw new Error(`Failed to fetch cloud tools: ${response.status}`);
323
+ }
330
324
 
331
- const result = await response.json();
332
-
333
- if (result.error) {
334
- throw new Error(result.error.message || 'Failed to fetch cloud tools');
335
- }
336
-
337
- return result.result?.tools || [];
338
- } catch (error) {
339
- console.error('Error fetching cloud tools:', error);
340
- return [];
325
+ const result = await response.json();
326
+ if (result.error) {
327
+ throw new Error(result.error.message || 'Failed to fetch cloud tools');
341
328
  }
329
+ return result.result?.tools || [];
342
330
  }
343
331
  }