dsh-model-groups 0.3.4 → 0.3.6

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.
@@ -0,0 +1,250 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ const digest = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
+ const messages = {
9
+ inspection_unknown: '宿主依赖未完整验证。',
10
+ config_inspection_failed: '配置依赖未完整验证。',
11
+ default_inspection_failed: '默认模型依赖未完整验证。',
12
+ session_service_missing: '会话查询服务不可用,会话依赖未完整验证。',
13
+ session_inspection_failed: '部分会话依赖未完整验证。',
14
+ agent_inspection_failed: '部分智能体依赖未完整验证。',
15
+ activity_unverified: '部分任务活动状态未验证,请确认任务已结束。',
16
+ process_inspection_failed: '引擎进程活动状态未完整验证。',
17
+ profile_unknown: '部分配置组合依赖未完整验证。',
18
+ cross_profile_bundle: '其他配置仍安装或选择本插件,卸载后可能受影响。',
19
+ alias_dependency: '观察到多密钥模型路由,卸载后相关路由可能不可用。',
20
+ key_config_dependency: '观察到多密钥配置,维护不会自动删除这些配置。',
21
+ expression_unknown: '配置含动态表达式,无法证明不存在依赖。',
22
+ active_turns: '存在正在运行或维护中的任务,请先等待任务结束。',
23
+ other_process: '发现其他引擎进程,无法验证它们的活动状态。',
24
+ };
25
+
26
+ // These scalar configuration fields cannot supply a plugin, model route or key
27
+ // collection. Unknown fields/roots remain conservative. Never infer safety from
28
+ // expression text, plugin enablement, or a name/id that might itself be dynamic.
29
+ const nonRouteScalars = new Set([
30
+ 'apiKey', 'apiKeyEnv', 'baseURL', 'baseUrl', 'endpoint', 'timeout', 'timeoutMs',
31
+ 'maxRetries', 'temperature', 'topP', 'maxTokens', 'contextWindow',
32
+ 'enabled', 'disabled', 'label', 'description', 'locale', 'language', 'port', 'host',
33
+ ]);
34
+
35
+ /** Pure scanner: never evaluates expressions or returns inspected values. */
36
+ export function dependencySignals(value) {
37
+ const result = { alias: 0, keys: 0, expression: 0 };
38
+ const seen = new WeakSet();
39
+ const routeCollections = new Set(['providers', 'profiles', 'keyEntries', 'models', 'modelOverrides', 'allowedModels', 'plugins']);
40
+ const walk = (input, safeScalar = false, field, parentField) => {
41
+ if (typeof input === 'string') { if (input.startsWith('@dsh-key/')) result.alias++; return; }
42
+ if (!input || typeof input !== 'object') return;
43
+ if (Object.hasOwn(input, '__jsExpr')) {
44
+ if (!safeScalar) result.expression++;
45
+ return;
46
+ }
47
+ if (seen.has(input)) { result.expression++; return; }
48
+ seen.add(input);
49
+ if (!Array.isArray(input)) {
50
+ if (input.multiKey === true || (input.keyEntries && typeof input.keyEntries === 'object' && Object.keys(input.keyEntries).length)) result.keys++;
51
+ if (Object.hasOwn(input, 'multiKey') && typeof input.multiKey !== 'boolean' && !input.multiKey?.__jsExpr) result.expression++;
52
+ if (Object.hasOwn(input, 'keyEntries') && (!input.keyEntries || typeof input.keyEntries !== 'object' || Array.isArray(input.keyEntries))) result.expression++;
53
+ }
54
+ // Safety applies only to the immediate scalar slot, never a whole subtree.
55
+ const configFields = field === undefined || field === 'config' || ['value', 'base', 'user'].includes(field) || routeCollections.has(parentField);
56
+ for (const [key, child] of Object.entries(input)) walk(child,
57
+ configFields && !Array.isArray(input) && !routeCollections.has(field) && nonRouteScalars.has(key), key, field);
58
+ seen.delete(input);
59
+ };
60
+ walk(value);
61
+ return result;
62
+ }
63
+
64
+ // Fingerprints never serialize arbitrary host objects, credentials or expression text.
65
+ // Unknown shapes remain an explicit inspection signal, not an assertion of safety.
66
+ function routeProjection(value, ancestors = new WeakSet()) {
67
+ if (value === undefined || value === null) return value;
68
+ if (typeof value === 'string') return value;
69
+ if (typeof value !== 'object' || ancestors.has(value)) throw new Error('Invalid route projection');
70
+ if (Object.hasOwn(value, '__jsExpr')) return { __jsExpr: true };
71
+ ancestors.add(value);
72
+ try {
73
+ if (Array.isArray(value)) return value.map(item => routeProjection(item, ancestors));
74
+ const result = {};
75
+ for (const key of ['provider', 'model', 'reasoningEffort', 'agentProvider', 'agentModel', 'allowedModels']) {
76
+ if (!Object.hasOwn(value, key)) continue;
77
+ const child = value[key];
78
+ if (key === 'allowedModels') {
79
+ if (!Array.isArray(child)) throw new Error('Invalid route policy');
80
+ } else if (child != null && typeof child !== 'string' && !(typeof child === 'object' && Object.hasOwn(child, '__jsExpr'))) {
81
+ throw new Error('Invalid route scalar');
82
+ }
83
+ result[key] = routeProjection(child, ancestors);
84
+ }
85
+ return result;
86
+ } finally { ancestors.delete(value); }
87
+ }
88
+
89
+ /** Fold explicit routes only; message bodies and Session objects are never projected. */
90
+ export function sessionDependencies(events) {
91
+ if (!Array.isArray(events)) throw new Error('Invalid session observation');
92
+ let lastUsed, pending;
93
+ const sameSelection = (a, b) => a && b && a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort;
94
+ const durable = [];
95
+ let openTurns = 0;
96
+ for (const event of events) {
97
+ if (!event || typeof event.type !== 'string') throw new Error('Invalid event observation');
98
+ if (event.type === 'model/selection') {
99
+ if (!event.data || typeof event.data.model !== 'string') throw new Error('Invalid selection observation');
100
+ pending = routeProjection(event.data);
101
+ }
102
+ if (event.type === 'request/header') {
103
+ const header = event.data?.header, route = header?.config;
104
+ if (!route || typeof route.provider !== 'string' || typeof route.model !== 'string') throw new Error('Invalid route observation');
105
+ lastUsed = routeProjection({ provider: route.provider, model: route.model,
106
+ reasoningEffort: header.adapterDefaults?.reasoningEffort === true ? undefined : route.reasoningEffort });
107
+ durable.push(lastUsed);
108
+ if (sameSelection(pending, lastUsed)) pending = undefined;
109
+ }
110
+ if (event.type === 'subagent/descriptor') {
111
+ if (typeof event.data?.agentModel !== 'string') throw new Error('Invalid descriptor observation');
112
+ durable.push(routeProjection({ provider: event.data.agentProvider, model: event.data.agentModel }));
113
+ }
114
+ if (event.type === 'subagent/model-selection-policy') {
115
+ if (!Array.isArray(event.data?.allowedModels)) throw new Error('Invalid policy observation');
116
+ durable.push(routeProjection(event.data.allowedModels));
117
+ }
118
+ if (event.type === 'turn/start') openTurns++;
119
+ if (event.type === 'turn/end') openTurns = Math.max(0, openTurns - 1);
120
+ }
121
+ return { routes: [pending ?? lastUsed, ...durable], openTurns };
122
+ }
123
+
124
+ /** Read-only inspection bounded to the supplied home and installation. No global-install claim. */
125
+ export function createHostInspection(ctx, root, home) {
126
+ return async function inspect() {
127
+ const counts = new Map(), observations = new Set(), dependencies = new Set();
128
+ const block = (code, count = 1) => counts.set(code, (counts.get(code) || 0) + count);
129
+ const observe = value => {
130
+ const signals = dependencySignals(value), routes = [], seen = new WeakSet();
131
+ const collect = input => {
132
+ if (!input || typeof input !== 'object' || seen.has(input)) return;
133
+ seen.add(input);
134
+ if (Object.hasOwn(input, '__jsExpr')) return;
135
+ if (['provider', 'model', 'agentProvider', 'agentModel', 'allowedModels'].some(key => Object.hasOwn(input, key))) routes.push(routeProjection(input));
136
+ for (const child of Object.values(input)) collect(child);
137
+ };
138
+ collect(value);
139
+ const observation = digest([signals.alias > 0, signals.keys > 0, signals.expression > 0, routes]);
140
+ if (dependencies.has(observation)) return;
141
+ dependencies.add(observation);
142
+ observations.add(observation);
143
+ if (signals.alias) block('alias_dependency');
144
+ if (signals.keys) block('key_config_dependency');
145
+ if (signals.expression) block('expression_unknown');
146
+ };
147
+ try {
148
+ const require = createRequire(path.join(root, 'package.json'));
149
+ const boot = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-app-boot')).href);
150
+ const profile = ctx.profileContext;
151
+ if (!profile || !Array.isArray(profile.overlays) || fs.realpathSync(profile.home) !== fs.realpathSync(home)) throw new Error('Unknown launch scope');
152
+ const profilesRoot = path.join(home, boot.PROFILES_DIR);
153
+ const directories = fs.readdirSync(profilesRoot, { withFileTypes: true });
154
+ let currentFound = false;
155
+ for (const entry of directories.sort((a, b) => a.name.localeCompare(b.name))) {
156
+ if (!entry.isDirectory()) { block('profile_unknown'); continue; }
157
+ const dir = path.join(profilesRoot, entry.name);
158
+ const current = fs.realpathSync(dir) === fs.realpathSync(profile.dir);
159
+ currentFound ||= current;
160
+ try {
161
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
162
+ observations.add(digest([entry.name, Object.hasOwn(manifest.dependencies || {}, 'dsh-model-groups'), manifest.dsh?.profile?.bundles?.includes('dsh-model-groups') === true]));
163
+ if (!current && (Object.hasOwn(manifest.dependencies || {}, 'dsh-model-groups') || manifest.dsh?.profile?.bundles?.includes('dsh-model-groups'))) block('cross_profile_bundle');
164
+ const loaded = boot.loadProfileDirectory('dsh', dir, profile.installAnchor, { readOnly: true });
165
+ if (loaded.skippedBundles.length) block('profile_unknown', loaded.skippedBundles.length);
166
+ const context = { ...profile, dir, patchPath: loaded.patchPath, home, overlays: current ? profile.overlays : [] };
167
+ const patches = boot.readProfilePatches('dsh', context, loaded);
168
+ // Scan raw layers too: disabled rows and overridden residual config must not disappear.
169
+ observe(patches);
170
+ // Raw layers retain disabled/overridden residues without composing or evaluating them.
171
+ } catch { block('profile_unknown'); }
172
+ }
173
+ if (!currentFound) block('profile_unknown');
174
+ } catch { block('profile_unknown'); }
175
+ try {
176
+ for (const { entry, inherited, override } of ctx.configEditor.configuration()) observe({
177
+ plugin: entry.options.name, config: entry.options.config, base: inherited, user: override,
178
+ });
179
+ } catch { block('config_inspection_failed'); }
180
+ try { observe(routeProjection(ctx.agentDefaultModel.currentSelection())); }
181
+ catch { block('default_inspection_failed'); }
182
+ // Do not call settings.describe: it evaluates expressions a second time.
183
+ try {
184
+ if (typeof ctx.sessionQuery?.listSessions !== 'function' || typeof ctx.sessionQuery?.readSession !== 'function') {
185
+ block('session_service_missing'); block('activity_unverified');
186
+ } else {
187
+ const sessions = await ctx.sessionQuery.listSessions();
188
+ if (!Array.isArray(sessions)) throw new Error('Invalid session list');
189
+ for (const record of sessions) {
190
+ try {
191
+ const id = record?.header?.id;
192
+ if (typeof id !== 'string' || !id) throw new Error('Invalid session identity');
193
+ observations.add(digest(['session', id, record.live === true]));
194
+ const loaded = await ctx.sessionQuery.readSession(id);
195
+ if (loaded.session?.id !== id) throw new Error('Invalid session snapshot identity');
196
+ if (!Array.isArray(loaded.events)) throw new Error('Invalid session events');
197
+ // Activity survives malformed route observations in this same snapshot.
198
+ let openTurns = 0;
199
+ for (const event of loaded.events) {
200
+ if (event?.type === 'turn/start') openTurns++;
201
+ if (event?.type === 'turn/end') openTurns = Math.max(0, openTurns - 1);
202
+ }
203
+ observations.add(digest(['activity', id, openTurns]));
204
+ if (record.live === true && openTurns) block('active_turns');
205
+ const state = sessionDependencies(loaded.events);
206
+ observe(state.routes);
207
+ observations.add(digest(['session', id, state.routes]));
208
+ // Historical unfinished turns are dependency observations, never activity locks.
209
+ if (typeof record.live !== 'boolean') block('activity_unverified');
210
+ } catch { block('session_inspection_failed'); block('activity_unverified'); }
211
+ }
212
+ }
213
+ } catch { block('session_inspection_failed'); block('activity_unverified'); }
214
+ try {
215
+ for (const agent of ctx.agents.list()) {
216
+ // Inspect activity independently: a broken model service must not hide a live agent.
217
+ try {
218
+ const status = agent.status, phase = agent.phase?.kind;
219
+ observations.add(digest(['agent', typeof agent.id === 'string' ? agent.id : null,
220
+ typeof status === 'string' ? status : null, typeof phase === 'string' ? phase : null]));
221
+ // Host status is idle/running; maintenance is an idle-status exclusive phase.
222
+ if (status === 'running' || phase === 'maintenance') block('active_turns');
223
+ else if (status !== 'idle' || (agent.phase && phase !== 'idle')) block('activity_unverified');
224
+ } catch { block('activity_unverified'); }
225
+ try { observe(routeProjection(agent.options)); observe(routeProjection(agent.model.current())); }
226
+ catch { block('agent_inspection_failed'); }
227
+ }
228
+ } catch { block('agent_inspection_failed'); block('activity_unverified'); }
229
+ // Bounded process observation: no credentials/configuration files are opened.
230
+ // A second Node DSH process is unknown, not presumed inactive. This is not a cross-home lock.
231
+ try {
232
+ for (const entry of fs.readdirSync('/proc', { withFileTypes: true })) {
233
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name) || Number(entry.name) === process.pid) continue;
234
+ let command;
235
+ try { command = fs.readFileSync(`/proc/${entry.name}/cmdline`, 'utf8').split('\0'); }
236
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'ESRCH') continue; throw error; }
237
+ if (/(?:^|\/)node(?:js)?$/.test(command[0] || '') && command.slice(1).some(arg => {
238
+ if (!path.isAbsolute(arg)) return /^(?:dsh|@deepseek-ai\/dsh)(?:\/|$)/.test(arg);
239
+ const relative = path.relative(root, arg);
240
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
241
+ && /(?:^|\/)dsh(?:\/|$)|@deepseek-ai\/dsh/.test(arg);
242
+ })) {
243
+ block('other_process'); observations.add(digest(entry.name));
244
+ }
245
+ }
246
+ } catch { block('process_inspection_failed'); }
247
+ const blockers = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({ code, message: messages[code], count }));
248
+ return { blockers, fingerprint: digest([[...observations].sort(), blockers]) };
249
+ };
250
+ }
@@ -0,0 +1,186 @@
1
+ import { compatibilitySnapshot, publicVersion } from './compatibility.js';
2
+ export const MAINTENANCE_CLIENT_PROTOCOL = 2;
3
+ export const MAINTENANCE_CONFIRMATION = 'CONFIRM PATCH OPERATION';
4
+ export const MAINTENANCE_ENDPOINT = '/dsh-model-groups/maintenance';
5
+ const MAINTENANCE_KEYS = ['dsh:model-groups:v1', 'dsh:model-groups:mode'];
6
+ const ACTION_CAPABILITIES = { apply: 'canApply', 'restore-only': 'canRestoreOnly', 'restore-and-uninstall': 'canRestore' };
7
+
8
+ // Never project arbitrary server messages, patch paths, or tokens into the DOM.
9
+ export function maintenanceSnapshot(value) {
10
+ const phases = ['idle', 'applied', 'restored', 'removing', 'removed', 'failed'];
11
+ if (!value || !phases.includes(value.phase) || typeof value.restartRequired !== 'boolean'
12
+ || !Object.values(ACTION_CAPABILITIES).every(key => typeof value[key] === 'boolean')
13
+ || !(value.token == null || (typeof value.token === 'string' && value.token.length > 0))
14
+ || (Object.values(ACTION_CAPABILITIES).some(key => value[key]) && !(typeof value.token === 'string' && value.token.length > 0))
15
+ || !Array.isArray(value.patches) || !Array.isArray(value.blockers)
16
+ || !(value.warnings == null || (Array.isArray(value.warnings) && value.warnings.every(warning => warning && typeof warning.code === 'string'
17
+ && (warning.count == null || (Number.isSafeInteger(warning.count) && warning.count > 0)))))
18
+ || !value.patches.every(patch => patch && ['patched', 'pristine', 'conflict'].includes(patch.state)
19
+ && (patch.updateAvailable == null || typeof patch.updateAvailable === 'boolean')
20
+ && (patch.applySupported == null || typeof patch.applySupported === 'boolean'))
21
+ || !value.blockers.every(blocker => blocker && typeof blocker.code === 'string' && typeof blocker.message === 'string'
22
+ && (blocker.count == null || (Number.isSafeInteger(blocker.count) && blocker.count > 0)))) {
23
+ throw new Error('Invalid maintenance snapshot');
24
+ }
25
+ return { phase: value.phase, maintenanceProtocol: value.maintenanceProtocol === MAINTENANCE_CLIENT_PROTOCOL ? MAINTENANCE_CLIENT_PROTOCOL : null,
26
+ pluginVersion: publicVersion(value.pluginVersion), compatibility: compatibilitySnapshot(value.compatibility), restartRequired: value.restartRequired, canApply: value.canApply,
27
+ canRestoreOnly: value.canRestoreOnly, canRestore: value.canRestore,
28
+ token: value.token ?? null, patches: value.patches.map(patch => ({ state: patch.state, updateAvailable: patch.updateAvailable === true, applySupported: patch.applySupported !== false })),
29
+ blockers: value.blockers.map(blocker => ({ code: blocker.code, count: blocker.count ?? null })),
30
+ warnings: (value.warnings ?? []).map(warning => ({ code: warning.code, count: warning.count ?? null })) };
31
+ }
32
+ export function maintenanceCanAction(snapshot, action) {
33
+ const capability = Object.hasOwn(ACTION_CAPABILITIES, action) ? ACTION_CAPABILITIES[action] : null;
34
+ return !!capability && !!snapshot && ['idle', 'applied', 'restored', 'failed'].includes(snapshot.phase) && snapshot[capability]
35
+ && typeof snapshot.token === 'string' && snapshot.token.length > 0
36
+ && !snapshot.patches.some(patch => patch.state === 'conflict')
37
+ && (action !== 'apply' || !snapshot.patches.some(patch => patch.applySupported === false));
38
+ }
39
+ export function maintenanceCanRestore(snapshot) { return maintenanceCanAction(snapshot, 'restore-and-uninstall'); }
40
+ export function maintenanceBlockerText(blocker) {
41
+ const labels = {
42
+ active_sessions: '存在正在运行的会话,请先停止运行中的任务。',
43
+ active_turns: '存在正在运行的回合,请先等待结束或停止。',
44
+ patch_conflict: '补丁状态冲突,不会强制覆盖。请复制脱敏诊断;不要先卸载、还原或删除配置。',
45
+ client_version_mismatch: '网页与插件后端版本不一致,已禁止维护。请重启原引擎并刷新网页后重新检查。',
46
+ unsupported_patch_build: '此宿主构建尚无经过验证的新版协议补丁,已阻止不完整更新;仍可关闭或卸载,不会改动密钥。',
47
+ backup_missing: '缺少可验证的原版备份,无法安全恢复。',
48
+ restart_required: '需要重启原引擎后重新检查。',
49
+ inspection_failed: '状态检查失败,请使用安装诊断排查。',
50
+ config_inspection_failed: '配置检查失败,无法验证模型与密钥依赖;请使用安装诊断排查。',
51
+ session_inspection_failed: '会话检查失败,无法验证活动回合与模型引用;请使用安装诊断排查。',
52
+ session_service_missing: '缺少会话查询或持久化服务,无法验证历史会话的模型依赖;请使用安装诊断排查。',
53
+ process_inspection_failed: '进程检查失败,无法排除其他引擎使用补丁;请使用安装诊断排查。',
54
+ profile_inspection_failed: '配置档检查失败,无法排除跨配置档依赖;请使用安装诊断排查。',
55
+ patch_inspection_failed: '补丁文件检查失败,无法验证磁盘状态;不会修改未知文件。',
56
+ not_removable: '当前插件状态不允许安全卸载。',
57
+ not_applicable: '当前状态不允许安全应用补丁。',
58
+ manager_unavailable: '插件管理服务不可用,请稍后重试。',
59
+ host_unknown: '无法验证宿主版本,不会修改未知文件。',
60
+ state_changed: '状态已发生变化,请刷新检查后重新打开操作并确认。',
61
+ apply_failed: '补丁应用未完成,请刷新检查,不要视为成功。',
62
+ restore_failed: '原版恢复未完成,请刷新检查,不要视为成功。',
63
+ remove_failed: '卸载未完成,请刷新检查并使用安装诊断排查。',
64
+ remove_unverified: '无法验证卸载结果,请刷新检查,不要视为成功。',
65
+ verify_failed: '安全验证失败,不会强制覆盖文件。',
66
+ busy: '维护或其他任务正在运行,请结束后刷新。',
67
+ confirmation_required: '安全确认已失效,请刷新检查后重新打开操作并确认。',
68
+ maintenance_failed: '维护失败,请刷新检查并使用安装诊断排查。',
69
+ inspection_unknown: '无法确认运行状态,请先停止任务并使用安装诊断排查。',
70
+ profile_unknown: '无法确认当前配置档,不会执行维护。',
71
+ cross_profile_bundle: '其他配置档也在使用此插件,请先处理跨配置档依赖。',
72
+ alias_dependency: '仍有会话或配置引用多密钥模型,请先切换为普通模型再检查;本预览不会自动转换。',
73
+ key_config_dependency: '仍有多密钥配置依赖:请先禁用 multiKey 并清理 keyEntries 配置;已保存的密钥会保留。本预览不会自动转换。',
74
+ expression_unknown: '存在无法安全判断的配置表达式,请先人工检查配置。',
75
+ other_process: '其他引擎进程可能正在使用补丁,请先停止相关进程。',
76
+ };
77
+ return (labels[blocker.code.toLowerCase()] || '安全检查未通过,请先处理运行中的任务或使用安装诊断排查。')
78
+ + (blocker.count ? `(${blocker.count} 项)` : '');
79
+ }
80
+
81
+ // Copy only allowlisted codes and enumerated state: unknown codes may contain secrets or paths.
82
+ export function maintenanceDiagnostic(state) {
83
+ const snapshot = state.snapshot;
84
+ const knownCodes = new Set(['active_sessions', 'active_turns', 'patch_conflict', 'client_version_mismatch', 'unsupported_patch_build', 'backup_missing', 'restart_required',
85
+ 'inspection_failed', 'config_inspection_failed', 'session_inspection_failed', 'session_service_missing',
86
+ 'process_inspection_failed', 'profile_inspection_failed', 'patch_inspection_failed', 'default_inspection_failed',
87
+ 'agent_inspection_failed', 'activity_unverified', 'not_removable', 'not_applicable', 'manager_unavailable',
88
+ 'host_unknown', 'state_changed', 'apply_failed', 'restore_failed', 'remove_failed', 'remove_unverified',
89
+ 'verify_failed', 'busy', 'confirmation_required', 'maintenance_failed', 'inspection_unknown', 'profile_unknown',
90
+ 'cross_profile_bundle', 'alias_dependency', 'key_config_dependency', 'expression_unknown', 'other_process']);
91
+ const diagnosticItem = item => {
92
+ const code = item.code.toLowerCase();
93
+ return { code: knownCodes.has(code) ? code : 'unknown', count: item.count };
94
+ };
95
+ return JSON.stringify({
96
+ status: snapshot?.phase ?? 'unknown', busy: !!state.busy, resultUnverified: !!state.error,
97
+ restartRequired: snapshot?.restartRequired ?? null,
98
+ maintenanceProtocol: snapshot?.maintenanceProtocol ?? null, pluginVersion: publicVersion(snapshot?.pluginVersion),
99
+ compatibility: compatibilitySnapshot(snapshot?.compatibility),
100
+ capabilities: snapshot ? { apply: snapshot.canApply, close: snapshot.canRestoreOnly, uninstall: snapshot.canRestore } : null,
101
+ patches: snapshot?.patches.map(patch => patch.state) ?? [],
102
+ blockers: snapshot?.blockers.map(diagnosticItem) ?? [],
103
+ warnings: snapshot?.warnings.map(diagnosticItem) ?? [],
104
+ }, null, 2);
105
+ }
106
+ export function maintenanceWarningText(warning) {
107
+ const labels = {
108
+ alias_dependency: '旧多密钥模型选择可能影响后续使用或重启;本页不会自动切换服务或模型。',
109
+ key_config_dependency: '旧多密钥配置可能影响后续使用或重启;关闭会保留密钥和历史,不自动转换配置。',
110
+ expression_unknown: '部分配置表达式无法完整检查,请留意关闭后的使用和重启情况。',
111
+ };
112
+ const code = warning.code.toLowerCase();
113
+ const inspection = ['config_inspection_failed', 'session_inspection_failed', 'session_service_missing', 'process_inspection_failed', 'profile_inspection_failed', 'inspection_unknown', 'default_inspection_failed', 'agent_inspection_failed', 'activity_unverified'];
114
+ return (labels[code] || (inspection.includes(code) ? '检查不完整,无法确认全部依赖;这只是提醒,不会额外禁用操作。' : '部分使用情况未能验证,不等于安全;这只是提醒,不会额外禁用操作。'))
115
+ + (warning.count ? `(${warning.count} 项)` : '');
116
+ }
117
+
118
+ export function createMaintenanceController({ request = (...args) => fetch(...args), storage = () => globalThis.localStorage,
119
+ clientVersion = typeof MODEL_GROUPS_CLIENT_VERSION === 'string' ? MODEL_GROUPS_CLIENT_VERSION : null } = {}) {
120
+ let state = { snapshot: null, busy: false, error: '', cleanupWarning: false, patchedWarning: false };
121
+ let disposed = false;
122
+ let generation = 0;
123
+ const listeners = new Set();
124
+ const update = next => { if (disposed) return; state = { ...state, ...next }; for (const listener of listeners) listener(); };
125
+ async function readSnapshot(options) {
126
+ const response = await request(MAINTENANCE_ENDPOINT, { credentials: 'same-origin', cache: 'no-store', ...options });
127
+ if (!response.ok) throw new Error('Maintenance HTTP error');
128
+ const envelope = await response.json();
129
+ if (envelope?.ok !== true) throw new Error('Maintenance refused');
130
+ const snapshot = maintenanceSnapshot(envelope.value);
131
+ if (clientVersion !== null && (snapshot.maintenanceProtocol !== MAINTENANCE_CLIENT_PROTOCOL || snapshot.pluginVersion !== clientVersion)) {
132
+ return { ...snapshot, versionMismatch: true, canApply: false, canRestoreOnly: false, canRestore: false, token: null,
133
+ blockers: [...snapshot.blockers, { code: 'CLIENT_VERSION_MISMATCH', count: null }] };
134
+ }
135
+ return snapshot;
136
+ }
137
+ function accept(snapshot) {
138
+ let cleanupWarning = false;
139
+ if (snapshot.phase === 'removed' && !snapshot.versionMismatch) {
140
+ for (const key of MAINTENANCE_KEYS) {
141
+ try { storage()?.removeItem(key); } catch { cleanupWarning = true; }
142
+ }
143
+ }
144
+ update({ snapshot, cleanupWarning, patchedWarning: snapshot.patches.some(patch => patch.state === 'patched'),
145
+ error: snapshot.versionMismatch ? '网页与插件后端版本不一致,已停止维护。请重启原引擎并刷新网页后复查。' : snapshot.phase === 'failed' ? '维护操作失败,未确认完成。请刷新检查,不要强制覆盖。' : '' });
146
+ }
147
+ async function run(action = null) {
148
+ if (disposed || state.busy || (action && !maintenanceCanAction(state.snapshot, action))) return;
149
+ const sequence = ++generation;
150
+ const token = state.snapshot?.token;
151
+ const current = () => !disposed && sequence === generation;
152
+ update({ busy: true, error: '' });
153
+ try {
154
+ // Recheck immediately before using the confirmed token; a changed state needs new consent.
155
+ let snapshot = await readSnapshot({ method: 'GET' });
156
+ if (!current()) return;
157
+ if (action) {
158
+ if (snapshot.token !== token || !maintenanceCanAction(snapshot, action)) {
159
+ accept(snapshot);
160
+ update({ error: '状态已发生变化或操作被阻止,未执行操作。请检查当前状态并重新打开操作并确认。' });
161
+ return;
162
+ }
163
+ update({ snapshot: { ...snapshot, canApply: false, canRestoreOnly: false, canRestore: false, token: null } });
164
+ snapshot = await readSnapshot({ method: 'POST', headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify({ action, token, confirmation: MAINTENANCE_CONFIRMATION, ...(clientVersion === null ? {} : { maintenanceProtocol: MAINTENANCE_CLIENT_PROTOCOL, clientVersion }) }) });
166
+ if (!current()) return;
167
+ }
168
+ accept(snapshot);
169
+ } catch {
170
+ if (current()) update({ snapshot: null,
171
+ error: action ? '未能确认维护结果:连接中断、请求被拒绝或响应无效不代表成功。请刷新检查。' : '状态检查失败,请确认连接后刷新重试。' });
172
+ } finally { if (current()) update({ busy: false }); }
173
+ }
174
+ const operate = (action, checked) => checked === true && Object.hasOwn(ACTION_CAPABILITIES, action) ? run(action) : Promise.resolve();
175
+ return {
176
+ getSnapshot: () => state,
177
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); },
178
+ activate: () => { disposed = false; generation++; update({ busy: false }); },
179
+ refresh: () => run(),
180
+ operate,
181
+ apply: checked => operate('apply', checked),
182
+ restoreOnly: checked => operate('restore-only', checked),
183
+ restore: checked => operate('restore-and-uninstall', checked),
184
+ dispose: () => { disposed = true; generation++; listeners.clear(); },
185
+ };
186
+ }
@@ -0,0 +1,188 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { planPatches, inspectPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
5
+
6
+ export const BUNDLE_NAME = 'dsh-model-groups';
7
+ export const MAINTENANCE_PROTOCOL = 2;
8
+ const pluginPackage = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
+ const pluginVersion = pluginPackage.version;
10
+ // Process-local evidence survives controller replacement and module HMR, but not an
11
+ // engine restart. The restoration marker only suppresses automatic patching: it is
12
+ // not evidence that this process has loaded old code. A new process's runtime
13
+ // remains unverified; checking disk must never create persistent restart state.
14
+ const processChangesKey = Symbol.for('dsh-model-groups.maintenance.process-changes.v1');
15
+ const processChanges = globalThis[processChangesKey] ??= new Map();
16
+ export function maintenanceStorage(root, home) {
17
+ return path.join(home, BUNDLE_NAME, sha256(fs.realpathSync(root)).slice(0, 16));
18
+ }
19
+ export function restorationPending(root, home) {
20
+ return fs.existsSync(path.join(maintenanceStorage(root, home), 'restore-pending.json'));
21
+ }
22
+
23
+ // Discovery failure is a host diagnostic, not a stale-client mismatch. Keep the
24
+ // same public handshake even when no safe root or maintenance ticket exists.
25
+ export function createUnavailableMaintenance() {
26
+ const blocker = { code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装,不会修改未知文件。' };
27
+ return {
28
+ snapshot: async () => ({ phase: 'failed', maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion,
29
+ compatibility: { schema: 1, pluginVersion, hostVersion: null, supportedDsh: pluginPackage.engines.dsh, files: [] },
30
+ restartRequired: false, patches: [], blockers: [blocker], warnings: [], canRestore: false, canRestoreOnly: false, canApply: false, token: null }),
31
+ restore: async () => { throw { ...blocker }; },
32
+ };
33
+ }
34
+
35
+ /** Local-only transaction. Inspection and removal are injected to keep tests off the live host. */
36
+ export function createMaintenance({ root, home, inspect, manager, artifacts, now = Date.now }) {
37
+ let phase = 'idle', busy = false, ticket;
38
+ const rootKey = fs.realpathSync(root);
39
+ const storage = maintenanceStorage(root, home);
40
+ const marker = path.join(storage, 'restore-pending.json');
41
+ const restartRequired = () => (processChanges.get(rootKey)?.length ?? 0) > 0;
42
+ function changeFiles(plans) {
43
+ try { applyPlan(plans, path.join(storage, 'backups')); }
44
+ finally {
45
+ // Record actual observed disk changes, not an attempted/no-op operation.
46
+ // Also retain evidence if verification later fails or a partial write survives.
47
+ const changes = plans.flatMap(plan => {
48
+ try {
49
+ const current = fs.readFileSync(plan.file, 'utf8');
50
+ return plan.source !== plan.result && current === plan.result
51
+ ? [{ file: plan.file, before: sha256(plan.source), after: sha256(current) }] : [];
52
+ } catch { return []; }
53
+ });
54
+ if (changes.length) processChanges.set(rootKey, [...(processChanges.get(rootKey) ?? []), ...changes]);
55
+ }
56
+ }
57
+ const patchPlans = () => planPatches(root, 'revert', artifacts);
58
+ const generic = (code, message) => ({ code, message });
59
+ async function assessment() {
60
+ const blockers = [];
61
+ let plans = [], removable = false, compatibility;
62
+ try {
63
+ plans = patchPlans();
64
+ if (plans.some(plan => plan.applySupported === false)) blockers.push(generic('UNSUPPORTED_PATCH_BUILD', '此宿主构建仅能识别旧补丁,尚无经过验证的新版协议补丁;不会执行不完整更新。仍可关闭或卸载。'));
65
+ }
66
+ catch { blockers.push(generic('PATCH_CONFLICT', '宿主版本或文件校验不匹配;未修改任何宿主文件,请先处理冲突。')); }
67
+ compatibility = plans.length ? { schema: 1, pluginVersion, hostVersion: '0.2.2-rc.1', supportedDsh: pluginPackage.engines.dsh,
68
+ files: plans.map(p => ({ id: p.name, state: p.state, code: !p.applySupported ? 'unsupported_patch_build' : p.updateAvailable ? 'update_available' : p.state === 'pristine' ? 'not_applied' : 'ready', installedVersion: p.artifact.version,
69
+ observedSha256: p.observedSha256, baseSha256: p.baseSha256, targetSha256: p.targetSha256 })) } : inspectPatches(root, artifacts);
70
+ compatibility.pluginVersion = pluginVersion;
71
+ try {
72
+ const report = await inspect();
73
+ if (!report || !Array.isArray(report.blockers) || typeof report.fingerprint !== 'string') throw new Error('inspection');
74
+ blockers.push(...report.blockers);
75
+ } catch { blockers.push(generic('INSPECTION_FAILED', '部分配置和会话使用情况未能检查;关闭后可能需要重新配置普通模型。')); }
76
+ try {
77
+ const bundles = await manager.listBundles();
78
+ const bundle = bundles.find(item => item.name === BUNDLE_NAME);
79
+ removable = !!bundle?.removable;
80
+ if (!removable) blockers.push(generic('NOT_REMOVABLE', '当前插件不属于可卸载的 profile 依赖,请使用安装它的原 profile 管理器。'));
81
+ } catch { blockers.push(generic('MANAGER_UNAVAILABLE', '插件管理器不可用,无法安全卸载。')); }
82
+ // Dependency observations describe consequences, not permission to overwrite files.
83
+ // Only proven activity, other engines sharing the installation and file conflicts
84
+ // stop patch changes. Unknown observations remain visible for explicit consent.
85
+ const hardCodes = new Set(['patch_conflict', 'unsupported_patch_build', 'active_turns', 'other_process', 'not_removable', 'manager_unavailable']);
86
+ const warnings = blockers.filter(b => !hardCodes.has(b.code.toLowerCase()));
87
+ const hard = blockers.filter(b => hardCodes.has(b.code.toLowerCase()));
88
+ return { plans, compatibility, blockers: hard, warnings, removable, stamp: sha256(JSON.stringify([pluginVersion, compatibility, hard, removable, plans.map(p => [p.name, sha256(p.source), !!p.updateAvailable, p.applySupported, sha256(JSON.stringify(p.artifact))])])) };
89
+ }
90
+ function view(report, executing = false) {
91
+ const valid = report.plans.length > 0 && !report.blockers.some(b => b.code.toLowerCase() === 'patch_conflict');
92
+ const restoreBlockers = report.blockers.filter(b => b.code.toLowerCase() !== 'unsupported_patch_build');
93
+ const dependencies = restoreBlockers.filter(b => !['not_removable', 'manager_unavailable'].includes(b.code.toLowerCase()));
94
+ const installBlockers = report.blockers.filter(b => !['not_removable', 'manager_unavailable'].includes(b.code.toLowerCase()));
95
+ const available = (!busy || executing) && phase !== 'removed';
96
+ return { phase, maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion, compatibility: report.compatibility, restartRequired: restartRequired(), patches: report.plans.length ? report.plans.map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable, applySupported: p.applySupported !== false })) : report.compatibility.files.map(p => ({ name: p.id, state: p.state, updateAvailable: p.code === 'update_available', applySupported: ['ready', 'update_available', 'not_applied'].includes(p.code) })), blockers: report.blockers, warnings: report.warnings,
97
+ token: null, canApply: available && valid && installBlockers.length === 0 && report.plans.some(p => p.state === 'pristine' || p.updateAvailable),
98
+ canRestoreOnly: available && valid && dependencies.length === 0 && report.plans.some(p => p.state === 'patched'),
99
+ canRestore: available && valid && restoreBlockers.length === 0,
100
+ notice: '关闭会保留密钥和聊天记录,不自动更换服务。旧多密钥选择可能失效,旧配置可能影响重启;使用情况未完整检查时仍可确认继续。普通插件列表卸载不会还原补丁。' };
101
+ }
102
+ async function snapshot() {
103
+ const report = await assessment();
104
+ const value = view(report);
105
+ if (value.canRestore || value.canRestoreOnly || value.canApply) {
106
+ if (!ticket || ticket.stamp !== report.stamp || ticket.expires < now()) ticket = { token: crypto.randomBytes(32).toString('hex'), stamp: report.stamp, expires: now() + 120000 };
107
+ value.token = ticket.token;
108
+ }
109
+ return value;
110
+ }
111
+ async function restore(input) {
112
+ if (busy) throw generic('BUSY', '已有维护任务正在执行。');
113
+ if (input?.maintenanceProtocol !== MAINTENANCE_PROTOCOL || input?.clientVersion !== pluginVersion) throw generic('CLIENT_VERSION_MISMATCH', '网页与维护后端版本不一致,请重启原引擎并刷新网页后重新确认。');
114
+ if (!['apply', 'restore-only', 'restore-and-uninstall'].includes(input?.action) || input?.confirmation !== 'CONFIRM PATCH OPERATION' ||
115
+ !ticket || typeof input.token !== 'string' || input.token !== ticket.token || ticket.expires < now()) {
116
+ throw generic('CONFIRMATION_REQUIRED', '确认已失效,请重新检查并明确确认。');
117
+ }
118
+ const expected = ticket; ticket = undefined;
119
+ const action = input.action;
120
+ busy = true;
121
+ let locked = false;
122
+ const lock = path.join(storage, 'patch.lock');
123
+ try {
124
+ fs.mkdirSync(storage, { recursive: true, mode: 0o700 });
125
+ // Same inter-process lock as automatic installation. Never remove somebody else's lock.
126
+ fs.mkdirSync(lock, { mode: 0o700 }); locked = true;
127
+ const report = await assessment();
128
+ const permissions = view(report, true);
129
+ const permitted = action === 'apply' ? permissions.canApply : action === 'restore-only' ? permissions.canRestoreOnly : permissions.canRestore;
130
+ if (!permitted || report.stamp !== expected.stamp) throw generic('STATE_CHANGED', '文件或任务状态发生变化,请确认没有任务运行后重试。');
131
+ if (action === 'apply') {
132
+ changeFiles(planPatches(root, 'apply', artifacts));
133
+ if (!patchPlans().every(p => p.state === 'patched' && !p.updateAvailable && p.applySupported !== false)) throw generic('VERIFY_FAILED', '补丁验证失败。');
134
+ // Never delete a computed or unverified path; only this exact owned marker.
135
+ if (fs.existsSync(marker)) {
136
+ const stat = fs.lstatSync(marker);
137
+ if (!stat.isFile() || stat.isSymbolicLink()) throw generic('VERIFY_FAILED', '恢复标记异常。');
138
+ fs.unlinkSync(marker);
139
+ }
140
+ phase = 'applied';
141
+ return view(await assessment());
142
+ }
143
+ fs.writeFileSync(marker, JSON.stringify({ version: 1, purpose: 'explicit-restore-and-uninstall' }), { mode: 0o600 });
144
+ // Fresh synchronous planning checks concurrent target edits again; applyPlan performs an additional check.
145
+ changeFiles(patchPlans());
146
+ if (!patchPlans().every(p => p.state === 'pristine')) throw generic('VERIFY_FAILED', '原版文件复查失败,未宣告卸载成功。');
147
+ phase = 'restored';
148
+ if (action === 'restore-only') return view(await assessment());
149
+ phase = 'removing';
150
+ const result = await manager.removeBundle(BUNDLE_NAME);
151
+ if (!result || result.application !== 'applied' || result.error || (result.packageResult && (result.packageResult.exitCode !== 0 || result.packageResult.timedOut))) throw generic('REMOVE_FAILED', '已确认原版文件,但插件卸载失败。请保留恢复标记并在插件列表中重试卸载;是否需要重启以文件修改记录为准。');
152
+ const remaining = (await manager.listBundles()).find(item => item.name === BUNDLE_NAME);
153
+ if (remaining?.installed || remaining?.removable || remaining?.enabled) throw generic('REMOVE_UNVERIFIED', '尚未确认插件依赖已移除,请检查插件列表;补丁不会自动重新安装。');
154
+ if (!patchPlans().every(p => p.state === 'pristine')) throw generic('VERIFY_FAILED', '卸载后宿主文件再次变化,不能确认完整恢复。');
155
+ phase = 'removed';
156
+ return { phase, maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion, compatibility: (await assessment()).compatibility, restartRequired: restartRequired(), patches: patchPlans().map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable, applySupported: p.applySupported !== false })), blockers: [], canRestore: false, canRestoreOnly: false, canApply: false, token: null,
157
+ notice: '已确认原版文件和插件依赖移除状态。密钥、历史和安全备份未删除;运行时状态未验证,是否需要重启以本进程实际文件修改记录为准。' };
158
+ } catch (error) {
159
+ phase = 'failed';
160
+ throw ['STATE_CHANGED', 'VERIFY_FAILED', 'REMOVE_FAILED', 'REMOVE_UNVERIFIED'].includes(error?.code) ? error : generic('MAINTENANCE_FAILED', '维护未完成。未宣告成功;如已还原文件,恢复标记会阻止自动重新打补丁。请检查后重试。');
161
+ } finally {
162
+ busy = false;
163
+ if (locked) fs.rmdirSync(lock);
164
+ }
165
+ }
166
+ return { snapshot, restore, isBusy: () => busy };
167
+ }
168
+
169
+ /** Every request requires the host's authenticated owner admission, even on stock DSH. */
170
+ export function maintenanceHandler(controller, connection) {
171
+ return async (req, res) => {
172
+ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(JSON.stringify(body)); };
173
+ try {
174
+ const admission = connection.admit(req);
175
+ if (!admission?.peer || admission.peer.principal?.kind !== 'owner') return send(admission?.rejection || 403, { ok: false, error: { code: 'FORBIDDEN', message: '需要已登录的主机所有者。' } });
176
+ if (req.method === 'GET') return send(200, { ok: true, value: await controller.snapshot() });
177
+ if (req.method !== 'POST') return send(405, { ok: false, error: { code: 'METHOD', message: '不支持此操作。' } });
178
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) return send(415, { ok: false, error: { code: 'CONTENT_TYPE', message: '只接受 JSON。' } });
179
+ let body = '', length = 0;
180
+ for await (const chunk of req) { length += Buffer.byteLength(chunk); if (length > 4096) return send(413, { ok: false, error: { code: 'BODY_LIMIT', message: '请求过大。' } }); body += chunk; }
181
+ let input;
182
+ try { input = JSON.parse(body); } catch { return send(400, { ok: false, error: { code: 'JSON', message: '请求格式错误。' } }); }
183
+ return send(200, { ok: true, value: await controller.restore(input) });
184
+ } catch (error) {
185
+ return send(409, { ok: false, error: { code: error?.code || 'FAILED', message: error?.code ? error.message : '检查或维护失败,请重新检查。' } });
186
+ }
187
+ };
188
+ }