@hzxwonder/dsh-mcp 0.3.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 +70 -10
- package/README.md +162 -157
- package/config/routing.md +59 -4
- package/config/worker.patch.yml +13 -2
- package/package.json +9 -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 -6
- package/src/session-output.js +72 -0
- package/src/setup-terminal.js +183 -0
- package/src/setup.js +110 -81
- package/src/worker.js +65 -11
- package/src/setup-ui.js +0 -112
package/src/setup.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { parseArgs, parseEnv } from 'node:util';
|
|
7
|
-
import { createInterface } from 'node:readline';
|
|
8
|
-
import { Writable } from 'node:stream';
|
|
6
|
+
import { isDeepStrictEqual, parseArgs, parseEnv } from 'node:util';
|
|
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');
|
|
@@ -39,10 +38,13 @@ function managedBlock(text, begin, end, body) {
|
|
|
39
38
|
throw new Error(`Repair the incomplete or duplicate managed block: ${begin}`);
|
|
40
39
|
}
|
|
41
40
|
const block = `${begin}\n${body.trimEnd()}\n${end}\n`;
|
|
42
|
-
if (!starts.length)
|
|
41
|
+
if (!starts.length) {
|
|
42
|
+
return { text: text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block, previous: null };
|
|
43
|
+
}
|
|
44
|
+
const previous = text.slice(starts[0].index + starts[0][0].length, ends[0].index);
|
|
43
45
|
let after = ends[0].index + ends[0][0].length;
|
|
44
46
|
if (text[after] === '\n') after++;
|
|
45
|
-
return text.slice(0, starts[0].index) + block + text.slice(after);
|
|
47
|
+
return { text: text.slice(0, starts[0].index) + block + text.slice(after), previous };
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function parseConfig(text) {
|
|
@@ -50,6 +52,51 @@ function parseConfig(text) {
|
|
|
50
52
|
catch { throw new Error('Codex config.toml is not valid TOML. Repair this file before setup.'); }
|
|
51
53
|
}
|
|
52
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
|
+
|
|
78
|
+
async function matchesPreviousBundledSkill(server, skillText) {
|
|
79
|
+
const script = server?.args?.[0];
|
|
80
|
+
if (typeof script !== 'string' || !isAbsolute(script)) return false;
|
|
81
|
+
const previousRoot = dirname(dirname(script));
|
|
82
|
+
if (script !== join(previousRoot, 'src', 'server.js')) return false;
|
|
83
|
+
try {
|
|
84
|
+
const [currentPackage, previousPackage, previousSkill, scriptInfo] = await Promise.all([
|
|
85
|
+
readExisting(join(packageRoot, 'package.json')),
|
|
86
|
+
readExisting(join(previousRoot, 'package.json')),
|
|
87
|
+
readExisting(join(previousRoot, '.agents', 'skills', 'deepseek-worker', 'SKILL.md')),
|
|
88
|
+
inspect(script),
|
|
89
|
+
]);
|
|
90
|
+
const name = JSON.parse(currentPackage.text).name;
|
|
91
|
+
return typeof name === 'string' && name.length > 0
|
|
92
|
+
&& JSON.parse(previousPackage.text).name === name
|
|
93
|
+
&& scriptInfo?.isFile() === true
|
|
94
|
+
&& previousSkill.mode !== null && previousSkill.text === skillText;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
53
100
|
async function atomicWrite(path, text, mode) {
|
|
54
101
|
const temp = join(dirname(path), `.dsh-mcp-${randomUUID()}.tmp`);
|
|
55
102
|
try {
|
|
@@ -64,7 +111,7 @@ export async function installCodex({
|
|
|
64
111
|
home = defaultHome,
|
|
65
112
|
codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
|
|
66
113
|
skillsHome = join(homedir(), '.agents', 'skills'),
|
|
67
|
-
apiKey, baseUrl,
|
|
114
|
+
apiKey, baseUrl, protocol, model,
|
|
68
115
|
}) {
|
|
69
116
|
if (process.platform === 'win32') throw new Error('Setup requires macOS or Linux.');
|
|
70
117
|
home = resolve(home);
|
|
@@ -82,30 +129,34 @@ export async function installCodex({
|
|
|
82
129
|
readFile(join(packageRoot, '.agents', 'skills', 'deepseek-worker', 'SKILL.md'), 'utf8'),
|
|
83
130
|
]);
|
|
84
131
|
const parsed = parseConfig(config.text);
|
|
85
|
-
if (parsed.mcp_servers?.dsh && !/^# BEGIN dsh-mcp\r?$/m.test(config.text)) {
|
|
86
|
-
throw new Error('Codex already defines mcp_servers.dsh. Preserve or rename that server before setup.');
|
|
87
|
-
}
|
|
88
|
-
if (skill.mode !== null && skill.text !== bundledSkill) {
|
|
89
|
-
throw new Error('A different deepseek-worker skill already exists. Preserve or rename that skill before setup.');
|
|
90
|
-
}
|
|
91
132
|
const args = [join(packageRoot, 'src', 'server.js'), '--dsh-home', home];
|
|
92
133
|
const entry = [
|
|
93
134
|
'[mcp_servers.dsh]', `command = ${JSON.stringify(process.execPath)}`, `args = ${JSON.stringify(args)}`,
|
|
135
|
+
'enabled = true',
|
|
94
136
|
'startup_timeout_sec = 20', 'tool_timeout_sec = 960',
|
|
95
137
|
].join('\n');
|
|
96
|
-
const nextConfig = managedBlock(config.text, '# BEGIN dsh-mcp', '# END dsh-mcp', entry);
|
|
138
|
+
const { text: nextConfig, previous } = managedBlock(config.text, '# BEGIN dsh-mcp', '# END dsh-mcp', entry);
|
|
139
|
+
const previousServer = previous === null ? undefined : parseConfig(previous).mcp_servers?.dsh;
|
|
140
|
+
const ownsServer = parsed.mcp_servers?.dsh && previousServer
|
|
141
|
+
&& isDeepStrictEqual(parsed.mcp_servers.dsh, previousServer);
|
|
142
|
+
if (parsed.mcp_servers?.dsh && !ownsServer) {
|
|
143
|
+
throw new Error('Codex already defines mcp_servers.dsh outside this installer’s managed block. Preserve or rename that server before setup.');
|
|
144
|
+
}
|
|
145
|
+
if (skill.mode !== null && skill.text !== bundledSkill
|
|
146
|
+
&& !(ownsServer && await matchesPreviousBundledSkill(previousServer, skill.text))) {
|
|
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.');
|
|
148
|
+
}
|
|
97
149
|
parseConfig(nextConfig);
|
|
98
|
-
const existingKey = parseEnv(credentials.text).DEEPSEEK_API_KEY;
|
|
99
150
|
const providerSettings = parseProviderSettings(provider.text);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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);
|
|
109
160
|
|
|
110
161
|
// Check all user-file conflicts before creating credentials or Codex files.
|
|
111
162
|
for (const directory of directories) await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -115,78 +166,56 @@ export async function installCodex({
|
|
|
115
166
|
if (nextProvider !== provider.text) await atomicWrite(providerPath, nextProvider, 0o600);
|
|
116
167
|
else await chmod(providerPath, 0o600);
|
|
117
168
|
if (nextConfig !== config.text) await atomicWrite(configPath, nextConfig, config.mode ?? 0o644);
|
|
118
|
-
if (skill.mode === null) await atomicWrite(skillPath, bundledSkill, 0o644);
|
|
169
|
+
if (skill.text !== bundledSkill || skill.mode === null) await atomicWrite(skillPath, bundledSkill, skill.mode ?? 0o644);
|
|
119
170
|
return { home, configPath, skillPath };
|
|
120
171
|
}
|
|
121
172
|
|
|
122
|
-
function question(prompt, hidden = false) {
|
|
123
|
-
if (!process.stdin.isTTY) throw new Error('Use a terminal for prompts, or supply DEEPSEEK_API_KEY in the environment.');
|
|
124
|
-
const output = hidden ? new Writable({ write(_chunk, _encoding, callback) { callback(); } }) : process.stdout;
|
|
125
|
-
const input = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
|
126
|
-
if (hidden) process.stdout.write(prompt);
|
|
127
|
-
return new Promise((accept, reject) => {
|
|
128
|
-
input.once('SIGINT', () => { input.close(); reject(new Error('Setup cancelled.')); });
|
|
129
|
-
input.once('close', () => reject(new Error('Setup input closed.')));
|
|
130
|
-
input.question(hidden ? '' : prompt, (answer) => {
|
|
131
|
-
accept(answer);
|
|
132
|
-
input.close();
|
|
133
|
-
if (hidden) process.stdout.write('\n');
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
173
|
export async function runSetup() {
|
|
139
174
|
const { values } = parseArgs({ options: {
|
|
140
175
|
home: { type: 'string', default: defaultHome },
|
|
141
|
-
terminal: { type: 'boolean', default: false },
|
|
142
176
|
help: { type: 'boolean', default: false },
|
|
143
177
|
} });
|
|
144
178
|
if (values.help) {
|
|
145
|
-
console.log('
|
|
146
|
-
console.log('
|
|
147
|
-
console.log('
|
|
148
|
-
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');
|
|
149
183
|
return;
|
|
150
184
|
}
|
|
185
|
+
if (!process.stdin.isTTY) throw new Error('配置向导需要交互式终端。请在终端运行 dsh-mcp setup。');
|
|
151
186
|
const home = resolve(values.home);
|
|
152
187
|
await checkDirectory(home);
|
|
153
188
|
const credentials = await readExisting(join(home, '.env'));
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} finally {
|
|
178
|
-
process.removeListener('SIGINT', cancel);
|
|
179
|
-
process.removeListener('SIGTERM', cancel);
|
|
180
|
-
}
|
|
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 已显示该工具。');
|
|
181
212
|
}
|
|
182
|
-
console.log(`MCP
|
|
183
|
-
console.log(
|
|
184
|
-
console.log(
|
|
185
|
-
console.log(`Private provider settings: ${join(result.home, 'settings.json')}`);
|
|
186
|
-
console.log('Start a new Codex session. Codex supplies the active project_root with every delegated task.');
|
|
187
|
-
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 是正常状态。');
|
|
188
216
|
}
|
|
189
217
|
|
|
218
|
+
|
|
190
219
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
191
220
|
runSetup().catch((error) => { console.error(`Setup failed: ${error.message}`); process.exitCode = 1; });
|
|
192
221
|
}
|
package/src/worker.js
CHANGED
|
@@ -2,9 +2,12 @@ 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';
|
|
9
|
+
import { readSessionOutput } from './session-output.js';
|
|
10
|
+
import { buildProviderRuntime } from './provider.js';
|
|
8
11
|
|
|
9
12
|
const exec = promisify(execFile);
|
|
10
13
|
const dsh = fileURLToPath(new URL('./lib/bin.js', import.meta.resolve('@deepseek-ai/dsh/package.json')));
|
|
@@ -69,6 +72,7 @@ export function parseRun(stdout, exitCode) {
|
|
|
69
72
|
let sessionId = null;
|
|
70
73
|
let final = null;
|
|
71
74
|
let error = null;
|
|
75
|
+
let turn = null;
|
|
72
76
|
for (const line of stdout.split('\n')) {
|
|
73
77
|
if (!line.trim()) continue;
|
|
74
78
|
let event;
|
|
@@ -76,12 +80,13 @@ export function parseRun(stdout, exitCode) {
|
|
|
76
80
|
catch { throw new Error('Invalid DSH NDJSON output; check the pinned Harness version'); }
|
|
77
81
|
if (event?.type === 'session') sessionId = event.sessionId;
|
|
78
82
|
if (event?.type === 'error') error = event;
|
|
83
|
+
if (event?.type === 'status' && event.phase === 'turn_start') turn = event.turn;
|
|
79
84
|
if (event?.type === 'final') final = event;
|
|
80
85
|
}
|
|
81
86
|
return {
|
|
82
87
|
status: exitCode === 0 && final?.reason?.kind === 'completed' ? 'completed' : 'failed',
|
|
83
88
|
session_id: final?.sessionId ?? sessionId,
|
|
84
|
-
|
|
89
|
+
turn,
|
|
85
90
|
reason: final?.reason ?? error ?? null,
|
|
86
91
|
};
|
|
87
92
|
}
|
|
@@ -119,13 +124,18 @@ async function gitSnapshot(cwd) {
|
|
|
119
124
|
return { root, files };
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
export function createWorker({ home, command = [process.execPath, dsh] }) {
|
|
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)));
|
|
123
132
|
let busy = false;
|
|
124
133
|
return {
|
|
125
|
-
async delegate({ project_root, task, cwd = '.', permission_mode = 'read-only', session_id, timeout_seconds = 300 }, { signal } = {}) {
|
|
134
|
+
async delegate({ project_root, task, thinking_effort, cwd = '.', permission_mode = 'read-only', session_id, timeout_seconds = 300 }, { signal } = {}) {
|
|
126
135
|
if (busy) throw new Error('DeepSeek worker is busy; serialize tasks across all projects');
|
|
127
136
|
busy = true;
|
|
128
137
|
let before;
|
|
138
|
+
let pluginDirectory;
|
|
129
139
|
try {
|
|
130
140
|
if (typeof project_root !== 'string' || !isAbsolute(project_root)) {
|
|
131
141
|
throw new Error('project_root must be an absolute directory path');
|
|
@@ -138,8 +148,23 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
|
|
|
138
148
|
throw new Error('cwd is outside project_root');
|
|
139
149
|
}
|
|
140
150
|
if (!(await stat(directory)).isDirectory()) throw new Error('cwd must be a directory');
|
|
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`);
|
|
153
|
+
}
|
|
141
154
|
before = await gitSnapshot(directory);
|
|
142
|
-
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');
|
|
143
168
|
if (session_id) args.push('--session-id', session_id);
|
|
144
169
|
args.push('-');
|
|
145
170
|
const prompt = [
|
|
@@ -154,12 +179,30 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
|
|
|
154
179
|
].join('\n');
|
|
155
180
|
const result = await runProcess([...command, ...args], {
|
|
156
181
|
cwd: directory,
|
|
157
|
-
env: { ...process.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode },
|
|
182
|
+
env: { ...process.env, ...providerRuntime.env, DSH_HOME: home, DSH_PERMISSION_MODE: permission_mode, DSH_MCP_THINKING_EFFORT: thinking_effort },
|
|
158
183
|
input: prompt, timeoutMs: timeout_seconds * 1000, signal,
|
|
159
184
|
});
|
|
185
|
+
if (pluginDirectory) {
|
|
186
|
+
await rm(pluginDirectory, { recursive: true, force: true });
|
|
187
|
+
pluginDirectory = undefined;
|
|
188
|
+
}
|
|
160
189
|
let run;
|
|
161
190
|
try { run = parseRun(result.stdout, result.exit_code); }
|
|
162
|
-
catch (error) { run = { status: 'failed', session_id: null,
|
|
191
|
+
catch (error) { run = { status: 'failed', session_id: null, turn: null, reason: error.message }; }
|
|
192
|
+
let output = null;
|
|
193
|
+
let sessionOutputError = null;
|
|
194
|
+
try {
|
|
195
|
+
if (!run.session_id) throw new Error('The run returned no session ID for its persisted output');
|
|
196
|
+
const persisted = await readSession({ home, sessionId: run.session_id, cwd: directory });
|
|
197
|
+
if (run.turn === null || persisted.turn !== run.turn) {
|
|
198
|
+
throw new Error('The persisted latest turn does not match this run; no current answer is available');
|
|
199
|
+
}
|
|
200
|
+
output = persisted;
|
|
201
|
+
if (run.status === 'completed' && output.terminal_reason?.kind !== 'completed') {
|
|
202
|
+
sessionOutputError = 'The session turn did not complete durably';
|
|
203
|
+
}
|
|
204
|
+
} catch (error) { sessionOutputError = error.message; }
|
|
205
|
+
if (sessionOutputError && run.status === 'completed') run.status = 'failed';
|
|
163
206
|
let changedFiles = null;
|
|
164
207
|
let changeWarning = before ? null : 'Not a Git repository; changed_files is unavailable.';
|
|
165
208
|
try {
|
|
@@ -171,13 +214,24 @@ export function createWorker({ home, command = [process.execPath, dsh] }) {
|
|
|
171
214
|
} catch (error) { changeWarning = `Cannot inspect changes: ${error.message}`; }
|
|
172
215
|
return {
|
|
173
216
|
...run,
|
|
217
|
+
reason: run.reason == null ? run.reason : redactObject(run.reason),
|
|
174
218
|
status: result.failure ?? run.status,
|
|
175
|
-
|
|
219
|
+
answer: output?.text ?? '',
|
|
220
|
+
output_source: output ? 'session' : null,
|
|
221
|
+
session_file: output?.session_file ?? null,
|
|
222
|
+
assistant_seq: output?.assistant_seq ?? null,
|
|
223
|
+
terminal_reason: output?.terminal_reason == null ? null : redactObject(output.terminal_reason),
|
|
224
|
+
session_output_error: redact(sessionOutputError),
|
|
225
|
+
thinking_effort,
|
|
226
|
+
project_root: root, cwd: directory, model: providerRuntime.model, protocol: providerRuntime.protocol, permission_mode,
|
|
176
227
|
exit_code: result.exit_code, signal: result.signal,
|
|
177
228
|
changed_files: changedFiles, git_root: before?.root ?? null,
|
|
178
|
-
change_warning: changeWarning, diagnostics: result.stderr,
|
|
229
|
+
change_warning: redact(changeWarning), diagnostics: redact(result.stderr),
|
|
179
230
|
};
|
|
180
|
-
} finally {
|
|
231
|
+
} finally {
|
|
232
|
+
try { if (pluginDirectory) await rm(pluginDirectory, { recursive: true, force: true }); }
|
|
233
|
+
finally { busy = false; }
|
|
234
|
+
}
|
|
181
235
|
},
|
|
182
236
|
};
|
|
183
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
|
-
}
|