@sidady/dsh-steward 0.0.0-stage → 0.9.12
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/ACCEPTANCE.md +204 -0
- package/LICENSE +21 -0
- package/README.md +339 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +4 -0
- package/lib/actions.js +534 -0
- package/lib/brain.js +167 -0
- package/lib/client.js +1587 -0
- package/lib/index.js +780 -0
- package/lib/intent.js +298 -0
- package/lib/models.js +212 -0
- package/lib/report.js +1696 -0
- package/lib/settings.js +286 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -4
- package/smoke.mjs +1381 -0
package/lib/actions.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-steward · v0.2.2 action hub — plan / execute, single-use fingerprint,
|
|
3
|
+
* human-readable previews, attribution gate, mute (ack) store.
|
|
4
|
+
*
|
|
5
|
+
* Authorization model (user-approved, m01226/m01239):
|
|
6
|
+
* - `plan` is ZERO-WRITE (writes never happen here; read-only IO is allowed).
|
|
7
|
+
* The result carries a sha256[:16] fingerprint of its canonical form.
|
|
8
|
+
* - `execute` refuses anything without confirm:true AND a live plan hash.
|
|
9
|
+
* The pending plan is consumed BEFORE the effect runs — a hash is single-use,
|
|
10
|
+
* failures never become replays.
|
|
11
|
+
* - Every execute attempt (success or failure) is appended to an audit line.
|
|
12
|
+
* Passwords are NEVER persisted, logged, or echoed anywhere.
|
|
13
|
+
*
|
|
14
|
+
* v0.2.2 additions (m01541 gaps + m01550/m01557 human-first rendering):
|
|
15
|
+
* - Every plan and every executed result carries `human`: server-computed
|
|
16
|
+
* plain-language lines. The GUI renders ONLY these — raw JSON never reaches
|
|
17
|
+
* the user ("智能管家是用户和系统的桥梁,GUI 是给人看的").
|
|
18
|
+
* - Attribution gate: `disable` must be backed by a CURRENT (non-stale)
|
|
19
|
+
* boot-crash evidence that names that exact plugin as a suspect.
|
|
20
|
+
* - Counterparts: `enable` (undo disable), `ack`/`unack` (mute a finding in
|
|
21
|
+
* the report display; reversible; the underlying evidence is kept).
|
|
22
|
+
* Red lines unchanged: never touches the boot path, never runs elevated
|
|
23
|
+
* commands, never whole-restore of old configs.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash, scrypt as scryptCb, createDecipheriv } from 'node:crypto';
|
|
26
|
+
import { promisify } from 'node:util';
|
|
27
|
+
import { ackTtlDays as settingsAckTtl } from './settings.js';
|
|
28
|
+
|
|
29
|
+
const scryptAsync = promisify(scryptCb);
|
|
30
|
+
|
|
31
|
+
// DCA1 container verification (v0.3.1 ①). Format re-derived from observed
|
|
32
|
+
// bytes (encryption.js constants): header = 4 magic + 1 version + 16 salt +
|
|
33
|
+
// 12 iv + 16 authTag; body = AES-256-GCM ciphertext. Implemented locally with
|
|
34
|
+
// node:crypto — NO import from dsh-config-manager (red line: no cross-package
|
|
35
|
+
// coupling that could drift with host updates).
|
|
36
|
+
const DCA1_MAGIC = 'DCA1';
|
|
37
|
+
// Header layout (re-derived from security/encryption.js):
|
|
38
|
+
// 0..4 magic · 4 version · 5..21 salt(16) · 21..33 iv(12) · 33..49 authTag(16)
|
|
39
|
+
// HEADER_LENGTH = 4 + 1 + 16 + 12 + 16 = 49. Body ciphertext starts at 49.
|
|
40
|
+
const DCA1_HEADER = 49;
|
|
41
|
+
async function verifyDca1Blob(bytes, password) {
|
|
42
|
+
if (!bytes || bytes.length < DCA1_HEADER) return { ok: false, reason: '文件太小,不是完整容器' };
|
|
43
|
+
if (bytes.subarray(0, 4).toString('latin1') !== DCA1_MAGIC) return { ok: false, reason: '容器头不是 DCA1(可能不是加密档)' };
|
|
44
|
+
if (bytes[4] !== 1) return { ok: false, reason: '容器版本 ' + bytes[4] + ' 不受校验' };
|
|
45
|
+
const salt = bytes.subarray(5, 21);
|
|
46
|
+
const iv = bytes.subarray(21, 33);
|
|
47
|
+
const tag = bytes.subarray(33, 49);
|
|
48
|
+
const body = bytes.subarray(49);
|
|
49
|
+
let key;
|
|
50
|
+
try {
|
|
51
|
+
key = await scryptAsync(password, salt, 32, { N: 16384, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
|
|
52
|
+
} catch (e) { return { ok: false, reason: '口令派生失败:' + String(e && e.message || e) }; }
|
|
53
|
+
try {
|
|
54
|
+
const d = createDecipheriv('aes-256-gcm', key, iv);
|
|
55
|
+
d.setAuthTag(tag);
|
|
56
|
+
const plain = Buffer.concat([d.update(body), d.final()]);
|
|
57
|
+
return { ok: true, isZip: plain.subarray(0, 2).toString('latin1') === 'PK', plainBytes: plain.length };
|
|
58
|
+
} catch { return { ok: false, reason: 'GCM 校验未通过:口令不匹配或文件被篡改' }; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PENDING_LIMIT = 20;
|
|
62
|
+
|
|
63
|
+
export const BACKUP_SECTIONS = ['sessions', 'pluginData', 'pluginAssets'];
|
|
64
|
+
|
|
65
|
+
function canonical(value) {
|
|
66
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
67
|
+
if (value && typeof value === 'object') {
|
|
68
|
+
return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hashPlan(action, plan) {
|
|
74
|
+
return createHash('sha256').update(action + '|' + canonical(plan)).digest('hex').slice(0, 16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mb(bytes) {
|
|
78
|
+
const n = Number(bytes || 0);
|
|
79
|
+
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
|
|
80
|
+
return (n / 1048576).toFixed(1) + ' MB';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Human sentence explaining how long a mute will last. Pure. */
|
|
84
|
+
function ackTtlLine(id, settings) {
|
|
85
|
+
const days = settingsAckTtl(settings, id);
|
|
86
|
+
return '有效期限:' + days + ' 天后会自动重新提醒(静音只代表「此刻我知道」,不代表「永远别再说」)。可在面板「设置」里改。';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readAcked(deps) {
|
|
90
|
+
let list = [];
|
|
91
|
+
try { list = (await deps.io.readJson(deps.ackedFile)) || []; } catch { list = []; }
|
|
92
|
+
if (!Array.isArray(list)) return [];
|
|
93
|
+
// ⑥ v0.7.7 mute expiry: an ack says "I know about this right now", not
|
|
94
|
+
// "never tell me again". Entries carrying an expiresAt drop out once it
|
|
95
|
+
// passes, so a condition the user silenced months ago can come back if it
|
|
96
|
+
// is still true. Entries without expiresAt keep the old permanent behaviour.
|
|
97
|
+
const nowMs = Date.now();
|
|
98
|
+
return list.filter((a) => {
|
|
99
|
+
if (!a || typeof a !== 'object') return false;
|
|
100
|
+
if (!a.expiresAt) return true;
|
|
101
|
+
const t = Date.parse(a.expiresAt);
|
|
102
|
+
if (!isFinite(t)) return true;
|
|
103
|
+
return t > nowMs;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Default mute duration per finding id — a mutable condition gets a shorter
|
|
108
|
+
* leash than a structural one. These are FALLBACKS only: v0.8.0 起真正的值来自
|
|
109
|
+
* 用户设置(settings.ttl.<id> → settings.ttl.default),这里只作为 settings.js
|
|
110
|
+
* 默认值的一份镜像,防止旧调用方拿到 undefined。 */
|
|
111
|
+
const ACK_TTL_DAYS = {
|
|
112
|
+
'data-at-risk': 42, // the gap is structural; re-ask every ~6 weeks
|
|
113
|
+
'compat-check': 30, // becomes true again right after a host upgrade
|
|
114
|
+
'plugin-updates': 14, // new versions land constantly; short leash
|
|
115
|
+
'workspace-inventory': 30,
|
|
116
|
+
'backup-freshness': 7, // the whole point is freshness; never long
|
|
117
|
+
};
|
|
118
|
+
const ACK_TTL_DEFAULT_DAYS = 90;
|
|
119
|
+
|
|
120
|
+
/** 取当前设置;deps.getSettings 缺失时回落空对象(= 全默认)。 */
|
|
121
|
+
function currentSettings(deps) {
|
|
122
|
+
try {
|
|
123
|
+
return typeof deps.getSettings === 'function' ? deps.getSettings() : null;
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* deps:
|
|
129
|
+
* - routeFetch(path, body): loopback POST to another plugin's host route.
|
|
130
|
+
* - getPluginManager(): service or null (ctx.get('pluginManager'), inject-free).
|
|
131
|
+
* - getReport(): () => last collectReport() result (evidence for gates/knowledge).
|
|
132
|
+
* - io: { appendJsonl, readJson, writeJson, readBytes?, readText?, writeText? } — injectable for smoke.
|
|
133
|
+
* readText/writeText are optional: their only consumer is trimming over-long
|
|
134
|
+
* jsonl logs, and a caller without them simply keeps appending.
|
|
135
|
+
* - readBytes(file): Buffer|string — used ONLY to verify a freshly written
|
|
136
|
+
* DCA1 container in-request; never persisted, never logged.
|
|
137
|
+
* - now(): () => Date
|
|
138
|
+
* - auditFile / trendFile / knowledgeFile / ackedFile: paths under plugin data dir.
|
|
139
|
+
*/
|
|
140
|
+
export function createActionHub(deps) {
|
|
141
|
+
const pending = new Map(); // hash -> { kind, plan, createdAt }
|
|
142
|
+
|
|
143
|
+
function remember(hash, entry) {
|
|
144
|
+
pending.set(hash, entry);
|
|
145
|
+
if (pending.size > PENDING_LIMIT) {
|
|
146
|
+
const oldest = pending.keys().next().value;
|
|
147
|
+
pending.delete(oldest);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// v0.9.8: trend.json already capped itself at 400 rows; the two append-only jsonl
|
|
152
|
+
// logs did not, so "same kind of file, two sets of rules". The cap is generous —
|
|
153
|
+
// a row is ~200 B, so 2000 rows is still well under half a megabyte — but an
|
|
154
|
+
// unbounded audit trail is a growth bug even when it grows slowly.
|
|
155
|
+
const LOG_LINE_CAP = 2000;
|
|
156
|
+
async function trimJsonl(file) {
|
|
157
|
+
try {
|
|
158
|
+
if (typeof deps.io.writeText !== 'function' || typeof deps.io.readText !== 'function') return;
|
|
159
|
+
const text = await deps.io.readText(file);
|
|
160
|
+
if (typeof text !== 'string') return;
|
|
161
|
+
const lines = text.split('\n').filter((l) => l.trim() !== '');
|
|
162
|
+
if (lines.length <= LOG_LINE_CAP) return;
|
|
163
|
+
await deps.io.writeText(file, lines.slice(-LOG_LINE_CAP).join('\n') + '\n');
|
|
164
|
+
} catch { /* trimming is housekeeping: never let it break the record that just landed */ }
|
|
165
|
+
}
|
|
166
|
+
async function audit(record) {
|
|
167
|
+
try {
|
|
168
|
+
await deps.io.appendJsonl(deps.auditFile, { at: deps.now().toISOString(), ...record });
|
|
169
|
+
await trimJsonl(deps.auditFile);
|
|
170
|
+
} catch { /* audit failure must not mask the action result */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------- PLAN (zero-write) ----------
|
|
174
|
+
async function plan(kind, params = {}) {
|
|
175
|
+
try {
|
|
176
|
+
if (kind === 'clean') {
|
|
177
|
+
const data = await deps.routeFetch('/api/disk-cleaner/', { action: 'scan' });
|
|
178
|
+
const all = Array.isArray(data.targets) ? data.targets : [];
|
|
179
|
+
const safe = all.filter((t) => t.risk === 'safe');
|
|
180
|
+
const caution = all.filter((t) => t.risk === 'caution');
|
|
181
|
+
const withCaution = params.withCaution === true;
|
|
182
|
+
const selected = withCaution ? [...safe, ...caution] : safe;
|
|
183
|
+
if (!selected.length) return { ok: false, error: withCaution ? 'disk-cleaner 没有报出任何目标。' : 'disk-cleaner 未报出 safe 级目标,无可清理项。' };
|
|
184
|
+
const ids = selected.map((t) => t.id);
|
|
185
|
+
const totalBytes = selected.reduce((s, t) => s + Number(t.bytes || 0), 0);
|
|
186
|
+
const p = {
|
|
187
|
+
kind: 'clean', ids, totalBytes, withCaution,
|
|
188
|
+
humanTotal: mb(totalBytes),
|
|
189
|
+
human: [
|
|
190
|
+
`清理 ${selected.length} 个目标,预计回收约 ${mb(totalBytes)}。`,
|
|
191
|
+
'明细:' + selected.map((t) => `${t.id} ≈ ${mb(t.bytes)}`).join(';') + '。',
|
|
192
|
+
withCaution
|
|
193
|
+
? '范围含「谨慎级」目标(caution,可能是仍在使用的数据)——请核对明细后再执行。'
|
|
194
|
+
: '范围仅「安全级」缓存:删后可自动重建,不含谨慎项。',
|
|
195
|
+
],
|
|
196
|
+
note: withCaution ? 'safe + 用户显式勾选的 caution 项。' : '仅 safe 级纯缓存,删后可重建。',
|
|
197
|
+
};
|
|
198
|
+
const hash = hashPlan('clean', p);
|
|
199
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
200
|
+
return { ok: true, planHash: hash, plan: p };
|
|
201
|
+
}
|
|
202
|
+
if (kind === 'backup') {
|
|
203
|
+
const only = Array.isArray(params.only) && params.only.length ? params.only : BACKUP_SECTIONS;
|
|
204
|
+
const prev = await deps.routeFetch('/api/dsh-config-manager/export-preview', { only, includeSecrets: false });
|
|
205
|
+
const sections = Array.isArray(prev.sections) ? prev.sections : [];
|
|
206
|
+
const sizeBytes = sections.reduce((s, x) => s + Number(x.sizeBytes || 0), 0);
|
|
207
|
+
const p = {
|
|
208
|
+
kind: 'backup', only: sections.map((s) => s.section), itemTotals: sections.map((s) => ({ section: s.section, sizeBytes: s.sizeBytes })),
|
|
209
|
+
humanTotal: mb(sizeBytes),
|
|
210
|
+
human: [
|
|
211
|
+
`加密备份 ${sections.length} 个分区,共约 ${mb(sizeBytes)}。`,
|
|
212
|
+
'分区:' + sections.map((s) => `${s.section} ≈ ${mb(s.sizeBytes)}`).join(';') + '。',
|
|
213
|
+
'产物:exports/ 目录下一份 DCA1 加密档(AES-256-GCM)。',
|
|
214
|
+
'口令只在执行那一次请求中出现,不落盘、不进审计、不回显;丢了口令档案无法恢复。',
|
|
215
|
+
],
|
|
216
|
+
note: '真 DCA1 加密导出;这些分区不在任何自动备份里。',
|
|
217
|
+
};
|
|
218
|
+
const hash = hashPlan('backup', p);
|
|
219
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
220
|
+
return { ok: true, planHash: hash, plan: p };
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'disable' || kind === 'enable') {
|
|
223
|
+
const plugin = typeof params.plugin === 'string' ? params.plugin.trim() : '';
|
|
224
|
+
if (!plugin) return { ok: false, error: `需要 plugin 名(bundle 包名)。` };
|
|
225
|
+
if (kind === 'disable') {
|
|
226
|
+
const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
|
|
227
|
+
if (!report || !Array.isArray(report.findings)) return { ok: false, error: '还没有体检报告——停用插件需要现行崩溃证据,先做一次体检。' };
|
|
228
|
+
if (report.staleAfterRestart) return { ok: false, error: '归因门拒绝:这是重启前持久化的历史报告,不能作现行崩溃证据。先点「重新体检」生成当前报告。' };
|
|
229
|
+
const crash = report.findings.find((f) => f.id === 'boot-crash-latest');
|
|
230
|
+
if (!crash) return { ok: false, error: '当前报告里没有启动崩溃记录,停用动作无的放矢。' };
|
|
231
|
+
if (crash.data && crash.data.stale) return { ok: false, error: '归因门拒绝:那条崩溃早于当前实例启动(历史记录,当前启动正常)。没有现行崩溃就不该停用插件。' };
|
|
232
|
+
const suspects = crash.data && Array.isArray(crash.data.suspects) ? crash.data.suspects.filter(Boolean) : [];
|
|
233
|
+
if (!suspects.includes(plugin)) return { ok: false, error: `"${plugin}" 不在最近一次崩溃点名的疑似列表${suspects.length ? `(${suspects.join('、')})` : '(无点名项)'}。` };
|
|
234
|
+
}
|
|
235
|
+
const mgr = deps.getPluginManager();
|
|
236
|
+
if (!mgr || typeof mgr.setBundleEnabled !== 'function') return { ok: false, error: 'pluginManager 服务不可用(降级:只能建议,不能执行)。' };
|
|
237
|
+
let exists = false; let enabled = null;
|
|
238
|
+
const disabledBundles = [];
|
|
239
|
+
try {
|
|
240
|
+
if (typeof mgr.listBundles === 'function') {
|
|
241
|
+
const list = await mgr.listBundles();
|
|
242
|
+
const arr = Array.isArray(list) ? list : [];
|
|
243
|
+
for (const b of arr) {
|
|
244
|
+
const n = b && (b.name || b.package);
|
|
245
|
+
if (n && b.enabled === false) disabledBundles.push(n);
|
|
246
|
+
}
|
|
247
|
+
const hit = arr.find((b) => b.name === plugin || b.package === plugin);
|
|
248
|
+
exists = hit !== undefined;
|
|
249
|
+
if (hit && typeof hit.enabled === 'boolean') enabled = hit.enabled;
|
|
250
|
+
}
|
|
251
|
+
} catch { /* listing optional — execute path still guarded by the service itself */ }
|
|
252
|
+
if (!exists) return { ok: false, error: `bundle "${plugin}" 不在当前 profile。` };
|
|
253
|
+
if (kind === 'disable' && enabled === false) return { ok: false, error: `bundle "${plugin}" 已是停用状态。` };
|
|
254
|
+
if (kind === 'enable' && enabled === true) {
|
|
255
|
+
// v0.4.4: 说清「为什么这次没用」而不只是拒绝。启用只对已停用的插件有意义。
|
|
256
|
+
// 候选可能很多(官方默认停用的 bundle 有十几个),列前 6 个 + 总数即可。
|
|
257
|
+
const head = disabledBundles.slice(0, 6).join('、');
|
|
258
|
+
const tail = disabledBundles.length > 6 ? ` 等 ${disabledBundles.length} 个` : '';
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
disabled: disabledBundles,
|
|
262
|
+
error: disabledBundles.length
|
|
263
|
+
? `bundle "${plugin}" 现在是启用状态——启用动作只对「已停用」的插件有意义。当前已停用、可以启用的有:${head}${tail}。`
|
|
264
|
+
: `bundle "${plugin}" 现在是启用状态——启用动作只对「已停用」的插件有意义。当前没有任何已停用的插件,所以这个动作暂时无可对象。`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const p = kind === 'disable' ? {
|
|
268
|
+
kind, plugin,
|
|
269
|
+
human: [
|
|
270
|
+
`停用插件「${plugin}」。`,
|
|
271
|
+
'依据:最近一次启动崩溃点名的疑似插件(首个失败会连坐其余,优先处理被点名的)。',
|
|
272
|
+
'保护:宿主 #575 守卫——状态变更失败不会写入启动配置,不会引发启动循环。',
|
|
273
|
+
'可逆:随时可重新启用(本卡片的反向动作,或 Agent 工具 kind=enable)。',
|
|
274
|
+
],
|
|
275
|
+
note: '经 pluginManager.setBundleEnabled(false),可逆。',
|
|
276
|
+
} : {
|
|
277
|
+
kind, plugin,
|
|
278
|
+
human: [
|
|
279
|
+
`重新启用插件「${plugin}」。`,
|
|
280
|
+
'依据:该 bundle 当前 enabled=false(启用动作只对已停用的插件有意义)。',
|
|
281
|
+
'保护:同 #575 守卫——若它在导入期就会崩溃,宿主会拒绝持久化启用,不会拖垮界面。',
|
|
282
|
+
'可逆:随时可再停用(本卡片的反向动作,或 Agent 工具 kind=disable)。',
|
|
283
|
+
],
|
|
284
|
+
note: '经 pluginManager.setBundleEnabled(true)。',
|
|
285
|
+
};
|
|
286
|
+
const hash = hashPlan(kind, p);
|
|
287
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
288
|
+
return { ok: true, planHash: hash, plan: p };
|
|
289
|
+
}
|
|
290
|
+
if (kind === 'knowledge') {
|
|
291
|
+
const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
|
|
292
|
+
if (!report || !report.summary) return { ok: false, error: '还没有体检报告,先做一次体检。' };
|
|
293
|
+
const c = report.summary.counts || {};
|
|
294
|
+
const activeIds = Array.isArray(report.findings)
|
|
295
|
+
? report.findings.filter((f) => !f.muted && f.severity !== 'info').map((f) => f.id) : [];
|
|
296
|
+
const p = {
|
|
297
|
+
kind: 'knowledge',
|
|
298
|
+
headline: report.summary.headline,
|
|
299
|
+
counts: c,
|
|
300
|
+
cFreeBytes: typeof report.cFreeBytes === 'number' ? report.cFreeBytes : null,
|
|
301
|
+
findingIds: activeIds,
|
|
302
|
+
human: [
|
|
303
|
+
`记录本次体检结论:${report.summary.headline}。`,
|
|
304
|
+
`计数:严重 ${c.critical || 0} · 留意 ${c.warning || 0} · 提示 ${c.info || 0}。`,
|
|
305
|
+
typeof report.cFreeBytes === 'number' ? `系统盘剩余空间快照:${mb(report.cFreeBytes)}(趋势里可看变化)。` : '系统盘剩余空间:本次未读到(不影响记录)。',
|
|
306
|
+
'指纹条目:' + (activeIds.length ? activeIds.join('、') : '(无现行风险项)') + '——同类问题下次体检时会自动召回这条记录。',
|
|
307
|
+
'去向:管家本地知识/趋势日志(只增不改)。Mnemon 项目知识库写入仍由 Agent 经你确认。',
|
|
308
|
+
],
|
|
309
|
+
note: '追加到本地 knowledge/trend 存储(只增不改)。',
|
|
310
|
+
};
|
|
311
|
+
const hash = hashPlan('knowledge', p);
|
|
312
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
313
|
+
return { ok: true, planHash: hash, plan: p };
|
|
314
|
+
}
|
|
315
|
+
if (kind === 'ack' || kind === 'unack') {
|
|
316
|
+
const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
|
|
317
|
+
const id = typeof params.id === 'string' ? params.id.trim() : '';
|
|
318
|
+
if (!report || !Array.isArray(report.findings)) return { ok: false, error: '还没有体检报告——先做一次体检。' };
|
|
319
|
+
if (!id) return { ok: false, error: '需要 finding id(报告条目的编号)。' };
|
|
320
|
+
const f = report.findings.find((x) => x.id === id);
|
|
321
|
+
if (!f) return { ok: false, error: `报告里没有编号 "${id}" 的条目。` };
|
|
322
|
+
const list = await readAcked(deps);
|
|
323
|
+
const isAcked = list.some((a) => a && a.id === id);
|
|
324
|
+
if (kind === 'ack' && isAcked) return { ok: false, error: `「${f.title}」已经处于静音状态。` };
|
|
325
|
+
if (kind === 'unack' && !isAcked) return { ok: false, error: `「${f.title}」没有被静音过。` };
|
|
326
|
+
const p = {
|
|
327
|
+
kind, findingId: id, title: f.title,
|
|
328
|
+
human: kind === 'ack'
|
|
329
|
+
? [`静音报告条目:「${f.title}」。`,
|
|
330
|
+
'效果:从下次体检起不再计入风险计数;证据本身保留在报告里,随时可恢复显示。',
|
|
331
|
+
ackTtlLine(id, currentSettings(deps))]
|
|
332
|
+
: [`恢复显示:「${f.title}」。`],
|
|
333
|
+
};
|
|
334
|
+
const hash = hashPlan(kind, p);
|
|
335
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
336
|
+
return { ok: true, planHash: hash, plan: p };
|
|
337
|
+
}
|
|
338
|
+
if (kind === 'model-default') {
|
|
339
|
+
// 只改「默认模型是哪一个」这一项选择,不碰任何插件的配置。
|
|
340
|
+
// 这是三个模型类动作里唯一会写宿主状态的,所以它必须有 plan 与指纹。
|
|
341
|
+
const provider = typeof params.provider === 'string' ? params.provider.trim() : '';
|
|
342
|
+
const model = typeof params.model === 'string' ? params.model.trim() : '';
|
|
343
|
+
if (!provider || !model) return { ok: false, error: '需要 provider 与 model(可以点报告里推荐的「切到这个模型」)。' };
|
|
344
|
+
const sel = typeof deps.getDefaultModel === 'function' ? deps.getDefaultModel() : null;
|
|
345
|
+
if (!sel || typeof sel.saveSelection !== 'function') {
|
|
346
|
+
return { ok: false, error: 'agentDefaultModel 服务不可用(降级:只能看,不能改默认模型)。' };
|
|
347
|
+
}
|
|
348
|
+
let current = null;
|
|
349
|
+
try {
|
|
350
|
+
const cur = typeof sel.currentSelection === 'function' ? sel.currentSelection() : null;
|
|
351
|
+
if (cur && cur.provider && cur.model) current = { provider: String(cur.provider), model: String(cur.model) };
|
|
352
|
+
} catch { current = null; }
|
|
353
|
+
if (current && current.provider === provider && current.model === model) {
|
|
354
|
+
return { ok: false, error: `默认模型已经是 ${provider} / ${model} 了。` };
|
|
355
|
+
}
|
|
356
|
+
// 目标必须真实存在于目录里:拒绝把一个不存在的名字写进宿主选择。
|
|
357
|
+
let known = false;
|
|
358
|
+
try {
|
|
359
|
+
const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
|
|
360
|
+
const health = report && report.modelHealth ? report.modelHealth : null;
|
|
361
|
+
const catalog = health && health.catalog;
|
|
362
|
+
const providers = catalog && Array.isArray(catalog.providers) ? catalog.providers : [];
|
|
363
|
+
for (const pv of providers) {
|
|
364
|
+
if (pv.id !== provider) continue;
|
|
365
|
+
if ((pv.models || []).some((m) => m.id === model)) known = true;
|
|
366
|
+
}
|
|
367
|
+
} catch { known = false; }
|
|
368
|
+
const from = current ? current.provider + ' / ' + current.model : '(当前未设置)';
|
|
369
|
+
const p = {
|
|
370
|
+
kind: 'model-default', provider, model, from,
|
|
371
|
+
human: [
|
|
372
|
+
`把默认模型从 ${from} 换成 ${provider} / ${model}。`,
|
|
373
|
+
'影响范围:只改宿主记住的「默认模型」这一项,不动任何插件的配置、不禁用任何模型、也不改选择器里列出的清单。',
|
|
374
|
+
'可逆:换过去之后随时可以再换回来(再点一次就会给出反向计划)。',
|
|
375
|
+
known ? '依据:这个模型在当前模型目录里存在。' : '注意:这次没能从模型目录确认它存在(目录可能没读到),执行前请确认名字没写错。',
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
const hash = hashPlan('model-default', p);
|
|
379
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
380
|
+
return { ok: true, planHash: hash, plan: p };
|
|
381
|
+
}
|
|
382
|
+
return { ok: false, error: `unknown plan kind: ${String(kind)}` };
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------- EXECUTE (single-use fingerprint) ----------
|
|
389
|
+
async function execute(input = {}) {
|
|
390
|
+
const hash = typeof input.planHash === 'string' ? input.planHash : '';
|
|
391
|
+
const entry = hash ? pending.get(hash) : undefined;
|
|
392
|
+
if (!entry) { await audit({ op: 'execute', hash, ok: false, error: 'unknown-or-expired-plan' }); return { ok: false, error: '计划不存在或已过期——先重新 plan。' }; }
|
|
393
|
+
if (input.confirm !== true) { await audit({ op: 'execute', hash, kind: entry.kind, ok: false, error: 'confirm-missing' }); return { ok: false, error: '必须 confirm:true(授权门)。' }; }
|
|
394
|
+
pending.delete(hash); // single-use BEFORE the effect: no replay even on failure
|
|
395
|
+
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
if (entry.kind === 'clean') {
|
|
399
|
+
const data = await deps.routeFetch('/api/disk-cleaner/', { action: 'clean', ids: entry.plan.ids, confirm: true });
|
|
400
|
+
const rows = Array.isArray(data.results) ? data.results : [];
|
|
401
|
+
const freedBytes = rows.reduce((s, r) => s + Number(r.bytes || 0), 0);
|
|
402
|
+
const removedFiles = rows.reduce((s, r) => s + Number(r.removed || 0), 0);
|
|
403
|
+
const failedFiles = rows.reduce((s, r) => s + Number(r.failed || 0), 0);
|
|
404
|
+
result = {
|
|
405
|
+
ok: true, dryRun: data.dryRun === true, freedBytes, humanFreed: mb(freedBytes), removedFiles, failedFiles, targets: rows.map((r) => r.id), plan: entry.plan,
|
|
406
|
+
human: [
|
|
407
|
+
data.dryRun === true ? '演练模式:未实际删除。' : `已回收约 ${mb(freedBytes)}。`,
|
|
408
|
+
`删除 ${removedFiles} 个文件${failedFiles ? `,失败 ${failedFiles} 个(被占用,稍后可重试)` : ',无失败'}。`,
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
} else if (entry.kind === 'backup') {
|
|
412
|
+
const password = typeof input.password === 'string' && input.password !== '' ? input.password : '';
|
|
413
|
+
if (!password) { pending.set(hash, entry); return { ok: false, error: '加密备份需要密码(本次请求内提供,不落盘)。计划已保留,可直接重试。' }; }
|
|
414
|
+
const data = await deps.routeFetch('/api/dsh-config-manager/export', { only: entry.plan.only, includeSecrets: false, password, note: 'steward: volatile data baseline' }, { requireOk: false });
|
|
415
|
+
// requireOk:false 是必需的(真机成功返回 {zipPath,manifest,report,runId},不带 ok 字段),
|
|
416
|
+
// 但它也放行了「200 + 异常体」。没有 zipPath 就证明没有档案落盘,绝不能报「已生成」。
|
|
417
|
+
// 一律抛给下方 catch 统一记审计、由客户端渲染,不在这里伪造成功人话。
|
|
418
|
+
if (!data || data.ok === false || typeof data.zipPath !== 'string' || !data.zipPath) {
|
|
419
|
+
throw new Error((data && (data.error || data.message)) || '导出接口未返回备份文件路径,未生成加密备份。');
|
|
420
|
+
}
|
|
421
|
+
const zipPath = typeof data.zipPath === 'string' ? data.zipPath : null;
|
|
422
|
+
const file = zipPath ? zipPath.split(/[\\/]/).pop() : null;
|
|
423
|
+
// ① v0.3.1 execute-then-verify: read the container back and decrypt-check
|
|
424
|
+
// it in-request (password never leaves this scope). A failed verification
|
|
425
|
+
// does NOT fail the backup — the file exists — it warns honestly.
|
|
426
|
+
let verify = { ok: false, reason: '未校验(文件路径未返回或读文件能力不可用)' };
|
|
427
|
+
if (zipPath && typeof deps.io.readBytes === 'function') {
|
|
428
|
+
try { verify = await verifyDca1Blob(await deps.io.readBytes(zipPath), password); }
|
|
429
|
+
catch (e) { verify = { ok: false, reason: '校验读取失败:' + String(e && e.message || e) }; }
|
|
430
|
+
}
|
|
431
|
+
result = {
|
|
432
|
+
ok: true, file, sections: entry.plan.only, encrypted: true, verified: verify.ok === true, verifyReason: verify.ok ? undefined : verify.reason, manifestSize: data.manifest ? Object.keys(data.manifest).length : null, plan: entry.plan,
|
|
433
|
+
human: [
|
|
434
|
+
file ? `已生成加密备份:${file}` : '已生成加密备份(文件名未返回)。',
|
|
435
|
+
verify.ok
|
|
436
|
+
? '已当场回读校验:DCA1 容器头正确、口令可解开、' + (verify.isZip ? '内层 ZIP 结构完整' : '内层不是 ZIP(异常,请核对文件)') + `(解密后约 ${mb(verify.plainBytes)})。`
|
|
437
|
+
: '⚠ 未能完成回读校验:' + (verify.reason || '原因未知') + '。文件已生成,但无法确认换口令后可恢复。',
|
|
438
|
+
`分区:${entry.plan.only.join('、')}。口令只在刚才那一次请求里存在过。`,
|
|
439
|
+
'恢复:GUI 导入向导 + 你设的口令;口令丢失无法恢复,请自己保管好。',
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
} else if (entry.kind === 'disable' || entry.kind === 'enable') {
|
|
443
|
+
const target = entry.kind === 'enable';
|
|
444
|
+
const mgr = deps.getPluginManager();
|
|
445
|
+
if (!mgr || typeof mgr.setBundleEnabled !== 'function') { result = { ok: false, error: 'pluginManager 服务不可用。' }; }
|
|
446
|
+
else {
|
|
447
|
+
const cr = await mgr.setBundleEnabled(entry.plan.plugin, target);
|
|
448
|
+
const app = cr && cr.application;
|
|
449
|
+
result = (app === 'applied' || app === 'restart-required' || app === 'overridden')
|
|
450
|
+
? {
|
|
451
|
+
ok: true, application: app, plugin: entry.plan.plugin,
|
|
452
|
+
human: [
|
|
453
|
+
`${target ? '已启用' : '已停用'}「${entry.plan.plugin}」${app === 'restart-required' ? '(重启后完全生效)' : ''}。`,
|
|
454
|
+
target ? '若之后启动异常,可用本卡片反向操作再停用。' : '如需还原:面板或 Agent 工具 kind=enable 重新启用。',
|
|
455
|
+
],
|
|
456
|
+
}
|
|
457
|
+
: { ok: false, error: (cr && cr.error && cr.error.message) || `application=${String(app)}`, plugin: entry.plan.plugin };
|
|
458
|
+
}
|
|
459
|
+
} else if (entry.kind === 'knowledge') {
|
|
460
|
+
const line = { at: deps.now().toISOString(), headline: entry.plan.headline, counts: entry.plan.counts, findingIds: entry.plan.findingIds || [], ...(typeof entry.plan.cFreeBytes === 'number' ? { cFreeBytes: entry.plan.cFreeBytes } : {}) };
|
|
461
|
+
await deps.io.appendJsonl(deps.knowledgeFile, line);
|
|
462
|
+
await trimJsonl(deps.knowledgeFile);
|
|
463
|
+
let trend = [];
|
|
464
|
+
try { trend = (await deps.io.readJson(deps.trendFile)) || []; } catch { trend = []; }
|
|
465
|
+
trend.push({ at: line.at, counts: line.counts, ...(typeof line.cFreeBytes === 'number' ? { cFreeBytes: line.cFreeBytes } : {}) });
|
|
466
|
+
if (trend.length > 400) trend = trend.slice(-400);
|
|
467
|
+
await deps.io.writeJson(deps.trendFile, trend);
|
|
468
|
+
result = { ok: true, recorded: line, human: ['已把本次结论与趋势快照写入管家本地记录(带条目指纹,供日后召回)。'] };
|
|
469
|
+
} else if (entry.kind === 'ack' || entry.kind === 'unack') {
|
|
470
|
+
let list = await readAcked(deps);
|
|
471
|
+
const id = entry.plan.findingId;
|
|
472
|
+
if (entry.kind === 'ack') {
|
|
473
|
+
if (!list.some((a) => a && a.id === id)) {
|
|
474
|
+
const ttlDays = settingsAckTtl(currentSettings(deps), id);
|
|
475
|
+
const expiresAt = new Date(deps.now().getTime() + ttlDays * 864e5).toISOString();
|
|
476
|
+
list.push({ id, title: entry.plan.title, at: deps.now().toISOString(), expiresAt });
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
list = list.filter((a) => a && a.id !== id);
|
|
480
|
+
}
|
|
481
|
+
await deps.io.writeJson(deps.ackedFile, list);
|
|
482
|
+
result = {
|
|
483
|
+
ok: true, findingId: id,
|
|
484
|
+
human: entry.kind === 'ack'
|
|
485
|
+
? [`已静音「${entry.plan.title}」——下次体检起不计入风险。`,
|
|
486
|
+
ackTtlLine(id, currentSettings(deps)),
|
|
487
|
+
'恢复:该条目上的「取消知晓」,或 Agent 工具 kind=unack。']
|
|
488
|
+
: [`「${entry.plan.title}」已恢复计入风险计数。`],
|
|
489
|
+
};
|
|
490
|
+
} else if (entry.kind === 'model-default') {
|
|
491
|
+
const sel = typeof deps.getDefaultModel === 'function' ? deps.getDefaultModel() : null;
|
|
492
|
+
if (!sel || typeof sel.saveSelection !== 'function') {
|
|
493
|
+
result = { ok: false, error: 'agentDefaultModel 服务不可用。' };
|
|
494
|
+
} else {
|
|
495
|
+
try {
|
|
496
|
+
await sel.saveSelection({ provider: entry.plan.provider, model: entry.plan.model });
|
|
497
|
+
let now = null;
|
|
498
|
+
try {
|
|
499
|
+
const cur = typeof sel.currentSelection === 'function' ? sel.currentSelection() : null;
|
|
500
|
+
if (cur && cur.provider && cur.model) now = { provider: String(cur.provider), model: String(cur.model) };
|
|
501
|
+
} catch { now = null; }
|
|
502
|
+
// 回读确认:调用没抛错不等于真的换过去了。
|
|
503
|
+
const applied = now && now.provider === entry.plan.provider && now.model === entry.plan.model;
|
|
504
|
+
result = applied
|
|
505
|
+
? {
|
|
506
|
+
ok: true, provider: entry.plan.provider, model: entry.plan.model, from: entry.plan.from,
|
|
507
|
+
human: [
|
|
508
|
+
`默认模型已切到 ${entry.plan.provider} / ${entry.plan.model}(原来 ${entry.plan.from})。`,
|
|
509
|
+
'新的会话会用这个模型;已经开着的会话可能仍用旧的。',
|
|
510
|
+
'改错了吗?再点一次「替换默认模型」就能换回去——它只是个选择,不动任何插件。',
|
|
511
|
+
],
|
|
512
|
+
}
|
|
513
|
+
: { ok: false, error: '写入没有生效:回读到的默认模型仍是 ' + (now ? now.provider + ' / ' + now.model : '(空)') + '。' };
|
|
514
|
+
} catch (e) {
|
|
515
|
+
result = { ok: false, error: '切换默认模型失败:' + (e && e.message ? e.message : String(e)) };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
result = { ok: false, error: `unknown kind ${entry.kind}` };
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
523
|
+
}
|
|
524
|
+
// NEVER put input.password into the audit record.
|
|
525
|
+
await audit({ op: 'execute', hash, kind: entry.kind, ok: result.ok === true, error: result.ok ? undefined : result.error, plugin: entry.plan && entry.plan.plugin });
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function listPending() {
|
|
530
|
+
return [...pending.entries()].map(([hash, e]) => ({ planHash: hash, kind: e.kind, createdAt: e.createdAt, plan: e.plan }));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return { plan, execute, listPending };
|
|
534
|
+
}
|