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 +17 -2
- package/connector-cli.js +50 -3
- package/index.js +11 -6
- package/package.json +17 -2
- package/src/cloud-client.js +4 -2
- package/src/codex-app-server.js +391 -0
- package/src/connector-bridge.js +2 -1
- package/src/device-identity.js +26 -9
- package/src/ide-configurator.js +10 -4
- package/src/reference-connectors.js +88 -9
- package/src/router.js +66 -47
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
|
-
-
|
|
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
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
+
"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.
|
|
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"
|
package/src/cloud-client.js
CHANGED
|
@@ -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:
|
|
34
|
+
pathname: requestUrl.pathname,
|
|
35
|
+
search: requestUrl.search,
|
|
34
36
|
body: serializedBody
|
|
35
37
|
})
|
|
36
38
|
: {};
|
|
37
|
-
const response = await fetch(
|
|
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
|
+
}
|
package/src/connector-bridge.js
CHANGED
|
@@ -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,
|
package/src/device-identity.js
CHANGED
|
@@ -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 = '
|
|
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
|
|
19
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/ide-configurator.js
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import path from 'path';
|
|
19
|
-
import {
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
202
|
-
data: result.error.data,
|
|
203
|
-
fullError: result.error
|
|
236
|
+
message: safeErrorMessage
|
|
204
237
|
});
|
|
205
238
|
|
|
206
|
-
throw new Error(
|
|
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:
|
|
235
|
-
stack: error.stack,
|
|
236
|
-
params: params
|
|
267
|
+
error: safeErrorMessage
|
|
237
268
|
});
|
|
238
269
|
|
|
239
270
|
// 如果错误信息已经包含 "Cloud tool call failed",直接抛出
|
|
240
271
|
// 否则添加前缀
|
|
241
|
-
if (
|
|
242
|
-
throw
|
|
272
|
+
if (safeErrorMessage.includes('Cloud tool call failed')) {
|
|
273
|
+
throw new Error(safeErrorMessage);
|
|
243
274
|
}
|
|
244
|
-
throw new Error(`Cloud tool call failed: ${
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
throw new Error(`Failed to fetch cloud tools: ${response.status}`);
|
|
323
|
+
}
|
|
299
324
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
}
|