@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,419 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.7.6 fang-umbrella — 「保护伞」:一个证据管道,两个评分出口。
4
+ //
5
+ // ├─ 插件评分:A/B/C/D/E(交给 fang-scorer 的 import 层扫描,按入口 mtime 缓存)
6
+ // └─ 动作评分:green/blue/yellow/red(每次动作重算)+ 等级下限抬高
7
+ //
8
+ // 这个插件自己**不扫源码**:静态证据的唯一来源是 `SERVICE.scorer`,缺它就失败往坏里判
9
+ // (E + degraded),绝不假装安全。纯逻辑在 `src/lib/umbrella.mjs`,契约见 docs/UMBRELLA.md。
10
+ import path from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { AUDIT, SERVICE, roleGate, z } from '../kernel/index.mjs'
13
+ import {
14
+ DEFAULT_ACTION_RULES,
15
+ DEFAULT_FLOOR_TABLE,
16
+ DECISION,
17
+ GRADE_FLOOR,
18
+ GRADE_FLOOR_STRICT,
19
+ LIGHT,
20
+ SEVERITY,
21
+ createUmbrella,
22
+ normalizeGrade,
23
+ normalizeLight,
24
+ } from '../lib/umbrella.mjs'
25
+ import {
26
+ createRuleRegistry,
27
+ isLockedRule,
28
+ loadRuleConfigFile,
29
+ watchRuleConfigFiles,
30
+ } from '../lib/rule-registry.mjs'
31
+
32
+ export const name = 'fang-umbrella'
33
+ export const inject = []
34
+
35
+ export const Config = z.object({
36
+ bufferRoot: z.string().default('')
37
+ .description('沙箱缓冲区根目录(空 = 取 buffer-keeper 的实时 dir)'),
38
+ productionRoot: z.string().default('')
39
+ .description('生产区根目录;越界写命中它时单列为 write-production(仍是黄灯)'),
40
+ allowPaths: z.array(z.string()).default([])
41
+ .description('允许写入的路径前缀(相对路径按本实例 cwd 解析)'),
42
+ networkAllowlist: z.array(z.string()).default([])
43
+ .description('外连允许列表:host、host:port、*.suffix 或 URL 前缀'),
44
+ credentialPatterns: z.array(z.string()).default([])
45
+ .description('凭据路径正则(空 = 内置默认;传非空数组 = 完全替换)'),
46
+ destructivePatterns: z.array(z.string()).default([])
47
+ .description('破坏性命令正则(空 = 内置默认;传非空数组 = 完全替换)'),
48
+ lifecyclePatterns: z.array(z.string()).default([])
49
+ .description('DSH 生命周期命令正则(空 = 内置默认)'),
50
+ lifecycleTools: z.array(z.string()).default([])
51
+ .description('视为生命周期的工具名(空 = 内置默认)'),
52
+ gradeFloorTable: z.string().default(DEFAULT_FLOOR_TABLE)
53
+ .description('动作灯色下限表:umbrella(默认)| strict(与 GRADE_COLOR 对齐);未知值回退 umbrella 并标记 degraded'),
54
+ cacheSize: z.natural().default(200)
55
+ .description('插件评分缓存容量(LRU,按入口文件 mtime 失效)'),
56
+ auditDecisions: z.boolean().default(true)
57
+ .description('每次 decideAction 落一条 umbrella-decision 审计'),
58
+ baselineFile: z.string().default('')
59
+ .description('推荐基线配置路径(空 = 随包 defaults/umbrella-rules.yaml;":none:" = 不加载基线)'),
60
+ envRulesFile: z.string().default('')
61
+ .description('环境级规则配置路径(空 = 读 UMBRELLA_RULES_PATH;CI/部署注入用)'),
62
+ rulesFile: z.string().default('')
63
+ .description('YAML 规则配置文件路径(空 = 只用内置规则;相对路径按本实例 cwd 解析)'),
64
+ rulesWatch: z.boolean().default(true)
65
+ .description('监听 rulesFile 变化并热加载(失败保留上一次有效规则)'),
66
+ rulesDebounceMs: z.natural().default(200)
67
+ .description('热加载防抖窗口(毫秒)'),
68
+ })
69
+
70
+ function safeGet(ctx, key) {
71
+ try { return ctx?.get?.(key) } catch { return undefined }
72
+ }
73
+
74
+ /** 空数组 = 用内置默认;非空数组 = 调用方完全接管。 */
75
+ function patternsOrUndefined(value) {
76
+ return Array.isArray(value) && value.length > 0 ? value.map(String) : undefined
77
+ }
78
+
79
+ export function apply(ctx, config) {
80
+ const gate = roleGate('umbrella', { cfgRole: config?.role, logger: ctx.logger })
81
+ if (!gate.active) return
82
+
83
+ const cfg = Config(config ?? {})
84
+
85
+ /** 评分服务按需取:loader 行序不保证 scorer 先于 umbrella 完成注册。 */
86
+ const scorerAdapter = () => ({
87
+ scoreSource(source, pkg, opts) {
88
+ const scorer = safeGet(ctx, SERVICE.scorer)
89
+ if (typeof scorer?.scoreSource !== 'function') return unavailable()
90
+ return scorer.scoreSource(source, pkg, opts)
91
+ },
92
+ scoreDir(dir, manifest, opts) {
93
+ const scorer = safeGet(ctx, SERVICE.scorer)
94
+ if (typeof scorer?.scoreDir !== 'function') return unavailable()
95
+ return scorer.scoreDir(dir, manifest, opts)
96
+ },
97
+ })
98
+
99
+ /** buffer root 的实时来源:config → buffer-keeper.dir → buffer-keeper.config.root。 */
100
+ const policyNow = () => {
101
+ const configuredBuffer = String(cfg.bufferRoot ?? '').trim()
102
+ const buffer = safeGet(ctx, SERVICE.buffer)
103
+ const liveBuffer = typeof buffer?.dir === 'string' ? buffer.dir : ''
104
+ const configBuffer = typeof buffer?.config?.root === 'string' ? buffer.config.root : ''
105
+ return {
106
+ bufferRoot: configuredBuffer || liveBuffer || configBuffer,
107
+ productionRoot: String(cfg.productionRoot ?? '').trim(),
108
+ allowPaths: cfg.allowPaths,
109
+ networkAllowlist: cfg.networkAllowlist,
110
+ credentialPatterns: patternsOrUndefined(cfg.credentialPatterns),
111
+ destructivePatterns: patternsOrUndefined(cfg.destructivePatterns),
112
+ lifecyclePatterns: patternsOrUndefined(cfg.lifecyclePatterns),
113
+ lifecycleTools: Array.isArray(cfg.lifecycleTools) && cfg.lifecycleTools.length > 0
114
+ ? cfg.lifecycleTools.map(String)
115
+ : undefined,
116
+ }
117
+ }
118
+
119
+ // ── 规则配置层(分三层叠加):包内基线 → 项目级 rulesFile → 环境级 UMBRELLA_RULES_PATH ──
120
+ // fail-safe:任一层的加载/应用失败都不影响已生效的层;事务化由 registry 负责。
121
+ const ruleRegistry = createRuleRegistry(DEFAULT_ACTION_RULES)
122
+ const baselinePath = (() => {
123
+ const configured = String(cfg.baselineFile ?? '').trim()
124
+ if (configured === ':none:') return ''
125
+ if (configured.length > 0) return path.resolve(configured)
126
+ try {
127
+ return fileURLToPath(new URL('../../defaults/umbrella-rules.yaml', import.meta.url))
128
+ } catch {
129
+ return ''
130
+ }
131
+ })()
132
+ const projectPath = String(cfg.rulesFile ?? '').trim()
133
+ const projectFilePath = projectPath.length > 0 ? path.resolve(projectPath) : ''
134
+ const envPath = String(cfg.envRulesFile ?? '').trim() || String(process.env.UMBRELLA_RULES_PATH ?? '').trim()
135
+ const envFilePath = envPath.length > 0 ? path.resolve(envPath) : ''
136
+
137
+ let ruleConfigStatus = {
138
+ source: 'builtin',
139
+ layers: [],
140
+ ruleCount: ruleRegistry.ids().length,
141
+ applied: false,
142
+ warnings: [],
143
+ errors: [],
144
+ appliedAt: null,
145
+ strictMode: null,
146
+ }
147
+ let lastStrictStamp = null
148
+
149
+ const layerCandidates = [
150
+ { name: 'baseline', file: baselinePath },
151
+ { name: 'project', file: projectFilePath },
152
+ { name: 'env', file: envFilePath },
153
+ ].filter((layer) => layer.file.length > 0)
154
+
155
+ const applyAllLayers = () => {
156
+ ruleRegistry.clearLayers()
157
+ const layerStatus = []
158
+ const warnings = []
159
+ const errors = []
160
+ for (const layer of layerCandidates) {
161
+ const loaded = loadRuleConfigFile(layer.file)
162
+ if (loaded.errors.length > 0) {
163
+ layerStatus.push({ name: layer.name, file: layer.file, applied: false, found: loaded.found, errors: loaded.errors })
164
+ errors.push(...loaded.errors.map((e) => `${layer.name}: ${e}`))
165
+ try { ctx.logger?.warn?.(`[umbrella] ${layer.name} rule config skipped: ${loaded.errors.join('; ')}`) } catch { /* ignore */ }
166
+ continue
167
+ }
168
+ if (loaded.config === null) {
169
+ layerStatus.push({ name: layer.name, file: layer.file, applied: false, found: loaded.found, errors: [] })
170
+ continue
171
+ }
172
+ const applied = ruleRegistry.applyLayer(loaded.config)
173
+ layerStatus.push({
174
+ name: layer.name,
175
+ file: layer.file,
176
+ applied: applied.applied,
177
+ found: true,
178
+ ruleCount: applied.ruleCount,
179
+ warnings: applied.warnings,
180
+ errors: applied.errors,
181
+ revertedToLayerIndex: applied.revertedToLayerIndex ?? null,
182
+ })
183
+ warnings.push(...applied.warnings)
184
+ errors.push(...applied.errors)
185
+ if (!applied.applied) {
186
+ try { ctx.logger?.warn?.(`[umbrella] ${layer.name} rule config rejected, keeping previous rules: ${applied.errors.join('; ')}`) } catch { /* ignore */ }
187
+ }
188
+ }
189
+ const registryStatus = ruleRegistry.status()
190
+ ruleConfigStatus = {
191
+ source: registryStatus.source,
192
+ layers: layerStatus,
193
+ ruleCount: ruleRegistry.ids().length,
194
+ applied: layerStatus.some((layer) => layer.applied === true),
195
+ warnings,
196
+ errors,
197
+ appliedAt: Date.now(),
198
+ strictMode: registryStatus.strictMode ?? null,
199
+ }
200
+ // 严格模式是重大配置行为:首次激活(或层/丢弃数变化)时落一条审计
201
+ const strict = ruleConfigStatus.strictMode
202
+ const stamp = strict === null ? null : `${strict.layerIndex}:${strict.discardedRuleCount}:${strict.preservedHardRules.join(',')}`
203
+ if (stamp !== null && stamp !== lastStrictStamp) {
204
+ lastStrictStamp = stamp
205
+ try {
206
+ safeGet(ctx, SERVICE.auditor)?.append?.(AUDIT.UMBRELLA_STRICT_MODE, {
207
+ component: name,
208
+ at: strict.activatedAt,
209
+ layerIndex: strict.layerIndex,
210
+ preservedHardRules: strict.preservedHardRules,
211
+ preservedFallback: strict.preservedFallback ?? [],
212
+ discardedRuleCount: strict.discardedRuleCount,
213
+ layers: ruleConfigStatus.layers.map((layer) => ({ name: layer.name, applied: layer.applied })),
214
+ })
215
+ } catch { /* 审计缺失不影响决策 */ }
216
+ try {
217
+ ctx.logger?.info?.(`[umbrella] strict mode (inherit:false) active at layer ${strict.layerIndex}: preserved ${strict.preservedHardRules.length} hard rules, discarded ${strict.discardedRuleCount} adjustable rules`)
218
+ } catch { /* logger 可选 */ }
219
+ } else if (stamp === null) {
220
+ lastStrictStamp = null
221
+ }
222
+ return ruleConfigStatus
223
+ }
224
+
225
+ applyAllLayers()
226
+
227
+ let ruleWatcher = null
228
+ const reloadRules = () => applyAllLayers()
229
+
230
+ const watchedFiles = layerCandidates.map((layer) => layer.file)
231
+ if (watchedFiles.length > 0 && cfg.rulesWatch) {
232
+ ruleWatcher = watchRuleConfigFiles(watchedFiles, () => {
233
+ // Re-read every layer, not just the file that changed: later layers override
234
+ // earlier ones by id, so a single-layer applyConfig would discard the stack.
235
+ applyAllLayers()
236
+ }, { debounceMs: cfg.rulesDebounceMs, logger: ctx.logger })
237
+ ctx.effect(() => () => { try { ruleWatcher?.stop?.() } catch { /* ignore */ } })
238
+ }
239
+
240
+ const umbrella = createUmbrella({
241
+ scorer: scorerAdapter(),
242
+ policy: policyNow,
243
+ floorTable: cfg.gradeFloorTable,
244
+ cacheSize: cfg.cacheSize,
245
+ registry: ruleRegistry,
246
+ decisionCenter: () => safeGet(ctx, SERVICE.decisionCenter),
247
+ audit(record) {
248
+ if (!cfg.auditDecisions) return { ok: true, skipped: true }
249
+ const auditor = safeGet(ctx, SERVICE.auditor)
250
+ if (!auditor || typeof auditor.append !== 'function') return { ok: false, reason: 'auditor-unavailable' }
251
+ try {
252
+ auditor.append(AUDIT.UMBRELLA_DECISION, record)
253
+ return { ok: true }
254
+ } catch (error) {
255
+ return { ok: false, reason: 'audit-failed', error: String(error?.message ?? error) }
256
+ }
257
+ },
258
+ })
259
+
260
+ if (umbrella.floorDegraded) {
261
+ try {
262
+ ctx.logger?.warn?.(`[umbrella] unknown gradeFloorTable=${JSON.stringify(cfg.gradeFloorTable)}; fell back to "${DEFAULT_FLOOR_TABLE}"`)
263
+ } catch { /* logger 可选 */ }
264
+ }
265
+
266
+ const service = {
267
+ /** 本实例部署环境(mother | fork)——插件评分按它选阈值。 */
268
+ environment: gate.role,
269
+
270
+ get policy() {
271
+ return umbrella.policy
272
+ },
273
+
274
+ floorTable: umbrella.floorTable,
275
+ floorDegraded: umbrella.floorDegraded,
276
+ floors: umbrella.grades,
277
+
278
+ /** YAML 规则配置状态(来源 / 文件 / 是否应用 / 警告与错误)。 */
279
+ ruleConfigStatus() {
280
+ return { ...ruleConfigStatus, rules: ruleRegistry.ids() }
281
+ },
282
+
283
+ /** 立即重读 rulesFile(热加载的手动入口)。 */
284
+ reloadRules() {
285
+ return reloadRules()
286
+ },
287
+
288
+ /** 直接应用一份已解析的配置对象(测试/嵌入用);失败保留旧规则。 */
289
+ applyRuleConfig(config) {
290
+ const applied = ruleRegistry.applyConfig(config)
291
+ ruleConfigStatus = {
292
+ ...ruleConfigStatus,
293
+ applied: applied.applied,
294
+ source: applied.applied ? 'yaml' : ruleConfigStatus.source,
295
+ ruleCount: applied.ruleCount,
296
+ warnings: applied.warnings,
297
+ errors: applied.errors,
298
+ appliedAt: Date.now(),
299
+ }
300
+ return applied
301
+ },
302
+
303
+ /** 规则注册表(只读视图 + 运行时注册/覆盖)。 */
304
+ get ruleRegistry() {
305
+ return ruleRegistry
306
+ },
307
+
308
+ /**
309
+ * 插件评分(静态)。input: { pluginId?, dir?, manifest?, source?, pkg?, version?, environment?, refresh? }
310
+ * 无 scorer / 扫描失败 → grade 'E' + degraded(失败往坏里判)。
311
+ */
312
+ assessPlugin(input = {}) {
313
+ return umbrella.assessPlugin({ environment: gate.role, ...(input ?? {}) })
314
+ },
315
+
316
+ /** 动作评分(动态):基础灯色 + 等级下限,返回 light/baseLight/gradeFloor/raised。 */
317
+ assessAction(evidence = {}, opts = {}) {
318
+ return umbrella.assessAction(evidence, opts ?? {})
319
+ },
320
+
321
+ /** 动作评分 + 决策矩阵 + 审计。 */
322
+ decideAction(evidence = {}, opts = {}) {
323
+ return umbrella.decideAction(evidence, opts ?? {})
324
+ },
325
+
326
+ /** 只算基础灯色(不考虑插件等级),带 matchedRule / category / tags / 结构化证据。 */
327
+ baseLight(evidence = {}) {
328
+ return umbrella.baseLight(evidence)
329
+ },
330
+
331
+ /** 面向 UI/审批的解释:规则 id、类别、tags、证据(为什么是这个灯色)。 */
332
+ explain(evidence = {}) {
333
+ const base = umbrella.baseLight(evidence)
334
+ return {
335
+ light: base.light,
336
+ matchedRule: base.matchedRule,
337
+ category: base.category,
338
+ tags: base.tags,
339
+ trusted: base.trusted === true,
340
+ reason: base.reason,
341
+ evidence: base.evidence,
342
+ target: base.target,
343
+ }
344
+ },
345
+
346
+ /** 规则表只读视图(数组顺序即优先级)。 */
347
+ get rules() {
348
+ return umbrella.rules
349
+ },
350
+
351
+ ruleIds() {
352
+ return umbrella.ruleIds()
353
+ },
354
+
355
+ /** 动态注册/覆盖:形状不合法或插入位置不存在会立刻抛错,不会被静默忽略。 */
356
+ registerRule(rule, opts) {
357
+ return umbrella.registerRule(rule, opts)
358
+ },
359
+
360
+ overrideRule(id, patch) {
361
+ return umbrella.overrideRule(id, patch)
362
+ },
363
+
364
+ removeRule(id) {
365
+ return umbrella.removeRule(id)
366
+ },
367
+
368
+ resetRules() {
369
+ return umbrella.resetRules()
370
+ },
371
+
372
+ gradeFloor(grade) {
373
+ return umbrella.gradeFloor(grade)
374
+ },
375
+
376
+ effectiveLight(baseLightValue, grade) {
377
+ return umbrella.effectiveLight(baseLightValue, grade)
378
+ },
379
+
380
+ cachedGrade(pluginId) {
381
+ return umbrella.cachedGrade(pluginId)
382
+ },
383
+
384
+ cacheStats() {
385
+ return umbrella.cacheStats()
386
+ },
387
+
388
+ invalidate(pluginId) {
389
+ return umbrella.invalidate(pluginId)
390
+ },
391
+
392
+ clearCache() {
393
+ return umbrella.clearCache()
394
+ },
395
+ }
396
+
397
+ ctx.effect(() => ctx.provide(SERVICE.umbrella, service))
398
+ return service
399
+ }
400
+
401
+ /** 评分服务不可用时给 createUmbrella 的失败形状(guard 见 lib 的 runScorer)。 */
402
+ function unavailable() {
403
+ return {
404
+ grade: 'E', S: 100, color: LIGHT.RED, veto: false, degraded: true,
405
+ signals: [], reasons: ['scorer-unavailable'], basis: null,
406
+ }
407
+ }
408
+
409
+ export const _internals = {
410
+ DECISION,
411
+ SEVERITY,
412
+ LIGHT,
413
+ GRADE_FLOOR,
414
+ GRADE_FLOOR_STRICT,
415
+ patternsOrUndefined,
416
+ normalizeGrade,
417
+ normalizeLight,
418
+ unavailable,
419
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // tools/boot-guard.mjs — DSH 启动前/发布前插件树自检。
3
+ //
4
+ // node tools/boot-guard.mjs --profile-dir <profile> # dry-run:检查行/模块/import
5
+ // node tools/boot-guard.mjs --profile-dir <profile> --apply # 禁用失败行(写 profile patch + 备份)
6
+ // node tools/boot-guard.mjs --profile-dir <profile> --safe-mode # 禁用全部 fang 行(安全模式)
7
+ //
8
+ // 只处理 fang bundle 的行;不碰 DSH 核心/其他插件;写 profile 前备份 .bak-<ts>。
9
+ import path from 'node:path'
10
+
11
+ import {
12
+ applyDisabledRows,
13
+ checkBundleRows,
14
+ findFangPackageDir,
15
+ readBundleRows,
16
+ readPackageName,
17
+ smokePackage,
18
+ } from '../src/lib/boot-guard.mjs'
19
+
20
+ function parseArgs(argv = []) {
21
+ const out = { profileDir: '', pkgDir: '', candidateDir: '', apply: false, safeMode: false, json: false, timeoutMs: 8000 }
22
+ for (let index = 0; index < argv.length; index += 1) {
23
+ const arg = argv[index]
24
+ if (arg === '--apply') { out.apply = true; continue }
25
+ if (arg === '--safe-mode') { out.safeMode = true; continue }
26
+ if (arg === '--json') { out.json = true; continue }
27
+ if (arg === '--profile-dir') { out.profileDir = String(argv[++index] ?? ''); continue }
28
+ if (arg === '--pkg-dir') { out.pkgDir = String(argv[++index] ?? ''); continue }
29
+ if (arg === '--candidate-dir') { out.candidateDir = String(argv[++index] ?? ''); continue }
30
+ if (arg === '--timeout') { out.timeoutMs = Math.max(1000, Number(argv[++index]) || 8000); continue }
31
+ throw new Error(`unknown argument: ${arg}`)
32
+ }
33
+ return out
34
+ }
35
+
36
+ const args = parseArgs(process.argv.slice(2))
37
+ // 更新器候选包冒烟:不读 profile、不写任何文件,只报告是否可转正。
38
+ if (args.candidateDir) {
39
+ const candidateDir = path.resolve(args.candidateDir)
40
+ const smoke = smokePackage(candidateDir, { timeoutMs: args.timeoutMs })
41
+ const result = { candidateDir, ...smoke }
42
+ if (args.json) console.log(JSON.stringify(result, null, 2))
43
+ else console.log(`${smoke.ok ? 'PASS' : 'FAIL'} candidate smoke ${candidateDir}${smoke.ok ? '' : ' — ' + (smoke.error ?? 'no apply()')}`)
44
+ process.exit(smoke.ok ? 0 : 1)
45
+ }
46
+ const profileDir = path.resolve(args.profileDir || '')
47
+ if (!args.profileDir) {
48
+ console.error('usage: node tools/boot-guard.mjs --profile-dir <profile> [--apply|--safe-mode] [--json]')
49
+ process.exit(2)
50
+ }
51
+ const result = { profileDir, ok: false, checked: 0, findings: [], applied: null }
52
+ try {
53
+ const pkgDir = args.pkgDir ? path.resolve(args.pkgDir) : findFangPackageDir(profileDir)
54
+ if (!pkgDir) {
55
+ result.findings = [{ code: 'fang-package-not-found', level: 'block', detail: profileDir }]
56
+ } else {
57
+ result.packageDir = pkgDir
58
+ const parsed = readBundleRows(pkgDir)
59
+ if (!parsed.ok) result.findings.push({ code: 'bundle-patch-invalid', level: 'block', detail: parsed.error })
60
+ result.rows = parsed.rows.length
61
+ if (parsed.rows.length > 0) {
62
+ const checked = checkBundleRows(parsed.rows, { profileDir, ownPackage: readPackageName(pkgDir), timeoutMs: args.timeoutMs })
63
+ result.ok = checked.ok
64
+ result.checked = checked.checked
65
+ result.findings = [...result.findings, ...checked.findings]
66
+ } else if (parsed.ok) {
67
+ result.ok = true
68
+ }
69
+ if (args.apply || args.safeMode) {
70
+ const ids = args.safeMode
71
+ ? parsed.rows.map((row) => row.id)
72
+ : [...new Set(result.findings.filter((finding) => finding.level === 'block' && finding.id).map((finding) => finding.id))]
73
+ if (!ids.length) {
74
+ result.applied = { tried: false, reason: 'nothing-to-disable' }
75
+ } else {
76
+ result.applied = applyDisabledRows(profileDir, ids)
77
+ // 失败行已写 disabled:true(profile 覆盖层会跳过它们)→ 本次 guard 视为已恢复。
78
+ if (result.applied.ok) result.ok = true
79
+ }
80
+ }
81
+ }
82
+ } catch (error) {
83
+ result.findings = [{ code: 'boot-guard-threw', level: 'block', detail: String(error?.message ?? error) }]
84
+ }
85
+ if (args.json) {
86
+ console.log(JSON.stringify(result, null, 2))
87
+ } else {
88
+ console.log(`${result.ok ? 'PASS' : 'FAIL'} boot-guard ${result.profileDir}`)
89
+ for (const finding of result.findings) console.log(` ${finding.level}: ${finding.code}${finding.id ? ' ' + finding.id : ''}${finding.detail ? ' — ' + finding.detail : ''}`)
90
+ if (result.applied) console.log(` applied: ${JSON.stringify(result.applied)}`)
91
+ if (!result.ok && !args.apply && !args.safeMode) {
92
+ console.log(' hint: add --apply to disable failing rows (profile patch backup is written), or --safe-mode')
93
+ }
94
+ }
95
+ process.exit(result.ok ? 0 : 1)
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // decision-ingest CLI — 独立进程把 ticket 送进母体决策中心的最低成本方式:
5
+ // node tools/decision-ingest.mjs --outbox-dir <dir> --payload '{"producer":"PJM",...}'
6
+ // node tools/decision-ingest.mjs --outbox-dir <dir> --flush
7
+ // 母体地址默认读 env DSH_FANG_MOTHER,也可 --mother-dir 显式指定。
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import process from 'node:process'
11
+
12
+ import { createForkIngestClient } from '../src/lib/decision-ingest-client.mjs'
13
+
14
+ function parseArgs(argv = []) {
15
+ const out = { payload: '', payloadFile: '', outboxDir: '', motherDir: '', flush: false, json: false }
16
+ for (let i = 0; i < argv.length; i += 1) {
17
+ const arg = argv[i]
18
+ if (arg === '--flush') { out.flush = true; continue }
19
+ if (arg === '--json') { out.json = true; continue }
20
+ const next = () => argv[++i]
21
+ if (arg === '--payload') { out.payload = String(next() ?? ''); continue }
22
+ if (arg === '--payload-file') { out.payloadFile = String(next() ?? ''); continue }
23
+ if (arg === '--outbox-dir') { out.outboxDir = String(next() ?? ''); continue }
24
+ if (arg === '--mother-dir') { out.motherDir = String(next() ?? ''); continue }
25
+ throw new Error(`unknown argument: ${arg}`)
26
+ }
27
+ return out
28
+ }
29
+
30
+ async function main() {
31
+ const args = parseArgs(process.argv.slice(2))
32
+ const outboxDir = args.outboxDir || process.env.DSH_FANG_INGEST_OUTBOX || path.join(process.cwd(), '.fang-decision-outbox')
33
+ const motherDir = args.motherDir || process.env.DSH_FANG_MOTHER || ''
34
+ const client = createForkIngestClient({ dataDir: outboxDir, motherDataDir: motherDir })
35
+ let payload = null
36
+ if (args.payload) payload = JSON.parse(args.payload)
37
+ else if (args.payloadFile) payload = JSON.parse(fs.readFileSync(args.payloadFile, 'utf8'))
38
+ const result = {}
39
+ if (payload) result.submitted = await client.submit(payload)
40
+ if (args.flush || !payload) result.flushed = await client.flush()
41
+ result.pending = client.pending()
42
+ if (args.json) console.log(JSON.stringify(result))
43
+ else {
44
+ if (result.submitted) console.log(result.submitted.queued ? `queued: ${result.submitted.outboxId}` : `sent: ${result.submitted.ticket?.id ?? 'ok'}`)
45
+ if (result.flushed) console.log(`flush: sent=${result.flushed.sent} failed=${result.flushed.failed} remaining=${result.flushed.remaining}`)
46
+ console.log(`pending: ${result.pending}`)
47
+ }
48
+ }
49
+
50
+ main().catch((error) => {
51
+ console.error(`[decision-ingest] ${String(error?.message ?? error)}`)
52
+ process.exitCode = 1
53
+ })
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // 启动 DSH Desktop 并把它的重启权交给 restart-supervisor(默认 dry-run)。
5
+ //
6
+ // node tools/desktop-launch.mjs --exe "C:\\Path\\DSH Desktop.exe" --data-root <fang-data>
7
+ // node tools/desktop-launch.mjs --execute --exe ... --data-root ... --arg=--some-desktop-flag
8
+ import { spawn } from 'node:child_process'
9
+ import process from 'node:process'
10
+
11
+ import { describeDesktopLaunch, detectDesktopProfileDir, resolveDesktopLaunch, runBootGuard } from '../src/lib/desktop-launch.mjs'
12
+
13
+ function parseArgs(argv = []) {
14
+ const out = { exe: '', dataRoot: '', profileDir: '', execute: false, args: [], bootGuard: true, safeModeOnFailure: true }
15
+ for (let i = 0; i < argv.length; i += 1) {
16
+ const arg = argv[i]
17
+ if (arg === '--execute') { out.execute = true; continue }
18
+ if (arg === '--exe') { out.exe = String(argv[++i] ?? ''); continue }
19
+ if (arg === '--data-root') { out.dataRoot = String(argv[++i] ?? ''); continue }
20
+ if (arg === '--profile-dir') { out.profileDir = String(argv[++i] ?? ''); continue }
21
+ if (arg === '--no-boot-guard') { out.bootGuard = false; continue }
22
+ if (arg === '--no-safe-mode') { out.safeModeOnFailure = false; continue }
23
+ if (arg.startsWith('--arg=')) { out.args.push(arg.slice('--arg='.length)); continue }
24
+ if (arg === '--arg') { out.args.push(String(argv[++i] ?? '')); continue }
25
+ throw new Error(`unknown argument: ${arg}`)
26
+ }
27
+ return out
28
+ }
29
+
30
+ const args = parseArgs(process.argv.slice(2))
31
+ // 未显式给 profile-dir 时,按 DSH_PROFILE_DIR / DSH_HOME/profiles/<profile> 自动探测;
32
+ // 探测不到就跳过自检(不阻塞启动),但会打印提示。
33
+ const detectedProfileDir = (args.profileDir || !args.bootGuard) ? '' : detectDesktopProfileDir({ env: process.env, args: args.args })
34
+ const profileDir = args.profileDir || detectedProfileDir
35
+ if (args.bootGuard && !args.profileDir && detectedProfileDir) console.log(`[boot-guard] auto-detected profile: ${detectedProfileDir}`)
36
+ const plan = resolveDesktopLaunch({ exe: args.exe, dataRoot: args.dataRoot, profileDir, args: args.args, execute: args.execute })
37
+ if (!plan.ok) {
38
+ console.error(`desktop-launch: ${plan.error}`)
39
+ process.exitCode = 2
40
+ } else {
41
+ console.log(describeDesktopLaunch(plan))
42
+ // 启动前自检:失败时(--execute 且未 --no-safe-mode)自动安全模式禁用 fang 行。
43
+ let guard = null
44
+ if (args.bootGuard && plan.profileDir) {
45
+ guard = runBootGuard(plan.profileDir, { dataRoot: plan.dataRoot, safeModeOnFailure: args.execute && args.safeModeOnFailure })
46
+ if (guard.ok && guard.safeModeApplied) {
47
+ console.warn(`[boot-guard] safe-mode applied: disabled ${guard.applied?.added ?? 0} fang row(s); fix profile before re-enabling`)
48
+ } else if (!guard.ok) {
49
+ console.error(`[boot-guard] blocked: ${guard.error ?? 'boot-guard-blocked'}`)
50
+ for (const finding of (guard.blocked ?? guard.findings ?? []).slice(0, 10)) {
51
+ console.error(` ${finding.level}: ${finding.code}${finding.id ? ' ' + finding.id : ''}${finding.detail ? ' — ' + finding.detail : ''}`)
52
+ }
53
+ if (!args.execute && args.safeModeOnFailure) console.error('[boot-guard] dry-run: would apply safe-mode on --execute')
54
+ console.error('[boot-guard] 不启动;先运行 tools/boot-guard.mjs --profile-dir <profile> --apply/--safe-mode 修复,或加 --no-boot-guard')
55
+ process.exitCode = 2
56
+ } else {
57
+ console.log(`[boot-guard] PASS checked=${guard.checked ?? 0} findings=${(guard.findings ?? []).length}`)
58
+ }
59
+ }
60
+ if (process.exitCode === 2) {
61
+ // fail-closed:自检失败绝不继续启动
62
+ } else if (plan.dryRun) {
63
+ console.log('dry-run:未启动。加 --execute 才会真正交给 supervisor。')
64
+ } else {
65
+ const child = spawn(plan.argv[0], plan.argv.slice(1), { stdio: 'inherit', windowsHide: false })
66
+ child.on('exit', (code, signal) => {
67
+ if (signal) process.kill(process.pid, signal)
68
+ else process.exitCode = code ?? 0
69
+ })
70
+ }
71
+ }