@hzxwonder/dsh-mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/deepseek-worker/SKILL.md +40 -9
- package/README.md +79 -32
- package/config/routing.md +26 -4
- package/config/worker.patch.yml +12 -2
- package/package.json +5 -1
- package/server.json +21 -6
- package/src/cli.js +4 -0
- package/src/doctor.js +230 -0
- package/src/provider.js +301 -5
- package/src/server.js +12 -7
- package/src/setup-terminal.js +183 -0
- package/src/setup.js +69 -69
- package/src/worker.js +39 -13
- package/src/setup-ui.js +0 -112
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
import { PROTOCOLS, DEFAULT_PROTOCOL, normalizeBaseUrl, resolveProviderSettings, discoverModels, testConnection } from './provider.js';
|
|
4
|
+
|
|
5
|
+
const clean = (value) => String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
|
|
6
|
+
|
|
7
|
+
export function maskApiKey(value) {
|
|
8
|
+
if (!value) return '未设置';
|
|
9
|
+
if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return '[REDACTED]';
|
|
10
|
+
const masked = value.length >= 12 ? `********${value.slice(-4)}` : '********';
|
|
11
|
+
return masked === value ? '[REDACTED]' : masked;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function question(prompt, hidden = false) {
|
|
15
|
+
if (!process.stdin.isTTY) throw new Error('配置向导需要交互式终端。请在终端运行 dsh-mcp setup。');
|
|
16
|
+
const output = hidden ? new Writable({ write(_chunk, _encoding, done) { done(); } }) : process.stdout;
|
|
17
|
+
const input = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
|
18
|
+
if (hidden) process.stdout.write(prompt);
|
|
19
|
+
return new Promise((accept, reject) => {
|
|
20
|
+
input.once('SIGINT', () => { input.close(); reject(new Error('配置已取消,尚未保存。')); });
|
|
21
|
+
input.once('close', () => reject(new Error('输入已关闭,尚未保存。')));
|
|
22
|
+
input.question(hidden ? '' : prompt, (answer) => {
|
|
23
|
+
accept(answer);
|
|
24
|
+
input.close();
|
|
25
|
+
if (hidden) process.stdout.write('\n');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function progress(label, operation) {
|
|
31
|
+
const frames = ['|', '/', '-', '\\'];
|
|
32
|
+
let frame = 0;
|
|
33
|
+
process.stdout.write(`${label} ${frames[frame]}`);
|
|
34
|
+
const timer = setInterval(() => process.stdout.write(`\r\u001b[2K${label} ${frames[++frame % frames.length]}`), 120);
|
|
35
|
+
try { return await operation(AbortSignal.timeout(45000)); }
|
|
36
|
+
finally { clearInterval(timer); process.stdout.write('\r\u001b[2K'); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runTerminalSetup({ settings, apiKey = '', existing = false, io = {} }) {
|
|
40
|
+
const ask = io.ask ?? question;
|
|
41
|
+
const log = io.log ?? console.log;
|
|
42
|
+
const run = io.progress ?? progress;
|
|
43
|
+
const draft = { ...settings };
|
|
44
|
+
let key = apiKey;
|
|
45
|
+
const errorText = (error) => {
|
|
46
|
+
const message = String(error?.message ?? error);
|
|
47
|
+
return clean(key ? message.split(key).join('[REDACTED]') : message);
|
|
48
|
+
};
|
|
49
|
+
const protocol = () => PROTOCOLS.find((item) => item.id === draft.protocol);
|
|
50
|
+
const summary = () => {
|
|
51
|
+
log('\n配置汇总(密钥不会明文显示)');
|
|
52
|
+
log(` 1. 接口类型: ${protocol()?.label ?? clean(draft.protocol)}`);
|
|
53
|
+
log(` 2. Base URL: ${clean(draft.baseUrl)}`);
|
|
54
|
+
log(` 3. API Key: ${maskApiKey(key)}`);
|
|
55
|
+
log(` 4. 模型: ${draft.model ? clean(draft.model) : '待选择'}`);
|
|
56
|
+
};
|
|
57
|
+
const chooseProtocol = async () => {
|
|
58
|
+
log('\n第一层:选择接口协议');
|
|
59
|
+
PROTOCOLS.forEach((item, index) => log(` ${index + 1}. ${item.label}`));
|
|
60
|
+
const current = Math.max(0, PROTOCOLS.findIndex((item) => item.id === (draft.protocol || DEFAULT_PROTOCOL)));
|
|
61
|
+
for (;;) {
|
|
62
|
+
const answer = (await ask(`接口 [${current + 1}]: `)).trim();
|
|
63
|
+
const index = answer ? Number(answer) - 1 : current;
|
|
64
|
+
if (Number.isInteger(index) && PROTOCOLS[index]) {
|
|
65
|
+
const selected = PROTOCOLS[index];
|
|
66
|
+
if (draft.protocol !== selected.id) {
|
|
67
|
+
draft.protocol = selected.id;
|
|
68
|
+
draft.baseUrl = selected.defaultBaseUrl;
|
|
69
|
+
draft.model = '';
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
log('请输入列表中的编号。');
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const chooseUrl = async () => {
|
|
77
|
+
log('\n第二层:输入 API 根地址,不是具体的推理端点。');
|
|
78
|
+
for (;;) {
|
|
79
|
+
const value = (await ask(`Base URL [${draft.baseUrl || protocol().defaultBaseUrl}]: `)).trim() || draft.baseUrl || protocol().defaultBaseUrl;
|
|
80
|
+
try {
|
|
81
|
+
const normalized = normalizeBaseUrl(value, protocol().defaultBaseUrl);
|
|
82
|
+
if (draft.baseUrl !== normalized) draft.model = '';
|
|
83
|
+
draft.baseUrl = normalized;
|
|
84
|
+
return;
|
|
85
|
+
} catch (error) { log(errorText(error)); }
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const chooseKey = async () => {
|
|
89
|
+
log(`\n第三层:输入 API Key。目标服务: ${clean(draft.baseUrl)}`);
|
|
90
|
+
if (key) log('回车表示保留已保存的密钥,并授权将它用于上方服务;也可以输入新密钥。');
|
|
91
|
+
for (;;) {
|
|
92
|
+
const value = (await ask(`API Key${key ? ` [${maskApiKey(key)},回车保留]` : ''}: `, true)).trim();
|
|
93
|
+
if (value && /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) { log('API Key 必须是单行文本。'); continue; }
|
|
94
|
+
if (value) key = value;
|
|
95
|
+
if (key) return;
|
|
96
|
+
log('API Key 不能为空。');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const manualModel = async () => {
|
|
100
|
+
for (;;) {
|
|
101
|
+
const id = (await ask(`模型 ID${draft.model ? ` [${clean(draft.model)}]` : ''}: `)).trim() || draft.model;
|
|
102
|
+
if (!id || /[\u0000-\u001f\u007f]/.test(id)) { log('请输入有效的模型 ID。'); continue; }
|
|
103
|
+
draft.model = id;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const chooseModel = async () => {
|
|
108
|
+
let models;
|
|
109
|
+
try {
|
|
110
|
+
models = await run('正在获取当前凭据可见的模型', (signal) => discoverModels({ ...draft, apiKey: key, signal }));
|
|
111
|
+
if (!models.length) throw new Error('服务没有返回可选择的模型。');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log(`模型探测失败: ${errorText(error)}`);
|
|
114
|
+
log('请检查协议、地址和 Key。部分网关不提供模型列表,可手工填写 ID 后进行真实测试。');
|
|
115
|
+
const action = (await ask('输入 m 手工填写模型,或回车返回配置汇总: ')).trim().toLowerCase();
|
|
116
|
+
return action === 'm' ? manualModel() : false;
|
|
117
|
+
}
|
|
118
|
+
let page = 0;
|
|
119
|
+
const pageSize = 15;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const pages = Math.ceil(models.length / pageSize);
|
|
122
|
+
log(`\n选择模型:共 ${models.length} 个,第 ${page + 1}/${pages} 页`);
|
|
123
|
+
models.slice(page * pageSize, (page + 1) * pageSize).forEach((model, index) => {
|
|
124
|
+
log(` ${page * pageSize + index + 1}. ${clean(model.id)}${model.id === draft.model ? ' [当前]' : ''}`);
|
|
125
|
+
});
|
|
126
|
+
log(' n 下一页 / p 上一页 / m 手工输入 / q 返回汇总');
|
|
127
|
+
const answer = (await ask(`模型编号${draft.model && models.some((model) => model.id === draft.model) ? ' [回车保留当前]' : ''}: `)).trim();
|
|
128
|
+
if (!answer && draft.model && models.some((model) => model.id === draft.model)) return true;
|
|
129
|
+
if (answer === 'q') return false;
|
|
130
|
+
if (answer === 'm') return manualModel();
|
|
131
|
+
if (answer === 'n') { page = Math.min(pages - 1, page + 1); continue; }
|
|
132
|
+
if (answer === 'p') { page = Math.max(0, page - 1); continue; }
|
|
133
|
+
const index = Number(answer) - 1;
|
|
134
|
+
if (Number.isInteger(index) && models[index]) { draft.model = models[index].id; return true; }
|
|
135
|
+
log('请输入有效编号,或使用 n/p 切换页面。');
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const verify = async () => {
|
|
139
|
+
if (!key) { log('请先设置 API Key。'); return null; }
|
|
140
|
+
if (!draft.model && !await chooseModel()) return null;
|
|
141
|
+
summary();
|
|
142
|
+
log('将向上方服务发送一次最小模型请求,可能产生少量 API 费用。');
|
|
143
|
+
try {
|
|
144
|
+
const canonical = resolveProviderSettings(draft);
|
|
145
|
+
await run('正在测试模型连接', (signal) => testConnection({ ...canonical, apiKey: key, signal }));
|
|
146
|
+
log('✅ Connection successful!');
|
|
147
|
+
return { settings: canonical, apiKey: key };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
log(`连接失败: ${errorText(error)}`);
|
|
150
|
+
log('尚未保存。请在配置汇总中选择要修改的项目。');
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
log('DeepSeek Harness MCP 终端配置');
|
|
156
|
+
if (!existing) {
|
|
157
|
+
await chooseProtocol();
|
|
158
|
+
await chooseUrl();
|
|
159
|
+
await chooseKey();
|
|
160
|
+
draft.model = '';
|
|
161
|
+
if (await chooseModel()) {
|
|
162
|
+
const result = await verify();
|
|
163
|
+
if (result) return result;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (;;) {
|
|
167
|
+
summary();
|
|
168
|
+
log(' 5. 重新探测模型并选择');
|
|
169
|
+
log(' 6. 测试并保存');
|
|
170
|
+
log(' q. 退出,不保存');
|
|
171
|
+
const action = (await ask('选择操作 [6]: ')).trim().toLowerCase() || '6';
|
|
172
|
+
if (action === 'q') throw new Error('配置已取消,尚未保存。');
|
|
173
|
+
if (action === '1') await chooseProtocol();
|
|
174
|
+
else if (action === '2') await chooseUrl();
|
|
175
|
+
else if (action === '3') await chooseKey();
|
|
176
|
+
else if (action === '4') await manualModel();
|
|
177
|
+
else if (action === '5') await chooseModel();
|
|
178
|
+
else if (action === '6') {
|
|
179
|
+
const result = await verify();
|
|
180
|
+
if (result) return result;
|
|
181
|
+
} else log('请输入 1–6 或 q。');
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -4,12 +4,11 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { isDeepStrictEqual, parseArgs, parseEnv } from 'node:util';
|
|
7
|
-
import { createInterface } from 'node:readline';
|
|
8
|
-
import { Writable } from 'node:stream';
|
|
9
7
|
import { randomUUID } from 'node:crypto';
|
|
10
8
|
import { parse } from 'smol-toml';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import { parseProviderSettings, resolveProviderSettings } from './provider.js';
|
|
10
|
+
import { runTerminalSetup } from './setup-terminal.js';
|
|
11
|
+
import { inspectRegistration } from './doctor.js';
|
|
13
12
|
|
|
14
13
|
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
15
14
|
const defaultHome = join(homedir(), '.dsh-mcp');
|
|
@@ -53,6 +52,29 @@ function parseConfig(text) {
|
|
|
53
52
|
catch { throw new Error('Codex config.toml is not valid TOML. Repair this file before setup.'); }
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
function updateCredential(text, requestedKey) {
|
|
56
|
+
const before = parseEnv(text);
|
|
57
|
+
const key = (requestedKey ?? before.DEEPSEEK_API_KEY)?.trim();
|
|
58
|
+
if (!key || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(key)) {
|
|
59
|
+
throw new Error('API Key 必须是非空的单行文本。');
|
|
60
|
+
}
|
|
61
|
+
if (before.DEEPSEEK_API_KEY === key) return text;
|
|
62
|
+
if (/[\r\n]/.test(before.DEEPSEEK_API_KEY ?? '')) {
|
|
63
|
+
throw new Error('旧凭据格式无法安全更新。请先备份私有 .env 文件,现有内容未改变。');
|
|
64
|
+
}
|
|
65
|
+
let replaced = false;
|
|
66
|
+
let next = text.replace(/^[\uFEFF \t]*(?:export[ \t]+)?DEEPSEEK_API_KEY[ \t]*=.*(?:\r?\n|$)/gm, () => {
|
|
67
|
+
if (replaced) return '';
|
|
68
|
+
replaced = true;
|
|
69
|
+
return `DEEPSEEK_API_KEY=${JSON.stringify(key)}\n`;
|
|
70
|
+
});
|
|
71
|
+
if (!replaced) next += `${next && !next.endsWith('\n') ? '\n' : ''}DEEPSEEK_API_KEY=${JSON.stringify(key)}\n`;
|
|
72
|
+
if (!isDeepStrictEqual({ ...parseEnv(next) }, { ...before, DEEPSEEK_API_KEY: key })) {
|
|
73
|
+
throw new Error('无法在保留其他凭据配置的同时安全更新 API Key,现有内容未改变。');
|
|
74
|
+
}
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
async function matchesPreviousBundledSkill(server, skillText) {
|
|
57
79
|
const script = server?.args?.[0];
|
|
58
80
|
if (typeof script !== 'string' || !isAbsolute(script)) return false;
|
|
@@ -89,7 +111,7 @@ export async function installCodex({
|
|
|
89
111
|
home = defaultHome,
|
|
90
112
|
codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
|
|
91
113
|
skillsHome = join(homedir(), '.agents', 'skills'),
|
|
92
|
-
apiKey, baseUrl,
|
|
114
|
+
apiKey, baseUrl, protocol, model,
|
|
93
115
|
}) {
|
|
94
116
|
if (process.platform === 'win32') throw new Error('Setup requires macOS or Linux.');
|
|
95
117
|
home = resolve(home);
|
|
@@ -110,6 +132,7 @@ export async function installCodex({
|
|
|
110
132
|
const args = [join(packageRoot, 'src', 'server.js'), '--dsh-home', home];
|
|
111
133
|
const entry = [
|
|
112
134
|
'[mcp_servers.dsh]', `command = ${JSON.stringify(process.execPath)}`, `args = ${JSON.stringify(args)}`,
|
|
135
|
+
'enabled = true',
|
|
113
136
|
'startup_timeout_sec = 20', 'tool_timeout_sec = 960',
|
|
114
137
|
].join('\n');
|
|
115
138
|
const { text: nextConfig, previous } = managedBlock(config.text, '# BEGIN dsh-mcp', '# END dsh-mcp', entry);
|
|
@@ -124,17 +147,16 @@ export async function installCodex({
|
|
|
124
147
|
throw new Error('A different deepseek-worker skill already exists and cannot be safely upgraded. Back up or rename that skill before setup; existing files were not changed.');
|
|
125
148
|
}
|
|
126
149
|
parseConfig(nextConfig);
|
|
127
|
-
const existingKey = parseEnv(credentials.text).DEEPSEEK_API_KEY;
|
|
128
150
|
const providerSettings = parseProviderSettings(provider.text);
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
const previousProvider = resolveProviderSettings(providerSettings, { legacy: !providerSettings.protocol && typeof providerSettings.baseUrl === 'string' });
|
|
152
|
+
const selected = resolveProviderSettings({
|
|
153
|
+
...previousProvider,
|
|
154
|
+
...(protocol === undefined ? {} : { protocol }),
|
|
155
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
156
|
+
...(model === undefined ? {} : { model }),
|
|
157
|
+
});
|
|
158
|
+
const nextProvider = JSON.stringify({ ...providerSettings, ...selected }, null, 2) + '\n';
|
|
159
|
+
const nextCredentials = updateCredential(credentials.text, apiKey);
|
|
138
160
|
|
|
139
161
|
// Check all user-file conflicts before creating credentials or Codex files.
|
|
140
162
|
for (const directory of directories) await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -148,74 +170,52 @@ export async function installCodex({
|
|
|
148
170
|
return { home, configPath, skillPath };
|
|
149
171
|
}
|
|
150
172
|
|
|
151
|
-
function question(prompt, hidden = false) {
|
|
152
|
-
if (!process.stdin.isTTY) throw new Error('Use a terminal for prompts, or supply DEEPSEEK_API_KEY in the environment.');
|
|
153
|
-
const output = hidden ? new Writable({ write(_chunk, _encoding, callback) { callback(); } }) : process.stdout;
|
|
154
|
-
const input = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
|
155
|
-
if (hidden) process.stdout.write(prompt);
|
|
156
|
-
return new Promise((accept, reject) => {
|
|
157
|
-
input.once('SIGINT', () => { input.close(); reject(new Error('Setup cancelled.')); });
|
|
158
|
-
input.once('close', () => reject(new Error('Setup input closed.')));
|
|
159
|
-
input.question(hidden ? '' : prompt, (answer) => {
|
|
160
|
-
accept(answer);
|
|
161
|
-
input.close();
|
|
162
|
-
if (hidden) process.stdout.write('\n');
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
173
|
export async function runSetup() {
|
|
168
174
|
const { values } = parseArgs({ options: {
|
|
169
175
|
home: { type: 'string', default: defaultHome },
|
|
170
|
-
terminal: { type: 'boolean', default: false },
|
|
171
176
|
help: { type: 'boolean', default: false },
|
|
172
177
|
} });
|
|
173
178
|
if (values.help) {
|
|
174
|
-
console.log('
|
|
175
|
-
console.log('
|
|
176
|
-
console.log('
|
|
177
|
-
console.log('
|
|
179
|
+
console.log('用法: dsh-mcp setup [--home /private/dsh-home]');
|
|
180
|
+
console.log('纯终端配置:协议 → Base URL → API Key → 探测并选择模型。');
|
|
181
|
+
console.log('再次运行可逐项修改现有配置;连接测试通过后才保存并注册 MCP。');
|
|
182
|
+
console.log('诊断 Codex 注册和工具握手: dsh-mcp doctor');
|
|
178
183
|
return;
|
|
179
184
|
}
|
|
185
|
+
if (!process.stdin.isTTY) throw new Error('配置向导需要交互式终端。请在终端运行 dsh-mcp setup。');
|
|
180
186
|
const home = resolve(values.home);
|
|
181
187
|
await checkDirectory(home);
|
|
182
188
|
const credentials = await readExisting(join(home, '.env'));
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
} finally {
|
|
207
|
-
process.removeListener('SIGINT', cancel);
|
|
208
|
-
process.removeListener('SIGTERM', cancel);
|
|
209
|
-
}
|
|
189
|
+
const saved = await readExisting(join(home, 'settings.json'));
|
|
190
|
+
const rawSettings = parseProviderSettings(saved.text);
|
|
191
|
+
const existing = saved.mode !== null || Boolean(parseEnv(credentials.text).DEEPSEEK_API_KEY);
|
|
192
|
+
const initial = existing ? rawSettings : {
|
|
193
|
+
...(process.env.DSH_MCP_PROTOCOL ? { protocol: process.env.DSH_MCP_PROTOCOL } : {}),
|
|
194
|
+
...(process.env.DEEPSEEK_BASE_URL ? { baseUrl: process.env.DEEPSEEK_BASE_URL } : {}),
|
|
195
|
+
...(process.env.DSH_MCP_MODEL ? { model: process.env.DSH_MCP_MODEL } : {}),
|
|
196
|
+
};
|
|
197
|
+
const settings = resolveProviderSettings(initial, { legacy: existing && !rawSettings.protocol && typeof rawSettings.baseUrl === 'string' });
|
|
198
|
+
const apiKey = parseEnv(credentials.text).DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || '';
|
|
199
|
+
const chosen = await runTerminalSetup({ settings, apiKey, existing });
|
|
200
|
+
const result = await installCodex({ home, ...chosen.settings, apiKey: chosen.apiKey });
|
|
201
|
+
console.log(`Codex 配置: ${result.configPath}`);
|
|
202
|
+
console.log(`用户 skill: ${result.skillPath}`);
|
|
203
|
+
console.log(`私有 API Key 文件: ${join(result.home, '.env')}`);
|
|
204
|
+
console.log(`私有服务配置: ${join(result.home, 'settings.json')}`);
|
|
205
|
+
console.log('正在检查 MCP 注册和工具握手...');
|
|
206
|
+
const health = await inspectRegistration({ configPath: result.configPath });
|
|
207
|
+
if (!health.ok) {
|
|
208
|
+
console.error('API 连接通过,配置也已保存,但 MCP 加载检查失败。');
|
|
209
|
+
for (const hint of health.hints ?? []) console.error(hint);
|
|
210
|
+
if (health.error) console.error(health.error);
|
|
211
|
+
throw new Error('请运行 dsh-mcp doctor 查看注册路径和启动状态;不能据此认为 Codex 已显示该工具。');
|
|
210
212
|
}
|
|
211
|
-
console.log(`MCP
|
|
212
|
-
console.log(
|
|
213
|
-
console.log(
|
|
214
|
-
console.log(`Private provider settings: ${join(result.home, 'settings.json')}`);
|
|
215
|
-
console.log('Start a new Codex session. Codex supplies the active project_root with every delegated task.');
|
|
216
|
-
console.log('Use normal task prompts. Codex can select eligible DeepSeek tasks without an explicit tool request.');
|
|
213
|
+
console.log(`MCP 握手成功,工具: ${health.tools.join(', ')}`);
|
|
214
|
+
console.log('如果已打开的 Codex 仍未显示,请完整退出并重新打开 Codex Desktop,而不只是新开标签。');
|
|
215
|
+
console.log('确认 Codex 与安装器使用同一主机、用户和 CODEX_HOME。Auth unsupported 对本地 stdio 是正常状态。');
|
|
217
216
|
}
|
|
218
217
|
|
|
218
|
+
|
|
219
219
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
220
220
|
runSetup().catch((error) => { console.error(`Setup failed: ${error.message}`); process.exitCode = 1; });
|
|
221
221
|
}
|
package/src/worker.js
CHANGED
|
@@ -2,16 +2,17 @@ import { spawn, execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
|
-
import { lstat, readlink, realpath, stat } from 'node:fs/promises';
|
|
6
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { lstat, mkdtemp, readlink, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import { readSessionOutput } from './session-output.js';
|
|
10
|
+
import { buildProviderRuntime } from './provider.js';
|
|
9
11
|
|
|
10
12
|
const exec = promisify(execFile);
|
|
11
13
|
const dsh = fileURLToPath(new URL('./lib/bin.js', import.meta.resolve('@deepseek-ai/dsh/package.json')));
|
|
12
14
|
const patch = fileURLToPath(new URL('../config/worker.patch.yml', import.meta.url));
|
|
13
15
|
const MAX_OUTPUT = 8 * 1024 * 1024;
|
|
14
|
-
const THINKING_EFFORTS = new Set(['off', 'low', 'high', 'max']);
|
|
15
16
|
|
|
16
17
|
// Wait for termination before releasing the worker; kill the process group, not just dsh.
|
|
17
18
|
export function runProcess(command, { cwd, env, input, timeoutMs, signal }) {
|
|
@@ -123,13 +124,18 @@ async function gitSnapshot(cwd) {
|
|
|
123
124
|
return { root, files };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
export function createWorker({ home, command = [process.execPath, dsh], readSession = readSessionOutput }) {
|
|
127
|
+
export function createWorker({ home, command = [process.execPath, dsh], readSession = readSessionOutput, providerRuntime = buildProviderRuntime({}, process.env.DEEPSEEK_API_KEY) }) {
|
|
128
|
+
const allowedEfforts = new Set(providerRuntime.thinkingEfforts);
|
|
129
|
+
const secret = providerRuntime.env.DEEPSEEK_API_KEY;
|
|
130
|
+
const redact = (value) => typeof value === 'string' && secret ? value.replaceAll(secret, '[REDACTED]') : value;
|
|
131
|
+
const redactObject = (value) => JSON.parse(JSON.stringify(value, (_name, item) => redact(item)));
|
|
127
132
|
let busy = false;
|
|
128
133
|
return {
|
|
129
134
|
async delegate({ project_root, task, thinking_effort, cwd = '.', permission_mode = 'read-only', session_id, timeout_seconds = 300 }, { signal } = {}) {
|
|
130
135
|
if (busy) throw new Error('DeepSeek worker is busy; serialize tasks across all projects');
|
|
131
136
|
busy = true;
|
|
132
137
|
let before;
|
|
138
|
+
let pluginDirectory;
|
|
133
139
|
try {
|
|
134
140
|
if (typeof project_root !== 'string' || !isAbsolute(project_root)) {
|
|
135
141
|
throw new Error('project_root must be an absolute directory path');
|
|
@@ -142,11 +148,23 @@ export function createWorker({ home, command = [process.execPath, dsh], readSess
|
|
|
142
148
|
throw new Error('cwd is outside project_root');
|
|
143
149
|
}
|
|
144
150
|
if (!(await stat(directory)).isDirectory()) throw new Error('cwd must be a directory');
|
|
145
|
-
if (!
|
|
146
|
-
throw new Error(
|
|
151
|
+
if (!allowedEfforts.has(thinking_effort)) {
|
|
152
|
+
throw new Error(`thinking_effort is required and must be one of: ${providerRuntime.thinkingEfforts.join(', ')} for the selected model`);
|
|
147
153
|
}
|
|
148
154
|
before = await gitSnapshot(directory);
|
|
149
|
-
const args = ['headless', '--patch', patch
|
|
155
|
+
const args = ['headless', '--patch', patch];
|
|
156
|
+
const plugin = providerRuntime.env.DSH_MCP_PI_PLUGIN;
|
|
157
|
+
if (plugin && plugin !== '@deepseek-ai/dsh-llm-pi-ai') {
|
|
158
|
+
pluginDirectory = await mkdtemp(join(tmpdir(), 'dsh-mcp-plugin-'));
|
|
159
|
+
const pluginPatch = join(pluginDirectory, 'provider.json');
|
|
160
|
+
// Cordis permits expressions in config, but plugin identities must be literal names.
|
|
161
|
+
await writeFile(pluginPatch, JSON.stringify([
|
|
162
|
+
{ id: 'llm-pi-ai', disabled: true },
|
|
163
|
+
{ insert: [{ id: 'dsh-mcp-off-only', name: plugin }] },
|
|
164
|
+
]), { mode: 0o600 });
|
|
165
|
+
args.push('--patch', pluginPatch);
|
|
166
|
+
}
|
|
167
|
+
args.push('--json', '--permission-mode', permission_mode, '--approval-policy', 'never');
|
|
150
168
|
if (session_id) args.push('--session-id', session_id);
|
|
151
169
|
args.push('-');
|
|
152
170
|
const prompt = [
|
|
@@ -161,9 +179,13 @@ export function createWorker({ home, command = [process.execPath, dsh], readSess
|
|
|
161
179
|
].join('\n');
|
|
162
180
|
const result = await runProcess([...command, ...args], {
|
|
163
181
|
cwd: directory,
|
|
164
|
-
env: { ...process.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode, DSH_MCP_THINKING_EFFORT: thinking_effort },
|
|
182
|
+
env: { ...process.env, ...providerRuntime.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode, DSH_MCP_THINKING_EFFORT: thinking_effort },
|
|
165
183
|
input: prompt, timeoutMs: timeout_seconds * 1000, signal,
|
|
166
184
|
});
|
|
185
|
+
if (pluginDirectory) {
|
|
186
|
+
await rm(pluginDirectory, { recursive: true, force: true });
|
|
187
|
+
pluginDirectory = undefined;
|
|
188
|
+
}
|
|
167
189
|
let run;
|
|
168
190
|
try { run = parseRun(result.stdout, result.exit_code); }
|
|
169
191
|
catch (error) { run = { status: 'failed', session_id: null, turn: null, reason: error.message }; }
|
|
@@ -192,20 +214,24 @@ export function createWorker({ home, command = [process.execPath, dsh], readSess
|
|
|
192
214
|
} catch (error) { changeWarning = `Cannot inspect changes: ${error.message}`; }
|
|
193
215
|
return {
|
|
194
216
|
...run,
|
|
217
|
+
reason: run.reason == null ? run.reason : redactObject(run.reason),
|
|
195
218
|
status: result.failure ?? run.status,
|
|
196
219
|
answer: output?.text ?? '',
|
|
197
220
|
output_source: output ? 'session' : null,
|
|
198
221
|
session_file: output?.session_file ?? null,
|
|
199
222
|
assistant_seq: output?.assistant_seq ?? null,
|
|
200
|
-
terminal_reason: output?.terminal_reason
|
|
201
|
-
session_output_error: sessionOutputError,
|
|
223
|
+
terminal_reason: output?.terminal_reason == null ? null : redactObject(output.terminal_reason),
|
|
224
|
+
session_output_error: redact(sessionOutputError),
|
|
202
225
|
thinking_effort,
|
|
203
|
-
project_root: root, cwd: directory, model:
|
|
226
|
+
project_root: root, cwd: directory, model: providerRuntime.model, protocol: providerRuntime.protocol, permission_mode,
|
|
204
227
|
exit_code: result.exit_code, signal: result.signal,
|
|
205
228
|
changed_files: changedFiles, git_root: before?.root ?? null,
|
|
206
|
-
change_warning: changeWarning, diagnostics: result.stderr,
|
|
229
|
+
change_warning: redact(changeWarning), diagnostics: redact(result.stderr),
|
|
207
230
|
};
|
|
208
|
-
} finally {
|
|
231
|
+
} finally {
|
|
232
|
+
try { if (pluginDirectory) await rm(pluginDirectory, { recursive: true, force: true }); }
|
|
233
|
+
finally { busy = false; }
|
|
234
|
+
}
|
|
209
235
|
},
|
|
210
236
|
};
|
|
211
237
|
}
|
package/src/setup-ui.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { createServer } from 'node:http';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
|
|
5
|
-
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
|
6
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
7
|
-
})[character]);
|
|
8
|
-
|
|
9
|
-
function page({ baseUrl, hasSavedKey, csrf, error = '', saved = false }) {
|
|
10
|
-
return `<!doctype html>
|
|
11
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
12
|
-
<title>DeepSeek MCP settings</title><style>
|
|
13
|
-
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:48px 20px}
|
|
14
|
-
main{max-width:540px;margin:auto;background:white;padding:32px;border:1px solid #d9e0e7;border-radius:12px}
|
|
15
|
-
h1{font-size:24px;margin-top:0}label{display:block;font-weight:600;margin-top:24px}
|
|
16
|
-
input{box-sizing:border-box;width:100%;font:inherit;padding:10px;border:1px solid #9aa9b8;border-radius:6px;margin-top:6px}
|
|
17
|
-
small{display:block;color:#4e6071;margin-top:6px}code{overflow-wrap:anywhere}button{font:inherit;background:#165bc4;color:white;border:0;padding:11px 18px;border-radius:6px;margin-top:28px;cursor:pointer}
|
|
18
|
-
.error{color:#a21818}.scope{background:#f1f4f7;padding:12px;border-radius:6px}
|
|
19
|
-
</style></head><body><main><h1>DeepSeek MCP settings</h1>
|
|
20
|
-
${saved ? '<p>Settings saved. You can close this tab.</p><p>Start a new Codex session in any project.</p>' : `
|
|
21
|
-
<p>These settings apply to the local DeepSeek Harness worker.</p>
|
|
22
|
-
<p class="scope">Codex supplies the project directory for each task. Setup does not bind this server to a project.</p>
|
|
23
|
-
${error ? `<p class="error" role="alert">${escape(error)}</p>` : ''}
|
|
24
|
-
<form method="post" autocomplete="off">
|
|
25
|
-
<input type="hidden" name="csrf" value="${csrf}">
|
|
26
|
-
<label for="apiKey">API Key</label><input id="apiKey" name="apiKey" type="password" autocomplete="new-password" ${hasSavedKey ? 'disabled placeholder="An existing key will be used"' : 'required'}>
|
|
27
|
-
<small>The key stays in your private settings file. Setup keeps an existing key unchanged.</small>
|
|
28
|
-
<label for="baseUrl">Base URL (optional)</label><input id="baseUrl" name="baseUrl" type="url" value="${escape(baseUrl)}">
|
|
29
|
-
<small>Use a Messages API root. Leave empty to use https://api.deepseek.com/anthropic.</small>
|
|
30
|
-
<small>A custom endpoint receives your API key and task data.</small>
|
|
31
|
-
<button type="submit">Save and configure Codex</button></form>`}
|
|
32
|
-
</main></body></html>`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function createSetupForm({ baseUrl, hasSavedKey = false, onSubmit }) {
|
|
36
|
-
const path = `/${randomBytes(32).toString('hex')}`;
|
|
37
|
-
const csrf = randomBytes(32).toString('hex');
|
|
38
|
-
let origin;
|
|
39
|
-
let submitting = false;
|
|
40
|
-
let closing = false;
|
|
41
|
-
let timer;
|
|
42
|
-
let accept;
|
|
43
|
-
let reject;
|
|
44
|
-
const completed = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
|
|
45
|
-
const server = createServer(async (request, response) => {
|
|
46
|
-
response.setHeader('Cache-Control', 'no-store');
|
|
47
|
-
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
48
|
-
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
49
|
-
response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
|
|
50
|
-
if (request.headers.host !== new URL(origin).host || request.url !== path) {
|
|
51
|
-
response.writeHead(404).end('Not found.');
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const render = (options, status = 200) => {
|
|
55
|
-
response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
56
|
-
response.end(page({ baseUrl, hasSavedKey, csrf, ...options }));
|
|
57
|
-
};
|
|
58
|
-
if (request.method === 'GET') { render({}); return; }
|
|
59
|
-
if (request.method !== 'POST') { response.writeHead(405).end('Use GET or POST.'); return; }
|
|
60
|
-
if (request.headers.origin && ![origin, 'null'].includes(request.headers.origin)) {
|
|
61
|
-
response.writeHead(403).end('Invalid origin.'); return;
|
|
62
|
-
}
|
|
63
|
-
if (request.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') {
|
|
64
|
-
response.writeHead(415).end('Use the setup form.'); return;
|
|
65
|
-
}
|
|
66
|
-
if (submitting || closing) { response.writeHead(409).end('Setup is already saving.'); return; }
|
|
67
|
-
try {
|
|
68
|
-
const chunks = [];
|
|
69
|
-
let bytes = 0;
|
|
70
|
-
for await (const chunk of request) {
|
|
71
|
-
bytes += chunk.length;
|
|
72
|
-
if (bytes > 16384) { response.writeHead(413).end('Settings are too large.'); return; }
|
|
73
|
-
chunks.push(chunk);
|
|
74
|
-
}
|
|
75
|
-
const fields = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
|
|
76
|
-
// A separate form token also protects webviews with a null or missing Origin.
|
|
77
|
-
if (fields.get('csrf') !== csrf) { response.writeHead(403).end('Invalid form token.'); return; }
|
|
78
|
-
submitting = true;
|
|
79
|
-
const result = await onSubmit({ apiKey: fields.get('apiKey')?.trim() || undefined, baseUrl: fields.get('baseUrl') });
|
|
80
|
-
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
-
response.end(page({ baseUrl, hasSavedKey, saved: true }), () => finish(null, result));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
submitting = false;
|
|
84
|
-
render({ error: error.message }, 400);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
server.requestTimeout = 30000;
|
|
88
|
-
server.headersTimeout = 10000;
|
|
89
|
-
await new Promise((resolve, fail) => {
|
|
90
|
-
server.once('error', fail);
|
|
91
|
-
server.listen(0, '127.0.0.1', resolve);
|
|
92
|
-
});
|
|
93
|
-
origin = `http://127.0.0.1:${server.address().port}`;
|
|
94
|
-
function finish(error, result) {
|
|
95
|
-
if (closing) return;
|
|
96
|
-
closing = true;
|
|
97
|
-
clearTimeout(timer);
|
|
98
|
-
server.close(() => error ? reject(error) : accept(result));
|
|
99
|
-
if (error) server.closeAllConnections();
|
|
100
|
-
else server.closeIdleConnections();
|
|
101
|
-
}
|
|
102
|
-
timer = setTimeout(() => finish(new Error('Setup form expired. Run setup again.')), 600000);
|
|
103
|
-
return { url: origin + path, completed, close: () => finish(new Error('Setup cancelled.')) };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function openSetupBrowser(url) {
|
|
107
|
-
return new Promise((resolve) => {
|
|
108
|
-
const child = spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore', shell: false });
|
|
109
|
-
child.once('error', () => resolve(false));
|
|
110
|
-
child.once('exit', (code) => resolve(code === 0));
|
|
111
|
-
});
|
|
112
|
-
}
|