dsh-model-groups 0.3.4 → 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.
- package/README.md +21 -22
- package/lib/client.js +249 -4
- package/native/settings-models.patch.json +189 -1
- package/package.json +4 -2
- package/scripts/build.mjs +5 -1
- package/scripts/native-patches.mjs +17 -12
- package/src/auto-patch.js +3 -0
- package/src/client.js +8 -4
- package/src/index.js +30 -22
- package/src/maintenance-fallback.js +160 -0
- package/src/maintenance-host.js +250 -0
- package/src/maintenance-state.js +171 -0
- package/src/maintenance.js +164 -0
- package/src/settings-page.css +21 -0
- package/src/settings-page.js +56 -0
package/src/auto-patch.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { planPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
|
|
7
|
+
import { restorationPending } from './maintenance.js';
|
|
7
8
|
|
|
8
9
|
export function findDshRoot(entry = process.argv[1]) {
|
|
9
10
|
if (!entry) throw new Error('无法确定 DSH 进程入口。');
|
|
@@ -26,6 +27,7 @@ export function findDshRoot(entry = process.argv[1]) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
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 };
|
|
29
31
|
const plans = planPatches(root, 'apply');
|
|
30
32
|
const pending = plans.some(plan => plan.source !== plan.result);
|
|
31
33
|
if (!pending) return { state: 'disk-ready', changed: 0, runtimeVerified: false };
|
|
@@ -35,6 +37,7 @@ export function autoPatch(root, home = process.env.DSH_HOME || path.join(os.home
|
|
|
35
37
|
try { fs.mkdirSync(lock, { mode: 0o700 }); }
|
|
36
38
|
catch { throw new Error('补丁安装已被锁定;可能有另一进程正在安装。确认没有安装任务后再处理锁目录,勿并发重试。'); }
|
|
37
39
|
try {
|
|
40
|
+
if (restorationPending(root, home)) return { state: 'restore-pending', changed: 0, runtimeVerified: false };
|
|
38
41
|
const changed = applyPlan(planPatches(root, 'apply'), path.join(storage, 'backups'));
|
|
39
42
|
if (!planPatches(root, 'check').every(plan => plan.state === 'patched')) throw new Error('自动补丁复查失败。');
|
|
40
43
|
return { state: changed ? 'restart-required' : 'disk-ready', changed, runtimeVerified: false };
|
package/src/client.js
CHANGED
|
@@ -5,6 +5,10 @@ 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');
|
|
9
13
|
let statusDisposed = false;
|
|
10
14
|
let statusNotice;
|
|
@@ -14,11 +18,11 @@ export function apply(ctx) {
|
|
|
14
18
|
if (statusDisposed || (!status.restartRequired && !status.failed)) return;
|
|
15
19
|
statusNotice = document.createElement('div');
|
|
16
20
|
statusNotice.dataset.plugin = 'dsh-model-groups';
|
|
17
|
-
statusNotice.setAttribute('role', '
|
|
18
|
-
statusNotice.style.cssText = 'position:fixed;
|
|
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;';
|
|
19
23
|
statusNotice.textContent = status.failed
|
|
20
|
-
? '多密钥自动补丁未完成。请回到 App
|
|
21
|
-
: '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App
|
|
24
|
+
? '多密钥自动补丁未完成。请回到 App 对话查看引擎日志或运行安装诊断,勿强制覆盖。请暂勿编辑模型配置;可到「设置 → 模型分组」检查并安全恢复。'
|
|
25
|
+
: '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App 重启原引擎并刷新此网页。请暂勿编辑模型配置,避免新旧模块混用;维护入口仍可使用。';
|
|
22
26
|
document.body.appendChild(statusNotice);
|
|
23
27
|
}).catch(() => {});
|
|
24
28
|
ctx.on('dispose', () => { statusDisposed = true; statusNotice?.remove(); });
|
package/src/index.js
CHANGED
|
@@ -1,29 +1,37 @@
|
|
|
1
|
-
import
|
|
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';
|
|
2
6
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
7
|
+
// Neither disposal nor ordinary package removal restores patches: HMR and updates
|
|
8
|
+
// also dispose plugins. Only the explicitly confirmed maintenance transaction does.
|
|
5
9
|
export function apply(ctx) {
|
|
6
10
|
const key = Symbol.for('dsh-model-groups.patch-state');
|
|
7
11
|
const state = globalThis[key] ??= { restartRequired: false, error: null };
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
kind: 'exact', path: '/dsh-model-groups/status',
|
|
11
|
-
handler(req, res) {
|
|
12
|
-
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
13
|
-
res.end(JSON.stringify({ restartRequired: state.restartRequired, failed: state.error !== null }));
|
|
14
|
-
},
|
|
15
|
-
}));
|
|
16
|
-
});
|
|
12
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
13
|
+
let root;
|
|
17
14
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} catch (error) {
|
|
26
|
-
state.error = error.message;
|
|
27
|
-
ctx.logger.error(`多密钥自动安装未完成:${error.message}。未强制覆盖未知文件,请运行安装诊断;不要把模型分组出现视为多密钥已就绪。`);
|
|
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('多密钥安装校验失败,未强制覆盖未知文件。请查看维护页。');
|
|
28
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
|
+
});
|
|
29
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
|
+
}
|