@ljwei-stak/dsh-model-router 0.16.4 → 0.17.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/.dsh-plugin/client.js +737 -99
- package/.dsh-plugin/index.mjs +133 -27
- package/.dsh-plugin/official-tools-remote-service.mjs +82 -10
- package/.dsh-plugin/shared/latest-versions.mjs +19 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +8 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +223 -12
- package/.dsh-plugin/shared/official-tools-remote.mjs +38 -2
- package/.dsh-plugin/shared/official-tools-runtime.mjs +460 -40
- package/.dsh-plugin/shared/tool-health.mjs +7 -0
- package/.dsh-plugin/shared/tool-install-preferences.mjs +294 -0
- package/CHANGELOG.md +14 -0
- package/README.md +11 -1
- package/README.zh.md +11 -1
- package/docs/WORKBENCH_USER_GUIDE.zh.md +35 -4
- package/package.json +3 -2
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the user's plugin settings into one concrete, reviewable install plan.
|
|
3
|
+
*
|
|
4
|
+
* Two separate "addresses" are configurable, and both stay on the Host: a
|
|
5
|
+
* unified install directory and a set of download sources (an npm registry plus
|
|
6
|
+
* per-tool installer-script URLs). The client never sends either — the RPC
|
|
7
|
+
* carries only a registry id and a method id, so a hostile client cannot point
|
|
8
|
+
* this plugin at an arbitrary package or URL. Everything here is Host config
|
|
9
|
+
* the user typed into their own settings form.
|
|
10
|
+
*
|
|
11
|
+
* Script installs are never piped into an interpreter. A plan carries the
|
|
12
|
+
* vendor URL plus the markers that still identify that vendor's script; the
|
|
13
|
+
* runtime downloads it with a size cap, checks the markers, stages a copy and
|
|
14
|
+
* runs that. So a mirror may serve different bytes, but it may not serve a
|
|
15
|
+
* different program.
|
|
16
|
+
*
|
|
17
|
+
* This module is imported by the browser bundle too, so it must stay free of
|
|
18
|
+
* Node built-ins: paths are handled with plain string rules, and the Host passes
|
|
19
|
+
* the home directory in when it needs one.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_NPM_REGISTRY,
|
|
23
|
+
defaultInstallMethod,
|
|
24
|
+
installMethodBlockReason,
|
|
25
|
+
installMethodFor,
|
|
26
|
+
installMethodsFor,
|
|
27
|
+
methodHonoursInstallDir,
|
|
28
|
+
} from './official-tool-registry.mjs'
|
|
29
|
+
|
|
30
|
+
export const MAX_INSTALL_DIR_CHARS = 4_096
|
|
31
|
+
export const MAX_SOURCE_URL_CHARS = 2_048
|
|
32
|
+
export const MAX_OVERRIDES = 64
|
|
33
|
+
|
|
34
|
+
/** Windows drive paths, UNC paths, or anything rooted at `/`. */
|
|
35
|
+
export function isAbsolutePath(value) {
|
|
36
|
+
const raw = String(value ?? '')
|
|
37
|
+
if (!raw) return false
|
|
38
|
+
if (raw.startsWith('/')) return true
|
|
39
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) return true
|
|
40
|
+
return /^\\\\[^\\]+\\[^\\]+/.test(raw)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Join path segments with the separator the first absolute segment already uses. */
|
|
44
|
+
export function joinPath(base, ...segments) {
|
|
45
|
+
const separator = String(base ?? '').includes('\\') && !String(base ?? '').includes('/') ? '\\' : '/'
|
|
46
|
+
const parts = [String(base ?? '').replace(/[\\/]+$/, ''), ...segments.map(item => String(item ?? '').replace(/^[\\/]+|[\\/]+$/g, ''))]
|
|
47
|
+
return parts.filter(Boolean).join(separator) || separator
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Expand `~` and environment references the way a shell would on this host. */
|
|
51
|
+
export function expandInstallDir(value, { home = '', env = {} } = {}) {
|
|
52
|
+
const raw = String(value ?? '').trim()
|
|
53
|
+
if (!raw) return ''
|
|
54
|
+
if (raw.includes('\0')) throw new Error('安装目录包含无效字符。')
|
|
55
|
+
let expanded = raw
|
|
56
|
+
const userHome = String(home ?? '')
|
|
57
|
+
if (expanded === '~') expanded = userHome
|
|
58
|
+
else if (expanded.startsWith('~/') || expanded.startsWith('~\\')) expanded = joinPath(userHome, expanded.slice(2))
|
|
59
|
+
// `%VAR%` on Windows, `$VAR` elsewhere — both forms the vendor scripts accept.
|
|
60
|
+
expanded = expanded
|
|
61
|
+
.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (match, name) => env[name] ?? match)
|
|
62
|
+
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => env[name] ?? match)
|
|
63
|
+
if (expanded.length > MAX_INSTALL_DIR_CHARS) throw new Error('安装目录过长。')
|
|
64
|
+
if (!isAbsolutePath(expanded)) throw new Error('安装目录必须是绝对路径。')
|
|
65
|
+
// A bare root ("/", "D:\\", "\\\\server\\share") would take the machine with it.
|
|
66
|
+
const withoutTrailing = expanded.replace(/[\\/]+$/, '')
|
|
67
|
+
if (!withoutTrailing) throw new Error('安装目录不能是文件系统根目录。')
|
|
68
|
+
if (/^[A-Za-z]:$/.test(withoutTrailing)) throw new Error('安装目录不能是文件系统根目录。')
|
|
69
|
+
if (/^\\\\[^\\]+\\[^\\]+$/.test(withoutTrailing)) throw new Error('安装目录不能是网络共享根目录。')
|
|
70
|
+
return expanded
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Only https mirrors are accepted; a plain-http script source can be tampered with in transit. */
|
|
74
|
+
export function normalizeSourceUrl(value, subject) {
|
|
75
|
+
const raw = String(value ?? '').trim()
|
|
76
|
+
if (!raw) return ''
|
|
77
|
+
if (raw.length > MAX_SOURCE_URL_CHARS) throw new Error(`${subject}地址过长。`)
|
|
78
|
+
let url
|
|
79
|
+
try { url = new URL(raw) } catch { throw new Error(`${subject}不是有效网址。`) }
|
|
80
|
+
if (url.protocol !== 'https:') throw new Error(`${subject}必须使用 https。`)
|
|
81
|
+
if (url.username || url.password) throw new Error(`${subject}不能包含账号密码。`)
|
|
82
|
+
return url.toString()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseJsonObject(value, subject) {
|
|
86
|
+
const raw = String(value ?? '').trim()
|
|
87
|
+
if (!raw) return {}
|
|
88
|
+
let parsed
|
|
89
|
+
try { parsed = JSON.parse(raw) } catch { throw new Error(`${subject}不是有效 JSON。`) }
|
|
90
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
91
|
+
throw new Error(`${subject}必须是 JSON 对象。`)
|
|
92
|
+
}
|
|
93
|
+
const entries = Object.entries(parsed)
|
|
94
|
+
if (entries.length > MAX_OVERRIDES) throw new Error(`${subject}条目过多。`)
|
|
95
|
+
return Object.fromEntries(entries.filter(([, item]) => typeof item === 'string' && item.trim()))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function valueOf(config, key, fallback) {
|
|
99
|
+
const value = config?.[key]
|
|
100
|
+
return value !== undefined && typeof value?.get === 'function' ? value.get() : value ?? fallback
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Registry entries used by the installer are the only keys an override may name. */
|
|
104
|
+
function keepKnownToolIds(raw, known, subject) {
|
|
105
|
+
for (const key of Object.keys(raw)) {
|
|
106
|
+
if (!known.has(key)) throw new Error(`${subject}中的 ${key} 不是官方工具 ID。`)
|
|
107
|
+
}
|
|
108
|
+
return raw
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the plugin settings into a normalized, already-validated shape.
|
|
113
|
+
* Throws with a user-facing message on the first bad value, so the settings
|
|
114
|
+
* form and the installer fail the same way.
|
|
115
|
+
*/
|
|
116
|
+
export function parseInstallPreferences(config = {}, { tools = [] } = {}) {
|
|
117
|
+
const known = new Set(tools.map(tool => tool.id))
|
|
118
|
+
const installDir = expandInstallDir(valueOf(config, 'toolInstallDir', ''))
|
|
119
|
+
const registry = normalizeSourceUrl(valueOf(config, 'toolNpmRegistry', DEFAULT_NPM_REGISTRY), 'npm 源') || DEFAULT_NPM_REGISTRY
|
|
120
|
+
const methodRaw = keepKnownToolIds(
|
|
121
|
+
parseJsonObject(valueOf(config, 'toolInstallMethodsJson', '{}'), '安装方式覆盖'), known, '安装方式覆盖')
|
|
122
|
+
const scriptRaw = keepKnownToolIds(
|
|
123
|
+
parseJsonObject(valueOf(config, 'toolScriptUrlsJson', '{}'), '安装脚本源覆盖'), known, '安装脚本源覆盖')
|
|
124
|
+
const methods = {}
|
|
125
|
+
const scriptUrls = {}
|
|
126
|
+
for (const [id, methodId] of Object.entries(methodRaw)) methods[id] = methodId.trim()
|
|
127
|
+
for (const [id, url] of Object.entries(scriptRaw)) scriptUrls[id] = normalizeSourceUrl(url, `${id} 安装脚本源`)
|
|
128
|
+
for (const [id, methodId] of Object.entries(methods)) {
|
|
129
|
+
if (!installMethodFor({ id, installMethods: tools.find(tool => tool.id === id)?.installMethods }, methodId)) {
|
|
130
|
+
throw new Error(`${id} 不支持安装方式 ${methodId}。`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
installDir,
|
|
135
|
+
registry,
|
|
136
|
+
methods,
|
|
137
|
+
scriptUrls,
|
|
138
|
+
/** Script execution stays a deliberate act; a config file alone must not run one. */
|
|
139
|
+
allowScriptInstall: valueOf(config, 'toolAllowScriptInstall', true) !== false,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where a script install puts the binary: the user's directory, else the vendor default. */
|
|
144
|
+
export function scriptInstallDir(tool, preferences, { home = '' } = {}) {
|
|
145
|
+
if (!tool?.defaultScriptInstallDir) return ''
|
|
146
|
+
return preferences?.installDir || vendorScriptDir(tool, { home })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The location the vendor script uses on its own, ignoring any configured directory. */
|
|
150
|
+
export function vendorScriptDir(tool, { home = '' } = {}) {
|
|
151
|
+
return joinPath(home, ...String(tool?.defaultScriptInstallDir ?? '').split('/').filter(Boolean))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** argv for a package-manager method, honouring the configured registry and prefix. */
|
|
155
|
+
export function packageManagerArgs(method, preferences, { installDir = '' } = {}) {
|
|
156
|
+
const registry = preferences?.registry || DEFAULT_NPM_REGISTRY
|
|
157
|
+
const args = method.installArgs.filter(arg => !arg.startsWith('--registry='))
|
|
158
|
+
args.push(`--registry=${registry}`)
|
|
159
|
+
if (installDir) args.push('--prefix', installDir)
|
|
160
|
+
return [...args]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function baseUrlOf(scriptUrl) {
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(scriptUrl)
|
|
166
|
+
return `${url.origin}${url.pathname.replace(/\/[^/]*$/, '')}`
|
|
167
|
+
} catch { return '' }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The method to use: the requested one, else the user's saved choice, else the
|
|
172
|
+
* registry default — and if none of those run on this platform, the first
|
|
173
|
+
* declared method that does, so a Windows user is not asked to pick between
|
|
174
|
+
* two Unix shells.
|
|
175
|
+
*/
|
|
176
|
+
function pickMethod(tool, methodId, preferences, platform) {
|
|
177
|
+
const requested = methodId ?? preferences?.methods?.[tool.id]
|
|
178
|
+
if (!requested) {
|
|
179
|
+
return installMethodsFor(tool).find(method => !installMethodBlockReason(method, platform)) ?? defaultInstallMethod(tool)
|
|
180
|
+
}
|
|
181
|
+
// A named method is never silently swapped. If the tool does not declare it,
|
|
182
|
+
// or it does not run here, the caller gets the reason instead of a surprise.
|
|
183
|
+
const chosen = installMethodFor(tool, requested)
|
|
184
|
+
if (!chosen) throw new Error(`${tool.label} 没有名为 ${requested} 的安装方式。`)
|
|
185
|
+
return chosen
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function unsupportedMethodError(tool) {
|
|
189
|
+
return tool?.manager === 'signed-windows-installer'
|
|
190
|
+
? 'ZCode 使用官方签名桌面安装器,由用户在安装窗口选择目录。'
|
|
191
|
+
: `${tool?.label ?? '此工具'} 没有可用的安装方式。`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One concrete plan for one tool and one method. A thrown Error means the user
|
|
196
|
+
* asked for something this tool cannot do, with a reason they can act on.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveInstallPlan(tool, methodId, preferences = {}, { platform = 'linux', home = '' } = {}) {
|
|
199
|
+
if (!tool || tool.unsupported) throw new Error(tool?.unsupportedReason ?? '此工具不支持一键安装。')
|
|
200
|
+
const method = pickMethod(tool, methodId, preferences, platform)
|
|
201
|
+
if (!method) throw new Error(unsupportedMethodError(tool))
|
|
202
|
+
const blocked = installMethodBlockReason(method, platform)
|
|
203
|
+
if (blocked) throw new Error(`${tool.label}:${blocked}`)
|
|
204
|
+
if (method.kind === 'script' && preferences?.allowScriptInstall === false) {
|
|
205
|
+
throw new Error('安装脚本方式已在插件设置中关闭;请改用包管理器方式,或在设置里重新允许。')
|
|
206
|
+
}
|
|
207
|
+
const installDir = preferences?.installDir || ''
|
|
208
|
+
if (method.kind === 'script') {
|
|
209
|
+
const scriptUrl = preferences?.scriptUrls?.[tool.id] || method.scriptUrl
|
|
210
|
+
const target = methodHonoursInstallDir(method) ? scriptInstallDir(tool, preferences, { home }) : ''
|
|
211
|
+
return {
|
|
212
|
+
toolId: tool.id,
|
|
213
|
+
method,
|
|
214
|
+
methodId: method.id,
|
|
215
|
+
kind: 'script',
|
|
216
|
+
label: method.label,
|
|
217
|
+
shell: method.shell,
|
|
218
|
+
scriptUrl,
|
|
219
|
+
verify: method.verify,
|
|
220
|
+
installArg: method.installArgs,
|
|
221
|
+
installDir: target,
|
|
222
|
+
env: scriptEnv(method, { installDir: target, scriptUrl }),
|
|
223
|
+
notices: target ? [] : installDir
|
|
224
|
+
? [`${method.label} 无法使用自定义安装目录(官方脚本写死了安装路径),将安装到 ${vendorScriptDir(tool, { home })}。`]
|
|
225
|
+
: [],
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
toolId: tool.id,
|
|
230
|
+
method,
|
|
231
|
+
methodId: method.id,
|
|
232
|
+
kind: 'package-manager',
|
|
233
|
+
label: method.label,
|
|
234
|
+
manager: method.manager,
|
|
235
|
+
args: packageManagerArgs(method, preferences, { installDir }),
|
|
236
|
+
installDir,
|
|
237
|
+
env: {},
|
|
238
|
+
notices: [],
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Same plan shape for removal, so a UI can show exactly what will be deleted. */
|
|
243
|
+
export function resolveUninstallPlan(tool, methodId, preferences = {}, options = {}) {
|
|
244
|
+
const platform = options.platform ?? 'linux'
|
|
245
|
+
const method = pickMethod(tool, methodId, preferences, platform)
|
|
246
|
+
if (!method) throw new Error(tool?.manager === 'signed-windows-installer'
|
|
247
|
+
? 'ZCode 是桌面安装版,请在系统“应用”中卸载它。'
|
|
248
|
+
: `${tool?.label ?? '此工具'} 没有可用的卸载方式。`)
|
|
249
|
+
const blocked = installMethodBlockReason(method, platform)
|
|
250
|
+
if (blocked) throw new Error(`${tool.label}:${blocked}`)
|
|
251
|
+
if (method.kind === 'script') {
|
|
252
|
+
return { toolId: tool.id, method, methodId: method.id, kind: 'script', label: method.label, ...resolveInstallLocation(tool, method, preferences, options) }
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
toolId: tool.id,
|
|
256
|
+
method,
|
|
257
|
+
methodId: method.id,
|
|
258
|
+
kind: 'package-manager',
|
|
259
|
+
label: method.label,
|
|
260
|
+
manager: method.manager,
|
|
261
|
+
args: [
|
|
262
|
+
...method.uninstallArgs,
|
|
263
|
+
...(preferences?.registry ? [`--registry=${preferences.registry}`] : []),
|
|
264
|
+
...(preferences?.installDir ? ['--prefix', preferences.installDir] : []),
|
|
265
|
+
],
|
|
266
|
+
installDir: preferences?.installDir || '',
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Where the tool's binary lives, so a later uninstall can find it. */
|
|
271
|
+
export function resolveInstallLocation(tool, method, preferences = {}, { home = '' } = {}) {
|
|
272
|
+
if (!method) return null
|
|
273
|
+
if (method.kind === 'script') {
|
|
274
|
+
// A method that cannot honour the configured directory always lands in the
|
|
275
|
+
// vendor's own location, whatever the user typed.
|
|
276
|
+
const dir = methodHonoursInstallDir(method)
|
|
277
|
+
? scriptInstallDir(tool, preferences, { home })
|
|
278
|
+
: vendorScriptDir(tool, { home })
|
|
279
|
+
return { kind: 'script', dir, binaries: [...(tool.scriptBinaryNames ?? [])] }
|
|
280
|
+
}
|
|
281
|
+
return { kind: 'package-manager', dir: preferences?.installDir || '', binaries: [] }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function scriptEnv(method, { installDir, scriptUrl }) {
|
|
285
|
+
const env = {}
|
|
286
|
+
if (installDir && method.env?.includes('STEP_INSTALL_DIR')) env.STEP_INSTALL_DIR = installDir
|
|
287
|
+
// The step installers read their release base from this variable, so a mirror
|
|
288
|
+
// URL also moves the binaries and the checksum manifest, not just the script.
|
|
289
|
+
if (method.env?.includes('STEP_RELEASE_BASE_URL') && scriptUrl) {
|
|
290
|
+
const base = baseUrlOf(scriptUrl)
|
|
291
|
+
if (base) env.STEP_RELEASE_BASE_URL = base
|
|
292
|
+
}
|
|
293
|
+
return env
|
|
294
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
Stable minor under the npm `latest` tag. Fully quit Harness, including the tray or WebUI host process, and restart after upgrading so the Host reloads. Existing 0.16.4 artifacts remain unchanged.
|
|
6
|
+
|
|
7
|
+
- **Two new official tools.** `opencode` (OpenCode, `opencode-ai` on npm or the official `curl -fsSL … | bash` installer) and `stepcode` (Step Code from StepFun, installed only by the vendor's `curl | bash` / `irm | iex` script — it publishes no npm package). Both are covered by the registry's probe, version lookup, health check and login guidance. Step Code's latest version is read from the same `latest.json` release manifest its own installer uses. Neither has a verified restricted execution entry yet, and both say so rather than claiming one.
|
|
8
|
+
- **Every tool can now declare several install methods.** `installMethods` is the closed list the Host may use: `npm` and `pnpm` everywhere a package exists, plus the vendor installer script where the vendor ships one. The existing `manager` / `installArgs` stay the default method, so nothing about the old one-click path changed.
|
|
9
|
+
- **Unified install settings.** New volatile config: `toolInstallDir`, `toolNpmRegistry`, `toolInstallMethodsJson`, `toolScriptUrlsJson`, `toolAllowScriptInstall`. They are editable from the **官方工具** page and from the plugin settings form, and apply to one-click install, repair and uninstall as well as `/tools` and the agent tools. An install directory becomes `--prefix` for npm/pnpm and `-InstallDir` / `--install-dir` for Step Code. OpenCode's official script hardcodes `~/.opencode/bin` and takes no directory, so for that tool the row says where it will actually land.
|
|
10
|
+
- **Vendor scripts are verified before they run, never piped.** The plugin downloads the script with a size cap, requires the vendor's own markers, refuses an HTML error page or another vendor's installer, writes a staged copy and runs that. A configured mirror may rehost the same script; it may not substitute another program. A Step Code script mirror also redirects that vendor's binary and checksum downloads. Script installs can be switched off entirely.
|
|
11
|
+
- **One-click repair and uninstall.** Repair re-runs the same fixed official command even when the tool already reports the latest version, which is the case a broken install actually presents. Uninstall removes only the program: package-manager installs go back through the same manager, script installs delete the single executable the vendor installer placed plus that install's own `# stepcode` PATH block in the shell profile. Configuration, credentials and session history are always kept. `checkedRemovableFile` re-checks the basename, the install directory and the root/home guards on every delete.
|
|
12
|
+
- **The RPC stays closed.** `installTool`, `uninstallTool` and `repairTool` take one strict `{ tool, method? }` request; an unknown field, an unknown tool id or a method the tool does not declare is refused. Install addresses still come only from Host config, so a compromised client cannot redirect an install. `list()` now also reports the effective install directory, source and per-tool commands so the panel can show what will run.
|
|
13
|
+
- `/tools` accepts `install`, `uninstall`, `repair`, `cancel` and `method`, each with an optional method id. New agent tools `model_router_tool_repair` and `model_router_tool_uninstall`, plus a `method` parameter on `model_router_tool_install`. Install, repair and uninstall each ask for approval, and uninstall's prompt states that settings and login are kept.
|
|
14
|
+
- The workbench panel shows the settings block, a per-row method selector with the platform reason for anything unavailable, the resolved command, and repair/uninstall buttons. Install settings also appear in the plugin settings card with the same client-side validation as the Host.
|
|
15
|
+
- Release validation on Windows 11: 649 tests passed, 3 conditional skips, `npm run build:client` and `npm run check:client` passed. No real vendor CLI was installed, no vendor script was executed, and no paid model call was made as part of this verification.
|
|
16
|
+
|
|
3
17
|
## 0.16.4 — 2026-10-09
|
|
4
18
|
|
|
5
19
|
Stable patch under the npm `latest` tag. Fully quit Harness, including the tray or WebUI host process, and restart after upgrading so the Host reloads. Existing 0.16.3 artifacts remain unchanged.
|
package/README.md
CHANGED
|
@@ -143,12 +143,22 @@ The CLI team runner is the plugin's own sequential dependency executor. It does
|
|
|
143
143
|
| `mimo-code` | MiMo Code | `read-only`, approved `workspace-write` |
|
|
144
144
|
| `grok-build` | Grok Build | `read-only`, approved `workspace-write` |
|
|
145
145
|
| `gemini` | Gemini CLI | Routed headless adapter; not the managed single-tool/team runner |
|
|
146
|
+
| `opencode` | OpenCode | Install, probe and login check; no verified restricted execution entry yet |
|
|
147
|
+
| `stepcode` | Step Code | Install, probe and login check; no verified restricted execution entry yet |
|
|
146
148
|
| `zcode` | ZCode | Windows; approved `workspace-write` |
|
|
147
149
|
|
|
148
|
-
Installers use fixed official npm packages at each vendor's `@latest`, or the official publisher-signed Windows ZCode installer. An installation newer than the reported latest is not downgraded. Latest-version lookups are cached for about 12 hours; unavailable lookups leave the version unknown. New vendor releases are not automatically validated by this project. ZCode's installation window still requires you to choose a directory and finish installation.
|
|
150
|
+
Installers use fixed official npm packages at each vendor's `@latest`, the vendor's own installer script, or the official publisher-signed Windows ZCode installer. An installation newer than the reported latest is not downgraded. Latest-version lookups are cached for about 12 hours; unavailable lookups leave the version unknown. New vendor releases are not automatically validated by this project. ZCode's installation window still requires you to choose a directory and finish installation.
|
|
151
|
+
|
|
152
|
+
**Unified install settings.** The top of the **官方工具** page (also in the plugin settings form) sets the install address for every tool at once: one install directory (empty means each vendor's default location), an npm source, and per-tool overrides for vendor installer-script mirrors. Directories must be absolute; sources must be https. Each row picks its own install method from `npm`, `pnpm` or the vendor script (`curl | bash` or `irm | iex`); a method that cannot run on this platform says so on the row instead of being silently swapped. The exact command is shown above the buttons before anything runs.
|
|
153
|
+
|
|
154
|
+
**Script installs never pipe remote content into an interpreter.** The plugin downloads the vendor script, checks it is still that vendor's official installer (vendor markers required, HTML pages and other vendors' scripts refused), and runs the local copy. A mirror may rehost the same script; it may not substitute a different program. Step Code's script accepts an install directory; the OpenCode script hardcodes `~/.opencode/bin`, so a custom directory only applies to it through npm/pnpm's `--prefix`. Script installs can be switched off entirely, leaving only the package managers.
|
|
155
|
+
|
|
156
|
+
**One-click repair and uninstall.** Repair re-runs the same fixed official command, which is what a broken install needs even when it already reports the latest version. Uninstall removes only the program: a package-manager install goes back through the same manager, and a script install deletes just the executable the vendor installer placed plus that install's own PATH entry in the shell profile. **Configuration, credentials and session history are always kept.**
|
|
149
157
|
|
|
150
158
|
Installation, trusted launch readiness, account login and model entitlement are separate checks. An unknown login status is not proof of logout. On Windows, supported managed launch entries are checked against publisher signatures or official npm-tarball attestations; ZCode also records trust for its bundled script per signed build. See [the registry](.dsh-plugin/shared/official-tool-registry.mjs) and [executor](.dsh-plugin/shared/official-tool-executor.mjs) for the exact platform rules.
|
|
151
159
|
|
|
160
|
+
In a session, `/tools install|uninstall|repair|method <tool-id> [method]` does the same; agents can call `model_router_tool_install`, `model_router_tool_repair` and `model_router_tool_uninstall`. The install directory and download sources always come from the user's own plugin settings and never travel over the RPC, so no call can redirect an install at an arbitrary package or URL.
|
|
161
|
+
|
|
152
162
|
`model_router_execute` has fixed headless adapters for Claude, Codex and Gemini. Other suppliers use the model-directory API unless a supported subscription route applies. Missing/unusable CLIs can fall back to that API; subscription failures follow the policy below.
|
|
153
163
|
|
|
154
164
|
Editable execution needs a clean Git repository, Host approval and the Harness process sandbox. Changes are made in an isolated worktree and applied back only after successful execution and a matching clean source workspace. Failed or conflicting integration is reported. Git-ignored outputs need separate review. Read-only CLI flags do not necessarily limit file reads to the workspace; check **安全边界**, especially Codex's broader readable scope.
|
package/README.zh.md
CHANGED
|
@@ -143,12 +143,22 @@ CLI 团队是插件自有的顺序依赖执行器。Harness 内建 Agent Teams
|
|
|
143
143
|
| `mimo-code` | MiMo Code | `read-only`、经审批的 `workspace-write` |
|
|
144
144
|
| `grok-build` | Grok Build | `read-only`、经审批的 `workspace-write` |
|
|
145
145
|
| `gemini` | Gemini CLI | 路由执行的无界面适配器;不属于单工具/团队托管执行器 |
|
|
146
|
+
| `opencode` | OpenCode | 安装、检测与登录体检;尚未完成受限执行入口核验 |
|
|
147
|
+
| `stepcode` | Step Code | 安装、检测与登录体检;尚未完成受限执行入口核验 |
|
|
146
148
|
| `zcode` | ZCode | Windows;经审批的 `workspace-write` |
|
|
147
149
|
|
|
148
|
-
安装使用固定官方 npm 包的 `@latest
|
|
150
|
+
安装使用固定官方 npm 包的 `@latest`、厂商官方安装脚本,或 ZCode 官方、经过发布者签名验证的 Windows 安装器。已安装版本比查询到的最新版更高时不会降级。最新版查询缓存约 12 小时;离线/查询失败时保持未知。厂商新版本没有自动获得本项目验证。ZCode 打开安装窗口后,仍需手动选目录并完成安装。
|
|
151
|
+
|
|
152
|
+
**统一安装设置。** 在**官方工具**页顶部(或插件设置页)可以一次设定所有工具的安装目录与下载源:统一安装目录(留空=各厂商默认位置)、npm 源地址,以及按工具 ID 覆盖厂商安装脚本的镜像地址。目录必须是绝对路径,下载地址必须是 https。每个工具都可以在**安装方式**下拉框里单独选择 `npm`、`pnpm`、厂商安装脚本(`curl | bash` 或 `irm | iex`);选了某个平台不支持的方式时,行内会写明原因而不是悄悄换成别的。每行上方实时显示将要执行的完整命令。
|
|
153
|
+
|
|
154
|
+
**脚本方式不会把远程内容直接管道给解释器。** 插件先下载厂商安装脚本、核对它仍然是该厂商的官方脚本(校验厂商标记、拒绝 HTML 页面和其他厂商的脚本),再执行本地副本。镜像可以重新托管同一个脚本,但不能替换成别的程序。阶跃的脚本还支持安装目录参数,OpenCode 的官方脚本写死了 `~/.opencode/bin`,因此自定义安装目录对它只能通过 npm/pnpm 的 `--prefix` 生效。脚本方式可以整体关闭,只保留包管理器方式。
|
|
155
|
+
|
|
156
|
+
**一键修复与一键卸载。** 修复会用同一条固定官方命令重新安装一遍,适合修复损坏或不完整的安装;卸载只删除程序本身——包管理器安装走同一个管理器卸载,脚本安装只删除厂商安装器放下的那一个可执行文件,并顺带清掉它在 shell 配置里写的 PATH 记录。**配置、登录信息和历史记录一律保留**,重新安装后仍在。
|
|
149
157
|
|
|
150
158
|
**已安装、可信入口就绪、账号已登录、模型有权限是不同状态。** 登录未知不代表已退出。在 Windows,托管入口按发布者签名或官方 npm tarball 校验;ZCode 另对签名构建中的脚本记录首次信任摘要。平台细则见[注册表](.dsh-plugin/shared/official-tool-registry.mjs)和[执行器](.dsh-plugin/shared/official-tool-executor.mjs)。
|
|
151
159
|
|
|
160
|
+
会话内可用 `/tools install|uninstall|repair|method <工具id> [安装方式]`;Agent 可调用 `model_router_tool_install`、`model_router_tool_repair` 和 `model_router_tool_uninstall`。安装目录与下载源始终来自用户自己的插件设置,不会由客户端或 Agent 传入,因此任何一条调用都无法把安装指向自定义包名或任意地址。
|
|
161
|
+
|
|
152
162
|
`model_router_execute` 的固定无界面适配器为 Claude、Codex 和 Gemini;其他供应商经模型目录 API 或适用的订阅路线执行。CLI 缺失/不可用时可回退 API;真正尝试后的订阅失败按下方策略处理。
|
|
153
163
|
|
|
154
164
|
可编辑执行要求干净 Git 仓库、Harness 审批与进程沙箱。修改先在独立工作树进行,执行成功且原仓库仍干净并匹配时才整合源码补丁;执行或整合失败会明确报告。Git 忽略的输出另行检查。只读参数不一定把可读文件限制在工作区,尤其需在**安全边界**核对 Codex 的较大可读范围。
|
|
@@ -97,14 +97,44 @@
|
|
|
97
97
|
|
|
98
98
|
## 官方工具:体检、安装与登录
|
|
99
99
|
|
|
100
|
-
当前注册表包括 Kimi、Claude、Codex、MiniMax、MiMo、Grok、Gemini 和 ZCode。点**重新体检**查看实际安装、版本、入口及账号状态;最新版查询失败时保持未知,不表示已无更新。
|
|
100
|
+
当前注册表包括 Kimi、Claude、Codex、MiniMax、MiMo、Grok、Gemini、OpenCode、Step Code 和 ZCode。点**重新体检**查看实际安装、版本、入口及账号状态;最新版查询失败时保持未知,不表示已无更新。
|
|
101
101
|
|
|
102
102
|
1. 未安装时可从卡片固定官方来源安装;npm 工具安装厂商 `@latest`。新厂商版本没有自动通过本项目测试。
|
|
103
103
|
2. 安装中可查看日志或取消;取消后重新体检确认状态。
|
|
104
104
|
3. 有更新时可更新;已安装版本比查询结果更高时不会降级。
|
|
105
|
-
4.
|
|
106
|
-
5.
|
|
107
|
-
6.
|
|
105
|
+
4. 已安装但未就绪时点**一键修复**,用同一条固定命令重新安装一遍。
|
|
106
|
+
5. 不再需要时点**一键卸载**,只删除程序本身;配置、登录信息和历史记录保留,重新安装后仍在。
|
|
107
|
+
6. ZCode 打开官方安装窗口后,还需要手动选目录完成安装,再体检。它属于 Windows 桌面应用,由系统“应用”卸载。
|
|
108
|
+
7. 按**去登录**提示,在对应 CLI/终端完成厂商授权,然后用小任务验证权限。
|
|
109
|
+
|
|
110
|
+
### 统一安装设置
|
|
111
|
+
|
|
112
|
+
卡片顶部**统一安装设置**一次设定所有工具的安装地址,改动后下一次安装、修复、卸载立即使用。插件设置页里有同样的字段。
|
|
113
|
+
|
|
114
|
+
| 设置 | 作用 | 限制 |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| 统一安装目录 | 所有一键安装的目标目录 | 必须是绝对路径,不能是磁盘或共享根目录;留空=各厂商默认位置 |
|
|
117
|
+
| npm 源地址 | npm / pnpm 方式的下载源 | 必须 https,可填国内镜像 |
|
|
118
|
+
| 安装脚本源覆盖 | 按工具 ID 指定厂商安装脚本的镜像 | JSON,键是官方工具 ID,值是 https 链接 |
|
|
119
|
+
| 允许 curl / irm 安装脚本 | 是否允许执行厂商安装脚本 | 关闭后只剩 npm / pnpm |
|
|
120
|
+
|
|
121
|
+
几个容易踩的点:
|
|
122
|
+
|
|
123
|
+
- **不同工具对"自定义目录"的支持不一样。** npm 与 pnpm 都接受 `--prefix`,所以一定生效。阶跃的安装脚本接受 `--install-dir`(Windows 上是 `-InstallDir`),也生效。OpenCode 的官方脚本把路径写死成 `~/.opencode/bin`,**无法**接受自定义目录——如果设置里填了目录,它的行内会明确写出将装到哪里。
|
|
124
|
+
- **脚本方式不是把远程内容直接交给解释器。** 插件会先下载脚本、核对它仍然是该厂商的官方安装脚本,再执行本地副本;HTML 报错页和其他厂商的脚本都会被拒绝执行。镜像可以重新托管同一个脚本,不能替换成别的程序。阶跃的脚本源覆盖还会同时改写它下载二进制与校验和的地址,所以镜像需要提供完整的发布文件。
|
|
125
|
+
- **每行可以单独选安装方式。** 下拉框里列出该工具声明的全部方式;当前系统不支持的方式会写明原因,而不是悄悄换成别的。选中某个工具的方式会保存成该工具的默认,`/tools` 和 Agent 工具也会照此执行。
|
|
126
|
+
- **命令是可见的。** 每行上方实时显示这条设置下真正会执行的完整命令,点击前就能核对。
|
|
127
|
+
|
|
128
|
+
### 安装方式的会话与 Agent 等价物
|
|
129
|
+
|
|
130
|
+
| 工作台操作 | 会话命令 | Agent 工具 |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| 一键安装 / 更新 | `/tools install <工具id> [安装方式]` | `model_router_tool_install` |
|
|
133
|
+
| 一键修复 | `/tools repair <工具id> [安装方式]` | `model_router_tool_repair` |
|
|
134
|
+
| 一键卸载 | `/tools uninstall <工具id> [安装方式]` | `model_router_tool_uninstall` |
|
|
135
|
+
| 查看可用方式 | `/tools method <工具id>` | `model_router_tools` 的 `installMethods` |
|
|
136
|
+
|
|
137
|
+
安装目录与下载源只来自你自己的插件设置,不会由客户端或 Agent 传入;任何一条调用都无法把安装指向自定义包名或任意地址。安装和卸载都需要 Harness 审批。
|
|
108
138
|
|
|
109
139
|
注册表 ID 和托管模式如下:
|
|
110
140
|
|
|
@@ -113,6 +143,7 @@
|
|
|
113
143
|
| `claude-code`、`codex`、`mimo-code`、`grok-build` | 只读,以及经审批的可编辑执行。 |
|
|
114
144
|
| `kimi-code`、`minimax-code`、`zcode` | 经审批的可编辑执行。 |
|
|
115
145
|
| `gemini` | 路由无界面适配器,不进入单工具/团队托管执行器。 |
|
|
146
|
+
| `opencode`、`stepcode` | 尚未核验受限执行入口;插件负责安装、检测与登录体检,执行请在终端中直接进行。 |
|
|
116
147
|
|
|
117
148
|
### 交互终端
|
|
118
149
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ljwei-stak/dsh-model-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Model Router for official DeepSeek Harness Desktop: cost-aware task routing, dependent work packages and official vendor CLI discovery, installation and execution. GAL is available separately as @ljwei-stak/dsh-galgame.",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
".dsh-plugin/shared/official-tools-remote.mjs",
|
|
55
55
|
".dsh-plugin/shared/official-tool-executor.mjs",
|
|
56
56
|
".dsh-plugin/shared/task-executors.mjs",
|
|
57
|
+
".dsh-plugin/shared/tool-install-preferences.mjs",
|
|
57
58
|
".dsh-plugin/shared/vendor-minimax-adapter.mjs",
|
|
58
59
|
".dsh-plugin/shared/vendor-mimo-grok-adapter.mjs",
|
|
59
60
|
".dsh-plugin/shared/latest-versions.mjs",
|
|
@@ -102,7 +103,7 @@
|
|
|
102
103
|
"experiment:routing-summary": "node scripts/summarize-routing-search.mjs",
|
|
103
104
|
"experiment:routing-adaptive": "node scripts/adaptive-routing-study.mjs",
|
|
104
105
|
"audit:livebench": "node scripts/livebench-aggregate-audit.mjs",
|
|
105
|
-
"test": "node --test tests/adaptive-*.test.mjs tests/dynamic-data.test.mjs tests/router.test.mjs tests/routing-optimizer.test.mjs tests/routing-evaluation.test.mjs tests/routing-paired-statistics.test.mjs tests/routing-selftest-tools.test.mjs tests/livebench-aggregate-audit.test.mjs tests/livebench-prior-study.test.mjs tests/routing-search-experiment.test.mjs tests/routing-regression-study.test.mjs tests/routing-search-summary.test.mjs tests/harness-*.test.mjs tests/official-tools.test.mjs tests/official-team-runtime.test.mjs tests/model-profiles.test.mjs tests/model-profile-editor.test.mjs tests/tool-install-state.test.mjs tests/vendor-adapters.test.mjs tests/desktop-compat.test.mjs tests/plugin-split.test.mjs tests/task-executors.test.mjs tests/roadmap.test.mjs tests/insights-state.test.mjs tests/billing-team-ledger.test.mjs tests/subscription-billing.test.mjs tests/subscription-host.test.mjs tests/test-pass-regressions.test.mjs tests/roadmap-gaps.test.mjs tests/cordis-inject.test.mjs tests/cli-terminal.test.mjs tests/cli-terminal-gateway.test.mjs tests/latest-versions.test.mjs",
|
|
106
|
+
"test": "node --test tests/adaptive-*.test.mjs tests/dynamic-data.test.mjs tests/router.test.mjs tests/routing-optimizer.test.mjs tests/routing-evaluation.test.mjs tests/routing-paired-statistics.test.mjs tests/routing-selftest-tools.test.mjs tests/livebench-aggregate-audit.test.mjs tests/livebench-prior-study.test.mjs tests/routing-search-experiment.test.mjs tests/routing-regression-study.test.mjs tests/routing-search-summary.test.mjs tests/harness-*.test.mjs tests/official-tools.test.mjs tests/official-team-runtime.test.mjs tests/model-profiles.test.mjs tests/model-profile-editor.test.mjs tests/tool-install-state.test.mjs tests/vendor-adapters.test.mjs tests/desktop-compat.test.mjs tests/plugin-split.test.mjs tests/task-executors.test.mjs tests/tool-install-preferences.test.mjs tests/roadmap.test.mjs tests/insights-state.test.mjs tests/billing-team-ledger.test.mjs tests/subscription-billing.test.mjs tests/subscription-host.test.mjs tests/test-pass-regressions.test.mjs tests/roadmap-gaps.test.mjs tests/cordis-inject.test.mjs tests/cli-terminal.test.mjs tests/cli-terminal-gateway.test.mjs tests/latest-versions.test.mjs",
|
|
106
107
|
"prepack": "node scripts/prepack.mjs",
|
|
107
108
|
"pack:local": "npm pack --pack-destination dist"
|
|
108
109
|
},
|