@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,100 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang kernel — ids, tiny event bus, process resource sampling, exit codes.
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ export function newId(prefix = 'id') {
7
+ return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
8
+ }
9
+
10
+ export function newUuid() {
11
+ return randomUUID()
12
+ }
13
+
14
+ /** Deterministic, dependency-free event bus (no Node emitter semantics to leak). */
15
+ export function createBus() {
16
+ const map = new Map()
17
+ return {
18
+ on(topic, fn) {
19
+ if (typeof fn !== 'function') return () => {}
20
+ if (!map.has(topic)) map.set(topic, new Set())
21
+ map.get(topic).add(fn)
22
+ return () => map.get(topic)?.delete(fn)
23
+ },
24
+ off(topic, fn) {
25
+ map.get(topic)?.delete(fn)
26
+ },
27
+ emit(topic, payload) {
28
+ const set = map.get(topic)
29
+ if (!set) return 0
30
+ let n = 0
31
+ for (const fn of [...set]) {
32
+ try { fn(payload); n++ } catch { /* a listener must never break the emitter */ }
33
+ }
34
+ return n
35
+ },
36
+ listenerCount(topic) {
37
+ return map.get(topic)?.size ?? 0
38
+ },
39
+ clear(topic) {
40
+ if (topic === undefined) map.clear()
41
+ else map.delete(topic)
42
+ },
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Process-wide bus shared by the 1 + 9 plugins. Every module imports this from the
48
+ * kernel, and the kernel module instance is shared inside one host process, so plugins
49
+ * can talk without a service handshake. Topic names live in `types.mjs` (AUDIT / FORK_*).
50
+ */
51
+ export const bus = createBus()
52
+
53
+ /** Handle/resource census used by the health window (leak detection). */
54
+ export function sampleResources() {
55
+ const counts = {}
56
+ try {
57
+ for (const t of process.getActiveResourcesInfo()) counts[t] = (counts[t] ?? 0) + 1
58
+ } catch { /* platform without the API */ }
59
+ return counts
60
+ }
61
+
62
+ export function totalResources(counts) {
63
+ let n = 0
64
+ for (const v of Object.values(counts ?? {})) n += v
65
+ return n
66
+ }
67
+
68
+ export function diffResources(before, after) {
69
+ const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])
70
+ const added = {}
71
+ const removed = {}
72
+ for (const k of keys) {
73
+ const d = (after?.[k] ?? 0) - (before?.[k] ?? 0)
74
+ if (d > 0) added[k] = d
75
+ else if (d < 0) removed[k] = -d
76
+ }
77
+ return {
78
+ added,
79
+ removed,
80
+ leakedRefed: Object.values(added).reduce((a, b) => a + b, 0),
81
+ freed: Object.values(removed).reduce((a, b) => a + b, 0),
82
+ }
83
+ }
84
+
85
+ export const EXIT = {
86
+ OK: 0,
87
+ ERROR: 1,
88
+ /** "guard allowed this restart" — the fork/supervisor contract. */
89
+ RESTART: 75,
90
+ }
91
+
92
+ export const RISK_RANK = { blue: 1, yellow: 2, red: 3 }
93
+
94
+ export function riskAtLeast(risk, bar) {
95
+ return (RISK_RANK[risk] ?? 0) >= (RISK_RANK[bar] ?? 99)
96
+ }
97
+
98
+ export function sleep(ms) {
99
+ return new Promise(resolve => setTimeout(resolve, ms))
100
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang kernel — commercial (Relay-backed) extension points.
4
+ //
5
+ // v2.0.0 ships the hooks, not the paid features. Every hook is optional and a no-op
6
+ // when absent: the offline development loop never depends on a Relay provider.
7
+ // A future commercial plugin provides the service `fangCommercial` with any subset of:
8
+ //
9
+ // collaboration: { publishChange({taskId, pluginId, files}) -> Promise<void>,
10
+ // onRemoteChange(cb) -> unsubscribe }
11
+ // git: { snapshot({dir, message}) -> Promise<{commit}>,
12
+ // log({dir, limit}) -> Promise<{commits}> }
13
+ // backup: { upload({kind, id, key, body}) -> Promise<{uri}>,
14
+ // status() -> Promise<{lastAt, ok}> }
15
+ // tokenApi: { issue({scope, ttlMs}) -> Promise<{token}> }
16
+ // teams: { whoami() -> Promise<{id, role}>, check({action, group}) -> Promise<{allowed}> }
17
+ //
18
+ // Providers must be async-safe and must never throw into the host: callers wrap them.
19
+
20
+ export const COMMERCIAL = {
21
+ collaboration: 'collaboration',
22
+ git: 'git',
23
+ backup: 'backup',
24
+ tokenApi: 'tokenApi',
25
+ teams: 'teams',
26
+ }
27
+
28
+ export const SERVICE_COMMERCIAL = 'fangCommercial'
29
+
30
+ /** Default config gate: every paid capability is off unless the user enables it. */
31
+ export function commercialConfigSchemaDefaults() {
32
+ return { collaboration: false, git: false, backup: false, tokenApi: false, teams: false }
33
+ }
34
+
35
+ /**
36
+ * Resolve one commercial capability. Returns undefined when the feature is disabled by
37
+ * config, the provider service is absent, or that provider does not implement it.
38
+ * @param {object} opts
39
+ * @param {object} opts.commercialHooks Config flag map (see commercialConfigSchemaDefaults)
40
+ * @param {object|undefined} opts.provider ctx.get('fangCommercial')
41
+ * @param {string} opts.capability
42
+ */
43
+ export function resolveCommercial({ commercialHooks, provider, capability } = {}) {
44
+ if (!commercialHooks || commercialHooks[capability] !== true) return undefined
45
+ if (!provider) return undefined
46
+ const impl = provider[capability]
47
+ if (!impl || typeof impl !== 'object') return undefined
48
+ return impl
49
+ }
50
+
51
+ /** Call one provider method with failure isolation. Never rejects. */
52
+ export async function invokeCommercial({ commercialHooks, provider, capability, method, args = [] } = {}) {
53
+ const impl = resolveCommercial({ commercialHooks, provider, capability })
54
+ if (!impl || typeof impl[method] !== 'function') return { ok: false, error: 'not-configured' }
55
+ try {
56
+ const value = await impl[method](...args)
57
+ return { ok: true, value }
58
+ } catch (e) {
59
+ return { ok: false, error: String(e?.message ?? e) }
60
+ }
61
+ }
@@ -0,0 +1,349 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang kernel — filesystem helpers.
4
+ // All disk mutation used by Fang plugins goes through here so that:
5
+ // * writes are atomic (tmp + rename),
6
+ // * tree removal is junction-safe (never follows a reparse point into the mother),
7
+ // * digests are stable across platforms.
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { createHash } from 'node:crypto'
11
+
12
+ export function ensureDir(p) {
13
+ fs.mkdirSync(p, { recursive: true })
14
+ return p
15
+ }
16
+
17
+ export function exists(p) {
18
+ try {
19
+ fs.lstatSync(p)
20
+ return true
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ export function readJson(file, fallback = null) {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
29
+ } catch {
30
+ return fallback
31
+ }
32
+ }
33
+
34
+ export function readTextSafe(file, fallback = '') {
35
+ try {
36
+ return fs.readFileSync(file, 'utf8')
37
+ } catch {
38
+ return fallback
39
+ }
40
+ }
41
+
42
+ let _tmpSeq = 0
43
+ export function tmpName(dir, stem = 'tmp') {
44
+ _tmpSeq = (_tmpSeq + 1) % 1e9
45
+ return path.join(dir, `.${stem}.${process.pid}.${Date.now().toString(36)}.${_tmpSeq}.tmp`)
46
+ }
47
+
48
+ export function writeFileAtomic(file, data) {
49
+ ensureDir(path.dirname(file))
50
+ const tmp = tmpName(path.dirname(file), path.basename(file))
51
+ fs.writeFileSync(tmp, data)
52
+ try {
53
+ fs.renameSync(tmp, file)
54
+ } catch {
55
+ // Windows can refuse a rename while a reader holds the file open; retry once, then fall back.
56
+ try {
57
+ fs.copyFileSync(tmp, file)
58
+ } finally {
59
+ try { fs.unlinkSync(tmp) } catch { /* ignore */ }
60
+ }
61
+ }
62
+ return file
63
+ }
64
+
65
+ export function writeJsonAtomic(file, value, { indent = 2 } = {}) {
66
+ return writeFileAtomic(file, JSON.stringify(value, null, indent) + '\n')
67
+ }
68
+
69
+ export function appendJsonl(file, record) {
70
+ ensureDir(path.dirname(file))
71
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8')
72
+ return record
73
+ }
74
+
75
+ /** Read a JSONL file into records; unparsable lines are skipped (verifyChain reports them). */
76
+ export function readJsonl(file) {
77
+ const out = []
78
+ let raw = ''
79
+ try { raw = fs.readFileSync(file, 'utf8') } catch { return out }
80
+ for (const line of raw.split('\n')) {
81
+ const t = line.trim()
82
+ if (!t) continue
83
+ try { out.push(JSON.parse(t)) } catch { /* skip */ }
84
+ }
85
+ return out
86
+ }
87
+
88
+ export function sha256(input) {
89
+ return createHash('sha256').update(input).digest('hex')
90
+ }
91
+
92
+ export function sha256File(file) {
93
+ try {
94
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex')
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** Stable recursive digest of a tree: sorted rel paths + content hash; links carry their target. */
101
+ export function digestTree(dir, { maxFiles = 20000 } = {}) {
102
+ const out = {}
103
+ let files = 0
104
+ const walk = (d, rel = '') => {
105
+ let names
106
+ try { names = fs.readdirSync(d).sort() } catch { return }
107
+ for (const n of names) {
108
+ if (files >= maxFiles) return
109
+ const abs = path.join(d, n)
110
+ const r = rel ? rel + '/' + n : n
111
+ let st
112
+ try { st = fs.lstatSync(abs) } catch { continue }
113
+ if (st.isSymbolicLink()) {
114
+ try { out[r] = 'link:' + fs.readlinkSync(abs) } catch { out[r] = 'link:?' }
115
+ files++
116
+ continue
117
+ }
118
+ if (st.isDirectory()) { walk(abs, r); continue }
119
+ out[r] = sha256File(abs)
120
+ files++
121
+ }
122
+ }
123
+ walk(dir)
124
+ return out
125
+ }
126
+
127
+ export function diffDigests(before, after) {
128
+ const added = []
129
+ const removed = []
130
+ const changed = []
131
+ for (const [k, v] of Object.entries(after ?? {})) {
132
+ if (!(k in (before ?? {}))) added.push(k)
133
+ else if (before[k] !== v) changed.push(k)
134
+ }
135
+ for (const k of Object.keys(before ?? {})) if (!(k in (after ?? {}))) removed.push(k)
136
+ return { added, removed, changed, same: Object.keys(after ?? {}).length - added.length - changed.length }
137
+ }
138
+
139
+ /** Copy a tree. Symlinks/junctions are recreated as junctions, never traversed.
140
+ * Safe when `to` lives inside `from`: the destination is created only after the
141
+ * source listing is taken, and the destination itself is never re-entered. */
142
+ export function copyTree(from, to, { filter = null } = {}) {
143
+ let names
144
+ try { names = fs.readdirSync(from) } catch {
145
+ ensureDir(to)
146
+ return to
147
+ }
148
+ ensureDir(to)
149
+ const toAbs = path.resolve(to)
150
+ for (const n of names) {
151
+ const src = path.join(from, n)
152
+ const dst = path.join(to, n)
153
+ if (filter && !filter(n, src)) continue
154
+ if (path.resolve(src) === toAbs) continue
155
+ let st
156
+ try { st = fs.lstatSync(src) } catch { continue }
157
+ if (st.isSymbolicLink()) {
158
+ try {
159
+ const target = fs.readlinkSync(src)
160
+ fs.symlinkSync(target, dst, 'junction')
161
+ } catch { /* ignore */ }
162
+ continue
163
+ }
164
+ if (st.isDirectory()) { copyTree(src, dst, { filter }); continue }
165
+ ensureDir(path.dirname(dst))
166
+ try { fs.copyFileSync(src, dst) } catch { /* ignore */ }
167
+ }
168
+ return to
169
+ }
170
+
171
+ export function isLink(p) {
172
+ try {
173
+ return fs.lstatSync(p).isSymbolicLink()
174
+ } catch {
175
+ return false
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Copy a tree with hardlinked files: directories are recreated, ordinary files are
181
+ * hardlinked (same volume only; falls back to a copy), and links are recreated as links
182
+ * rather than traversed. Used when the essence runtime's touched packages must be
183
+ * materialized into the essence module tree without duplicating file bytes and without
184
+ * junction cycles (the runtime mirror would otherwise point back at the essence tree).
185
+ * @param {string} from Source directory.
186
+ * @param {string} to Destination directory (created; expected to be empty/new).
187
+ * @returns {string} The destination.
188
+ */
189
+ export function hardlinkTree(from, to) {
190
+ let names
191
+ try { names = fs.readdirSync(from) } catch {
192
+ ensureDir(to)
193
+ return to
194
+ }
195
+ ensureDir(to)
196
+ for (const name of names) {
197
+ const src = path.join(from, name)
198
+ const dst = path.join(to, name)
199
+ let st
200
+ try { st = fs.lstatSync(src) } catch { continue }
201
+ if (st.isSymbolicLink()) {
202
+ let target = ''
203
+ try { target = fs.readlinkSync(src) } catch { continue }
204
+ try {
205
+ const absolute = path.isAbsolute(target) ? target : path.resolve(from, target)
206
+ const stTarget = fs.lstatSync(absolute)
207
+ if (stTarget.isDirectory()) fs.symlinkSync(absolute, dst, 'junction')
208
+ else fs.copyFileSync(absolute, dst)
209
+ } catch { /* an unmirrorable link is skipped */ }
210
+ continue
211
+ }
212
+ if (st.isDirectory()) { hardlinkTree(src, dst); continue }
213
+ try { fs.linkSync(src, dst) } catch {
214
+ try { fs.copyFileSync(src, dst) } catch { /* skip */ }
215
+ }
216
+ }
217
+ return to
218
+ }
219
+
220
+ /** Create (or replace) a Windows junction / POSIX dir symlink. */
221
+ export function makeJunction(target, linkPath) {
222
+ ensureDir(path.dirname(linkPath))
223
+ if (exists(linkPath)) return linkPath
224
+ fs.symlinkSync(path.resolve(target), linkPath, 'junction')
225
+ return linkPath
226
+ }
227
+
228
+ /**
229
+ * Replace `dest` with a real directory that mirrors `source`'s top-level entries:
230
+ * directories become junctions to their real targets, plain root files are copied.
231
+ * Package contents are never copied, so the module tree stays shared — but every
232
+ * entry is its own link. That is what DSH's profile-scope resolver needs: an
233
+ * importer below a linked root gets the installation's `@deepseek-ai/*` packages
234
+ * supplied (subject to the peer declaration), while a single whole-node_modules
235
+ * junction leaves the importer outside every known scope. Measured consequence of
236
+ * the whole-tree link: plugins that lazily `import('@deepseek-ai/dsh-tools')` in a
237
+ * fork fall back to their offline shim — w2m's shim stamps `required: false` into a
238
+ * property schema, and the provider rejects every request with
239
+ * `Invalid schema for function 'w2m_report'` (v3.1.7 report).
240
+ * Junction-safe: a reparse point in `dest` is unlinked, never entered; link targets
241
+ * are resolved with realpath first so a link-to-link (pnpm) also lands on a dir.
242
+ * @param {string} source Directory whose entries are mirrored (the module tree).
243
+ * @param {string} dest Directory to replace with the mirror.
244
+ * @returns {{ok:boolean, links:string[], files:string[], error?:string}}
245
+ */
246
+ export function mirrorModuleTree(source, dest) {
247
+ const src = path.resolve(String(source ?? ''))
248
+ if (!exists(src)) return { ok: false, links: [], files: [], error: 'source-missing' }
249
+ let srcStat
250
+ try { srcStat = fs.lstatSync(src) } catch { return { ok: false, links: [], files: [], error: 'source-unreadable' } }
251
+ if (!srcStat.isDirectory() && !srcStat.isSymbolicLink()) return { ok: false, links: [], files: [], error: 'source-not-directory' }
252
+ removeTree(dest)
253
+ ensureDir(dest)
254
+ const links = []
255
+ const files = []
256
+ let names = []
257
+ try { names = fs.readdirSync(src) } catch { names = [] }
258
+ for (const name of names) {
259
+ const from = path.join(src, name)
260
+ const to = path.join(dest, name)
261
+ let real = from
262
+ try {
263
+ if (fs.lstatSync(from).isSymbolicLink()) real = fs.realpathSync(from)
264
+ } catch { continue }
265
+ let targetStat
266
+ try { targetStat = fs.statSync(real) } catch { continue }
267
+ if (targetStat.isDirectory()) {
268
+ try {
269
+ fs.symlinkSync(path.resolve(real), to, 'junction')
270
+ links.push(name)
271
+ } catch { /* an unmirrorable entry is skipped; the remaining tree still boots */ }
272
+ continue
273
+ }
274
+ try {
275
+ fs.copyFileSync(from, to)
276
+ files.push(name)
277
+ } catch { /* ignore */ }
278
+ }
279
+ return { ok: true, links, files }
280
+ }
281
+
282
+ /**
283
+ * Repair a profile `node_modules` that is still a whole-tree junction (v3.1.6 and
284
+ * older) into the per-entry mirror form. A real directory is left untouched.
285
+ * @param {string} modulesPath The profile's node_modules path.
286
+ * @returns {{ok:boolean, changed:boolean, links?:string[], files?:string[], source?:string, error?:string}}
287
+ */
288
+ export function ensureModuleMirror(modulesPath) {
289
+ const p = String(modulesPath ?? '')
290
+ if (!p || !exists(p)) return { ok: false, changed: false, error: 'node-modules-missing' }
291
+ let st
292
+ try { st = fs.lstatSync(p) } catch { return { ok: false, changed: false, error: 'node-modules-unreadable' } }
293
+ if (!st.isSymbolicLink()) return { ok: true, changed: false, links: [], files: [] }
294
+ let source
295
+ try { source = fs.realpathSync(p) } catch { return { ok: false, changed: false, error: 'node-modules-target-missing' } }
296
+ const mirrored = mirrorModuleTree(source, p)
297
+ return { ...mirrored, changed: true, source }
298
+ }
299
+
300
+ /**
301
+ * Remove a file or directory tree. Junction-safe: a reparse point is unlinked,
302
+ * its target is never entered. This is the ONLY removal path plugins may use on
303
+ * fork homes, because a fork home contains a node_modules junction to the mother.
304
+ */
305
+ export function removeTree(target) {
306
+ let st
307
+ try { st = fs.lstatSync(target) } catch { return false }
308
+ if (st.isSymbolicLink()) {
309
+ try { fs.unlinkSync(target); return true } catch { return false }
310
+ }
311
+ if (!st.isDirectory()) {
312
+ try { fs.unlinkSync(target); return true } catch { return false }
313
+ }
314
+ let names = []
315
+ try { names = fs.readdirSync(target) } catch { names = [] }
316
+ for (const n of names) removeTree(path.join(target, n))
317
+ try { fs.rmdirSync(target) } catch { /* ignore */ }
318
+ return true
319
+ }
320
+
321
+ export function listFiles(dir, { max = 5000 } = {}) {
322
+ const out = []
323
+ const walk = (d, rel = '') => {
324
+ if (out.length >= max) return
325
+ let names
326
+ try { names = fs.readdirSync(d).sort() } catch { return }
327
+ for (const n of names) {
328
+ if (out.length >= max) return
329
+ const abs = path.join(d, n)
330
+ const r = rel ? rel + '/' + n : n
331
+ let st
332
+ try { st = fs.lstatSync(abs) } catch { continue }
333
+ if (st.isDirectory()) walk(abs, r)
334
+ else out.push(r)
335
+ }
336
+ }
337
+ walk(dir)
338
+ return out
339
+ }
340
+
341
+ export function nowMs() {
342
+ return Date.now()
343
+ }
344
+
345
+ export function iso(ms = Date.now()) {
346
+ return new Date(ms).toISOString()
347
+ }
348
+ // NOTE: `sleep` lives in core.mjs only — two `export *` sources exporting the same
349
+ // name make the whole kernel barrel un-importable (`conflicting star exports`).
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang kernel barrel. Plugin modules import from here:
4
+ // import { createRounds, resolveFangLayout, SERVICE } from '../kernel/index.mjs'
5
+ export * from './core.mjs'
6
+ export * from './fsx.mjs'
7
+ export * from './paths.mjs'
8
+ export * from './rounds.mjs'
9
+ export * from './types.mjs'
10
+ export * from './role.mjs'
11
+ export * from './extensions.mjs'
12
+ export { default as z } from './schema.mjs'
13
+ export {
14
+ GENESIS,
15
+ canonical,
16
+ hashOf,
17
+ lastHash,
18
+ appendChained,
19
+ verifyChain,
20
+ verifyAndAppendBoot,
21
+ } from './ledger.mjs'
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // 链式审计账本(共享模块)
4
+ // 每条记录携带 prevHash 与 hash = sha256(prevHash + canonical(record))。
5
+ // 能发现:改字段、改 hash、删中间行、换行序、追加伪造。
6
+ import fs from 'node:fs'
7
+ import { createHash } from 'node:crypto'
8
+
9
+ export const GENESIS = 'GENESIS'
10
+
11
+ /** 规范化:**递归**按键排序 + 去掉 hash/prevHash 自身
12
+ * (TS 规范 audit.canonicalJson 是递归排序;只排顶层会让嵌套键序影响 hash) */
13
+ export function canonical(record) {
14
+ const walk = (x) => {
15
+ if (x === null || typeof x !== 'object') return x
16
+ if (Array.isArray(x)) return x.map(walk)
17
+ const o = {}
18
+ for (const k of Object.keys(x).sort()) o[k] = walk(x[k])
19
+ return o
20
+ }
21
+ const { hash, prevHash, ...rest } = record ?? {}
22
+ return JSON.stringify(walk(rest))
23
+ }
24
+
25
+ /** 与 TS 规范 chainHash 完全一致:sha256(canonicalJson(entry) + prev) */
26
+ export function hashOf(record, prevHash) {
27
+ return createHash('sha256').update(canonical(record) + String(prevHash), 'utf8').digest('hex')
28
+ }
29
+
30
+ function readLines(file) {
31
+ let raw = ''
32
+ try { raw = fs.readFileSync(file, 'utf8') } catch { return [] }
33
+ return raw.split('\n').filter(l => l.trim().length > 0)
34
+ }
35
+
36
+ export function lastHash(file) {
37
+ const lines = readLines(file)
38
+ if (!lines.length) return GENESIS
39
+ try { return JSON.parse(lines[lines.length - 1]).hash ?? GENESIS } catch { return null }
40
+ }
41
+
42
+ /** 追加一条链式记录,返回 { hash, prevHash, seq } */
43
+ export function appendChained(file, record, opts = {}) {
44
+ const prevHash = lastHash(file)
45
+ if (prevHash === null) {
46
+ // 上一行不可解析 → 不静默续写(否则等于帮着掩盖篡改)
47
+ throw new Error(`ledger tail unparsable: ${file}`)
48
+ }
49
+ const seq = opts.seq ?? readLines(file).length
50
+ const body = { ...record, seq, prevHash }
51
+ const hash = hashOf(body, prevHash)
52
+ const line = JSON.stringify({ ...body, hash }) + '\n'
53
+ fs.appendFileSync(file, line, 'utf8')
54
+ return { hash, prevHash, seq }
55
+ }
56
+
57
+ /**
58
+ * 全链校验:不只看最后一条 head,而是逐条重算 + 检查 prevHash 链接。
59
+ * 返回 { ok, count, reason, badIndex, expected, actual }
60
+ */
61
+ export function verifyChain(file) {
62
+ const lines = readLines(file)
63
+ if (!lines.length) return { ok: true, count: 0, reason: 'empty' }
64
+ let prev = GENESIS
65
+ for (let i = 0; i < lines.length; i++) {
66
+ let rec
67
+ try { rec = JSON.parse(lines[i]) } catch (e) {
68
+ return { ok: false, count: lines.length, badIndex: i, reason: 'unparsable', error: String(e?.message ?? e) }
69
+ }
70
+ if (rec.prevHash !== prev) {
71
+ return { ok: false, count: lines.length, badIndex: i, reason: 'broken-link', expected: prev, actual: rec.prevHash }
72
+ }
73
+ const recomputed = hashOf(rec, rec.prevHash)
74
+ if (recomputed !== rec.hash) {
75
+ return { ok: false, count: lines.length, badIndex: i, reason: 'hash-mismatch', expected: recomputed, actual: rec.hash }
76
+ }
77
+ prev = rec.hash
78
+ }
79
+ return { ok: true, count: lines.length, head: prev }
80
+ }
81
+
82
+ /** 启动自检 + 追加 boot 记录(corrupt 时不续写)
83
+ * 注意:与 TS 规范 audit.bootAudit **同名不同义** —— 那个是 sink+marker 的中断检测;
84
+ * 这里只是"校验全链后追加一条 boot 记录",故命名为 verifyAndAppendBoot。 */
85
+ export function verifyAndAppendBoot(file, extra = {}) {
86
+ const v = verifyChain(file)
87
+ if (!v.ok) return { ...v, action: 'refused-write' } // 账本可疑:不往上写
88
+ try {
89
+ const r = appendChained(file, { kind: 'boot-verify', ok: true, count: v.count, ...extra })
90
+ return { ...v, action: 'appended', bootHash: r.hash }
91
+ } catch (e) {
92
+ return { ...v, ok: false, reason: 'append-failed', error: String(e?.message ?? e) }
93
+ }
94
+ }