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.
@@ -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
+ }