@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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
|
+
}
|