dsh-gitbash-shell 0.39.3 → 0.40.0
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/CHANGELOG.md +185 -0
- package/README.md +75 -35
- package/README_EN.md +48 -49
- package/docs/experimental-native-execution.md +224 -0
- package/docs/experimental-native-verification.md +184 -0
- package/dsh.plugin.json +1 -1
- package/package.json +4 -3
- package/src/bash-tool.js +5 -7
- package/src/client.js +75 -22
- package/src/experimental-tools.js +47 -0
- package/src/index.js +98 -26
- package/src/inline-tools.js +103 -15
- package/src/native-executor.js +283 -0
- package/src/native-output.js +347 -0
- package/src/native-tool.js +195 -0
- package/src/path-tool.js +297 -0
- package/src/session-workdir.js +12 -0
- package/src/shell.js +78 -76
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { resolveSessionCwd } from './session-workdir.js'
|
|
3
|
+
|
|
4
|
+
// No host peer imports here: the owner supplies modules via its host loader.
|
|
5
|
+
const KEYS = new Set(['description', 'executable', 'argv', 'cwd', 'timeoutMs', 'run_in_background', 'entrypoint', 'stdoutEncoding', 'stderrEncoding', 'errors'])
|
|
6
|
+
const ENCODINGS = new Set(['utf8', 'gbk', 'utf16le', 'raw'])
|
|
7
|
+
// A usability guard for known names, NOT executable attestation or a sandbox:
|
|
8
|
+
// a renamed/custom .exe may implement its own language. Direct argv is not a
|
|
9
|
+
// promise about arbitrary Windows parsers, even after explicit opt-in.
|
|
10
|
+
const INTERPRETERS = /^(?:cmd|powershell|pwsh|bash|sh|dash|zsh|fish|wsl|wscript|cscript|mshta|node|nodejs|pythonw?(?:\d+(?:\.\d+)*)?|pypy(?:\d+)?|py|perl|ruby|php|lua|javaw?|jshell|dotnet|csi|tclsh|wish|rscript|busybox|deno|bun)(?:\.exe)?$/i
|
|
11
|
+
export const NATIVE_TIMEOUT_MS = 120000
|
|
12
|
+
export const NATIVE_MAX_TIMEOUT_MS = 600000
|
|
13
|
+
|
|
14
|
+
function object(value, label, allowed) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label}: expected an object`)
|
|
16
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new TypeError(`${label}: unsupported field ${key}`)
|
|
17
|
+
}
|
|
18
|
+
function string(value, label, empty = false) {
|
|
19
|
+
if (typeof value !== 'string' || (!empty && value.trim() === '') || value.includes('\0')) throw new TypeError(`${label}: expected ${empty ? 'a' : 'a non-empty'} NUL-free string`)
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only explicitly typed paths cross the existing mount translator. No argv guessing. */
|
|
24
|
+
export function nativePath(input, translateMsysPath, platform = process.platform, label = 'path') {
|
|
25
|
+
object(input, label, new Set(['kind', 'value']))
|
|
26
|
+
if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`)
|
|
27
|
+
const value = string(input.value, label)
|
|
28
|
+
if (/^[~$]|%[^%]+%/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`)
|
|
29
|
+
if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world')
|
|
30
|
+
// A rooted Windows path without a drive is not absolute in this contract.
|
|
31
|
+
const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v)
|
|
32
|
+
const translated = absolute(value) ? value : typeof translateMsysPath === 'function' ? translateMsysPath(value) : value
|
|
33
|
+
if (typeof translated !== 'string' || translated.includes('\0') || !absolute(translated)) throw new TypeError(`${label}: unresolved or non-absolute typed path`)
|
|
34
|
+
return path.win32.normalize(translated)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strict request validation is also applied outside ToolRuntime. */
|
|
38
|
+
export function parseNativeRequest(args, { translateMsysPath, platform = process.platform, sessionCwd } = {}) {
|
|
39
|
+
object(args, 'gitbash_native', KEYS)
|
|
40
|
+
const description = string(args.description, 'description')
|
|
41
|
+
const executable = nativePath(args.executable, translateMsysPath, platform, 'executable')
|
|
42
|
+
if (!/\.exe$/i.test(executable)) throw new TypeError('executable: only native .exe files are supported; .cmd/.bat and script files are never wrapped')
|
|
43
|
+
const entrypoint = args.entrypoint ?? 'native'
|
|
44
|
+
if (!['native', 'interpreter'].includes(entrypoint)) throw new TypeError('entrypoint: expected native or interpreter')
|
|
45
|
+
if (INTERPRETERS.test(path.win32.basename(executable)) && entrypoint !== 'interpreter') throw new TypeError('executable: a known interpreter requires explicit entrypoint: interpreter; its language and quoting remain your responsibility')
|
|
46
|
+
if (!Array.isArray(args.argv) || args.argv.length > 4096) throw new TypeError('argv: expected an array of at most 4096 typed arguments')
|
|
47
|
+
const argv = args.argv.map((arg, index) => {
|
|
48
|
+
object(arg, `argv[${index}]`, new Set(['kind', 'value']))
|
|
49
|
+
if (arg.kind === 'literal') return string(arg.value, `argv[${index}].value`, true)
|
|
50
|
+
if (arg.kind === 'path') return nativePath(arg, translateMsysPath, platform, `argv[${index}]`)
|
|
51
|
+
throw new TypeError(`argv[${index}]: kind must be literal or path`)
|
|
52
|
+
})
|
|
53
|
+
// Conservative bound before the provider's Windows command-line serialization.
|
|
54
|
+
if ([executable, ...argv].reduce((n, arg) => n + arg.length * 2 + 3, 0) > 30000) throw new TypeError('argv: conservative Windows command-line size limit exceeded')
|
|
55
|
+
const cwd = nativePath(args.cwd ?? { kind: 'path', value: sessionCwd }, translateMsysPath, platform, 'cwd')
|
|
56
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number')
|
|
57
|
+
if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') throw new TypeError('run_in_background: expected boolean')
|
|
58
|
+
if (args.run_in_background === true && args.timeoutMs !== undefined) throw new TypeError('timeoutMs is not supported with run_in_background; managed jobs have no tool deadline')
|
|
59
|
+
const stdoutEncoding = args.stdoutEncoding ?? 'utf8'
|
|
60
|
+
const stderrEncoding = args.stderrEncoding ?? 'utf8'
|
|
61
|
+
const errors = args.errors ?? 'replace'
|
|
62
|
+
if (!ENCODINGS.has(stdoutEncoding) || !ENCODINGS.has(stderrEncoding)) throw new TypeError('encoding: expected utf8, gbk, utf16le or raw; auto is not supported')
|
|
63
|
+
if (!['replace', 'strict'].includes(errors)) throw new TypeError('errors: expected replace or strict')
|
|
64
|
+
return Object.freeze({ description, executable, argv: Object.freeze(argv), cwd, entrypoint,
|
|
65
|
+
timeoutMs: Math.min(args.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS),
|
|
66
|
+
run_in_background: args.run_in_background === true,
|
|
67
|
+
output: Object.freeze({ stdoutEncoding, stderrEncoding, errors }),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve the *call's* standing policy, never the composition default. */
|
|
72
|
+
export function requireNativePolicy(ctx, exec, sandboxModule) {
|
|
73
|
+
const service = ctx.get('sandboxPolicy')
|
|
74
|
+
const policy = service?.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session })
|
|
75
|
+
if (!policy || policy.mode !== 'danger-full-access') {
|
|
76
|
+
const detail = 'gitbash_native has no confined backend adapter. This entry refuses read-only, workspace-write and missing policy before spawn or artifact creation; do not bypass a denied operation. The user can select danger-full-access in the session access-mode control, or turn off Experimental native execution / fail-closed (experimentalNative) in Settings > Plugins > dsh-gitbash-shell to disable native/path tools and restore explicitly UNCONFINED legacy Bash.'
|
|
77
|
+
if (typeof sandboxModule?.SandboxUnavailableError === 'function') throw new sandboxModule.SandboxUnavailableError(policy?.mode ?? 'unknown', detail)
|
|
78
|
+
throw Object.assign(new Error(detail), { code: 'SANDBOX_UNAVAILABLE' })
|
|
79
|
+
}
|
|
80
|
+
return policy
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkedDshEnv(value) {
|
|
84
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('shellEnv.collect must return managed environment facts')
|
|
85
|
+
const seen = new Set()
|
|
86
|
+
const result = {}
|
|
87
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
88
|
+
if (!/^DSH_/i.test(key) || key.includes('=') || key.includes('\0') || seen.has(key.toUpperCase())) throw new TypeError('shellEnv: invalid or conflicting managed environment key')
|
|
89
|
+
result[key] = string(entry, 'shellEnv managed fact', true)
|
|
90
|
+
seen.add(key.toUpperCase())
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(result)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function preparationTimeout() {
|
|
96
|
+
return Object.assign(new Error('gitbash_native preparation timed out before spawn; no native producer was started'), { code: 'NATIVE_PREPARATION_TIMEOUT' })
|
|
97
|
+
}
|
|
98
|
+
function cancellation(parent, timeoutMs) {
|
|
99
|
+
const controller = new AbortController()
|
|
100
|
+
const forward = () => controller.abort(parent.reason)
|
|
101
|
+
parent?.addEventListener('abort', forward, { once: true })
|
|
102
|
+
if (parent?.aborted) forward()
|
|
103
|
+
let timer
|
|
104
|
+
if (timeoutMs <= 0) controller.abort(preparationTimeout())
|
|
105
|
+
else timer = setTimeout(() => controller.abort(preparationTimeout()), timeoutMs)
|
|
106
|
+
return { controller, signal: controller.signal,
|
|
107
|
+
stopDeadline() { clearTimeout(timer) },
|
|
108
|
+
dispose() { clearTimeout(timer); parent?.removeEventListener('abort', forward) },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function whileActive(work, signal) {
|
|
112
|
+
signal.throwIfAborted()
|
|
113
|
+
let abort
|
|
114
|
+
const interrupted = new Promise((_, reject) => {
|
|
115
|
+
abort = () => reject(signal.reason)
|
|
116
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
117
|
+
})
|
|
118
|
+
try { return await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return work() }), interrupted]) }
|
|
119
|
+
finally { signal.removeEventListener('abort', abort) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Per-operation private Core executor. Only this operation's scope and handle are
|
|
124
|
+
* owned here. In particular, disposing it never disposes the root subprocess.
|
|
125
|
+
* Prepared requests are private capabilities, not caller-supplied policy DTOs.
|
|
126
|
+
*/
|
|
127
|
+
export function createNativeExecutor(ctx, deps) {
|
|
128
|
+
const { localBashModule, sandboxModule, createScope, translateMsysPath, createNativeOutputAdapter, outputOptions = {} } = deps
|
|
129
|
+
const LocalBashExecutor = localBashModule?.LocalBashExecutor
|
|
130
|
+
if (typeof LocalBashExecutor !== 'function' || typeof createScope !== 'function' || typeof createNativeOutputAdapter !== 'function') throw new Error('gitbash_native: required host executor/output/scope capability is unavailable')
|
|
131
|
+
const prepared = new WeakMap()
|
|
132
|
+
const requireEnabled = () => {
|
|
133
|
+
if (deps.experimentalEnabled?.() === false) throw new Error('gitbash_native is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.')
|
|
134
|
+
}
|
|
135
|
+
const remaining = request => request.timeoutMs - (Date.now() - prepared.get(request))
|
|
136
|
+
return {
|
|
137
|
+
waitBudget: request => Math.max(1, remaining(request)),
|
|
138
|
+
async prepare(args, exec) {
|
|
139
|
+
requireEnabled()
|
|
140
|
+
const policy = requireNativePolicy(ctx, exec, sandboxModule)
|
|
141
|
+
exec.signal?.throwIfAborted()
|
|
142
|
+
if (args?.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number')
|
|
143
|
+
const startedAt = Date.now()
|
|
144
|
+
const timeoutMs = Math.min(args?.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS)
|
|
145
|
+
const preparation = cancellation(exec.signal, timeoutMs)
|
|
146
|
+
try {
|
|
147
|
+
const sessionCwd = await whileActive(() => resolveSessionCwd(ctx, { ...exec, signal: preparation.signal }, policy), preparation.signal)
|
|
148
|
+
requireEnabled()
|
|
149
|
+
preparation.signal.throwIfAborted()
|
|
150
|
+
const request = parseNativeRequest(args, { translateMsysPath, sessionCwd, platform: deps.platform ?? process.platform })
|
|
151
|
+
const value = Object.freeze({ ...request, dshEnv: checkedDshEnv(ctx.shellEnv.collect(exec)) })
|
|
152
|
+
prepared.set(value, startedAt)
|
|
153
|
+
return value
|
|
154
|
+
} finally { preparation.dispose() }
|
|
155
|
+
},
|
|
156
|
+
async execute(request, exec, { signal = exec.signal, onExpiry = 'kill' } = {}) {
|
|
157
|
+
requireEnabled()
|
|
158
|
+
const policy = requireNativePolicy(ctx, exec, sandboxModule)
|
|
159
|
+
if (!prepared.has(request)) throw new TypeError('gitbash_native: request must be prepared by this executor')
|
|
160
|
+
if (!['kill', 'none'].includes(onExpiry)) throw new TypeError('invalid native expiry mode')
|
|
161
|
+
signal?.throwIfAborted()
|
|
162
|
+
const provider = ctx.get('subprocess')
|
|
163
|
+
if (typeof provider?.spawn !== 'function') throw new Error('gitbash_native: subprocess provider unavailable')
|
|
164
|
+
// Jobs registrations survive the tool fiber. Their operation scopes belong
|
|
165
|
+
// directly to the agent, not to a disposable tool registration fiber.
|
|
166
|
+
const owner = exec.agent?.ctx ?? ctx
|
|
167
|
+
const operation = cancellation(signal, remaining(request))
|
|
168
|
+
let scope
|
|
169
|
+
let handle
|
|
170
|
+
let scopeDisposed = false
|
|
171
|
+
let producerSettled = false
|
|
172
|
+
let proc
|
|
173
|
+
let resultPromise
|
|
174
|
+
const cancel = reason => {
|
|
175
|
+
if (producerSettled || operation.signal.aborted) return false
|
|
176
|
+
operation.controller.abort(Object.assign(new Error(reason), { code: 'NATIVE_OPERATION_CANCELLED' }))
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
// OutputCollector DTOs may contain optional undefined fields. ToolRuntime
|
|
180
|
+
// requires lossless JSON, so project the adapter's facts at this boundary.
|
|
181
|
+
// Even a facts projection problem must not erase a producer's real exit.
|
|
182
|
+
const outputFacts = () => {
|
|
183
|
+
try { return JSON.parse(JSON.stringify(handle?.outputFacts?.() ?? { unavailable: true })) }
|
|
184
|
+
catch (error) { return { projectionError: String(error?.message ?? error), reliableText: false } }
|
|
185
|
+
}
|
|
186
|
+
const release = async () => {
|
|
187
|
+
if (scopeDisposed) return
|
|
188
|
+
scopeDisposed = true
|
|
189
|
+
try { await scope?.dispose() } finally { operation.dispose() }
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
operation.signal.throwIfAborted()
|
|
193
|
+
scope = createScope(owner.isolate('shell').isolate('subprocess'))
|
|
194
|
+
if (scope.fiber) await whileActive(() => scope.fiber, operation.signal)
|
|
195
|
+
operation.signal.throwIfAborted()
|
|
196
|
+
// Install ownership cancellation before any further preparation await.
|
|
197
|
+
scope.ctx.effect(() => async () => {
|
|
198
|
+
if (!producerSettled) cancel('native execution owner disposed')
|
|
199
|
+
if (!handle) return
|
|
200
|
+
handle.terminate()
|
|
201
|
+
if (typeof handle.waitForExit === 'function') await handle.waitForExit(AbortSignal.timeout(5000))
|
|
202
|
+
})
|
|
203
|
+
requireEnabled()
|
|
204
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
205
|
+
const outputConfig = { ...outputOptions, ...request.output, artifactPolicyValidated: true }
|
|
206
|
+
// Capability/encoding validation is pure and precedes directory creation.
|
|
207
|
+
createNativeOutputAdapter(provider, { ...outputConfig, artifactDirectory: undefined })
|
|
208
|
+
if (outputConfig.artifactDirectory === undefined) {
|
|
209
|
+
if (typeof deps.createArtifactDirectory !== 'function') throw new Error('gitbash_native: no trusted artifact directory owner is configured')
|
|
210
|
+
requireEnabled()
|
|
211
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
212
|
+
operation.signal.throwIfAborted()
|
|
213
|
+
outputConfig.artifactDirectory = await whileActive(async () => {
|
|
214
|
+
const directory = await deps.createArtifactDirectory(request, { ...exec, signal: operation.signal }, operation.signal)
|
|
215
|
+
if (operation.signal.aborted) {
|
|
216
|
+
// The directory owner retains late results under its normal retention
|
|
217
|
+
// policy. Never spawn from a late callback or delete a path still
|
|
218
|
+
// potentially owned/referenced by that trusted asynchronous producer.
|
|
219
|
+
deps.log?.(`gitbash_native: cancelled preparation retained its late artifact directory under owner retention: ${String(directory)}`)
|
|
220
|
+
}
|
|
221
|
+
return directory
|
|
222
|
+
}, operation.signal)
|
|
223
|
+
if (typeof outputConfig.artifactDirectory !== 'string') throw new Error('gitbash_native: artifact directory owner did not return a path')
|
|
224
|
+
}
|
|
225
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
226
|
+
operation.signal.throwIfAborted()
|
|
227
|
+
requireEnabled()
|
|
228
|
+
const adapter = createNativeOutputAdapter(provider, outputConfig)
|
|
229
|
+
const facade = { spawn(spec) {
|
|
230
|
+
requireEnabled()
|
|
231
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
232
|
+
spec.signal?.throwIfAborted()
|
|
233
|
+
handle = adapter.spawn(spec)
|
|
234
|
+
return handle
|
|
235
|
+
} }
|
|
236
|
+
scope.ctx.provide('subprocess', facade)
|
|
237
|
+
const mounted = scope.ctx.plugin(LocalBashExecutor)
|
|
238
|
+
await whileActive(() => mounted, operation.signal)
|
|
239
|
+
requireEnabled()
|
|
240
|
+
operation.signal.throwIfAborted()
|
|
241
|
+
const executor = scope.ctx.get('shell')
|
|
242
|
+
if (typeof executor?.executeArgv !== 'function') throw new Error('gitbash_native: host executeArgv contract is unavailable')
|
|
243
|
+
const budget = remaining(request)
|
|
244
|
+
if (budget <= 0) throw preparationTimeout()
|
|
245
|
+
const spec = executor.resolve({ command: request.description, workdir: request.cwd, timeoutMs: onExpiry === 'kill' ? budget : request.timeoutMs,
|
|
246
|
+
signal: operation.signal, onExpiry, dshEnv: request.dshEnv, sandboxPolicy: policy })
|
|
247
|
+
// Core owns runtime kill deadlines. For Jobs, onExpiry:none keeps running
|
|
248
|
+
// after the bounded preparation phase and the caller's promotion deadline.
|
|
249
|
+
operation.stopDeadline()
|
|
250
|
+
proc = await executor.executeArgv(spec, [request.executable, ...request.argv])
|
|
251
|
+
const originalResult = proc.result.bind(proc)
|
|
252
|
+
const originalDone = proc.done
|
|
253
|
+
// Keep the original producer exit/status even when decoding/storage fails.
|
|
254
|
+
const result = () => resultPromise ??= originalResult().then(value => ({ ...value, status: proc.status, timeoutMs: request.timeoutMs,
|
|
255
|
+
cwd: request.cwd, sandbox: { mode: policy.mode, denied: false },
|
|
256
|
+
outputFacts: outputFacts(),
|
|
257
|
+
}))
|
|
258
|
+
const finished = originalDone.then(async () => {
|
|
259
|
+
// Normal settlement must not be relabelled as an owner cancellation
|
|
260
|
+
// merely because its now-finished execution scope is being released.
|
|
261
|
+
producerSettled = true
|
|
262
|
+
try { await result() } catch { /* result() preserves provider rejection */ }
|
|
263
|
+
await release()
|
|
264
|
+
}, async error => { producerSettled = true; await release(); throw error })
|
|
265
|
+
// Ensure lifecycle cleanup is observed even before a foreground reader attaches.
|
|
266
|
+
finished.catch(() => {})
|
|
267
|
+
return { get status() { return proc.status }, get exitCode() { return proc.exitCode }, get signal() { return proc.signal },
|
|
268
|
+
observed: proc.observed, readOutput: () => proc.readOutput(), kill: () => {
|
|
269
|
+
if (proc.status !== 'running') return false
|
|
270
|
+
cancel('native execution explicitly killed')
|
|
271
|
+
return proc.kill()
|
|
272
|
+
},
|
|
273
|
+
done: finished, result: async () => { await finished; return result() },
|
|
274
|
+
outputFacts,
|
|
275
|
+
sandbox: { mode: policy.mode, denied: false },
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await release()
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Private, operation-local adapter over the public subprocess raw-pipe seam.
|
|
2
|
+
// No peer imports: the host must inject its exported ./output OutputCollector.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const ENCODINGS = Object.freeze({ utf8: 'utf-8', 'utf-8': 'utf-8', gbk: 'gbk', cp936: 'gbk', utf16le: 'utf-16le', 'utf-16le': 'utf-16le', raw: 'raw' });
|
|
8
|
+
const freeze = value => Object.freeze(value);
|
|
9
|
+
const failure = (error, code) => freeze({ code: String(error?.code ?? code), message: String(error?.message ?? error).slice(0, 400) });
|
|
10
|
+
function integer(value, fallback, name, min = 0) {
|
|
11
|
+
const result = value ?? fallback;
|
|
12
|
+
if (!Number.isSafeInteger(result) || result < min) throw new TypeError(`${name} must be a safe integer >= ${min}`);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function encoding(value = 'utf8') {
|
|
16
|
+
const label = ENCODINGS[value];
|
|
17
|
+
if (!label) throw new TypeError(`Unsupported native output encoding: ${value}`);
|
|
18
|
+
if (label !== 'raw') {
|
|
19
|
+
// Probe both replacement and fatal support in the actual host, before spawn.
|
|
20
|
+
new TextDecoder(label, { fatal: false, ignoreBOM: true });
|
|
21
|
+
new TextDecoder(label, { fatal: true, ignoreBOM: true });
|
|
22
|
+
}
|
|
23
|
+
return label;
|
|
24
|
+
}
|
|
25
|
+
function isCollect(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
&& Number.isSafeInteger(value.maxBytes) && value.maxBytes >= 0
|
|
28
|
+
&& Object.keys(value).every(key => key === 'maxBytes' || key === 'spill')
|
|
29
|
+
&& (value.spill === undefined || (value.spill !== null && typeof value.spill === 'object'
|
|
30
|
+
&& Number.isSafeInteger(value.spill.maxBytes) && value.spill.maxBytes >= 0
|
|
31
|
+
&& Object.keys(value.spill).every(key => key === 'maxBytes')));
|
|
32
|
+
}
|
|
33
|
+
function alignedStart(bytes, start) {
|
|
34
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
|
35
|
+
return start;
|
|
36
|
+
}
|
|
37
|
+
function alignedEnd(bytes, end) {
|
|
38
|
+
if (end === bytes.length) return end;
|
|
39
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
|
|
40
|
+
return end;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Construct one immutable operation's provider facade. Create another facade for
|
|
45
|
+
* a different encoding/configuration; no ambient per-agent or global settings.
|
|
46
|
+
*
|
|
47
|
+
* Only known {maxBytes, spill?} stdout/stderr modes opt in. Pipe, inherit and
|
|
48
|
+
* future/unknown modes are delegated unchanged. The real provider still owns
|
|
49
|
+
* execution, abort, termination, range observation, stdin and control.
|
|
50
|
+
*
|
|
51
|
+
* artifactDirectory must ALREADY exist and be owned/authorized by the caller.
|
|
52
|
+
* artifactPolicyValidated is only a misuse guard, NOT a sandbox grant: the
|
|
53
|
+
* native executor must enforce its actual resolved danger-only policy first.
|
|
54
|
+
* This adapter never creates directories, chmods, removes files or grants access.
|
|
55
|
+
* The caller owns artifact retention/removal after all readers have finished.
|
|
56
|
+
*
|
|
57
|
+
* maxTotalBytes bounds adapter-owned byte payload reservations + artifact bytes,
|
|
58
|
+
* not provider/Node pipe buffers, JS object overhead or copies retained by callers.
|
|
59
|
+
* Memory reservation includes tail backing buffers, snapshot/string copies and
|
|
60
|
+
* bounded decode scratch (conservative multipliers). No raw in-memory history.
|
|
61
|
+
* Artifact quotas are SHARED by both streams and by raw + normalized UTF8 files.
|
|
62
|
+
* Readers use normalized UTF8 byte offsets, never raw/source-byte offsets.
|
|
63
|
+
*/
|
|
64
|
+
export function createNativeOutputAdapter(provider, options = {}) {
|
|
65
|
+
if (!provider || typeof provider.spawn !== 'function') throw new TypeError('A real subprocess provider is required');
|
|
66
|
+
if (typeof options.OutputCollector !== 'function') throw new TypeError('Inject the host OutputCollector from its public ./output export');
|
|
67
|
+
const config = freeze({
|
|
68
|
+
OutputCollector: options.OutputCollector,
|
|
69
|
+
stdoutEncoding: encoding(options.stdoutEncoding),
|
|
70
|
+
stderrEncoding: encoding(options.stderrEncoding),
|
|
71
|
+
errors: options.errors ?? 'replace',
|
|
72
|
+
maxSpillBytes: integer(options.maxSpillBytes, 8 * 1024 * 1024, 'maxSpillBytes'),
|
|
73
|
+
maxTotalBytes: integer(options.maxTotalBytes, 16 * 1024 * 1024, 'maxTotalBytes', 1024),
|
|
74
|
+
drainTimeoutMs: integer(options.drainTimeoutMs, 250, 'drainTimeoutMs', 1),
|
|
75
|
+
artifactDirectory: options.artifactDirectory,
|
|
76
|
+
});
|
|
77
|
+
if (!['replace', 'strict'].includes(config.errors)) throw new TypeError('errors must be replace or strict');
|
|
78
|
+
if (config.drainTimeoutMs > 2_147_483_647) throw new TypeError('drainTimeoutMs exceeds Node timer range');
|
|
79
|
+
if (config.artifactDirectory !== undefined) {
|
|
80
|
+
if (options.artifactPolicyValidated !== true) throw new Error('Native output artifacts require caller policy validation');
|
|
81
|
+
if (typeof config.artifactDirectory !== 'string' || !path.isAbsolute(config.artifactDirectory)) throw new TypeError('artifactDirectory must be absolute');
|
|
82
|
+
if (!fs.statSync(config.artifactDirectory).isDirectory()) throw new TypeError('artifactDirectory must already be a directory');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function spawn(spec) {
|
|
86
|
+
const names = ['stdout', 'stderr'].filter(name => isCollect(spec.stdio?.[name]));
|
|
87
|
+
if (!names.length) return provider.spawn(spec);
|
|
88
|
+
// Capture mutable caller dispositions before invoking the real provider.
|
|
89
|
+
const modes = Object.fromEntries(names.map(name => [name, freeze({ maxBytes: spec.stdio[name].maxBytes, spillMax: spec.stdio[name].spill?.maxBytes })]));
|
|
90
|
+
const scratchBytes = Math.max(1, Math.min(1024, Math.floor(config.maxTotalBytes / 256)));
|
|
91
|
+
const baseReservation = scratchBytes * 64 + 256 * names.length;
|
|
92
|
+
const tailShare = Math.max(0, Math.floor((config.maxTotalBytes - baseReservation) / (16 * names.length)));
|
|
93
|
+
const caps = Object.fromEntries(names.map(name => [name, Math.min(modes[name].maxBytes, tailShare)]));
|
|
94
|
+
const reservedMemoryBytes = baseReservation + names.reduce((sum, name) => sum + caps[name] * 16, 0);
|
|
95
|
+
const budget = { artifactBytes: 0, limit: Math.min(config.maxSpillBytes, config.maxTotalBytes - reservedMemoryBytes) };
|
|
96
|
+
const owners = names.map(name => makeOwner(name, modes[name], caps[name], config, budget, scratchBytes));
|
|
97
|
+
const stdio = { ...spec.stdio };
|
|
98
|
+
for (const name of names) stdio[name] = 'pipe';
|
|
99
|
+
let real;
|
|
100
|
+
try { real = provider.spawn({ ...spec, stdio }); }
|
|
101
|
+
catch (error) { for (const owner of owners) owner.finish('spawn-error'); throw error; }
|
|
102
|
+
for (const owner of owners) owner.attach(real[owner.name]);
|
|
103
|
+
let timer;
|
|
104
|
+
let processSettled = false;
|
|
105
|
+
const armDrain = reason => {
|
|
106
|
+
if (timer || owners.every(owner => owner.isSettled())) return;
|
|
107
|
+
timer = setTimeout(() => { for (const owner of owners) owner.finish(reason); }, config.drainTimeoutMs);
|
|
108
|
+
};
|
|
109
|
+
const onAbort = () => {
|
|
110
|
+
for (const owner of owners) owner.markAborted();
|
|
111
|
+
armDrain('abort-drain-timeout');
|
|
112
|
+
};
|
|
113
|
+
const signal = spec.signal;
|
|
114
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
115
|
+
if (signal?.aborted) onAbort();
|
|
116
|
+
const outputSettled = Promise.all(owners.map(owner => owner.settled)).then(() => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
});
|
|
119
|
+
// Observe success AND rejection immediately. Decoding/storage NEVER changes
|
|
120
|
+
// process outcome or converts a successful side-effecting process into a
|
|
121
|
+
// provider failure that might invite an unsafe retry.
|
|
122
|
+
const done = Promise.resolve(real.done).then(async outcome => {
|
|
123
|
+
processSettled = true;
|
|
124
|
+
armDrain('exit-drain-timeout');
|
|
125
|
+
await outputSettled;
|
|
126
|
+
signal?.removeEventListener('abort', onAbort);
|
|
127
|
+
return outcome;
|
|
128
|
+
}, async error => {
|
|
129
|
+
processSettled = true;
|
|
130
|
+
armDrain('provider-error-drain-timeout');
|
|
131
|
+
await outputSettled;
|
|
132
|
+
signal?.removeEventListener('abort', onAbort);
|
|
133
|
+
throw error;
|
|
134
|
+
});
|
|
135
|
+
const collected = { ...real.collected };
|
|
136
|
+
for (const owner of owners) collected[owner.name] = owner.reader;
|
|
137
|
+
const additions = {
|
|
138
|
+
done, outputSettled, collected: freeze(collected),
|
|
139
|
+
terminate(...args) {
|
|
140
|
+
// Direct kill/dispose need not abort spec.signal. Record interruption
|
|
141
|
+
// before delegating, but never retroactively spoil a completed result
|
|
142
|
+
// merely because an owner invokes idempotent cleanup after settlement.
|
|
143
|
+
if (!processSettled || !owners.every(owner => owner.isSettled())) {
|
|
144
|
+
for (const owner of owners) owner.markAborted();
|
|
145
|
+
armDrain('terminate-drain-timeout');
|
|
146
|
+
}
|
|
147
|
+
return real.terminate(...args);
|
|
148
|
+
},
|
|
149
|
+
outputFacts: () => freeze({
|
|
150
|
+
...Object.fromEntries(owners.map(owner => [owner.name, owner.facts()])),
|
|
151
|
+
budget: freeze({ maxTotalBytes: config.maxTotalBytes, reservedMemoryBytes, artifactBytes: budget.artifactBytes, maxArtifactBytes: budget.limit }),
|
|
152
|
+
}),
|
|
153
|
+
...Object.fromEntries(names.map(name => [name, undefined])),
|
|
154
|
+
};
|
|
155
|
+
return delegate(real, additions);
|
|
156
|
+
}
|
|
157
|
+
return delegate(provider, { spawn, provider, outputConfig: config });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Bind unknown methods to their REAL receiver (including private-field methods).
|
|
161
|
+
// Do not mutate provider/handle identity or their service registration.
|
|
162
|
+
function delegate(real, additions) {
|
|
163
|
+
return new Proxy(Object.create(null), {
|
|
164
|
+
get(_target, key) {
|
|
165
|
+
if (Object.hasOwn(additions, key)) return additions[key];
|
|
166
|
+
const value = Reflect.get(real, key, real);
|
|
167
|
+
return typeof value === 'function' ? value.bind(real) : value;
|
|
168
|
+
},
|
|
169
|
+
has: (_target, key) => Object.hasOwn(additions, key) || key in real,
|
|
170
|
+
set() { return false; },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeOwner(name, mode, cap, config, budget, scratchBytes) {
|
|
175
|
+
const label = config[`${name}Encoding`];
|
|
176
|
+
const collector = new config.OutputCollector(cap, `native-${name}`); // no host-private spill fields
|
|
177
|
+
const decoder = label === 'raw' ? undefined : new TextDecoder(label, { fatal: config.errors === 'strict', ignoreBOM: true });
|
|
178
|
+
// A fatal validator distinguishes actual invalid bytes from a legitimate U+FFFD.
|
|
179
|
+
let validator = label === 'raw' || config.errors === 'strict' ? undefined : new TextDecoder(label, { fatal: true, ignoreBOM: true });
|
|
180
|
+
let rawBytes = 0, decodedUtf8Bytes = 0, decodeError, storageError, streamError;
|
|
181
|
+
let settled = false, naturalEOF = false, aborted = false, termination, stream, stopText = false;
|
|
182
|
+
let resolveSettled;
|
|
183
|
+
const settledPromise = new Promise(resolve => { resolveSettled = resolve; });
|
|
184
|
+
const artifacts = {};
|
|
185
|
+
function recordStorage(error) { storageError ??= failure(error, 'NATIVE_OUTPUT_STORAGE'); }
|
|
186
|
+
function artifact(kind) {
|
|
187
|
+
if (!config.artifactDirectory) return undefined;
|
|
188
|
+
return artifacts[kind] ??= { kind, encoding: kind === 'raw' ? label : 'utf-8', bytes: 0, capReached: false, error: undefined, path: undefined, fd: undefined, sealed: false };
|
|
189
|
+
}
|
|
190
|
+
function writeArtifact(kind, bytes) {
|
|
191
|
+
const target = artifact(kind);
|
|
192
|
+
if (!target || !bytes.length || target.error || target.capReached) return;
|
|
193
|
+
const streamLimit = kind === 'decoded' ? Math.min(mode.spillMax ?? config.maxSpillBytes, config.maxSpillBytes) : config.maxSpillBytes;
|
|
194
|
+
let length = Math.min(bytes.length, Math.max(0, budget.limit - budget.artifactBytes), Math.max(0, streamLimit - target.bytes));
|
|
195
|
+
if (kind === 'decoded') length = alignedEnd(bytes, length);
|
|
196
|
+
if (length < bytes.length) target.capReached = true;
|
|
197
|
+
if (!length) return;
|
|
198
|
+
try {
|
|
199
|
+
if (target.fd === undefined) {
|
|
200
|
+
const filename = path.join(config.artifactDirectory, `native-${randomUUID()}-${name}-${kind}.${kind === 'raw' ? 'bin' : 'utf8.log'}`);
|
|
201
|
+
target.fd = fs.openSync(filename, 'wx', 0o600);
|
|
202
|
+
target.path = filename;
|
|
203
|
+
}
|
|
204
|
+
let offset = 0;
|
|
205
|
+
while (offset < length) {
|
|
206
|
+
const written = fs.writeSync(target.fd, bytes, offset, length - offset);
|
|
207
|
+
if (!Number.isSafeInteger(written) || written <= 0) throw new Error('Native artifact write made no progress');
|
|
208
|
+
offset += written;
|
|
209
|
+
target.bytes += written;
|
|
210
|
+
budget.artifactBytes += written;
|
|
211
|
+
}
|
|
212
|
+
} catch (error) { target.error = failure(error, 'NATIVE_OUTPUT_SPILL'); recordStorage(error); }
|
|
213
|
+
}
|
|
214
|
+
function publish(text) {
|
|
215
|
+
if (!text) return;
|
|
216
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
217
|
+
decodedUtf8Bytes += bytes.length;
|
|
218
|
+
writeArtifact('decoded', bytes);
|
|
219
|
+
if (stopText) return;
|
|
220
|
+
try {
|
|
221
|
+
// Bound backing allocations, too: OutputCollector may retain subarray tails.
|
|
222
|
+
// Every push owns a small copy, never a view of the provider's giant chunk.
|
|
223
|
+
const step = Math.max(1, Math.min(scratchBytes, cap || 1));
|
|
224
|
+
for (let offset = 0; offset < bytes.length; offset += step) {
|
|
225
|
+
const slice = bytes.subarray(offset, offset + step);
|
|
226
|
+
// Unpooled storage prevents a one-byte retained tail pinning an 8KiB slab.
|
|
227
|
+
const owned = Buffer.allocUnsafeSlow(slice.length);
|
|
228
|
+
owned.set(slice);
|
|
229
|
+
collector.push(owned);
|
|
230
|
+
}
|
|
231
|
+
} catch (error) { recordStorage(error); stopText = true; }
|
|
232
|
+
}
|
|
233
|
+
function ingest(chunk) {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
236
|
+
streamError ??= failure(new Error('Raw pipe emitted non-byte data (setEncoding must not be used)'), 'NATIVE_OUTPUT_NOT_BYTES');
|
|
237
|
+
finish('stream-error');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
rawBytes += chunk.byteLength;
|
|
241
|
+
// Never decode a giant incoming chunk in one allocation. Borrow raw slices;
|
|
242
|
+
// output/string scratch is bounded even after an error or artifact cap.
|
|
243
|
+
for (let offset = 0; offset < chunk.byteLength; offset += scratchBytes) {
|
|
244
|
+
const bytes = chunk.subarray(offset, offset + scratchBytes);
|
|
245
|
+
writeArtifact('raw', bytes);
|
|
246
|
+
if (!decoder || (decodeError && config.errors === 'strict')) continue;
|
|
247
|
+
if (validator) {
|
|
248
|
+
try { validator.decode(bytes, { stream: true }); }
|
|
249
|
+
catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); validator = undefined; }
|
|
250
|
+
}
|
|
251
|
+
try { publish(decoder.decode(bytes, { stream: true })); }
|
|
252
|
+
catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function finish(reason) {
|
|
256
|
+
if (settled) return;
|
|
257
|
+
settled = true;
|
|
258
|
+
termination = reason;
|
|
259
|
+
naturalEOF = reason === 'end';
|
|
260
|
+
if (validator) {
|
|
261
|
+
try { validator.decode(); }
|
|
262
|
+
catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); }
|
|
263
|
+
}
|
|
264
|
+
if (decoder && !(decodeError && config.errors === 'strict')) {
|
|
265
|
+
try { publish(decoder.decode()); }
|
|
266
|
+
catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); }
|
|
267
|
+
}
|
|
268
|
+
try { collector.seal(); } catch (error) { recordStorage(error); }
|
|
269
|
+
for (const target of Object.values(artifacts)) {
|
|
270
|
+
if (target.fd !== undefined) {
|
|
271
|
+
try { fs.closeSync(target.fd); }
|
|
272
|
+
catch (error) { target.error ??= failure(error, 'NATIVE_OUTPUT_SPILL_CLOSE'); recordStorage(error); }
|
|
273
|
+
target.fd = undefined;
|
|
274
|
+
}
|
|
275
|
+
target.sealed = true;
|
|
276
|
+
}
|
|
277
|
+
if (stream) {
|
|
278
|
+
stream.removeListener('data', ingest);
|
|
279
|
+
stream.removeListener('end', onEnd);
|
|
280
|
+
stream.removeListener('close', onClose);
|
|
281
|
+
// Keep a harmless error sink until close: destroy can schedule a late
|
|
282
|
+
// error, which must not become an uncaught exception after settlement.
|
|
283
|
+
if (stream.closed) stream.removeListener('error', onError);
|
|
284
|
+
else stream.once('close', () => stream.removeListener('error', onError));
|
|
285
|
+
if (!naturalEOF && !stream.destroyed) stream.destroy();
|
|
286
|
+
}
|
|
287
|
+
resolveSettled();
|
|
288
|
+
}
|
|
289
|
+
const onEnd = () => finish('end');
|
|
290
|
+
const onClose = () => finish('close-before-end');
|
|
291
|
+
const onError = error => { if (!settled) { streamError ??= failure(error, 'NATIVE_OUTPUT_STREAM'); finish('stream-error'); } };
|
|
292
|
+
function artifactFacts() {
|
|
293
|
+
return freeze(Object.fromEntries(Object.entries(artifacts).map(([kind, target]) => [kind, freeze({
|
|
294
|
+
kind, encoding: target.encoding, path: target.path, bytes: target.bytes,
|
|
295
|
+
complete: target.sealed && naturalEOF && !aborted && !target.error && !target.capReached && (kind === 'raw' || !decodeError),
|
|
296
|
+
capReached: target.capReached, error: target.error,
|
|
297
|
+
})])));
|
|
298
|
+
}
|
|
299
|
+
const reader = freeze({ readFrom(fromByte) {
|
|
300
|
+
if (!Number.isSafeInteger(fromByte) || fromByte < 0 || fromByte > decodedUtf8Bytes) throw new RangeError('Expected a normalized UTF8 cursor between zero and decodedUtf8Bytes');
|
|
301
|
+
let snapshot;
|
|
302
|
+
try { snapshot = collector.snapshot(); }
|
|
303
|
+
catch (error) { recordStorage(error); return { text: '', nextOffset: decodedUtf8Bytes, lossy: true }; }
|
|
304
|
+
const start = snapshot.totalBytes - snapshot.bytes.length;
|
|
305
|
+
const requested = Math.max(0, fromByte - start);
|
|
306
|
+
const aligned = alignedStart(snapshot.bytes, requested);
|
|
307
|
+
const spill = artifactFacts().decoded;
|
|
308
|
+
return {
|
|
309
|
+
text: snapshot.bytes.subarray(aligned).toString('utf8'),
|
|
310
|
+
nextOffset: decodedUtf8Bytes,
|
|
311
|
+
lossy: label === 'raw' || !!decodeError || !!storageError || fromByte < start || aligned !== requested,
|
|
312
|
+
// Only finalized COMPLETE normalized text can be called a full-output path.
|
|
313
|
+
...(spill?.complete && spill.path ? { spillPath: spill.path } : {}),
|
|
314
|
+
};
|
|
315
|
+
} });
|
|
316
|
+
return {
|
|
317
|
+
name, reader, settled: settledPromise, finish, isSettled: () => settled,
|
|
318
|
+
markAborted() { aborted = true; },
|
|
319
|
+
attach(value) {
|
|
320
|
+
stream = value;
|
|
321
|
+
if (!stream || typeof stream.on !== 'function') {
|
|
322
|
+
stream = undefined;
|
|
323
|
+
streamError = failure(new Error('Provider did not expose requested raw pipe'), 'NATIVE_OUTPUT_PIPE_MISSING');
|
|
324
|
+
finish('missing-pipe');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
stream.on('error', onError);
|
|
328
|
+
stream.once('end', onEnd);
|
|
329
|
+
stream.once('close', onClose);
|
|
330
|
+
stream.on('data', ingest);
|
|
331
|
+
if (stream.readableEnded) finish('end');
|
|
332
|
+
else if (stream.destroyed || stream.closed) finish('close-before-end');
|
|
333
|
+
},
|
|
334
|
+
facts() {
|
|
335
|
+
const artifact = artifactFacts();
|
|
336
|
+
const lossy = reader.readFrom(0).lossy;
|
|
337
|
+
return freeze({
|
|
338
|
+
encoding: label, errors: config.errors, rawBytes, decodedUtf8Bytes,
|
|
339
|
+
rawCursor: rawBytes, decodedCursor: decodedUtf8Bytes,
|
|
340
|
+
reliableText: label !== 'raw' && !decodeError && !streamError && !storageError,
|
|
341
|
+
lossy, complete: naturalEOF && !aborted && !streamError && !decodeError && !storageError && (label !== 'raw' || artifact.raw?.complete === true),
|
|
342
|
+
sourceComplete: naturalEOF, aborted, settled, termination, decodeError, streamError, storageError, artifact,
|
|
343
|
+
retainedMaxBytes: cap,
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|