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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) 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/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,202 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // ai-secret-store — AI advisor 的 API Key 持久化:只走 OS 钥匙串 / DPAPI,绝不落明文。
5
+ //
6
+ // 支持的 backend:
7
+ // * win32 : DPAPI(用户级)——PowerShell 调 ProtectedData.Protect/Unprotect,secret 走 stdin
8
+ // * linux : secret-tool(Secret Service / libsecret)——secret 走 stdin
9
+ // * darwin : 暂未实现(明确返回 unavailable,不假装成功)
10
+ // * 其它 : unavailable
11
+ //
12
+ // 磁盘上只存:
13
+ // <dataDir>/decision-ai-secret.json { version, backend, provider, model, baseUrl, updatedAt }
14
+ // <dataDir>/decision-ai-secret.enc backend 加密后的 blob(DPAPI 是 base64 密文;secret-tool 不落盘)
15
+ // 若 backend 不可用 => available=false,调用方必须退回「仅内存」,绝不写明文。
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+
20
+ import { ensureDir, writeJsonAtomic } from '../kernel/index.mjs'
21
+
22
+ export const SECRET_META_FILE = 'decision-ai-secret.json'
23
+ export const SECRET_BLOB_FILE = 'decision-ai-secret.enc'
24
+ export const DEFAULT_SERVICE = 'dsh-fang-decision-ai'
25
+
26
+ const PS_PROTECT = [
27
+ 'Add-Type -AssemblyName System.Security',
28
+ '$b64=[Console]::In.ReadToEnd()',
29
+ '$bytes=[Convert]::FromBase64String($b64)',
30
+ '$enc=[System.Security.Cryptography.ProtectedData]::Protect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
31
+ '[Console]::Out.Write([Convert]::ToBase64String($enc))',
32
+ ].join('; ')
33
+
34
+ const PS_UNPROTECT = [
35
+ 'Add-Type -AssemblyName System.Security',
36
+ '$b64=[Console]::In.ReadToEnd()',
37
+ '$bytes=[Convert]::FromBase64String($b64)',
38
+ '$raw=[System.Security.Cryptography.ProtectedData]::Unprotect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
39
+ '[Console]::Out.Write([Convert]::ToBase64String($raw))',
40
+ ].join('; ')
41
+
42
+ /** 默认 runner:同步执行,stdin 传 secret,stdout 回结果;不把 secret 放进 argv。 */
43
+ export function defaultRunner(command, args, input = '') {
44
+ const result = spawnSync(command, args, { input, encoding: 'utf8', windowsHide: true, timeout: 15000 })
45
+ return {
46
+ ok: result.status === 0,
47
+ status: result.status ?? -1,
48
+ stdout: String(result.stdout ?? '').trim(),
49
+ stderr: String(result.stderr ?? '').trim(),
50
+ error: result.error ? String(result.error?.message ?? result.error) : '',
51
+ }
52
+ }
53
+
54
+ export function detectSecretBackend({ platform = process.platform, runner = defaultRunner } = {}) {
55
+ if (platform === 'win32') {
56
+ const probe = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; "ok"'])
57
+ return probe.ok ? { backend: 'dpapi', available: true } : { backend: 'unavailable', available: false, reason: 'dpapi-probe-failed' }
58
+ }
59
+ if (platform === 'linux') {
60
+ const probe = runner('secret-tool', ['--version'])
61
+ return probe.ok ? { backend: 'secret-service', available: true } : { backend: 'unavailable', available: false, reason: 'secret-tool-missing' }
62
+ }
63
+ return { backend: 'unavailable', available: false, reason: platform === 'darwin' ? 'keychain-not-implemented' : 'platform-unsupported' }
64
+ }
65
+
66
+ /**
67
+ * @param {{dataDir:string, platform?:string, runner?:Function, service?:string, logger?:object}} options
68
+ */
69
+ export function createAiSecretStore(options = {}) {
70
+ const dataDir = String(options.dataDir ?? '')
71
+ if (!dataDir) throw new Error('createAiSecretStore: dataDir required')
72
+ const platform = options.platform ?? process.platform
73
+ const runner = typeof options.runner === 'function' ? options.runner : defaultRunner
74
+ const service = String(options.service ?? DEFAULT_SERVICE)
75
+ const log = (level, message) => { try { options.logger?.[level]?.(`[ai-secret-store] ${message}`) } catch { /* ignore */ } }
76
+ const metaFile = path.join(dataDir, SECRET_META_FILE)
77
+ const blobFile = path.join(dataDir, SECRET_BLOB_FILE)
78
+ // 懒探测:只有真正需要持久化/读取能力时才执行后端探测(测试/未配置 AI 时零开销)。
79
+ let detection = null
80
+ const ensureBackend = () => {
81
+ if (detection === null) detection = detectSecretBackend({ platform, runner })
82
+ return detection
83
+ }
84
+
85
+ const readJson = (file) => {
86
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
87
+ }
88
+ const removeIfExists = (file) => { try { if (fs.existsSync(file)) fs.unlinkSync(file) } catch { /* ignore */ } }
89
+
90
+ function encrypt(secretJson) {
91
+ const detection = ensureBackend()
92
+ const plain = Buffer.from(secretJson, 'utf8').toString('base64')
93
+ if (detection.backend === 'dpapi') {
94
+ const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_PROTECT], plain)
95
+ if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-protect-failed', detail: out.stderr || out.error }
96
+ return { ok: true, payload: out.stdout }
97
+ }
98
+ if (detection.backend === 'secret-service') {
99
+ const out = runner('secret-tool', ['store', '--label', 'dsh-fang decision AI key', 'service', service, 'slot', 'api-key'], secretJson)
100
+ if (!out.ok) return { ok: false, reason: 'secret-tool-store-failed', detail: out.stderr || out.error }
101
+ return { ok: true, payload: '' }
102
+ }
103
+ return { ok: false, reason: 'secret-backend-unavailable' }
104
+ }
105
+
106
+ function decrypt(payload) {
107
+ const detection = ensureBackend()
108
+ if (detection.backend === 'dpapi') {
109
+ const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_UNPROTECT], String(payload ?? ''))
110
+ if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-unprotect-failed', detail: out.stderr || out.error }
111
+ try { return { ok: true, secretJson: Buffer.from(out.stdout, 'base64').toString('utf8') } } catch { return { ok: false, reason: 'dpapi-decode-failed' } }
112
+ }
113
+ if (detection.backend === 'secret-service') {
114
+ const out = runner('secret-tool', ['lookup', 'service', service, 'slot', 'api-key'])
115
+ if (!out.ok || !out.stdout) return { ok: false, reason: 'secret-tool-lookup-failed', detail: out.stderr || out.error }
116
+ return { ok: true, secretJson: out.stdout }
117
+ }
118
+ return { ok: false, reason: 'secret-backend-unavailable' }
119
+ }
120
+
121
+ return {
122
+ get backend() { return ensureBackend().backend },
123
+ get available() { return ensureBackend().available === true },
124
+ get reason() { return ensureBackend().reason ?? null },
125
+ service,
126
+ capabilities() {
127
+ const detection = ensureBackend()
128
+ return { backend: detection.backend, available: detection.available === true, reason: detection.reason ?? null, encryptedAtRest: detection.backend === 'dpapi' ? 'dpapi-user' : detection.backend === 'secret-service' ? 'os-secret-service' : 'none' }
129
+ },
130
+ /** 保存 { provider, model, baseUrl, apiKey };失败返回 ok:false,调用方保持仅内存。 */
131
+ save(config = {}) {
132
+ const detection = ensureBackend()
133
+ if (!detection.available) return { ok: false, reason: detection.reason ?? 'secret-backend-unavailable' }
134
+ const apiKey = String(config.apiKey ?? '').trim()
135
+ if (!apiKey) return { ok: false, reason: 'api-key-required' }
136
+ const meta = {
137
+ version: 1,
138
+ backend: detection.backend,
139
+ provider: String(config.provider ?? '').slice(0, 40),
140
+ model: String(config.model ?? '').slice(0, 120),
141
+ baseUrl: String(config.baseUrl ?? '').slice(0, 500),
142
+ updatedAt: Date.now(),
143
+ }
144
+ const encrypted = encrypt(JSON.stringify({
145
+ apiKey,
146
+ ...(Array.isArray(config.profiles) ? { profiles: config.profiles } : {}),
147
+ ...(config.routes && typeof config.routes === 'object' ? { routes: config.routes } : {}),
148
+ ...(config.modelByLevel && typeof config.modelByLevel === 'object' ? { modelByLevel: config.modelByLevel } : {}),
149
+ }))
150
+ if (!encrypted.ok) return encrypted
151
+ try {
152
+ ensureDir(dataDir)
153
+ if (encrypted.payload) {
154
+ fs.writeFileSync(blobFile, encrypted.payload, { encoding: 'utf8', mode: 0o600 })
155
+ try { fs.chmodSync(blobFile, 0o600) } catch { /* windows */ }
156
+ }
157
+ writeJsonAtomic(metaFile, meta)
158
+ return { ok: true, backend: detection.backend, meta }
159
+ } catch (error) {
160
+ log('warn', `save failed: ${String(error?.message ?? error)}`)
161
+ return { ok: false, reason: 'persist-failed', detail: String(error?.message ?? error) }
162
+ }
163
+ },
164
+ /** 读取已持久化配置;不存在/backend 不匹配/解密失败 => null(不抛)。 */
165
+ load() {
166
+ // 没有落盘元数据时不要探测 backend(避免无 AI 配置的宿主每次启动都拉起 PowerShell)。
167
+ if (!fs.existsSync(metaFile)) return null
168
+ const detection = ensureBackend()
169
+ if (!detection.available) return null
170
+ const meta = readJson(metaFile)
171
+ if (!meta || meta.backend !== detection.backend) return null
172
+ let payload = ''
173
+ try { payload = fs.existsSync(blobFile) ? fs.readFileSync(blobFile, 'utf8') : '' } catch { payload = '' }
174
+ if (detection.backend === 'dpapi' && !payload) return null
175
+ const decrypted = decrypt(payload)
176
+ if (!decrypted.ok) { log('warn', `load failed: ${decrypted.reason}`); return null }
177
+ let secret = null
178
+ try { secret = JSON.parse(decrypted.secretJson) } catch { return null }
179
+ const apiKey = String(secret?.apiKey ?? '').trim()
180
+ if (!apiKey) return null
181
+ return {
182
+ provider: String(meta.provider ?? 'custom'),
183
+ model: String(meta.model ?? ''),
184
+ baseUrl: String(meta.baseUrl ?? ''),
185
+ apiKey,
186
+ ...(Array.isArray(secret.profiles) ? { profiles: secret.profiles } : {}),
187
+ ...(secret.routes && typeof secret.routes === 'object' ? { routes: secret.routes } : {}),
188
+ ...(secret.modelByLevel && typeof secret.modelByLevel === 'object' ? { modelByLevel: secret.modelByLevel } : {}),
189
+ source: `os:${detection.backend}`,
190
+ backend: detection.backend,
191
+ }
192
+ },
193
+ clear() {
194
+ removeIfExists(metaFile)
195
+ removeIfExists(blobFile)
196
+ if (ensureBackend().backend === 'secret-service') {
197
+ try { runner('secret-tool', ['clear', 'service', service, 'slot', 'api-key']) } catch { /* ignore */ }
198
+ }
199
+ return { ok: true }
200
+ },
201
+ }
202
+ }
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // approval-drill — 在隔离的临时 dataRoot 里真实执行一次“提升审批”闭环:
5
+ // requestPromotion(red) → pending → decide(approved) → snapshot → 改动文件 → rollback
6
+ // 不触碰生产 dataDir、不加载真实插件、不写 profile;用于证明审批/快照/回滚链路可执行。
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ import { apply as applyApprover } from '../plugins/approver.mjs'
12
+
13
+ /**
14
+ * @returns {{
15
+ * ok:boolean, request:{id:string,kind:string,risk:string,outcome:string},
16
+ * pendingBefore:number, pendingAfter:number, snapshot:object, rollback:object,
17
+ * restored:boolean, target:string, dataRoot:string, cleanup:boolean
18
+ * }}
19
+ */
20
+ export async function runApprovalDrill() {
21
+ const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fang-approval-drill-'))
22
+ const target = path.join(dataRoot, 'target')
23
+ fs.mkdirSync(target, { recursive: true })
24
+ fs.writeFileSync(path.join(target, 'plugin.mjs'), 'export const version = 1\n', 'utf8')
25
+
26
+ const services = new Map()
27
+ const disposers = []
28
+ const ctx = {
29
+ effect(fn) {
30
+ const dispose = fn()
31
+ if (typeof dispose === 'function') disposers.push(dispose)
32
+ return () => { try { dispose?.() } catch { /* ignore */ } }
33
+ },
34
+ provide(key, value) { services.set(key, value); return () => services.delete(key) },
35
+ get(key) { return services.get(key) },
36
+ logger: { info() {}, warn() {}, debug() {}, error() {} },
37
+ }
38
+
39
+ let approver = null
40
+ let requestPromise = null
41
+ try {
42
+ applyApprover(ctx, {
43
+ dataRoot,
44
+ defaultTtlMs: 10000,
45
+ authMode: 'manual',
46
+ strictRedLine: true,
47
+ snapshotGenerations: 3,
48
+ })
49
+ approver = services.get('fangApprover')
50
+ if (!approver || typeof approver.requestPromotion !== 'function') {
51
+ return { ok: false, error: 'approver-service-missing', dataRoot, cleanup: false }
52
+ }
53
+
54
+ requestPromise = approver.requestPromotion({
55
+ pluginId: 'approval-drill',
56
+ taskId: 'approval-drill',
57
+ detail: '隔离审批 drill:验证 request→decide→snapshot→rollback',
58
+ ttlMs: 10000,
59
+ })
60
+ // request() 同步入队;让出一次事件循环以确保 pending 可见。
61
+ await new Promise((resolve) => setImmediate(resolve))
62
+ const pendingBefore = approver.pending()
63
+ if (pendingBefore.length !== 1) {
64
+ return { ok: false, error: 'request-not-pending', pendingBefore: pendingBefore.length, dataRoot, cleanup: false }
65
+ }
66
+ const id = String(pendingBefore[0].id)
67
+ const decided = approver.decide(id, 'approved', { by: 'approval-drill', note: 'isolated drill' })
68
+ const outcome = await requestPromise
69
+ const pendingAfter = approver.pending().length
70
+ requestPromise = null
71
+
72
+ const snapshot = await approver.snapshot({ tag: 'approval-drill', dir: target })
73
+ fs.writeFileSync(path.join(target, 'plugin.mjs'), 'export const version = 2\n', 'utf8')
74
+ const rollback = await approver.rollback({ tag: 'approval-drill', dir: target })
75
+ const restored = fs.readFileSync(path.join(target, 'plugin.mjs'), 'utf8') === 'export const version = 1\n'
76
+
77
+ return {
78
+ ok: outcome?.outcome === 'approved' && decided?.ok === true && snapshot?.ok !== false && rollback?.ok === true && restored && pendingAfter === 0,
79
+ request: { id, kind: 'promotion', risk: 'red', outcome: String(outcome?.outcome ?? '') },
80
+ decided,
81
+ pendingBefore: pendingBefore.length,
82
+ pendingAfter,
83
+ snapshot,
84
+ rollback,
85
+ restored,
86
+ target,
87
+ dataRoot,
88
+ cleanup: false,
89
+ }
90
+ } finally {
91
+ // 未决请求不能被遗留在计时器里:显式拒绝/超时结算。
92
+ if (requestPromise) {
93
+ const pending = approver?.pending?.() ?? []
94
+ for (const item of pending) {
95
+ try { approver.decide(item.id, 'denied', { by: 'approval-drill-cleanup' }) } catch { /* ignore */ }
96
+ }
97
+ await requestPromise.catch(() => {})
98
+ }
99
+ for (const dispose of disposers.reverse()) { try { dispose() } catch { /* ignore */ } }
100
+ }
101
+ }
102
+
103
+ /** 删除 drill 的临时目录;返回是否已不存在。 */
104
+ export function cleanupApprovalDrill(result) {
105
+ const dir = String(result?.dataRoot ?? '')
106
+ if (!dir) return true
107
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
108
+ return !fs.existsSync(dir)
109
+ }
110
+
111
+ export const _internals = { runApprovalDrill, cleanupApprovalDrill }
@@ -0,0 +1,186 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // boot-guard — 启动前/发布前插件树自检(fail-loud 之前拦住):
5
+ // * 解析 fang bundle 的 cordis.patch.yml 行(id/name);
6
+ // * 检查 id 重复、模块可解析、子进程 isolation import() 且导出 apply();
7
+ // * 可选:把失败行 disabled:true 合并进 profile 的 cordis.patch.yml(安全模式)。
8
+ // 原则:任何一项失败都只报告/禁用,不修改 DSH 核心文件;写 profile 前必须备份。
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { pathToFileURL } from 'node:url'
13
+ import { createRequire } from 'node:module'
14
+
15
+ import { parseYamlSubset } from './rule-registry.mjs'
16
+
17
+ export const FANG_PACKAGE_NAMES = Object.freeze(['@twinsearth/fang-dsh-plugin', '@twinsearth/dsh-fang', '@twinsearth/fang-dsh-plugin'])
18
+ export const SMOKE_TIMEOUT_MS = 8000
19
+
20
+ /** 找到 profile 里已安装的 fang 包目录(发布名优先,其次本地 link 名)。 */
21
+ export function findFangPackageDir(profileDir) {
22
+ for (const name of FANG_PACKAGE_NAMES) {
23
+ const candidate = path.join(String(profileDir ?? ''), 'node_modules', ...name.split('/'))
24
+ try { if (fs.statSync(candidate).isDirectory()) return candidate } catch { /* next */ }
25
+ }
26
+ return null
27
+ }
28
+
29
+ /** 解析 bundle patch → 行数组 [{id, name}]。 */
30
+ export function readBundleRows(pkgDir) {
31
+ const file = path.join(String(pkgDir ?? ''), 'cordis.patch.yml')
32
+ let text = ''
33
+ try { text = fs.readFileSync(file, 'utf8') } catch { return { ok: false, error: 'patch-missing', rows: [] } }
34
+ const parsed = parseYamlSubset(text)
35
+ const value = parsed?.value
36
+ const insert = Array.isArray(value) ? value.flatMap((item) => Array.isArray(item?.insert) ? item.insert : []) : (Array.isArray(value?.insert) ? value.insert : [])
37
+ const rows = insert.filter((row) => row && typeof row === 'object' && row.id).map((row) => ({
38
+ id: String(row.id),
39
+ name: row.name === undefined ? null : String(row.name),
40
+ disabled: row.disabled === true,
41
+ }))
42
+ return { ok: parsed.errors.length === 0, error: parsed.errors.length ? `patch-parse:${parsed.errors[0]}` : null, rows, parseErrors: parsed.errors }
43
+ }
44
+
45
+ /** 在隔离子进程 import 模块,确认可加载且导出 apply。 */
46
+ export function smokeModule(moduleUrl, { timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
47
+ const script = `
48
+ const url = process.argv[1];
49
+ import(url).then((m) => {
50
+ process.stdout.write(JSON.stringify({ ok: typeof m.apply === 'function', hasApply: typeof m.apply === 'function', name: m.name ?? null }));
51
+ }).catch((e) => {
52
+ process.stdout.write(JSON.stringify({ ok: false, hasApply: false, error: String(e && e.message || e).slice(0, 500) }));
53
+ });
54
+ `
55
+ const result = spawnSync(process.execPath, ['--input-type=module', '-e', script, moduleUrl], {
56
+ encoding: 'utf8', timeout: timeoutMs, windowsHide: true,
57
+ })
58
+ if (result.error?.code === 'ETIMEDOUT' || result.signal) return { ok: false, error: 'timeout' }
59
+ const line = String(result.stdout ?? '').trim().split('\n').filter(Boolean).pop()
60
+ if (!line) return { ok: false, error: String(result.stderr ?? '').trim().slice(0, 500) || `exit=${result.status}` }
61
+ try { return JSON.parse(line) } catch { return { ok: false, error: line.slice(0, 500) } }
62
+ }
63
+
64
+ /** 读取已安装 fang 包的 package.json name(用于区分“本包行”和可选子插件行)。 */
65
+ export function readPackageName(pkgDir) {
66
+ try { return String(JSON.parse(fs.readFileSync(path.join(String(pkgDir ?? ''), 'package.json'), 'utf8')).name ?? '') } catch { return '' }
67
+ }
68
+
69
+ /** 读取 package 的入口(exports["."]/main/ src/index.mjs),用于更新候选包冒烟。 */
70
+ export function readPackageEntry(packageDir) {
71
+ const dir = String(packageDir ?? '')
72
+ try {
73
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
74
+ const exported = pkg?.exports?.['.']
75
+ const candidate = typeof exported === 'string'
76
+ ? exported
77
+ : (exported && typeof exported === 'object' && typeof exported.default === 'string' ? exported.default : null)
78
+ const entry = candidate || pkg.main || './src/index.mjs'
79
+ return path.resolve(dir, entry)
80
+ } catch { return null }
81
+ }
82
+
83
+ /** 更新候选包冒烟:入口存在 + 隔离子进程 import + 导出 apply;失败即禁止转正。 */
84
+ export function smokePackage(packageDir, { timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
85
+ const dir = String(packageDir ?? '')
86
+ const entry = readPackageEntry(dir)
87
+ if (!entry) return { ok: false, error: 'package-json-missing', packageDir: dir }
88
+ try { if (!fs.statSync(entry).isFile()) return { ok: false, error: 'entry-missing', entry } } catch { return { ok: false, error: 'entry-missing', entry } }
89
+ const result = smokeModule(pathToFileURL(entry).href, { timeoutMs })
90
+ return { ...result, entry, packageDir: dir }
91
+ }
92
+
93
+ /**
94
+ * 检查行:重复 id、模块解析、隔离 import 冒烟。
95
+ * 本包行失败 = block;可选子插件(如 @twinsearth/dsh-fang-web-entry)缺失/失败 = warn。
96
+ * @returns {{ok:boolean, findings:Array<{code,level,id?,detail?}>, checked:number}}
97
+ */
98
+ export function checkBundleRows(rows, { profileDir, ownPackage = '', timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
99
+ const findings = []
100
+ const seen = new Set()
101
+ let checked = 0
102
+ const requireFrom = createRequire(path.join(String(profileDir ?? ''), 'package.json'))
103
+ const isOwnRow = (name) => {
104
+ const own = String(ownPackage ?? '').trim()
105
+ if (!own) return true
106
+ return name === own || name.startsWith(`${own}/`)
107
+ }
108
+ for (const row of rows) {
109
+ if (seen.has(row.id)) {
110
+ findings.push({ code: 'duplicate-id', level: 'block', id: row.id, detail: row.id })
111
+ continue
112
+ }
113
+ seen.add(row.id)
114
+ // v3.3.3:disabled 行不会加载;不因可选子插件未安装/未启用而阻塞启动。
115
+ if (row.disabled === true) continue
116
+ if (!row.name) {
117
+ findings.push({ code: 'row-name-missing', level: 'warn', id: row.id })
118
+ continue
119
+ }
120
+ checked += 1
121
+ const optional = !isOwnRow(row.name)
122
+ let resolved = null
123
+ try { resolved = requireFrom.resolve(row.name) } catch (error) {
124
+ findings.push({
125
+ code: 'module-unresolved', level: optional ? 'warn' : 'block', id: row.id,
126
+ detail: `${row.name}: ${String(error?.message ?? error).slice(0, 200)}`,
127
+ })
128
+ continue
129
+ }
130
+ const smoke = smokeModule(pathToFileURL(resolved).href, { timeoutMs })
131
+ if (!smoke.ok) {
132
+ findings.push({ code: 'module-smoke-failed', level: optional ? 'warn' : 'block', id: row.id, detail: String(smoke.error ?? 'no apply()').slice(0, 300) })
133
+ }
134
+ }
135
+ return { ok: findings.every((finding) => finding.level !== 'block'), findings, checked }
136
+ }
137
+
138
+ const jsonPatchEntries = (text) => {
139
+ const trimmed = String(text ?? '').trim()
140
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null
141
+ try {
142
+ const value = JSON.parse(trimmed)
143
+ if (Array.isArray(value)) return value
144
+ if (value && typeof value === 'object' && Array.isArray(value.insert)) return value.insert
145
+ } catch { /* yaml */ }
146
+ return null
147
+ }
148
+
149
+ /**
150
+ * 把 disabled 行合并进 profile 的 cordis.patch.yml;先写 .bak-<ts> 备份。
151
+ * 只接受 id(禁用行);不会删除/改写已有行。
152
+ */
153
+ export function applyDisabledRows(profileDir, ids) {
154
+ const dir = String(profileDir ?? '')
155
+ if (!dir) return { ok: false, error: 'profile-dir-required' }
156
+ const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(String).filter(Boolean))]
157
+ if (!wanted.length) return { ok: false, error: 'no-ids' }
158
+ const file = path.join(dir, 'cordis.patch.yml')
159
+ let text = ''
160
+ try { text = fs.readFileSync(file, 'utf8') } catch { text = '' }
161
+ try { fs.mkdirSync(dir, { recursive: true }); if (text) fs.writeFileSync(`${file}.bak-${Date.now()}`, text, 'utf8') } catch { /* backup best-effort */ }
162
+ const asJson = jsonPatchEntries(text)
163
+ if (asJson !== null) {
164
+ const existing = new Set(asJson.map((entry) => entry && typeof entry === 'object' ? String(entry.id ?? '') : ''))
165
+ const merged = asJson.slice()
166
+ let added = 0
167
+ for (const id of wanted) {
168
+ if (existing.has(id)) continue
169
+ merged.push({ id, disabled: true })
170
+ added += 1
171
+ }
172
+ fs.writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
173
+ return { ok: true, added, file, format: 'json' }
174
+ }
175
+ const kept = String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => !/^\s*\[\s*\]\s*(#.*)?$/.test(line))
176
+ const body = kept.join('\n')
177
+ const lines = []
178
+ for (const id of wanted) {
179
+ if (new RegExp(`(^|\\n)\\s*-\\s*id:\\s*${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm').test(body)) continue
180
+ lines.push(`- id: ${id}`, ' disabled: true')
181
+ }
182
+ if (!lines.length) return { ok: true, added: 0, file, format: 'yaml' }
183
+ const prefix = body.trim().length === 0 ? '' : (body.endsWith('\n') ? body : body + '\n')
184
+ fs.writeFileSync(file, `${prefix}${lines.join('\n')}\n`, 'utf8')
185
+ return { ok: true, added: wanted.length, file, format: 'yaml' }
186
+ }
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.8 branch persistence: one JSON file per task under <dataDir>/tasks/<taskId>/branches.json.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { activateBranch, createBranch, createSession, setBranchStatus } from './branch.mjs'
6
+
7
+ /** taskId is a path segment under <dataDir>/tasks; reject separators and traversal. */
8
+ const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
9
+
10
+ export class BranchStore {
11
+ constructor(dataDir) {
12
+ this.dataDir = dataDir
13
+ }
14
+
15
+ file(taskId) {
16
+ const id = String(taskId ?? '')
17
+ if (!TASK_ID_RE.test(id)) throw new Error('invalid-task-id')
18
+ return path.join(this.dataDir, 'tasks', id, 'branches.json')
19
+ }
20
+
21
+ load(taskId) {
22
+ const file = this.file(taskId) // validate outside the read fallback
23
+ try {
24
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
25
+ return {
26
+ sessionId: raw.sessionId,
27
+ activeBranchId: raw.activeBranchId,
28
+ branches: new Map(Object.entries(raw.branches || {})),
29
+ }
30
+ } catch {
31
+ return createSession(String(taskId))
32
+ }
33
+ }
34
+
35
+ save(taskId, session) {
36
+ const file = this.file(taskId)
37
+ fs.mkdirSync(path.dirname(file), { recursive: true })
38
+ const payload = {
39
+ sessionId: session.sessionId,
40
+ activeBranchId: session.activeBranchId,
41
+ branches: Object.fromEntries(session.branches),
42
+ }
43
+ const tmp = file + '.tmp'
44
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
45
+ fs.renameSync(tmp, file)
46
+ return payload
47
+ }
48
+
49
+ list(taskId) {
50
+ const session = this.load(taskId)
51
+ return { ok: true, sessionId: session.sessionId, activeBranchId: session.activeBranchId, branches: [...session.branches.values()] }
52
+ }
53
+
54
+ create(taskId, input) {
55
+ const session = this.load(taskId)
56
+ const branch = createBranch(session, input)
57
+ this.save(taskId, session)
58
+ return { ok: true, branch }
59
+ }
60
+
61
+ /** 把分支库的 sessionId 绑定到该分身真实会话(启动/还原时由单会话守卫调用)。 */
62
+ bindSession(taskId, sessionId) {
63
+ const session = this.load(taskId)
64
+ const next = String(sessionId ?? '').trim()
65
+ if (!next || session.sessionId === next) return { ok: true, sessionId: session.sessionId, changed: false }
66
+ session.sessionId = next
67
+ this.save(taskId, session)
68
+ return { ok: true, sessionId: session.sessionId, changed: true }
69
+ }
70
+
71
+ activate(taskId, branchId) {
72
+ const session = this.load(taskId)
73
+ const branch = activateBranch(session, branchId)
74
+ this.save(taskId, session)
75
+ return { ok: true, branch, activeBranchId: session.activeBranchId }
76
+ }
77
+
78
+ setStatus(taskId, branchId, status) {
79
+ const session = this.load(taskId)
80
+ const branch = setBranchStatus(session, branchId, status)
81
+ this.save(taskId, session)
82
+ return { ok: true, branch }
83
+ }
84
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.8 branch model: one session, many branch logs, one writer per branch, no merge.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ /** Branch/session ids become directory names; keep them to safe single segments. */
7
+ export const BRANCH_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
8
+ export const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
9
+
10
+ function assertBranchId(branchId) {
11
+ const id = String(branchId ?? '')
12
+ if (!BRANCH_ID_RE.test(id)) throw new Error('invalid-branch-id')
13
+ return id
14
+ }
15
+
16
+ function assertSessionId(sessionId) {
17
+ const id = String(sessionId ?? '')
18
+ if (!SESSION_ID_RE.test(id)) throw new Error('invalid-session-id')
19
+ return id
20
+ }
21
+
22
+ export function createSession(sessionId) {
23
+ const main = { branchId: 'main', parentBranchId: null, baseTurn: 0, status: 'active', createdAt: Date.now() }
24
+ return { sessionId, activeBranchId: 'main', branches: new Map([['main', main]]) }
25
+ }
26
+
27
+ export function createBranch(session, { branchId, parentBranchId = session.activeBranchId, baseTurn = 0, sessionId = null }) {
28
+ const id = assertBranchId(branchId)
29
+ if (id === 'main') throw new Error('invalid-branch-id')
30
+ if (session.branches.has(id)) throw new Error('branch-exists')
31
+ if (!session.branches.has(parentBranchId)) throw new Error('parent-missing')
32
+ session.branches.set(id, {
33
+ branchId: id, parentBranchId, baseTurn, status: 'active', createdAt: Date.now(),
34
+ sessionId: typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null,
35
+ })
36
+ return session.branches.get(id)
37
+ }
38
+
39
+ export function activateBranch(session, branchId) {
40
+ const id = assertBranchId(branchId)
41
+ const branch = session.branches.get(id)
42
+ if (!branch) throw new Error('branch-missing')
43
+ if (branch.status !== 'active') throw new Error('branch-not-active')
44
+ session.activeBranchId = id
45
+ return branch
46
+ }
47
+
48
+ export function setBranchStatus(session, branchId, status) {
49
+ const id = assertBranchId(branchId)
50
+ if (!['sealed', 'archived'].includes(status)) throw new Error('invalid-status')
51
+ const branch = session.branches.get(id)
52
+ if (!branch) throw new Error('branch-missing')
53
+ if (id === session.activeBranchId) throw new Error('cannot-seal-active')
54
+ branch.status = status
55
+ return branch
56
+ }
57
+
58
+ export function branchPath(root, sessionId, branchId) {
59
+ return path.join(root, assertSessionId(sessionId), 'branches', assertBranchId(branchId))
60
+ }
61
+
62
+ export function acquireWriter(root, sessionId, branchId) {
63
+ const dir = branchPath(root, sessionId, branchId)
64
+ fs.mkdirSync(dir, { recursive: true })
65
+ const lock = path.join(dir, 'writer.lock')
66
+ try {
67
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' })
68
+ } catch {
69
+ const error = new Error('branch-busy')
70
+ error.code = '409'
71
+ throw error
72
+ }
73
+ return { release() { try { fs.rmSync(lock, { force: true }) } catch { /* ignore */ } } }
74
+ }