dsh-model-groups 0.3.3 → 0.3.5

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.
@@ -32,7 +32,7 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
32
32
  if (dsh.name !== '@deepseek-ai/dsh' || dsh.version !== '0.2.2-rc.1') throw new Error('This patch supports only @deepseek-ai/dsh 0.2.2-rc.1');
33
33
  const seen = new Set();
34
34
  return ARTIFACTS.map(name => {
35
- const artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
35
+ let artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
36
36
  if (artifact.version !== '0.2.2-rc.1' || !/^@deepseek-ai\/dsh-[a-z-]+$/.test(artifact.packageName)) throw new Error('Unsupported patch package/version');
37
37
  if (!Array.isArray(artifact.replacements) || artifact.replacements.length === 0 || !/^[0-9a-f]{64}$/.test(artifact.baseSha256) || !/^[0-9a-f]{64}$/.test(artifact.resultSha256)) throw new Error('Invalid patch manifest');
38
38
  const prefix = `node_modules/${artifact.packageName}/`;
@@ -44,13 +44,25 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
44
44
  if (pkg.name !== artifact.packageName || pkg.version !== artifact.version) throw new Error('Installed native package version mismatch');
45
45
  const source = fs.readFileSync(file, 'utf8');
46
46
  const hash = sha256(source);
47
+ const primary = artifact;
48
+ if (artifact.variants !== undefined && (!Array.isArray(artifact.variants) || artifact.variants.some(variant => !/^[0-9a-f]{64}$/.test(variant.baseSha256) || !/^[0-9a-f]{64}$/.test(variant.resultSha256) || (variant.replacements !== undefined && !Array.isArray(variant.replacements))))) throw new Error('Invalid patch variants');
49
+ const candidates = [primary, ...(primary.variants || []).map(variant => ({ ...primary, ...variant }))];
50
+ // A shared pristine base is intentional for legacy revisions. Prefer current
51
+ // output, but never guess between two different recognized patched sources.
52
+ const patchedMatches = candidates.filter(candidate => hash === candidate.resultSha256);
53
+ if (patchedMatches.length > 1) throw new Error('Ambiguous patch variant');
54
+ artifact = patchedMatches[0] || candidates.find(candidate => hash === candidate.baseSha256) || primary;
47
55
  const state = hash === artifact.baseSha256 ? 'pristine' : hash === artifact.resultSha256 ? 'patched' : 'modified';
48
56
  if (state === 'modified') throw new Error(`Refusing unrecognized content: ${artifact.target}`);
49
- // A no-op still verifies both directions to detect a broken manifest.
50
- const reverse = state === 'patched';
51
- const transformed = transformSource(source, artifact, reverse);
52
- const result = (mode === 'apply' && state === 'pristine') || (mode === 'revert' && state === 'patched') ? transformed : source;
53
- return { name, artifact, file, source, result, state, mode: fs.statSync(file).mode & 0o777 };
57
+ const original = state === 'patched' ? transformSource(source, artifact, true) : source;
58
+ // Verify the observed revision bidirectionally, including check/no-op plans.
59
+ const observedPatched = transformSource(original, artifact);
60
+ if (state === 'patched' && observedPatched !== source) throw new Error('Patch roundtrip mismatch');
61
+ const targetArtifact = artifact.baseSha256 === primary.baseSha256 ? primary : artifact;
62
+ const latest = transformSource(original, targetArtifact);
63
+ const updateAvailable = state === 'patched' && sha256(latest) !== hash;
64
+ const result = mode === 'apply' ? latest : mode === 'revert' ? original : source;
65
+ return { name, artifact, file, source, result, state, updateAvailable, mode: fs.statSync(file).mode & 0o777 };
54
66
  });
55
67
  }
