@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,806 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.0.0 — container executor (AppContainer fallback) + declared write proxy.
4
+ //
5
+ // Two independent pieces live here so the router / buffer-keeper can use them without
6
+ // loading a container:
7
+ //
8
+ // 1. Write proxy (pure): validateWriteRequest() / applyWriteRequest() decide whether a
9
+ // write request that arrived from an isolated process may land in the buffer root,
10
+ // and only ever write inside it. encodeFrame()/parseFrames() are the NDJSON wire
11
+ // helper for `{op:'write'|'ping'|'stat', reqId, path, data, encoding}` frames.
12
+ //
13
+ // 2. Container host: createContainerHost() probes the vendored `probes/` assets and
14
+ // runs code through `container-exec-pipe.ps1` (AppContainer worker). Probing never
15
+ // throws: every failure degrades to `state:'unavailable'` (or 'disabled' off
16
+ // Windows) with a machine-readable `reason`. Real container execution is only
17
+ // established with the manual FANG_CONTAINER_LIVE=1 probe; unit tests never launch
18
+ // a container.
19
+ //
20
+ // Design rules (docs/CONTRACTS-V2.md §5.6):
21
+ // * path safety is checked on the RELATIVE path first (no drive letters, no UNC, no
22
+ // `\\?\`, no `..`), then on the resolved target (must stay under bufferRoot), then
23
+ // on the parent chain (lstat for junctions/symlinks + realpath containment).
24
+ // * declared writes are a prefix allowlist: a request is accepted only when it is
25
+ // exactly a declared path or a child of one.
26
+ // * a single request is capped at maxBytes (default 1 MiB).
27
+ // * zero third-party dependencies, no network, no writes outside the given root.
28
+ import fs from 'node:fs'
29
+ import os from 'node:os'
30
+ import path from 'node:path'
31
+ import { execFile as nodeExecFile } from 'node:child_process'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ /** Frame ops accepted by the write-proxy channel. */
35
+ export const WRITE_PROXY_OPS = Object.freeze(['write', 'ping', 'stat'])
36
+
37
+ /** Container host states — mirrors the kernel `CONTAINER` vocabulary. */
38
+ export const CONTAINER_STATE = Object.freeze({
39
+ DISABLED: 'disabled',
40
+ UNAVAILABLE: 'unavailable',
41
+ READY: 'ready',
42
+ })
43
+
44
+ /** Default cap for a single write request: 1 MiB (contract §5.6). */
45
+ export const DEFAULT_MAX_BYTES = 1048576
46
+
47
+ /** Files the vendored probe directory must contain for a static "ready" preflight. */
48
+ const PROBE_FILES = Object.freeze([
49
+ 'AppContainerHost.cs',
50
+ 'container-exec-pipe.ps1',
51
+ 'worker-pipe-exec.mjs',
52
+ ])
53
+
54
+ const isWin = () => process.platform === 'win32'
55
+
56
+ const msgOf = (e) => String(e && e.message ? e.message : e)
57
+
58
+ function isPlainObject(v) {
59
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
60
+ }
61
+
62
+ function existsIn(fsImpl, p) {
63
+ try {
64
+ return fsImpl.existsSync(p)
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ function safeLstat(fsImpl, p) {
71
+ try {
72
+ return fsImpl.lstatSync(p)
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ function safeRealpath(fsImpl, p) {
79
+ try {
80
+ return typeof fsImpl.realpathSync === 'function' ? fsImpl.realpathSync(p) : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** Case-insensitive path comparison on Windows; exact elsewhere. */
87
+ function compareKey(p) {
88
+ return isWin() ? String(p).toLowerCase() : String(p)
89
+ }
90
+
91
+ /** True when `p` is `base` or lives under it (both already absolute). */
92
+ function isInside(base, p) {
93
+ const rel = path.relative(base, p)
94
+ if (!rel) return true
95
+ if (path.isAbsolute(rel)) return false
96
+ const key = compareKey(rel)
97
+ return key !== '..' && !key.startsWith('..' + path.sep) && !key.startsWith('../')
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Relative-path normalisation + declaration allowlist
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Normalise a request/declaration path to a safe POSIX-style relative path.
106
+ * Rejects drive letters, UNC/namespace paths, absolute paths, `..` segments, NUL,
107
+ * ADS (`file.txt:stream`) and other non-relative shapes BEFORE path.resolve().
108
+ * @returns {{ok:true,path:string}|{ok:false,reason:string}}
109
+ */
110
+ function normalizeRelPath(raw) {
111
+ if (typeof raw !== 'string') return { ok: false, reason: 'bad-path' }
112
+ if (raw.length === 0) return { ok: false, reason: 'bad-path' }
113
+ if (raw.includes('\0')) return { ok: false, reason: 'bad-path' }
114
+ const slashed = raw.replace(/\\/g, '/')
115
+ if (slashed.startsWith('//')) return { ok: false, reason: 'unc-path' }
116
+ if (/^[A-Za-z]:/.test(slashed)) return { ok: false, reason: 'absolute-path' }
117
+ if (slashed.startsWith('/')) return { ok: false, reason: 'absolute-path' }
118
+ const segments = slashed.split('/')
119
+ if (segments.some((s) => s === '..')) return { ok: false, reason: 'dotdot-segment' }
120
+ const normalized = path.posix.normalize(slashed)
121
+ if (!normalized || normalized === '.') return { ok: false, reason: 'bad-path' }
122
+ if (normalized === '..' || normalized.startsWith('../')) return { ok: false, reason: 'escapes-buffer-root' }
123
+ // On Windows a colon in a relative path is an alternate data stream ("file.txt:evil").
124
+ if (normalized.includes(':')) return { ok: false, reason: 'ads-path' }
125
+ return { ok: true, path: normalized }
126
+ }
127
+
128
+ /**
129
+ * Normalise a declared-writes manifest into `[{path, reason}]`.
130
+ * Paths that are malformed or would escape are dropped (they could never match a
131
+ * safe request anyway). Accepts strings or `{path, reason}` entries.
132
+ */
133
+ export function normalizeDeclared(declared) {
134
+ const out = []
135
+ const seen = new Set()
136
+ if (!Array.isArray(declared)) return out
137
+ for (const entry of declared) {
138
+ const raw = typeof entry === 'string' ? entry : isPlainObject(entry) ? entry.path : null
139
+ if (typeof raw !== 'string') continue
140
+ const trimmed = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
141
+ const normalized = normalizeRelPath(trimmed)
142
+ if (!normalized.ok) continue
143
+ if (seen.has(normalized.path)) continue
144
+ seen.add(normalized.path)
145
+ out.push({
146
+ path: normalized.path,
147
+ reason: isPlainObject(entry) && typeof entry.reason === 'string' ? entry.reason : '',
148
+ })
149
+ }
150
+ return out
151
+ }
152
+
153
+ /** Prefix allowlist match; a declaration covers itself and its descendants. */
154
+ function declaredAllows(declaredNorm, rel) {
155
+ for (const entry of declaredNorm) {
156
+ if (rel === entry.path) return entry
157
+ if (rel.startsWith(entry.path + '/')) return entry
158
+ }
159
+ return null
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Payload decoding + size cap
164
+ // ---------------------------------------------------------------------------
165
+
166
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
167
+
168
+ function normalizedMaxBytes(maxBytes) {
169
+ const n = Number(maxBytes)
170
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_MAX_BYTES
171
+ return Math.floor(n)
172
+ }
173
+
174
+ /**
175
+ * Decode `req.data` with `req.encoding` ('utf8' default, 'base64' supported).
176
+ * @returns {{ok:true,bytes:Buffer,encoding:string,size:number}|{ok:false,reason:string}}
177
+ */
178
+ function decodePayload(req, maxBytes) {
179
+ const data = req?.data
180
+ let encoding = typeof req?.encoding === 'string' ? req.encoding.toLowerCase() : 'utf8'
181
+ if (encoding === 'utf-8') encoding = 'utf8'
182
+ if (encoding === 'b64') encoding = 'base64'
183
+ if (encoding !== 'utf8' && encoding !== 'base64') return { ok: false, reason: 'bad-encoding' }
184
+
185
+ let bytes
186
+ if (Buffer.isBuffer(data)) {
187
+ bytes = Buffer.from(data)
188
+ } else if (typeof data === 'string') {
189
+ if (encoding === 'base64') {
190
+ // Reject oversized strings before allocating the decoded copy.
191
+ if (data.length > maxBytes * 2 + 4) return { ok: false, reason: 'too-large' }
192
+ const compact = data.replace(/\s+/g, '')
193
+ if (compact && !BASE64_RE.test(compact)) return { ok: false, reason: 'bad-base64' }
194
+ bytes = Buffer.from(compact, 'base64')
195
+ } else {
196
+ bytes = Buffer.from(data.replace(/^\uFEFF/, ''), 'utf8')
197
+ }
198
+ } else {
199
+ return { ok: false, reason: 'bad-data' }
200
+ }
201
+ if (bytes.length > maxBytes) return { ok: false, reason: 'too-large' }
202
+ return { ok: true, bytes, encoding, size: bytes.length }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Path safety: resolved containment + parent-chain (junction) checks
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Validate that `rel` may be written under `bufferRoot`.
211
+ * @returns {{ok:true,target:string,bytes:Buffer,path:string,declaredPath:string,size:number}
212
+ * |{ok:false,reason:string, ...details}}
213
+ */
214
+ function checkWritePath({
215
+ bufferRoot,
216
+ declared,
217
+ rel,
218
+ req,
219
+ maxBytes,
220
+ fsImpl,
221
+ requireDeclared = true,
222
+ skipPayload = false,
223
+ }) {
224
+ if (typeof bufferRoot !== 'string' || !bufferRoot.trim()) return { ok: false, reason: 'no-buffer-root' }
225
+ const rootAbs = path.resolve(bufferRoot)
226
+ const targetAbs = path.resolve(rootAbs, rel)
227
+ if (compareKey(targetAbs) === compareKey(rootAbs)) return { ok: false, reason: 'bad-path' }
228
+ if (!isInside(rootAbs, targetAbs)) return { ok: false, reason: 'escapes-buffer-root' }
229
+
230
+ const declaredMatch = requireDeclared ? declaredAllows(declared, rel) : null
231
+ if (requireDeclared && !declaredMatch) return { ok: false, reason: 'not-declared', path: rel }
232
+
233
+ let payload = { ok: true, bytes: Buffer.alloc(0), size: 0 }
234
+ if (!skipPayload) {
235
+ payload = decodePayload(req, maxBytes)
236
+ if (!payload.ok) return { ok: false, reason: payload.reason, path: rel }
237
+ }
238
+
239
+ const rootReal = safeRealpath(fsImpl, rootAbs)
240
+ const parts = rel.split('/')
241
+ // Ancestors strictly below the root: a junction/symlink there can redirect the write.
242
+ for (let i = 1; i < parts.length; i++) {
243
+ const dirAbs = path.join(rootAbs, ...parts.slice(0, i))
244
+ const st = safeLstat(fsImpl, dirAbs)
245
+ if (!st) break
246
+ if (st.isSymbolicLink()) {
247
+ return { ok: false, reason: 'symlink-ancestor', path: rel, at: parts.slice(0, i).join('/') }
248
+ }
249
+ if (rootReal) {
250
+ const real = safeRealpath(fsImpl, dirAbs)
251
+ if (!real || !isInside(rootReal, real)) {
252
+ return { ok: false, reason: 'realpath-escape', path: rel, at: parts.slice(0, i).join('/') }
253
+ }
254
+ }
255
+ }
256
+ const targetSt = safeLstat(fsImpl, targetAbs)
257
+ if (targetSt) {
258
+ if (targetSt.isSymbolicLink()) return { ok: false, reason: 'symlink-target', path: rel }
259
+ if (targetSt.isDirectory()) return { ok: false, reason: 'target-is-directory', path: rel }
260
+ if (rootReal) {
261
+ const real = safeRealpath(fsImpl, targetAbs)
262
+ if (!real || !isInside(rootReal, real)) return { ok: false, reason: 'realpath-escape', path: rel }
263
+ }
264
+ }
265
+ return {
266
+ ok: true,
267
+ target: targetAbs,
268
+ bytes: payload.bytes,
269
+ path: rel,
270
+ declaredPath: declaredMatch ? declaredMatch.path : '',
271
+ size: payload.size,
272
+ }
273
+ }
274
+
275
+ /** Shared validation used by both the public validator and the applier. */
276
+ function validateInternal(input) {
277
+ const { bufferRoot, declared, req, fsImpl = fs } = input ?? {}
278
+ const maxBytes = normalizedMaxBytes(input?.maxBytes)
279
+ if (!isPlainObject(req)) return { ok: false, reason: 'bad-request' }
280
+ if (req.op !== 'write') {
281
+ return { ok: false, reason: typeof req.op === 'string' ? `unsupported-op:${req.op}` : 'bad-request' }
282
+ }
283
+ const normalized = normalizeRelPath(req.path)
284
+ if (!normalized.ok) return { ok: false, reason: normalized.reason, path: typeof req.path === 'string' ? req.path : '' }
285
+ return checkWritePath({
286
+ bufferRoot,
287
+ declared: normalizeDeclared(declared),
288
+ rel: normalized.path,
289
+ req,
290
+ maxBytes,
291
+ fsImpl,
292
+ })
293
+ }
294
+
295
+ /**
296
+ * Validate a write request against the declared manifest and the buffer root.
297
+ *
298
+ * @param {{bufferRoot:string, declared?:Array, req:object, maxBytes?:number,
299
+ * fsImpl?:object}} input
300
+ * @returns {{ok:true,target:string,path:string,declaredPath:string,size:number}
301
+ * |{ok:false,reason:string,path?:string}}
302
+ */
303
+ export function validateWriteRequest(input = {}) {
304
+ const check = validateInternal(input)
305
+ if (!check.ok) return { ok: false, reason: check.reason, ...(check.path ? { path: check.path } : {}) }
306
+ return {
307
+ ok: true,
308
+ target: check.target,
309
+ path: check.path,
310
+ declaredPath: check.declaredPath,
311
+ size: check.size,
312
+ }
313
+ }
314
+
315
+ /** Write bytes at `target`; prefers the injected kernel fsx atomic writer. */
316
+ function writeBytes({ target, bytes, fsx, fsImpl }) {
317
+ const dir = path.dirname(target)
318
+ if (fsx && typeof fsx.writeFileAtomic === 'function') {
319
+ fsx.writeFileAtomic(target, bytes)
320
+ return
321
+ }
322
+ if (fsx && typeof fsx.ensureDir === 'function') fsx.ensureDir(dir)
323
+ else if (typeof fsImpl.mkdirSync === 'function') fsImpl.mkdirSync(dir, { recursive: true })
324
+ if (fsx && typeof fsx.writeFileSync === 'function') {
325
+ fsx.writeFileSync(target, bytes)
326
+ return
327
+ }
328
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Date.now().toString(36)}.tmp`)
329
+ fsImpl.writeFileSync(tmp, bytes)
330
+ try {
331
+ fsImpl.renameSync(tmp, target)
332
+ } catch {
333
+ try {
334
+ fsImpl.copyFileSync(tmp, target)
335
+ } finally {
336
+ try {
337
+ fsImpl.unlinkSync(tmp)
338
+ } catch {
339
+ /* ignore */
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Validate then write one request. Validation runs again here (callers cannot skip it).
347
+ *
348
+ * @param {{bufferRoot:string, declared?:Array, req:object, maxBytes?:number,
349
+ * fsx?:object}} input
350
+ * @returns {{ok:true,target:string,bytes:number}|{ok:false,reason:string}}
351
+ */
352
+ export function applyWriteRequest(input = {}) {
353
+ const { fsx = null } = input ?? {}
354
+ const check = validateInternal(input)
355
+ if (!check.ok) return { ok: false, reason: check.reason, ...(check.path ? { path: check.path } : {}) }
356
+ try {
357
+ writeBytes({ target: check.target, bytes: check.bytes, fsx, fsImpl: fs })
358
+ } catch (e) {
359
+ return { ok: false, reason: 'write-failed', error: msgOf(e) }
360
+ }
361
+ return { ok: true, target: check.target, bytes: check.size }
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // NDJSON framing
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Encode one frame as a single NDJSON line (JSON + '\n').
370
+ * @param {object} obj
371
+ * @returns {string}
372
+ */
373
+ export function encodeFrame(obj) {
374
+ if (!isPlainObject(obj)) throw new TypeError('encodeFrame expects an object')
375
+ return JSON.stringify(obj) + '\n'
376
+ }
377
+
378
+ /**
379
+ * Split a stream buffer into complete NDJSON frames + the trailing partial line.
380
+ * Blank lines are skipped; non-JSON lines are skipped and reported in `errors`
381
+ * (never thrown: a hostile peer must not be able to break the parser).
382
+ * @param {string|Buffer} input
383
+ * @returns {{frames:object[], rest:string, errors:object[]}}
384
+ */
385
+ export function parseFrames(input) {
386
+ const frames = []
387
+ const errors = []
388
+ let text
389
+ if (Buffer.isBuffer(input)) text = input.toString('utf8')
390
+ else if (typeof input === 'string') text = input
391
+ else if (input === null || input === undefined) return { frames, rest: '', errors }
392
+ else throw new TypeError('parseFrames expects a string or Buffer')
393
+
394
+ const parts = text.split('\n')
395
+ const rest = parts.pop() ?? ''
396
+ for (const rawLine of parts) {
397
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
398
+ if (!line.trim()) continue
399
+ let parsed
400
+ try {
401
+ parsed = JSON.parse(line)
402
+ } catch (e) {
403
+ errors.push({ line, error: msgOf(e) })
404
+ continue
405
+ }
406
+ if (!isPlainObject(parsed)) {
407
+ errors.push({ line, error: 'not-an-object' })
408
+ continue
409
+ }
410
+ frames.push(parsed)
411
+ }
412
+ return { frames, rest, errors }
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Container host
417
+ // ---------------------------------------------------------------------------
418
+
419
+ const defaultProbeDir = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../probes')
420
+
421
+ /**
422
+ * Resolve a PowerShell executable without spawning it.
423
+ * @returns {{ok:true,path:string}|{ok:false,reason:string}}
424
+ */
425
+ function resolvePowerShell(name, { env = process.env, platform = process.platform } = {}) {
426
+ const raw = typeof name === 'string' ? name.trim() : ''
427
+ if (!raw) return { ok: false, reason: 'powershell-not-found' }
428
+ const looksLikePath = path.isAbsolute(raw) || raw.includes('/') || raw.includes('\\')
429
+ if (looksLikePath) {
430
+ const candidate = path.resolve(raw)
431
+ if (fs.existsSync(candidate)) return { ok: true, path: candidate }
432
+ return { ok: false, reason: `powershell-not-found:${candidate}` }
433
+ }
434
+ const pathValue = env?.PATH ?? env?.Path ?? env?.path ?? ''
435
+ const separators = platform === 'win32' ? [';', ':'] : [':']
436
+ const dirs = []
437
+ for (const sep of separators) {
438
+ for (const dir of String(pathValue).split(sep)) if (dir) dirs.push(dir)
439
+ }
440
+ const exts = platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']
441
+ for (const dir of dirs) {
442
+ for (const ext of exts) {
443
+ const candidate = path.join(dir, raw + ext)
444
+ if (fs.existsSync(candidate)) return { ok: true, path: candidate }
445
+ }
446
+ }
447
+ return { ok: false, reason: `powershell-not-found:${raw}` }
448
+ }
449
+
450
+ function stringEnv(env) {
451
+ const out = {}
452
+ if (!isPlainObject(env)) return out
453
+ for (const [k, v] of Object.entries(env)) {
454
+ if (v === undefined || v === null) continue
455
+ out[String(k)] = String(v)
456
+ }
457
+ return out
458
+ }
459
+
460
+ function clampTimeout(value, fallback) {
461
+ const n = Number(value)
462
+ if (!Number.isFinite(n) || n <= 0) return fallback
463
+ return Math.max(1, Math.floor(n))
464
+ }
465
+
466
+ /**
467
+ * Run one child process with a hard timeout. Resolves (never rejects) with a
468
+ * structured result: { ok, stdout, stderr, exitCode, error, timedOut }.
469
+ * Injected `execFileImpl` is node-style: (file, args, options, callback) -> ChildProcess.
470
+ */
471
+ function runProcess({ file, args, env, timeoutMs, execFileImpl, input }) {
472
+ return new Promise((resolve) => {
473
+ let settled = false
474
+ let child = null
475
+ let backstop = null
476
+ let backstopFired = false
477
+ const finish = (result) => {
478
+ if (settled) return
479
+ settled = true
480
+ if (backstop) clearTimeout(backstop)
481
+ resolve(result)
482
+ }
483
+ const runner = typeof execFileImpl === 'function' ? execFileImpl : nodeExecFile
484
+ const options = {
485
+ timeout: timeoutMs,
486
+ killSignal: 'SIGKILL',
487
+ windowsHide: true,
488
+ maxBuffer: 8 * 1024 * 1024,
489
+ encoding: 'utf8',
490
+ env,
491
+ }
492
+ try {
493
+ child = runner(file, args, options, (err, stdout, stderr) => {
494
+ const out = {
495
+ ok: !err,
496
+ stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''),
497
+ stderr: typeof stderr === 'string' ? stderr : String(stderr ?? ''),
498
+ exitCode: 0,
499
+ error: null,
500
+ timedOut: false,
501
+ }
502
+ if (err) {
503
+ const timedOut =
504
+ backstopFired ||
505
+ err.killed === true ||
506
+ err.signal === 'SIGKILL' ||
507
+ err.signal === 'SIGTERM' ||
508
+ err.code === 'ETIMEDOUT'
509
+ out.timedOut = timedOut
510
+ out.error = timedOut ? 'timeout' : msgOf(err)
511
+ out.exitCode = typeof err.code === 'number' ? err.code : null
512
+ }
513
+ finish(out)
514
+ })
515
+ if (input !== undefined && input !== null && child && child.stdin && typeof child.stdin.end === 'function' && !child.stdin.destroyed) {
516
+ try {
517
+ child.stdin.end(input)
518
+ } catch {
519
+ /* the runner may not expose a writable stdin */
520
+ }
521
+ }
522
+ } catch (e) {
523
+ finish({ ok: false, stdout: '', stderr: '', exitCode: null, error: msgOf(e), timedOut: false })
524
+ return
525
+ }
526
+ // Backstop: a runner that never calls back (or hangs past the native timeout) still settles.
527
+ backstop = setTimeout(() => {
528
+ backstopFired = true
529
+ try {
530
+ child?.kill?.('SIGKILL')
531
+ } catch {
532
+ /* ignore */
533
+ }
534
+ finish({
535
+ ok: false,
536
+ stdout: '',
537
+ stderr: '',
538
+ exitCode: null,
539
+ error: 'timeout',
540
+ timedOut: true,
541
+ })
542
+ }, timeoutMs + 250)
543
+ })
544
+ }
545
+
546
+ /** Read the probe result: the ResultFile first, then the last JSON line of stdout. */
547
+ function readProbeResult(resultFile, stdout) {
548
+ try {
549
+ const raw = fs.readFileSync(resultFile, 'utf8')
550
+ return JSON.parse(raw)
551
+ } catch {
552
+ /* fall through to stdout */
553
+ }
554
+ const lines = String(stdout).split('\n')
555
+ for (let i = lines.length - 1; i >= 0; i--) {
556
+ const line = lines[i].trim()
557
+ if (!line) continue
558
+ try {
559
+ return JSON.parse(line)
560
+ } catch {
561
+ /* keep looking */
562
+ }
563
+ }
564
+ return null
565
+ }
566
+
567
+ /** Validate/apply the frames emitted by container code, in order. */
568
+ async function handleProxyFrames(frames, { bufferRoot, declared, maxBytes, writeApplier, fsx }) {
569
+ const writes = []
570
+ for (const frame of frames) {
571
+ if (!isPlainObject(frame) || !WRITE_PROXY_OPS.includes(frame.op)) continue
572
+ const reqId = frame.reqId ?? null
573
+ if (frame.op === 'ping') {
574
+ writes.push({ op: 'ping', reqId, ok: true })
575
+ continue
576
+ }
577
+ if (frame.op === 'stat') {
578
+ const normalized = normalizeRelPath(frame.path)
579
+ if (!normalized.ok) {
580
+ writes.push({ op: 'stat', reqId, path: frame.path ?? '', ok: false, reason: normalized.reason })
581
+ continue
582
+ }
583
+ const check = checkWritePath({
584
+ bufferRoot,
585
+ declared: [],
586
+ rel: normalized.path,
587
+ req: { op: 'write', data: '' },
588
+ maxBytes,
589
+ fsImpl: fs,
590
+ requireDeclared: false,
591
+ skipPayload: true,
592
+ })
593
+ // Reuse the path checks but not the declaration requirement for stat.
594
+ writes.push(
595
+ check.ok
596
+ ? { op: 'stat', reqId, path: normalized.path, ok: true, target: check.target }
597
+ : { op: 'stat', reqId, path: normalized.path, ok: false, reason: check.reason },
598
+ )
599
+ continue
600
+ }
601
+ // op === 'write'
602
+ const check = validateInternal({ bufferRoot, declared, req: frame, maxBytes })
603
+ if (!check.ok) {
604
+ writes.push({ op: 'write', reqId, path: frame.path ?? '', ok: false, reason: check.reason })
605
+ continue
606
+ }
607
+ const entry = {
608
+ op: 'write',
609
+ reqId,
610
+ path: check.path,
611
+ ok: true,
612
+ target: check.target,
613
+ bytes: check.size,
614
+ applied: false,
615
+ }
616
+ if (typeof writeApplier === 'function') {
617
+ try {
618
+ // The applier receives the internal check (with the decoded Buffer in `bytes`).
619
+ await writeApplier({ ...check }, frame)
620
+ entry.applied = true
621
+ } catch (e) {
622
+ entry.ok = false
623
+ entry.reason = 'applier-failed'
624
+ entry.error = msgOf(e)
625
+ }
626
+ }
627
+ writes.push(entry)
628
+ }
629
+ return writes
630
+ }
631
+
632
+ /** Frames worth proxying: NDJSON lines on stdout plus `writes` carried by the probe result. */
633
+ function collectFrames(stdout, parsedResult) {
634
+ const frames = []
635
+ const fromStdout = parseFrames(String(stdout ?? '')).frames
636
+ for (const frame of fromStdout) if (WRITE_PROXY_OPS.includes(frame.op)) frames.push(frame)
637
+ const carried = []
638
+ if (parsedResult && Array.isArray(parsedResult.writes)) carried.push(...parsedResult.writes)
639
+ if (parsedResult && parsedResult.result && Array.isArray(parsedResult.result.writes)) {
640
+ carried.push(...parsedResult.result.writes)
641
+ }
642
+ for (const frame of carried) if (isPlainObject(frame)) frames.push(frame)
643
+ return frames
644
+ }
645
+
646
+ /**
647
+ * Create the container host. Probing is static and synchronous (files + PowerShell on
648
+ * disk): a failure NEVER throws, it degrades to `state:'unavailable'` (+reason).
649
+ *
650
+ * @param {{powershellPath?:string, probeDir?:string, timeoutMs?:number,
651
+ * execFileImpl?:Function, bufferRoot?:string, maxBytes?:number,
652
+ * writeApplier?:Function|null, platform?:string,
653
+ * fsImpl?:object}} [options]
654
+ * @returns {{state:string, available:Function, exec:Function, dispose:Function}}
655
+ */
656
+ export function createContainerHost(options = {}) {
657
+ const opts = isPlainObject(options) ? options : {}
658
+ const probeDir = typeof opts.probeDir === 'string' && opts.probeDir.trim()
659
+ ? path.resolve(opts.probeDir)
660
+ : defaultProbeDir()
661
+ const platform = typeof opts.platform === 'string' ? opts.platform : process.platform
662
+ const powershellPath = typeof opts.powershellPath === 'string' ? opts.powershellPath : 'powershell.exe'
663
+ const timeoutMs = clampTimeout(opts.timeoutMs, 30000)
664
+ const execFileImpl = typeof opts.execFileImpl === 'function' ? opts.execFileImpl : null
665
+ const bufferRoot = typeof opts.bufferRoot === 'string' ? opts.bufferRoot : ''
666
+ const maxBytes = normalizedMaxBytes(opts.maxBytes)
667
+ const writeApplier = typeof opts.writeApplier === 'function' ? opts.writeApplier : null
668
+ const fsImpl = opts.fsImpl && typeof opts.fsImpl === 'object' ? opts.fsImpl : fs
669
+
670
+ const detect = () => {
671
+ if (platform !== 'win32') return { state: CONTAINER_STATE.DISABLED, reason: `non-windows:${platform}` }
672
+ const missing = PROBE_FILES.filter((name) => !existsIn(fsImpl, path.join(probeDir, name)))
673
+ if (missing.length) return { state: CONTAINER_STATE.UNAVAILABLE, reason: `probe-missing:${missing.join(',')}` }
674
+ const ps = resolvePowerShell(powershellPath, { env: process.env, platform })
675
+ if (!ps.ok) return { state: CONTAINER_STATE.UNAVAILABLE, reason: ps.reason }
676
+ return { state: CONTAINER_STATE.READY, reason: 'preflight-ok', powershellPath: ps.path }
677
+ }
678
+
679
+ const detected = detect()
680
+ let state = detected.state
681
+ const stateReason = detected.reason
682
+ let disposed = false
683
+
684
+ const available = () => {
685
+ if (disposed) return { ok: false, state: CONTAINER_STATE.UNAVAILABLE, reason: 'disposed' }
686
+ return { ok: state === CONTAINER_STATE.READY, state, reason: stateReason }
687
+ }
688
+
689
+ /**
690
+ * Run code through the AppContainer pipe probe.
691
+ * @param {{code:string, env?:object, declaredWrites?:Array, timeoutMs?:number}} request
692
+ * @returns {Promise<{ok:boolean, stdout:string, stderr:string, exitCode:number|null,
693
+ * writes:object[], result?:any, error?:string, reason?:string}>}
694
+ */
695
+ async function exec(request = {}) {
696
+ const req = isPlainObject(request) ? request : {}
697
+ const budget = clampTimeout(req.timeoutMs, timeoutMs)
698
+ const base = { ok: false, stdout: '', stderr: '', exitCode: null, writes: [] }
699
+ if (disposed) return { ...base, error: 'disposed', reason: 'disposed' }
700
+ if (state !== CONTAINER_STATE.READY) {
701
+ return {
702
+ ...base,
703
+ error: state === CONTAINER_STATE.DISABLED ? 'disabled' : 'unavailable',
704
+ reason: stateReason,
705
+ }
706
+ }
707
+ const code = typeof req.code === 'string' ? req.code : ''
708
+ if (!code.trim()) return { ...base, error: 'empty-code' }
709
+
710
+ const declared = normalizeDeclared(req.declaredWrites)
711
+ let workDir = ''
712
+ try {
713
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fang-container-'))
714
+ } catch (e) {
715
+ return { ...base, error: 'tmp-failed', reason: msgOf(e) }
716
+ }
717
+ const codeFile = path.join(workDir, 'code.js')
718
+ const resultFile = path.join(workDir, 'result.json')
719
+ try {
720
+ fs.writeFileSync(codeFile, code, 'utf8')
721
+ const childEnv = { ...process.env, ...stringEnv(req.env) }
722
+ if (declared.length) childEnv.FANG_DECLARED_WRITES = JSON.stringify(declared.map((d) => d.path))
723
+ if (bufferRoot) childEnv.FANG_BUFFER_ROOT = path.resolve(bufferRoot)
724
+ const script = path.join(probeDir, 'container-exec-pipe.ps1')
725
+ const args = [
726
+ '-NoProfile',
727
+ '-ExecutionPolicy',
728
+ 'Bypass',
729
+ '-File',
730
+ script,
731
+ '-CodeFile',
732
+ codeFile,
733
+ '-ResultFile',
734
+ resultFile,
735
+ '-TimeoutSec',
736
+ String(Math.max(1, Math.ceil(budget / 1000))),
737
+ ]
738
+ const run = await runProcess({
739
+ file: detected.powershellPath,
740
+ args,
741
+ env: childEnv,
742
+ timeoutMs: budget,
743
+ execFileImpl,
744
+ })
745
+ if (run.timedOut) {
746
+ return { ...base, stdout: run.stdout, stderr: run.stderr, error: 'timeout' }
747
+ }
748
+ const parsed = readProbeResult(resultFile, run.stdout)
749
+ const frames = collectFrames(run.stdout, parsed)
750
+ const writes = await handleProxyFrames(frames, {
751
+ bufferRoot,
752
+ declared,
753
+ maxBytes,
754
+ writeApplier,
755
+ fsx: null,
756
+ })
757
+ const probeOk = run.ok && (!parsed || parsed.ok !== false)
758
+ const result = parsed && typeof parsed === 'object' && 'result' in parsed ? parsed.result : parsed
759
+ if (!probeOk) {
760
+ return {
761
+ ...base,
762
+ stdout: run.stdout,
763
+ stderr: run.stderr,
764
+ exitCode: run.exitCode,
765
+ writes,
766
+ result,
767
+ error: (parsed && parsed.error) || run.error || 'probe-failed',
768
+ }
769
+ }
770
+ return {
771
+ ok: true,
772
+ stdout: run.stdout,
773
+ stderr: run.stderr,
774
+ exitCode: run.exitCode,
775
+ writes,
776
+ result,
777
+ }
778
+ } catch (e) {
779
+ return { ...base, error: 'exec-failed', reason: msgOf(e) }
780
+ } finally {
781
+ try {
782
+ fs.rmSync(workDir, { recursive: true, force: true })
783
+ } catch {
784
+ /* temp cleanup is best-effort */
785
+ }
786
+ }
787
+ }
788
+
789
+ const dispose = () => {
790
+ disposed = true
791
+ state = CONTAINER_STATE.UNAVAILABLE
792
+ return { ok: true }
793
+ }
794
+
795
+ return {
796
+ get state() {
797
+ return state
798
+ },
799
+ available,
800
+ exec,
801
+ dispose,
802
+ get probeDir() {
803
+ return probeDir
804
+ },
805
+ }
806
+ }