@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,714 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2 Router — 母体初判(decide)+ 分身复核(confirm)+ 容器兜底执行入口(runInContainer)。
4
+ //
5
+ // 决策来源(v1 契约 5.6 保留,v2 契约 5.3 增量):
6
+ // 1. assessment.veto === true(dsh.hotReload === false 的 E 级否决)→ deny(终局,不可被复核推翻)
7
+ // 2. 不可信来源:
8
+ // - 需要子进程(needsChildProcess)→ deny(容器内没有真实进程执行体)
9
+ // - 容器可用 → container
10
+ // - 容器不可用 → deny + 原因(fail-closed)
11
+ // 3. 可信来源:
12
+ // - 有裸写残留(未申报/申报无效的写)→ container(容器可用);否则 deny + 原因
13
+ // - 已申报写 或 无写 → sandbox(真实开发环境,不升红)
14
+ // * 评分本身只作 advisory 透传(veto 除外):A 级不可信 ⚠ 仍然进容器,E 级可信仍然 sandbox。
15
+ //
16
+ // v2 增量:
17
+ // * apply 首行 roleGate('router'):router 在 mother 与 fork 都激活,但 basis.role / basis.environment 如实标注。
18
+ // * decide() = 母体初判:静态 quickScan(从 assessment.signals 取能力证据;调用方也可以直接给 code 现场扫)
19
+ // + 来源可信度 + 能力需求 → RouteDecision(basis 带 role/environment/grade/source)。
20
+ // * confirm() = 分身复核:用分身的 assessment 与分身的容器可用性重算一次建议,再与母体 decision 取
21
+ // max(rank);rank = sandbox(1) < container(2) < deny(3),**只升不降**;母体 deny 原样返回(不可推翻)。
22
+ // 分身评分是**单向升级**信号:fork grade E → deny;fork grade D 或比母体 basis.grade 更差 → 至少 container。
23
+ // 复核只能把路由抬高,任何输入都不会把 container 降回 sandbox。
24
+ // * containerAvailable() 返回 {ok, state:'disabled'|'unavailable'|'ready', reason}(v1 只有 {ok,reason})。
25
+ // Config.container.enabled 默认 false → state='disabled';显式断言 containerAvailable=true 时 state='ready'/reason='configured'。
26
+ // * runInContainer() 懒加载 `../lib/container/index.mjs`(T8 提供)。模块不存在 / enabled=false / 宿主不可用
27
+ // 一律返回 {ok:false, state, reason, stdout:'', stderr:'', exitCode:null, writes:[]} 且**绝不抛出**。
28
+ //
29
+ // 降级点(如实声明,见 docs/t6-report.md):
30
+ // * containerAvailable() 是同步方法(契约签名),ESM 懒加载只能是异步的:探测结果缓存在 probeCache,
31
+ // apply 时异步预热(container.enabled=true 才预热),真正执行/探针走 async probeContainer()。
32
+ // 在预热完成前,enabled 的实例返回 state='unavailable', reason='not-probed'(fail-closed,不假装 ready)。
33
+ // * v1 的 Config.containerAvailable=true 是“操作者断言”,保留为路由可用(state='ready'),但真正
34
+ // runInContainer 仍要求 ../lib/container 模块存在,否则如实返回 unavailable。
35
+ import { AUDIT, CONTAINER, ROLE, ROUTE, SERVICE, z, roleGate } from '../kernel/index.mjs'
36
+ import { scanSource, importsToSignals } from './imports.mjs'
37
+
38
+ export const name = 'fang-router'
39
+ export const inject = []
40
+
41
+ /** 路由秩:只允许 confirm 往上抬。 */
42
+ const RANK = { [ROUTE.SANDBOX]: 1, [ROUTE.CONTAINER]: 2, [ROUTE.DENY]: 3 }
43
+ const GRADE_RANK = { A: 1, B: 2, C: 3, D: 4, E: 5 }
44
+ const CONTAINER_STATES = new Set([CONTAINER.DISABLED, CONTAINER.UNAVAILABLE, CONTAINER.READY])
45
+
46
+ export const Config = z.object({
47
+ containerAvailable: z.boolean().default(false)
48
+ .description('操作者断言容器执行体可用(v1 兼容;state=ready/reason=configured)'),
49
+ trustedPlugins: z.array(z.string()).default([])
50
+ .description('受信插件名单(名字或包名;命中即视为可信来源)'),
51
+ container: z.object({
52
+ enabled: z.boolean().default(false)
53
+ .description('P5 容器分支:true 才懒加载 ../lib/container/index.mjs 并探测 AppContainer 执行体'),
54
+ powershellPath: z.string().default('powershell.exe'),
55
+ timeoutMs: z.number().default(30000),
56
+ }).default({}),
57
+ })
58
+
59
+ const TRUSTED_TOKENS = new Set(['trusted', 'first-party', 'local', 'official', 'mother'])
60
+ const UNTRUSTED_TOKENS = new Set(['untrusted', 'third-party', 'external'])
61
+
62
+ /** 角色/环境名归一化。 */
63
+ export function normalizeEnvironment(value, fallback = ROLE.MOTHER) {
64
+ const v = String(value ?? '').toLowerCase()
65
+ return v === ROLE.MOTHER || v === ROLE.FORK ? v : fallback
66
+ }
67
+
68
+ /** 来源描述归一化:字符串 | 对象 → {trusted, declaredWrites, bareWrites, name, via}。 */
69
+ export function normalizeSource(source, trustedPlugins = []) {
70
+ const list = Array.isArray(trustedPlugins) ? trustedPlugins.map(String) : []
71
+
72
+ if (typeof source === 'string') {
73
+ const raw = source.trim()
74
+ const low = raw.toLowerCase()
75
+ const listed = list.some(n => n === raw || n.toLowerCase() === low)
76
+ if (listed) return { name: raw, trusted: true, declaredWrites: false, bareWrites: false, via: 'trustedPlugins' }
77
+ if (TRUSTED_TOKENS.has(low)) return { name: raw, trusted: true, declaredWrites: false, bareWrites: false, via: 'trust-token' }
78
+ return { name: raw, trusted: false, declaredWrites: false, bareWrites: false, via: UNTRUSTED_TOKENS.has(low) ? 'untrusted-token' : 'unlisted-untrusted' }
79
+ }
80
+
81
+ if (!source || typeof source !== 'object') {
82
+ return { name: null, trusted: false, declaredWrites: false, bareWrites: false, via: 'missing-untrusted' }
83
+ }
84
+
85
+ const rawName = source.name ?? source.plugin ?? source.id
86
+ const name = typeof rawName === 'string' && rawName.trim() !== '' ? rawName : null
87
+ const token = String(source.trust ?? source.trustLevel ?? source.origin ?? source.kind ?? '').toLowerCase()
88
+ const trusted = source.trusted === true
89
+ || TRUSTED_TOKENS.has(token)
90
+ || (name !== null && list.includes(name))
91
+ || (typeof source.id === 'string' && list.includes(source.id))
92
+ const via = source.trusted === true
93
+ ? (typeof source.via === 'string' && source.via !== '' ? source.via : 'explicit')
94
+ : TRUSTED_TOKENS.has(token) ? 'trust-token'
95
+ : (name !== null && list.includes(name)) ? 'trustedPlugins'
96
+ : 'untrusted'
97
+
98
+ // 申报制(见《申报制落地-未申报不许装载.md》):path + 非空 reason 才算申报。
99
+ const declaredRaw = source.declaredWrites ?? source.writesDeclared ?? source.declared
100
+ let declaredWrites = false
101
+ let invalidDeclaration = false
102
+ if (Array.isArray(declaredRaw)) {
103
+ declaredWrites = declaredRaw.some(d => d && typeof d.path === 'string' && String(d.reason ?? '').trim() !== '')
104
+ invalidDeclaration = declaredRaw.length > 0 && !declaredWrites
105
+ } else {
106
+ declaredWrites = declaredRaw === true
107
+ }
108
+
109
+ const bareRaw = source.bareWrites ?? source.bareWrite ?? source.hasBareWrites ?? source.undeclaredWrites
110
+ let bareWrites
111
+ if (Array.isArray(bareRaw)) bareWrites = bareRaw.length > 0
112
+ else if (typeof bareRaw === 'number') bareWrites = bareRaw > 0
113
+ else bareWrites = bareRaw === true
114
+ // 申报无效(有写但 reason 全空)视为未申报残留 → 走容器(fail-closed)。
115
+ if (invalidDeclaration) bareWrites = true
116
+
117
+ return { name, trusted, declaredWrites, bareWrites, via }
118
+ }
119
+
120
+ /**
121
+ * decide() 的来源视图:`source` 是可信度权威(字符串/对象),`plugin` 只补充身份与申报/能力证据。
122
+ * 两者都给时 source 决定 trusted,plugin 补充 name/declaredWrites/bareWrites。
123
+ */
124
+ export function mergeSourceInput(plugin, source, trustedPlugins = []) {
125
+ const hasExplicit = source !== undefined && source !== null
126
+ const explicit = hasExplicit ? normalizeSource(source, trustedPlugins) : null
127
+ const fromPlugin = plugin !== undefined && plugin !== null
128
+ ? normalizeSource(plugin, trustedPlugins)
129
+ : null
130
+ if (explicit && fromPlugin) {
131
+ return {
132
+ // plugin 是身份(name),source 是可信度权威(trusted/via);申报/裸写证据取并集。
133
+ name: fromPlugin.name ?? explicit.name,
134
+ trusted: explicit.trusted,
135
+ via: explicit.via,
136
+ declaredWrites: explicit.declaredWrites || fromPlugin.declaredWrites,
137
+ bareWrites: explicit.bareWrites || fromPlugin.bareWrites,
138
+ }
139
+ }
140
+ return explicit ?? fromPlugin ?? normalizeSource(null, trustedPlugins)
141
+ }
142
+
143
+ /** 容器可用性归一化:单次输入 > 探针。 */
144
+ export function resolveContainer(input = {}, probe) {
145
+ const explicit = input?.containerAvailable
146
+ if (typeof explicit === 'boolean') {
147
+ return { ok: explicit, reason: explicit ? 'caller-asserted' : 'caller-denied' }
148
+ }
149
+ if (explicit && typeof explicit === 'object' && typeof explicit.ok === 'boolean') {
150
+ const out = { ok: explicit.ok, reason: explicit.reason ?? (explicit.ok ? 'caller-asserted' : 'caller-denied') }
151
+ if (CONTAINER_STATES.has(explicit.state)) out.state = explicit.state
152
+ return out
153
+ }
154
+ const probed = typeof probe === 'function' ? probe() : { ok: false, state: CONTAINER.UNAVAILABLE, reason: 'no-probe' }
155
+ return probed && typeof probed === 'object' ? probed : { ok: false, state: CONTAINER.UNAVAILABLE, reason: 'no-probe' }
156
+ }
157
+
158
+ /** assessment.signals 里的 S1 族 → 需要子进程执行体。 */
159
+ function assessmentNeedsChild(assessment) {
160
+ const signals = Array.isArray(assessment?.signals) ? assessment.signals : []
161
+ return signals.some(s => String(s?.id ?? '').split('-')[0] === 'S1')
162
+ }
163
+
164
+ /**
165
+ * 静态 quickScan(母体初判的“静态部分”,可独立测试):
166
+ * * capabilities 来自 assessment.signals 的 S1–S4 族,以及 plugin.capabilities/plugin.needs 的申报;
167
+ * * 调用方给了 code(源码文本)时现场跑 import 层扫描器,不依赖 scorer 已评分。
168
+ * 不做调用层分析(与 scorer 的 import-only 局限一致)。
169
+ */
170
+ export function quickScan({ plugin = null, assessment = null, source = null, code = null } = {}) {
171
+ const signals = []
172
+ const warnings = []
173
+ let scanned = false
174
+
175
+ if (typeof code === 'string' && code.trim() !== '') {
176
+ try {
177
+ const scan = scanSource(code)
178
+ const mapped = importsToSignals(scan, {})
179
+ signals.push(...mapped.signals)
180
+ warnings.push(...scan.warnings.map(w => w.reason ?? 'warning'))
181
+ scanned = true
182
+ } catch (e) {
183
+ warnings.push(`scan-error:${e?.message ?? e}`)
184
+ }
185
+ }
186
+ const assessmentSignals = Array.isArray(assessment?.signals) ? assessment.signals : []
187
+ if (assessmentSignals.length > 0) {
188
+ signals.push(...assessmentSignals)
189
+ scanned = true
190
+ }
191
+ const declared = [
192
+ ...(Array.isArray(plugin?.capabilities) ? plugin.capabilities : []),
193
+ ...(Array.isArray(plugin?.needs) ? plugin.needs : []),
194
+ ...(Array.isArray(source?.capabilities) ? source.capabilities : []),
195
+ ].map(v => String(v ?? '').toLowerCase())
196
+
197
+ const caps = new Set()
198
+ for (const sig of signals) {
199
+ const fam = String(sig?.id ?? '').split('-')[0]
200
+ if (fam === 'S1') caps.add('child-process')
201
+ else if (fam === 'S2') caps.add('network')
202
+ else if (fam === 'S3') caps.add('fs')
203
+ else if (fam === 'S4') caps.add('vm')
204
+ }
205
+ const declaresChild = declared.some(c => c === 'child-process' || c === 'childprocess' || c === 'child_process')
206
+ || plugin?.needsChildProcess === true
207
+ if (declaresChild) caps.add('child-process')
208
+
209
+ return {
210
+ grade: typeof assessment?.grade === 'string' ? assessment.grade : null,
211
+ veto: assessment?.veto === true,
212
+ signals: signals.length,
213
+ capabilities: [...caps].sort(),
214
+ needsChildProcess: caps.has('child-process'),
215
+ coverage: scanned ? 'import-only' : 'none',
216
+ warnings,
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 纯决策函数(decide/confirm 的数学部分,可独立测试)。
222
+ * @returns {{route:'sandbox'|'container'|'deny', reason:string, needsApproval:boolean,
223
+ * grade:string|null, veto:boolean, source:object, container:object, basis:object}}
224
+ */
225
+ export function decidePure(input = {}, { config = {}, probe, role = ROLE.MOTHER, environment = null } = {}) {
226
+ const safeInput = input && typeof input === 'object' ? input : {}
227
+ const assessment = safeInput.assessment && typeof safeInput.assessment === 'object' ? safeInput.assessment : null
228
+ const src = normalizeSource(safeInput.source, config?.trustedPlugins ?? [])
229
+ const needsChild = safeInput.needsChildProcess === true || assessmentNeedsChild(assessment)
230
+ const container = resolveContainer(safeInput, probe)
231
+ const veto = assessment?.veto === true
232
+ const roleName = normalizeEnvironment(role)
233
+ const envName = normalizeEnvironment(environment ?? roleName)
234
+ const grade = typeof assessment?.grade === 'string' ? assessment.grade : null
235
+
236
+ const sourceSummary = {
237
+ trusted: src.trusted,
238
+ name: src.name,
239
+ declaredWrites: src.declaredWrites,
240
+ bareWrites: src.bareWrites,
241
+ via: src.via,
242
+ needsChildProcess: needsChild,
243
+ }
244
+ const containerView = {
245
+ ok: container?.ok === true,
246
+ reason: String(container?.reason ?? (container?.ok === true ? 'ok' : 'unavailable')),
247
+ }
248
+ if (CONTAINER_STATES.has(container?.state)) containerView.state = container.state
249
+
250
+ const base = {
251
+ grade,
252
+ veto,
253
+ source: sourceSummary,
254
+ container: containerView,
255
+ basis: { role: roleName, environment: envName, grade, source: sourceSummary },
256
+ }
257
+
258
+ // 1) 静态硬否决:dsh.hotReload === false。终局,任何路线都不放行。
259
+ if (veto) {
260
+ const why = Array.isArray(assessment?.reasons) && assessment.reasons.length > 0 ? assessment.reasons.join(',') : 'hotReload-false'
261
+ return { route: ROUTE.DENY, reason: `veto:${why}`, needsApproval: false, ...base }
262
+ }
263
+
264
+ // 2) 不可信来源
265
+ if (!src.trusted) {
266
+ if (needsChild) {
267
+ return { route: ROUTE.DENY, reason: 'untrusted-needs-child-process', needsApproval: false, ...base }
268
+ }
269
+ if (containerView.ok) {
270
+ return { route: ROUTE.CONTAINER, reason: 'untrusted-source', needsApproval: true, ...base }
271
+ }
272
+ return { route: ROUTE.DENY, reason: `container-unavailable:${containerView.reason}`, needsApproval: false, ...base }
273
+ }
274
+
275
+ // 3) 可信来源
276
+ if (src.bareWrites) {
277
+ if (containerView.ok) {
278
+ return { route: ROUTE.CONTAINER, reason: 'trusted-bare-writes', needsApproval: true, ...base }
279
+ }
280
+ return { route: ROUTE.DENY, reason: `container-unavailable:${containerView.reason}`, needsApproval: false, ...base }
281
+ }
282
+ return {
283
+ route: ROUTE.SANDBOX,
284
+ reason: src.declaredWrites ? 'trusted-declared-writes' : 'trusted-clean',
285
+ needsApproval: false,
286
+ ...base,
287
+ }
288
+ }
289
+
290
+ /** confirm 的证据来源:母体 decision 里透传的来源视图(v2 basis.source,v1 退化到 decision.source)。 */
291
+ export function evidenceFrom(decision) {
292
+ const summary = decision?.basis?.source ?? decision?.source ?? null
293
+ if (!summary || typeof summary !== 'object') return null
294
+ const name = typeof summary.name === 'string' && summary.name.trim() !== '' ? summary.name : null
295
+ return {
296
+ name,
297
+ trusted: summary.trusted === true,
298
+ declaredWrites: summary.declaredWrites === true,
299
+ bareWrites: summary.bareWrites === true,
300
+ needsChildProcess: summary.needsChildProcess === true,
301
+ }
302
+ }
303
+
304
+ /** 容器错误归类(永不抛)。 */
305
+ function classifyContainerError(err) {
306
+ const code = String(err?.code ?? '')
307
+ const message = String(err?.message ?? err ?? '')
308
+ if (code === 'CONTAINER_MODULE_INVALID') return { state: CONTAINER.UNAVAILABLE, reason: 'container-module-invalid' }
309
+ if (code === 'ERR_MODULE_NOT_FOUND' || /cannot find (module|package)/i.test(message)) {
310
+ return { state: CONTAINER.UNAVAILABLE, reason: 'container-module-missing' }
311
+ }
312
+ return { state: CONTAINER.UNAVAILABLE, reason: `container-module-load-failed:${message}` }
313
+ }
314
+
315
+ function stateOf(avail, host = null) {
316
+ const raw = avail?.state ?? host?.state
317
+ if (CONTAINER_STATES.has(raw)) return raw
318
+ return avail?.ok === true ? CONTAINER.READY : CONTAINER.UNAVAILABLE
319
+ }
320
+
321
+ function readAvailable(host) {
322
+ try {
323
+ const avail = typeof host?.available === 'function' ? host.available() : null
324
+ if (avail && typeof avail === 'object') return avail
325
+ return { ok: host?.state === CONTAINER.READY, state: host?.state, reason: host?.state ?? 'no-probe' }
326
+ } catch (e) {
327
+ return { ok: false, state: CONTAINER.UNAVAILABLE, reason: `container-probe-failed:${e?.message ?? e}` }
328
+ }
329
+ }
330
+
331
+ // ─────────────────────── 容器模块懒加载(进程级缓存的只有“模块”本身) ───────────────────────
332
+ let containerModuleLoader = () => import('../lib/container/index.mjs')
333
+ let containerModulePromise = null
334
+
335
+ async function loadContainerModule() {
336
+ if (!containerModulePromise) {
337
+ containerModulePromise = Promise.resolve()
338
+ .then(() => containerModuleLoader())
339
+ .catch((e) => {
340
+ containerModulePromise = null // 下次重试(T8 模块可能在运行时才落地)
341
+ throw e
342
+ })
343
+ }
344
+ return containerModulePromise
345
+ }
346
+
347
+ // ─────────────────────────────── plugin ───────────────────────────────
348
+
349
+ export function apply(ctx, config) {
350
+ const gate = roleGate('router', { cfgRole: config?.role, logger: ctx.logger })
351
+ // v2 部署矩阵:router 在 mother/fork 都激活;这里的 return 只是守住“未部署角色不得注册服务”的契约。
352
+ if (!gate.active) return
353
+
354
+ const cfg = Config(config ?? {})
355
+ const normalized = {
356
+ containerAvailable: cfg.containerAvailable === true,
357
+ trustedPlugins: Array.isArray(cfg.trustedPlugins) ? cfg.trustedPlugins.map(String) : [],
358
+ container: {
359
+ enabled: cfg.container?.enabled === true,
360
+ powershellPath: typeof cfg.container?.powershellPath === 'string' ? cfg.container.powershellPath : 'powershell.exe',
361
+ timeoutMs: Number(cfg.container?.timeoutMs ?? 30000),
362
+ },
363
+ }
364
+ const role = gate.role
365
+
366
+ const safeGet = (key) => {
367
+ try { return ctx?.get?.(key) } catch { return undefined }
368
+ }
369
+ const audit = (data) => {
370
+ try { safeGet(SERVICE.auditor)?.append?.(AUDIT.ROUTE, { component: name, ...data }) } catch { /* 审计缺失不得影响路由 */ }
371
+ }
372
+
373
+ // ── 容器执行体:懒加载 + 可用性缓存(同步 API 只能读缓存,异步探测在 probeContainer/runInContainer 里刷新)──
374
+ let containerHost = null
375
+ let containerHostPromise = null
376
+ let disposed = false
377
+ let probeCache = { ok: false, state: CONTAINER.UNAVAILABLE, reason: 'not-probed' }
378
+
379
+ async function createHostOnce() {
380
+ const mod = await loadContainerModule()
381
+ if (typeof mod?.createContainerHost !== 'function') {
382
+ const err = new Error('container module does not export createContainerHost')
383
+ err.code = 'CONTAINER_MODULE_INVALID'
384
+ throw err
385
+ }
386
+ if (disposed) {
387
+ const err = new Error('router disposed')
388
+ err.code = 'ROUTER_DISPOSED'
389
+ throw err
390
+ }
391
+ containerHost = mod.createContainerHost({
392
+ powershellPath: normalized.container.powershellPath,
393
+ timeoutMs: normalized.container.timeoutMs,
394
+ })
395
+ return containerHost
396
+ }
397
+
398
+ async function ensureContainerHost() {
399
+ if (containerHost) return containerHost
400
+ if (!containerHostPromise) {
401
+ containerHostPromise = createHostOnce().catch((e) => {
402
+ containerHostPromise = null // 失败后允许下一次重试
403
+ throw e
404
+ })
405
+ }
406
+ return containerHostPromise
407
+ }
408
+
409
+ function disposeContainerHost() {
410
+ disposed = true
411
+ containerHostPromise = null
412
+ try { containerHost?.dispose?.() } catch { /* 容器宿主清理失败不得影响卸载 */ }
413
+ containerHost = null
414
+ }
415
+
416
+ /** 同步可用性(契约 5.3):disabled / unavailable / ready。 */
417
+ function containerAvailable() {
418
+ if (normalized.containerAvailable) {
419
+ return { ok: true, state: CONTAINER.READY, reason: 'configured' }
420
+ }
421
+ if (!normalized.container.enabled) {
422
+ return { ok: false, state: CONTAINER.DISABLED, reason: 'container-disabled' }
423
+ }
424
+ if (probeCache.ok && probeCache.state === CONTAINER.READY) {
425
+ return { ok: true, state: CONTAINER.READY, reason: probeCache.reason }
426
+ }
427
+ return {
428
+ ok: false,
429
+ state: probeCache.state === CONTAINER.DISABLED ? CONTAINER.DISABLED : CONTAINER.UNAVAILABLE,
430
+ reason: probeCache.reason ?? 'not-probed',
431
+ }
432
+ }
433
+
434
+ /** 异步刷新容器可用性(apply 预热 / probeContainer / runInContainer 共用)。永不抛出。 */
435
+ async function refreshContainer() {
436
+ if (normalized.containerAvailable) {
437
+ probeCache = { ok: true, state: CONTAINER.READY, reason: 'configured' }
438
+ return containerAvailable()
439
+ }
440
+ if (!normalized.container.enabled) {
441
+ probeCache = { ok: false, state: CONTAINER.DISABLED, reason: 'container-disabled' }
442
+ return containerAvailable()
443
+ }
444
+ try {
445
+ const host = await ensureContainerHost()
446
+ const avail = readAvailable(host)
447
+ const state = stateOf(avail, host)
448
+ probeCache = {
449
+ ok: avail?.ok === true && state === CONTAINER.READY,
450
+ state,
451
+ reason: String(avail?.reason ?? (state === CONTAINER.READY ? 'ready' : 'unavailable')),
452
+ }
453
+ } catch (e) {
454
+ probeCache = { ok: false, ...classifyContainerError(e) }
455
+ }
456
+ return containerAvailable()
457
+ }
458
+
459
+ /** 容器内执行(契约 5.3):{ok, stdout, stderr, exitCode, writes},任何失败路径都不抛出。 */
460
+ async function runInContainer(input = {}) {
461
+ const req = input && typeof input === 'object' ? input : {}
462
+ const code = typeof req.code === 'string' ? req.code : ''
463
+ const env = req.env && typeof req.env === 'object' ? req.env : {}
464
+ const declaredWrites = Array.isArray(req.declaredWrites) ? req.declaredWrites : []
465
+ const empty = { stdout: '', stderr: '', exitCode: null, writes: [] }
466
+
467
+ const enabled = normalized.container.enabled || normalized.containerAvailable
468
+ if (!enabled) return { ok: false, state: CONTAINER.DISABLED, reason: 'container-disabled', ...empty }
469
+ if (code.trim() === '') {
470
+ return { ok: false, state: containerAvailable().state, reason: 'code-required', ...empty }
471
+ }
472
+
473
+ // 先确保宿主存在(懒加载;模块缺失在这里被归类,不抛)。
474
+ try {
475
+ await ensureContainerHost()
476
+ } catch (e) {
477
+ const classified = classifyContainerError(e)
478
+ probeCache = { ok: false, ...classified }
479
+ return { ok: false, ...classified, ...empty }
480
+ }
481
+
482
+ let avail = readAvailable(containerHost)
483
+ let state = stateOf(avail, containerHost)
484
+ if (!(avail?.ok === true && state === CONTAINER.READY) && normalized.container.enabled) {
485
+ avail = await refreshContainer()
486
+ state = avail.state
487
+ }
488
+ if (!containerHost || !(avail?.ok === true)) {
489
+ const why = String(avail?.reason ?? 'container-unavailable')
490
+ return { ok: false, state: state ?? CONTAINER.UNAVAILABLE, reason: why, ...empty }
491
+ }
492
+
493
+ try {
494
+ const out = await containerHost.exec({
495
+ code,
496
+ env,
497
+ declaredWrites,
498
+ timeoutMs: normalized.container.timeoutMs,
499
+ })
500
+ probeCache = { ok: true, state: CONTAINER.READY, reason: probeCache.reason === 'not-probed' ? 'exec-ok' : probeCache.reason }
501
+ return {
502
+ ok: out?.ok === true,
503
+ state: CONTAINER.READY,
504
+ stdout: String(out?.stdout ?? ''),
505
+ stderr: String(out?.stderr ?? ''),
506
+ exitCode: typeof out?.exitCode === 'number' ? out.exitCode : (out?.exitCode ?? null),
507
+ writes: Array.isArray(out?.writes) ? out.writes : [],
508
+ ...(out?.ok === true ? {} : { reason: String(out?.reason ?? out?.error ?? 'exec-failed') }),
509
+ }
510
+ } catch (e) {
511
+ return {
512
+ ok: false,
513
+ state: containerAvailable().state,
514
+ reason: `container-exec-error:${e?.message ?? e}`,
515
+ ...empty,
516
+ }
517
+ }
518
+ }
519
+
520
+ // ── 母体初判 ──────────────────────────────────────────────────────────
521
+ function decide(input = {}) {
522
+ const raw = input && typeof input === 'object' ? input : {}
523
+ const qs = quickScan({ plugin: raw.plugin, assessment: raw.assessment, source: raw.source, code: raw.code })
524
+ const sourceView = mergeSourceInput(raw.plugin, raw.source, normalized.trustedPlugins)
525
+ const out = decidePure(
526
+ { ...raw, source: sourceView, needsChildProcess: raw.needsChildProcess === true || qs.needsChildProcess },
527
+ { config: normalized, probe: containerAvailable, role, environment: normalizeEnvironment(raw.environment, role) },
528
+ )
529
+ out.quickScan = qs
530
+ audit({
531
+ phase: 'decide',
532
+ route: out.route,
533
+ reason: out.reason,
534
+ needsApproval: out.needsApproval,
535
+ grade: out.grade,
536
+ role,
537
+ environment: out.basis.environment,
538
+ sourceTrusted: out.source.trusted,
539
+ sourceName: out.source.name,
540
+ capabilities: qs.capabilities,
541
+ container: out.container,
542
+ })
543
+ return out
544
+ }
545
+
546
+ // ── 分身复核 ──────────────────────────────────────────────────────────
547
+ function confirm(input = {}) {
548
+ const raw = input && typeof input === 'object' ? input : {}
549
+ const decision = raw.decision && typeof raw.decision === 'object' ? raw.decision : null
550
+ const environment = normalizeEnvironment(raw.environment, role)
551
+
552
+ if (!decision || !RANK[decision.route]) {
553
+ const out = {
554
+ route: ROUTE.DENY,
555
+ reason: `confirm:${decision ? 'invalid-decision-route' : 'missing-decision'}`,
556
+ needsApproval: false,
557
+ escalated: false,
558
+ grade: null,
559
+ basis: { role, environment, grade: null, source: null, escalated: false },
560
+ }
561
+ audit({ phase: 'confirm', route: out.route, reason: out.reason, escalated: false, role, environment })
562
+ return out
563
+ }
564
+
565
+ // 母体 deny 是终局:原样返回 route/reason/needsApproval,只补复核环境的 basis 与 escalated:false。
566
+ if (decision.route === ROUTE.DENY) {
567
+ const out = {
568
+ ...decision,
569
+ escalated: false,
570
+ basis: {
571
+ ...(decision.basis ?? {}),
572
+ role,
573
+ environment,
574
+ grade: decision.basis?.grade ?? decision.grade ?? null,
575
+ escalated: false,
576
+ },
577
+ }
578
+ audit({ phase: 'confirm', route: out.route, reason: out.reason, escalated: false, role, environment, from: decision.route })
579
+ return out
580
+ }
581
+
582
+ const evidence = evidenceFrom(decision) ?? mergeSourceInput(raw.plugin, raw.source, normalized.trustedPlugins)
583
+ const qs = quickScan({ plugin: raw.plugin, assessment: raw.assessment, source: evidence, code: raw.code })
584
+ const forkAdvice = decidePure(
585
+ {
586
+ assessment: raw.assessment ?? null,
587
+ source: evidence,
588
+ needsChildProcess: evidence?.needsChildProcess === true || qs.needsChildProcess,
589
+ },
590
+ { config: normalized, probe: containerAvailable, role, environment },
591
+ )
592
+
593
+ // max(母体 decision, 分身建议):只升不降。
594
+ let route = decision.route
595
+ const upgrades = []
596
+ if ((RANK[forkAdvice.route] ?? 0) > (RANK[route] ?? 0)) {
597
+ route = forkAdvice.route
598
+ upgrades.push(forkAdvice.reason)
599
+ }
600
+
601
+ // 评分第二意见只做单向升级:E → deny;D 或“比母体更差” → 至少 container。
602
+ const forkGrade = typeof raw.assessment?.grade === 'string' ? raw.assessment.grade : (forkAdvice.grade ?? null)
603
+ const motherGrade = decision.basis?.grade ?? decision.grade ?? null
604
+ if (route !== ROUTE.DENY && forkGrade === 'E') {
605
+ route = ROUTE.DENY
606
+ upgrades.push('fork-grade-E')
607
+ } else if (route === ROUTE.SANDBOX && forkGrade) {
608
+ const worseThanMother = motherGrade !== null
609
+ && typeof GRADE_RANK[forkGrade] === 'number'
610
+ && GRADE_RANK[forkGrade] > (GRADE_RANK[motherGrade] ?? 0)
611
+ if (forkGrade === 'D' || worseThanMother) {
612
+ route = ROUTE.CONTAINER
613
+ upgrades.push(`fork-grade-worse:${motherGrade ?? 'unknown'}->${forkGrade}`)
614
+ }
615
+ }
616
+
617
+ const escalated = route !== decision.route
618
+ let needsApproval
619
+ if (route === ROUTE.CONTAINER) needsApproval = true
620
+ else if (route === ROUTE.DENY) needsApproval = false
621
+ else needsApproval = decision.needsApproval === true
622
+ if (escalated && route === ROUTE.DENY) needsApproval = false
623
+
624
+ const grade = forkGrade ?? decision.basis?.grade ?? decision.grade ?? null
625
+ const basis = {
626
+ role,
627
+ environment,
628
+ grade,
629
+ source: forkAdvice.basis?.source ?? decision.basis?.source ?? null,
630
+ escalated,
631
+ ...(decision.basis?.role ? { decidedBy: decision.basis.role } : {}),
632
+ }
633
+ const out = {
634
+ route,
635
+ reason: escalated ? `escalated:${decision.route}->${route}:${upgrades.join('|') || forkAdvice.reason}` : decision.reason,
636
+ needsApproval,
637
+ escalated,
638
+ grade,
639
+ veto: decision.veto === true || forkAdvice.veto === true,
640
+ source: basis.source,
641
+ container: forkAdvice.container,
642
+ basis,
643
+ }
644
+ audit({
645
+ phase: 'confirm',
646
+ route: out.route,
647
+ reason: out.reason,
648
+ escalated,
649
+ from: decision.route,
650
+ role,
651
+ environment,
652
+ grade,
653
+ container: out.container,
654
+ })
655
+ return out
656
+ }
657
+
658
+ const service = {
659
+ role,
660
+ policy: {
661
+ role,
662
+ environment: role,
663
+ platform: process.platform,
664
+ containerAvailable: normalized.containerAvailable,
665
+ container: { ...normalized.container },
666
+ trustedPlugins: normalized.trustedPlugins,
667
+ },
668
+
669
+ /** 容器执行体可用性(契约 5.3):{ok, state, reason}。 */
670
+ containerAvailable,
671
+
672
+ /** 异步刷新容器可用性(补充方法,供 Orchestrator/Adapter 在需要确定性状态时调用)。 */
673
+ async probeContainer() {
674
+ return refreshContainer()
675
+ },
676
+
677
+ /** 母体初判(契约 5.3):静态 quickScan + 来源可信度 + 能力需求。 */
678
+ decide,
679
+
680
+ /** 分身复核(契约 5.3):只升不降;母体 deny 原样返回。 */
681
+ confirm,
682
+
683
+ /** 容器内执行(契约 5.3):懒加载 T8 容器执行器,缺模块/禁用时降级返回,绝不抛。 */
684
+ runInContainer,
685
+ }
686
+
687
+ ctx.effect(() => () => disposeContainerHost())
688
+ // 预热探测:enabled 时才触发;异步失败已内部归类,不会产生未处理拒绝。
689
+ if (normalized.container.enabled) {
690
+ refreshContainer().catch(() => {})
691
+ }
692
+ ctx.effect(() => ctx.provide(SERVICE.router, service))
693
+ }
694
+
695
+ export const _internals = {
696
+ normalizeSource,
697
+ mergeSourceInput,
698
+ resolveContainer,
699
+ decidePure,
700
+ quickScan,
701
+ evidenceFrom,
702
+ normalizeEnvironment,
703
+ RANK,
704
+ GRADE_RANK,
705
+ /** 测试注入容器模块加载器(T8 模块可能尚未落地);传 null 恢复真实懒加载。 */
706
+ setContainerModuleLoader(fn) {
707
+ containerModuleLoader = typeof fn === 'function' ? fn : () => import('../lib/container/index.mjs')
708
+ containerModulePromise = null
709
+ },
710
+ resetContainerModuleLoader() {
711
+ containerModuleLoader = () => import('../lib/container/index.mjs')
712
+ containerModulePromise = null
713
+ },
714
+ }