56
68
 
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { planPatches, applyPlan } from './native-patches.mjs';
5
+
6
+ const args = process.argv.slice(2);
7
+ try {
8
+ if (args.includes('--help')) {
9
+ console.log('Usage: node scripts/setup.mjs --dsh-root <directory> --profile <profile> [--apply --service-stopped]\nDefault: read-only compatibility check. Apply registers the plugin and patches DSH. Stop the original DSH service first; restart it afterwards.');
10
+ } else {
11
+ const values = {};
12
+ let apply = false, stopped = false;
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === '--apply') apply = true;
16
+ else if (arg === '--service-stopped') stopped = true;
17
+ else if (['--dsh-root', '--profile'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
18
+ else throw new Error(`未知或不完整的参数:${arg}`);
19
+ }
20
+ const root = values['--dsh-root'];
21
+ const profile = values['--profile'];
22
+ if (!root || !profile || !/^[A-Za-z0-9_-]+$/.test(profile)) throw new Error('请提供 --dsh-root 和有效的 --profile。');
23
+ if (apply && !stopped) throw new Error('应用前请停止原 DSH 服务,再提供 --apply --service-stopped。该参数由你确认停机,不会自动检测或停止服务。');
24
+ const plans = planPatches(root, 'check');
25
+ for (const plan of plans) console.log(`${plan.state}: ${plan.artifact.target}`);
26
+ if (!apply) {
27
+ console.log('兼容检查通过,尚未注册插件或应用补丁。停止原服务后,用相同命令加 --apply --service-stopped 完成安装。');
28
+ } else {
29
+ const pluginRoot = fileURLToPath(new URL('../', import.meta.url));
30
+ const registered = spawnSync('dsh', ['plugin', '--profile', profile, 'add', path.resolve(pluginRoot)], { stdio: 'inherit' });
31
+ if (registered.error || registered.status !== 0) throw new Error('插件注册失败,未应用补丁。请检查上方安装输出。');
32
+ const count = applyPlan(planPatches(root, 'apply'));
33
+ if (!planPatches(root, 'check').every(plan => plan.state === 'patched')) throw new Error('补丁复查未通过。插件可能已注册,请检查磁盘状态后再启动服务。');
34
+ console.log(`插件已注册,${count} 个宿主文件已更新,五项磁盘补丁全部就绪。请启动原 DSH 服务并刷新原网页;当前尚未验证运行中的功能。`);
35
+ }
36
+ }
37
+ } catch (error) {
38
+ console.error(`安装停止:${error.message}`);
39
+ process.exitCode = 1;
40
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { planPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
7
+ import { restorationPending } from './maintenance.js';
8
+
9
+ export function findDshRoot(entry = process.argv[1]) {
10
+ if (!entry) throw new Error('无法确定 DSH 进程入口。');
11
+ const require = createRequire(fs.realpathSync(entry));
12
+ const pkg = require.resolve('@deepseek-ai/dsh-llm-pi-ai/package.json');
13
+ const resolved = fs.realpathSync(pkg);
14
+ let dir = path.dirname(resolved);
15
+ while (true) {
16
+ const candidate = path.join(dir, 'node_modules/@deepseek-ai/dsh-llm-pi-ai/package.json');
17
+ if (fs.existsSync(candidate) && fs.realpathSync(candidate) === resolved) {
18
+ // Full patch planning validates the DSH package/version and every target.
19
+ planPatches(dir, 'check');
20
+ return dir;
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ throw new Error('未找到与当前 DSH 进程一致且可安全修改的安装目录。');
27
+ }
28
+
29
+ export function autoPatch(root, home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')) {
30
+ if (restorationPending(root, home)) return { state: 'restore-pending', changed: 0, runtimeVerified: false };
31
+ const plans = planPatches(root, 'apply');
32
+ const pending = plans.some(plan => plan.source !== plan.result);
33
+ if (!pending) return { state: 'disk-ready', changed: 0, runtimeVerified: false };
34
+ const storage = path.join(home, 'dsh-model-groups', sha256(fs.realpathSync(root)).slice(0, 16));
35
+ fs.mkdirSync(storage, { recursive: true, mode: 0o700 });
36
+ const lock = path.join(storage, 'patch.lock');
37
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
38
+ catch { throw new Error('补丁安装已被锁定;可能有另一进程正在安装。确认没有安装任务后再处理锁目录,勿并发重试。'); }
39
+ try {
40
+ if (restorationPending(root, home)) return { state: 'restore-pending', changed: 0, runtimeVerified: false };
41
+ const changed = applyPlan(planPatches(root, 'apply'), path.join(storage, 'backups'));
42
+ if (!planPatches(root, 'check').every(plan => plan.state === 'patched')) throw new Error('自动补丁复查失败。');
43
+ return { state: changed ? 'restart-required' : 'disk-ready', changed, runtimeVerified: false };
44
+ } finally {
45
+ if (path.dirname(lock) !== storage || fs.realpathSync(lock) !== lock || !fs.lstatSync(lock).isDirectory()) throw new Error('补丁锁路径异常。');
46
+ fs.rmdirSync(lock);
47
+ }
48
+ }
package/src/client.js CHANGED
@@ -5,7 +5,27 @@ const inject = ['slots', 'modelDirectories', 'sessions', 'locale', 'remote', 're
5
5
  export function apply(ctx) {
6
6
  installModelSelectStyles();
7
7
  installModelGroupsStyles();
8
+ installSettingsPageStyles();
9
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
10
+ name: 'settings.section', id: 'dsh-model-groups', order: 35, label: '模型分组',
11
+ }, ModelGroupsSettingsPage));
8
12
  const providersForm = ctx.configForms?.get('llm-pi-ai');
13
+ let statusDisposed = false;
14
+ let statusNotice;
15
+ if (typeof fetch === 'function') Promise.resolve().then(() => fetch('/dsh-model-groups/status', { credentials: 'same-origin', cache: 'no-store' }))
16
+ .then(response => { if (!response.ok) throw new Error('status unavailable'); return response.json(); })
17
+ .then(status => {
18
+ if (statusDisposed || (!status.restartRequired && !status.failed)) return;
19
+ statusNotice = document.createElement('div');
20
+ statusNotice.dataset.plugin = 'dsh-model-groups';
21
+ statusNotice.setAttribute('role', 'status');
22
+ statusNotice.style.cssText = 'position:fixed;bottom:16px;right:16px;max-width:min(360px,80vw);z-index:1000;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#222);padding:12px;border:1px solid currentColor;border-radius:8px;box-sizing:border-box;pointer-events:none;';
23
+ statusNotice.textContent = status.failed
24
+ ? '多密钥自动补丁未完成。请回到 App 对话查看引擎日志或运行安装诊断,勿强制覆盖。请暂勿编辑模型配置;可到「设置 → 模型分组」检查并安全恢复。'
25
+ : '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App 重启原引擎并刷新此网页。请暂勿编辑模型配置,避免新旧模块混用;维护入口仍可使用。';
26
+ document.body.appendChild(statusNotice);
27
+ }).catch(() => {});
28
+ ctx.on('dispose', () => { statusDisposed = true; statusNotice?.remove(); });
9
29
  const onStorage = event => {
10
30
  if (event.key === STORAGE_KEY || event.key === null) groupPreferences.sync(event.key === null ? null : event.newValue);
11
31
  };
package/src/groups.css CHANGED
@@ -278,3 +278,23 @@
278
278
  .dsh-mg-groupArrow { transition: none; }
279
279
  }
280
280
 
281
+
282
+ /* Cascade columns share the viewport rather than covering their ancestors. */
283
+ .dsh-mg-cascade-panel {
284
+ box-sizing: border-box;
285
+ min-width: 0;
286
+ }
287
+ .dsh-mg-cascade-panel button,
288
+ .dsh-mg-cascade-panel [role="menu"],
289
+ .dsh-mg-cascade-panel .dsh-mg-modebar,
290
+ .dsh-mg-cascade-panel .dsh-mg-toolbar {
291
+ min-width: 0;
292
+ }
293
+ .dsh-mg-cascade-panel .dsh-mg-toolbar,
294
+ .dsh-mg-cascade-panel .dsh-mg-modebar,
295
+ .dsh-mg-cascade-panel .dsh-mg-toolbar-actions {
296
+ flex-wrap: wrap;
297
+ }
298
+ .dsh-mg-cascade-panel .dsh-mg-summary {
299
+ white-space: normal;
300
+ }
package/src/index.js CHANGED
@@ -1,4 +1,37 @@
1
- // Client-only picker. Native multi-key support is installed explicitly via
2
- // scripts/native-patches.mjs, never by mutating runtime code during apply.
3
- // This plugin owns no provider credentials or independent grouping settings.
4
- export function apply() {}
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { findDshRoot } from './auto-patch.js';
4
+ import { createMaintenance, maintenanceHandler } from './maintenance.js';
5
+ import { createHostInspection } from './maintenance-host.js';
6
+
7
+ // Neither disposal nor ordinary package removal restores patches: HMR and updates
8
+ // also dispose plugins. Only the explicitly confirmed maintenance transaction does.
9
+ export function apply(ctx) {
10
+ const key = Symbol.for('dsh-model-groups.patch-state');
11
+ const state = globalThis[key] ??= { restartRequired: false, error: null };
12
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
13
+ let root;
14
+ try {
15
+ root = findDshRoot();
16
+ state.error = null;
17
+ state.restartRequired = false;
18
+ ctx.logger.info('多密钥补丁由设置页手动管理;插件启动不会修改宿主文件。');
19
+ } catch {
20
+ state.error = 'patch-validation-failed';
21
+ ctx.logger.error('多密钥安装校验失败,未强制覆盖未知文件。请查看维护页。');
22
+ }
23
+ ctx.inject(['webServer', 'connection', 'pluginManager'], child => {
24
+ const inspect = root ? createHostInspection(child, root, home) : async () => ({ blockers: [{ code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装,禁止恢复。' }], fingerprint: '' });
25
+ const controller = root ? createMaintenance({ root, home, inspect, manager: child.pluginManager }) : {
26
+ snapshot: async () => ({ phase: 'failed', restartRequired: false, patches: [], blockers: (await inspect()).blockers, canRestore: false, canRestoreOnly: false, canApply: false, token: null }),
27
+ restore: async () => { throw { code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装。' }; },
28
+ };
29
+ child.effect(() => child.webServer.register({ kind: 'exact', path: '/dsh-model-groups/maintenance', handler: maintenanceHandler(controller, child.connection) }));
30
+ child.effect(() => child.webServer.register({ kind: 'exact', path: '/dsh-model-groups/status', handler(req, res) {
31
+ const admission = child.connection.admit(req);
32
+ if (!admission?.peer || admission.peer.principal?.kind !== 'owner') { res.writeHead(admission?.rejection || 403); res.end(); return; }
33
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
34
+ res.end(JSON.stringify({ restartRequired: state.restartRequired, failed: state.error !== null }));
35
+ } }));
36
+ });
37
+ }
@@ -0,0 +1,160 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const alias = value => typeof value === 'string' && value.startsWith('@dsh-key/');
4
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 512;
5
+ const selection = value => {
6
+ if (!value || !validId(value.provider) || !validId(value.model) ||
7
+ (value.reasoningEffort !== undefined && !validId(value.reasoningEffort))) throw new Error('INVALID_SELECTION');
8
+ return { provider: value.provider, model: value.model,
9
+ ...(value.reasoningEffort === undefined ? {} : { reasoningEffort: value.reasoningEffort }) };
10
+ };
11
+ const equal = (a, b) => !!a && !!b && a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort;
12
+ const fail = code => Object.assign(new Error(code), { code });
13
+ const clone = value => JSON.parse(JSON.stringify(value));
14
+ const unsupported = () => [
15
+ { code: 'COLD_SESSIONS_UNSUPPORTED', message: 'Cold sessions cannot be safely changed by this adapter.' },
16
+ { code: 'HISTORICAL_REFERENCES_UNSUPPORTED', message: 'Historical request headers and replay/fork references are not rewritten.' },
17
+ { code: 'OTHER_REFERENCES_UNSUPPORTED', message: 'Agent options, subagent descriptors, presets, other profiles and processes are not migrated.' },
18
+ ];
19
+
20
+ /** Verified installed service spellings. Descriptive only: this module imports no host code. */
21
+ export const FALLBACK_HOST_APIS = Object.freeze({
22
+ live: 'ctx.agents.get(sessionId); agent.model.current(); agent.model.select(selection)',
23
+ durability: 'await ctx.sessions.flush(agent.session); await ctx.sessionQuery.readSession(sessionId)',
24
+ defaults: 'ctx.agentDefaultModel.currentSelection(); ctx.agentDefaultModel.remembersLastSelection(); await ctx.agentDefaultModel.saveSelection(next, { ifCurrent: previous })',
25
+ catalog: 'session.modelCatalog() uses ctx.llm.listProviders/listModels/resolveModelInfo; NOT called here because discovery may perform network I/O',
26
+ limitation: 'No host session-selection CAS or cross-service transaction exists; synchronous local check+select is not a process-wide maintenance lock.',
27
+ });
28
+
29
+ /**
30
+ * UNWIRED, local-only injected adapter. No discovery, inference, credential access, file
31
+ * writes, cold-agent creation, automatic default update or native patch operations.
32
+ *
33
+ * `catalog` is a detached ALREADY OBTAINED ordinary catalog: { groups: [{ id, models:
34
+ * [{ id, reasoning?: { efforts: [{id}] } }] }] }. `ordinaryProviders` is an explicit
35
+ * allowlist verified by the future caller from local configuration: the catalog alone
36
+ * cannot establish that a provider has no multi-key routing. Never derive it by merely
37
+ * excluding alias IDs. No provider discovery or settings/secret reads occur here.
38
+ *
39
+ * Inject formal services `agents`, `sessions`, `defaultModel`, and optional
40
+ * `readPersistedSelection(id)` (read-only; return the effective current selection).
41
+ * The future reader must fold model/selection and request/header events, not old
42
+ * request headers alone. `sessions.flush` must return true (a durability listener).
43
+ *
44
+ * Session CAS is a synchronous identity + full selection + seq + idle check immediately
45
+ * followed by the synchronous AgentModel.select. No await is permitted between them.
46
+ * This is same-process CAS only; it does NOT lock out a later prompt or other process.
47
+ * Defaults use the host's lock-held ifCurrent CAS and reject fixed-default mode.
48
+ * Each ticket changes exactly ONE explicitly chosen target. Partial failures are
49
+ * reported, never rolled back or represented as successful migration/removal safety.
50
+ */
51
+ export function createFallbackSelection({ catalog, ordinaryProviders = [], agents, sessions,
52
+ defaultModel, readPersistedSelection, now = Date.now, token = () => randomBytes(24).toString('hex'), ttlMs = 120000 } = {}) {
53
+ const allowed = new Set(ordinaryProviders);
54
+ const choices = [];
55
+ for (const group of catalog?.groups ?? []) {
56
+ if (!validId(group.id) || alias(group.id) || !allowed.has(group.id)) continue;
57
+ for (const model of group.models ?? []) {
58
+ if (!validId(model.id) || alias(model.id)) continue;
59
+ const efforts = (model.reasoning?.efforts ?? []).map(effort => effort.id).filter(validId);
60
+ if (choices.some(item => item.provider === group.id && item.model === model.id)) throw fail('AMBIGUOUS_CATALOG');
61
+ choices.push({ provider: group.id, model: model.id, efforts });
62
+ }
63
+ }
64
+ const tickets = new Map();
65
+ let busy = false;
66
+ const choice = input => {
67
+ const next = selection(input);
68
+ if (Object.keys(input).some(key => !['provider', 'model', 'reasoningEffort'].includes(key))) throw fail('INVALID_SELECTION');
69
+ const item = choices.find(item => item.provider === next.provider && item.model === next.model);
70
+ if (!item || alias(next.provider) || alias(next.model) ||
71
+ (next.reasoningEffort !== undefined && !item.efforts.includes(next.reasoningEffort))) throw fail('NOT_ORDINARY_CATALOG_SELECTION');
72
+ return next;
73
+ };
74
+ const readLive = id => {
75
+ const agent = agents?.get?.(id);
76
+ if (!agent || agent.id !== id || !agent.session || typeof agent.model?.current !== 'function' || typeof agent.model?.select !== 'function') throw fail('LIVE_SESSION_UNSUPPORTED');
77
+ if (agent.status !== 'idle' || (agent.phase && agent.phase.kind !== 'idle')) throw fail('SESSION_BUSY');
78
+ if (!Number.isSafeInteger(agent.session.seq) || agent.session.seq < 0) throw fail('SESSION_REVISION_UNAVAILABLE');
79
+ return { agent, session: agent.session, seq: agent.session.seq, current: selection(agent.model.current()) };
80
+ };
81
+ const readDefault = () => {
82
+ if (typeof defaultModel?.currentSelection !== 'function' || typeof defaultModel?.remembersLastSelection !== 'function' || typeof defaultModel?.saveSelection !== 'function') throw fail('DEFAULT_API_UNAVAILABLE');
83
+ if (defaultModel.remembersLastSelection() !== true) throw fail('FIXED_DEFAULT_UNSUPPORTED');
84
+ return selection(defaultModel.currentSelection());
85
+ };
86
+ function preview({ target, next } = {}) {
87
+ const blockers = [];
88
+ let selected, previous, observation, cleanTarget;
89
+ try {
90
+ selected = choice(next);
91
+ if (target?.kind === 'session' && validId(target.sessionId) && Object.keys(target).every(key => ['kind', 'sessionId'].includes(key))) {
92
+ cleanTarget = { kind: 'session', sessionId: target.sessionId };
93
+ if (typeof sessions?.flush !== 'function' || typeof readPersistedSelection !== 'function') throw fail('DURABILITY_VERIFICATION_UNAVAILABLE');
94
+ observation = readLive(target.sessionId);
95
+ previous = observation.current;
96
+ } else if (target?.kind === 'default' && Object.keys(target).length === 1) {
97
+ cleanTarget = { kind: 'default' };
98
+ previous = readDefault();
99
+ } else throw fail('TARGET_UNSUPPORTED');
100
+ if (!alias(previous.model) && !alias(previous.provider)) throw fail('NOT_ALIAS_SELECTION');
101
+ } catch (error) { blockers.push({ code: error.code || 'INVALID_SELECTION' }); }
102
+ let ticket = null;
103
+ if (!blockers.length) {
104
+ // Bound memory and invalidate expired confirmations without exposing observations.
105
+ for (const [key, value] of tickets) if (value.expires <= now()) tickets.delete(key);
106
+ if (tickets.size >= 128) tickets.delete(tickets.keys().next().value);
107
+ ticket = token();
108
+ if (typeof ticket !== 'string' || !ticket || tickets.has(ticket)) throw fail('TOKEN_UNAVAILABLE');
109
+ tickets.set(ticket, { target: cleanTarget, next: selected, previous, observation, expires: now() + ttlMs });
110
+ }
111
+ return { canApply: blockers.length === 0 && !busy, token: ticket,
112
+ target: cleanTarget ? { ...cleanTarget } : null, previous: previous ? { ...previous } : null, next: selected ? { ...selected } : null,
113
+ blockers, removalBlockers: unsupported(), safeToRemovePatches: false,
114
+ scope: 'one-current-selection-only', catalogFreshness: 'caller-supplied-snapshot' };
115
+ }
116
+ async function apply(input) {
117
+ if (busy) throw fail('BUSY');
118
+ const ticket = tickets.get(input?.token);
119
+ if (!ticket || ticket.expires <= now() || input?.confirmation !== 'CONFIRM FALLBACK SELECTION' ||
120
+ Object.keys(input).some(key => !['token', 'confirmation'].includes(key))) throw fail('CONFIRMATION_REQUIRED');
121
+ tickets.delete(input.token);
122
+ busy = true;
123
+ let changed = false;
124
+ try {
125
+ if (ticket.target.kind === 'session') {
126
+ const live = readLive(ticket.target.sessionId);
127
+ const old = ticket.observation;
128
+ if (live.agent !== old.agent || live.session !== old.session || live.seq !== old.seq || !equal(live.current, ticket.previous)) throw fail('STATE_CHANGED');
129
+ // The only live write: synchronous check+select with no intervening await.
130
+ changed = true; // A throwing host operation may already have appended.
131
+ const result = live.agent.model.select({ ...ticket.next });
132
+ if (result && typeof result.then === 'function') throw fail('ASYNC_SELECTION_UNSUPPORTED');
133
+ if (await sessions.flush(live.session) !== true) throw fail('DURABILITY_UNCONFIRMED');
134
+ const persisted = selection(await readPersistedSelection(ticket.target.sessionId));
135
+ const after = readLive(ticket.target.sessionId);
136
+ if (after.agent !== live.agent || after.session !== live.session || !equal(after.current, ticket.next) || !equal(persisted, ticket.next)) throw fail('POSTCHECK_FAILED');
137
+ } else {
138
+ if (!equal(readDefault(), ticket.previous)) throw fail('STATE_CHANGED');
139
+ const receipt = await defaultModel.saveSelection({ ...ticket.next }, { ifCurrent: { ...ticket.previous } });
140
+ if (receipt?.status !== 'saved') throw fail('DEFAULT_CAS_REJECTED');
141
+ changed = true;
142
+ if (!equal(readDefault(), ticket.next)) throw fail('POSTCHECK_FAILED');
143
+ }
144
+ return { status: 'applied', target: clone(ticket.target), selected: { ...ticket.next },
145
+ safeToRemovePatches: false, removalBlockers: unsupported() };
146
+ } catch (error) {
147
+ // Host errors can include secrets: do not propagate messages or causes.
148
+ return { status: changed ? 'changed-unverified' : 'not-applied', code: knownCode(error),
149
+ mayHaveChanged: changed || ticket.target.kind === 'default', safeToRemovePatches: false,
150
+ removalBlockers: unsupported() };
151
+ } finally { busy = false; }
152
+ }
153
+ return { preview, apply, catalog: () => choices.map(({ provider, model, efforts }) => ({ provider, model, efforts: [...efforts] })) };
154
+ }
155
+
156
+ function knownCode(error) {
157
+ return ['LIVE_SESSION_UNSUPPORTED', 'SESSION_BUSY', 'SESSION_REVISION_UNAVAILABLE', 'STATE_CHANGED',
158
+ 'FIXED_DEFAULT_UNSUPPORTED', 'DEFAULT_API_UNAVAILABLE', 'DEFAULT_CAS_REJECTED', 'ASYNC_SELECTION_UNSUPPORTED',
159
+ 'DURABILITY_UNCONFIRMED', 'POSTCHECK_FAILED'].includes(error?.code) ? error.code : 'OPERATION_FAILED';
160
+ }
@@ -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
+ }