dsh-gitbash-shell 0.39.4 → 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 +154 -1
- package/README.md +69 -33
- package/README_EN.md +38 -44
- 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 +73 -21
- package/src/experimental-tools.js +47 -0
- package/src/index.js +26 -20
- package/src/inline-tools.js +95 -8
- 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,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
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { registerExperimentalTool } from './experimental-tools.js'
|
|
2
|
+
import { createNativeExecutor } from './native-executor.js'
|
|
3
|
+
import { processJob, processSources, ringDelta } from './bash-render.js'
|
|
4
|
+
|
|
5
|
+
const typedPath = () => ({ type: 'object', additionalProperties: false, properties: {
|
|
6
|
+
kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true },
|
|
7
|
+
} })
|
|
8
|
+
|
|
9
|
+
/** defineTool's AUTHOR property-map, not a raw object-root JSON schema. */
|
|
10
|
+
export function nativeParameters({ background = false, promote = false } = {}) {
|
|
11
|
+
return {
|
|
12
|
+
description: { type: 'string', required: true, description: 'Short description of the intended native operation.' },
|
|
13
|
+
executable: { ...typedPath(), required: true, description: 'Absolute native .exe path. MSYS paths translate only in kind:path values. Bare commands, scripts, .cmd and .bat are unsupported.' },
|
|
14
|
+
argv: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
15
|
+
kind: { type: 'string', enum: ['literal', 'path'], required: true }, value: { type: 'string', required: true },
|
|
16
|
+
} }, description: 'Each literal is passed unchanged (including /all, /c, quotes and empty strings); each path must be absolute and is explicitly translated to native Windows spelling. No shell expansion.' },
|
|
17
|
+
cwd: { ...typedPath(), description: 'Absolute typed working directory; omitted means the agent workspace.' },
|
|
18
|
+
entrypoint: { type: 'string', enum: ['native', 'interpreter'], description: 'Default native. Known interpreters require explicit interpreter. Their own command language/quoting is not made safe by direct argv; no automatic script wrapper.' },
|
|
19
|
+
timeoutMs: { type: 'number', description: promote ? 'Foreground wait deadline, default 120000ms, capped at 600000ms; expiry promotes the managed process to a job. Not accepted with run_in_background.' : 'Kill deadline, default 120000ms, capped at 600000ms. Not accepted with run_in_background.' },
|
|
20
|
+
...(background ? { run_in_background: { type: 'boolean', description: 'Use existing managed Jobs; collect with job_output, stop with job_kill. No tool deadline. Requires an attached job controller.' } } : {}),
|
|
21
|
+
stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stdout encoding, default utf8. raw preserves unknown/mixed bytes without claiming reliable text. No auto detection.' },
|
|
22
|
+
stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stderr encoding, independently selected; default utf8.' },
|
|
23
|
+
errors: { type: 'string', enum: ['replace', 'strict'], description: 'Default replace; strict records decoding failures separately from actual command exit. Never rerun a side-effecting command merely because output decoding failed.' },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const streamSchema = { type: 'object', additionalProperties: false, properties: {
|
|
28
|
+
text: { type: 'string', required: true }, truncated: { type: 'boolean', required: true }, spillPath: { type: 'string' },
|
|
29
|
+
} }
|
|
30
|
+
// Core's author DSL supports integer, not numeric minimum keywords; the
|
|
31
|
+
// adapter owns non-negative counters and budget validation before execution.
|
|
32
|
+
const byteCount = () => ({ type: 'integer', required: true })
|
|
33
|
+
const factBoolean = () => ({ type: 'boolean', required: true })
|
|
34
|
+
const outputErrorSchema = { type: 'object', additionalProperties: false, properties: {
|
|
35
|
+
code: { type: 'string', required: true }, message: { type: 'string', required: true },
|
|
36
|
+
} }
|
|
37
|
+
const normalizedEncoding = ['utf-8', 'gbk', 'utf-16le', 'raw']
|
|
38
|
+
const artifactSchema = kind => ({ type: 'object', additionalProperties: false, properties: {
|
|
39
|
+
kind: { type: 'string', const: kind, required: true },
|
|
40
|
+
encoding: { type: 'string', ...(kind === 'decoded' ? { const: 'utf-8' } : { enum: normalizedEncoding }), required: true },
|
|
41
|
+
path: { type: 'string' }, bytes: byteCount(), complete: factBoolean(), capReached: factBoolean(), error: outputErrorSchema,
|
|
42
|
+
} })
|
|
43
|
+
const streamFactsSchema = { type: 'object', additionalProperties: false, properties: {
|
|
44
|
+
encoding: { type: 'string', enum: normalizedEncoding, required: true },
|
|
45
|
+
errors: { type: 'string', enum: ['replace', 'strict'], required: true },
|
|
46
|
+
rawBytes: byteCount(), decodedUtf8Bytes: byteCount(), rawCursor: byteCount(), decodedCursor: byteCount(),
|
|
47
|
+
reliableText: factBoolean(), lossy: factBoolean(), complete: factBoolean(), sourceComplete: factBoolean(),
|
|
48
|
+
aborted: factBoolean(), settled: factBoolean(),
|
|
49
|
+
termination: { type: 'string', enum: ['end', 'close-before-end', 'stream-error', 'missing-pipe', 'spawn-error', 'abort-drain-timeout', 'terminate-drain-timeout', 'exit-drain-timeout', 'provider-error-drain-timeout'] },
|
|
50
|
+
decodeError: outputErrorSchema, streamError: outputErrorSchema, storageError: outputErrorSchema,
|
|
51
|
+
artifact: { type: 'object', additionalProperties: false, required: true, properties: {
|
|
52
|
+
raw: artifactSchema('raw'), decoded: artifactSchema('decoded'),
|
|
53
|
+
} },
|
|
54
|
+
retainedMaxBytes: byteCount(),
|
|
55
|
+
} }
|
|
56
|
+
// Explicit current adapter DTO, including the two executor-owned degradation
|
|
57
|
+
// branches. Optional undefined projection never admits arbitrary output fields.
|
|
58
|
+
export const NATIVE_OUTPUT_FACTS_SCHEMA = { oneOf: [
|
|
59
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
60
|
+
stdout: { ...streamFactsSchema, required: true }, stderr: { ...streamFactsSchema, required: true },
|
|
61
|
+
budget: { type: 'object', additionalProperties: false, required: true, properties: {
|
|
62
|
+
maxTotalBytes: byteCount(), reservedMemoryBytes: byteCount(), artifactBytes: byteCount(), maxArtifactBytes: byteCount(),
|
|
63
|
+
} },
|
|
64
|
+
} },
|
|
65
|
+
{ type: 'object', additionalProperties: false, properties: { unavailable: { type: 'boolean', const: true, required: true } } },
|
|
66
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
67
|
+
projectionError: { type: 'string', required: true }, reliableText: { type: 'boolean', const: false, required: true },
|
|
68
|
+
} },
|
|
69
|
+
] }
|
|
70
|
+
const sandboxSchema = { type: 'object', additionalProperties: false, properties: {
|
|
71
|
+
mode: { type: 'string', const: 'danger-full-access', required: true }, denied: { type: 'boolean', const: false, required: true },
|
|
72
|
+
} }
|
|
73
|
+
const encodingSchema = { type: 'object', additionalProperties: false, properties: {
|
|
74
|
+
stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true },
|
|
75
|
+
stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true },
|
|
76
|
+
errors: { type: 'string', enum: ['replace', 'strict'], required: true },
|
|
77
|
+
} }
|
|
78
|
+
export const NATIVE_OUTPUT_SCHEMA = {
|
|
79
|
+
oneOf: [
|
|
80
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
81
|
+
kind: { type: 'string', const: 'foreground', required: true }, cwd: { type: 'string', required: true },
|
|
82
|
+
status: { type: 'string', enum: ['running', 'completed', 'killed'], required: true }, exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
83
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
84
|
+
timedOut: { type: 'boolean', required: true }, aborted: { type: 'boolean', required: true }, timeoutMs: { type: 'number', required: true },
|
|
85
|
+
stdout: { ...streamSchema, required: true }, stderr: { ...streamSchema, required: true },
|
|
86
|
+
sandbox: { ...sandboxSchema, required: true }, outputFacts: { ...NATIVE_OUTPUT_FACTS_SCHEMA, required: true },
|
|
87
|
+
} },
|
|
88
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
89
|
+
kind: { type: 'string', enum: ['background', 'promoted'], required: true }, cwd: { type: 'string', required: true },
|
|
90
|
+
jobId: { type: 'string', required: true }, timeoutMs: { type: 'number' }, output: { type: 'string' },
|
|
91
|
+
encoding: { ...encodingSchema, required: true },
|
|
92
|
+
} },
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderNativeResult(value) {
|
|
97
|
+
if (value.kind !== 'foreground') return `Native command ${value.kind === 'promoted' ? 'continues after the wait deadline' : 'admitted to managed Jobs'}; job id: ${value.jobId}. Collect with job_output, stop with job_kill.\n${value.output ?? ''}\n[requested encoding: ${JSON.stringify(value.encoding)}; final producer/output facts arrive in job output]`
|
|
98
|
+
const text = value.stdout.text + (value.stderr.text ? `\n[stderr]\n${value.stderr.text}` : '')
|
|
99
|
+
return `${text}\n[exit code: ${value.exitCode ?? 'unknown'}; status: ${value.status}; signal: ${value.signal ?? 'none'}; timedOut: ${value.timedOut}; aborted: ${value.aborted}]\n[native output facts: ${JSON.stringify(value.outputFacts)}]\nOutput decoding/storage problems do not mean the command did not execute; inspect producer exit facts before considering any retry.`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One per-agent module. Host packages and the shared path translator are injected. */
|
|
103
|
+
export function createNativeToolModule(deps) {
|
|
104
|
+
if (typeof deps.toolsModule?.defineTool !== 'function') throw new Error('gitbash_native: host defineTool capability unavailable')
|
|
105
|
+
return {
|
|
106
|
+
inject: ['tools', 'subprocess', 'shellEnv'],
|
|
107
|
+
apply(ctx, config = {}) {
|
|
108
|
+
const executor = createNativeExecutor(ctx, deps)
|
|
109
|
+
const enabled = config.enableRunInBackground !== false
|
|
110
|
+
const promoteOnTimeout = config.promoteOnTimeout !== false && enabled
|
|
111
|
+
const definition = jobs => {
|
|
112
|
+
const background = enabled && jobs !== undefined
|
|
113
|
+
const promote = background && promoteOnTimeout
|
|
114
|
+
const startJob = (request, exec) => {
|
|
115
|
+
let proc
|
|
116
|
+
let finalFacts = ''
|
|
117
|
+
const sources = processSources(() => proc)
|
|
118
|
+
sources.push({ channel: 'stderr', read(fromByte) {
|
|
119
|
+
const bytes = Buffer.from(finalFacts, 'utf8')
|
|
120
|
+
return { text: bytes.subarray(fromByte).toString('utf8'), nextOffset: bytes.length, lossy: false }
|
|
121
|
+
} })
|
|
122
|
+
const id = jobs.start({ kind: 'gitbash-native', label: request.description,
|
|
123
|
+
...(exec.agent ? { owner: exec.agent.id } : {}), output: sources,
|
|
124
|
+
run: () => processJob(async signal => {
|
|
125
|
+
proc = await executor.execute(request, exec, { signal, onExpiry: 'none' })
|
|
126
|
+
return proc
|
|
127
|
+
}, async started => {
|
|
128
|
+
// done has already sealed decoding and released this operation's scope.
|
|
129
|
+
const result = await started.result()
|
|
130
|
+
finalFacts = `\n[native producer: exitCode=${result.exitCode}, signal=${result.signal}, status=${result.status}, aborted=${result.aborted}]\n[native output facts: ${JSON.stringify(result.outputFacts)}]\nOutput failure is not command non-execution; do not blindly retry.\n`
|
|
131
|
+
return { status: result.status === 'killed' ? 'killed' : 'completed', detail: `exit code: ${result.exitCode ?? 'unknown'}; output facts retained in job output` }
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
return { id, process: () => proc }
|
|
135
|
+
}
|
|
136
|
+
const execute = async (args, exec) => {
|
|
137
|
+
// prepare resolves policy BEFORE ensure/path/env/artifact work.
|
|
138
|
+
const request = await executor.prepare(args, exec)
|
|
139
|
+
exec.signal?.throwIfAborted()
|
|
140
|
+
if (request.run_in_background && !background) throw new Error('run_in_background is unavailable or disabled; this request was not executed')
|
|
141
|
+
if (request.run_in_background) {
|
|
142
|
+
const attached = startJob(request, exec)
|
|
143
|
+
return { kind: 'background', jobId: attached.id, cwd: request.cwd, encoding: request.output }
|
|
144
|
+
}
|
|
145
|
+
if (promote) {
|
|
146
|
+
// No retry/fallback around admission: a host throwing after starting a
|
|
147
|
+
// producer must not cause a second execution of a side-effecting call.
|
|
148
|
+
const attached = startJob(request, exec)
|
|
149
|
+
const owner = exec.agent?.id
|
|
150
|
+
let view
|
|
151
|
+
try { view = await jobs.wait(attached.id, executor.waitBudget(request), owner, exec.signal) }
|
|
152
|
+
catch (error) {
|
|
153
|
+
jobs.kill(attached.id, owner, 'tool call aborted')
|
|
154
|
+
await jobs.wait(attached.id, 10000, owner)
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
if (view.status === 'running' || view.status === 'stopping') {
|
|
158
|
+
if (!attached.process()) {
|
|
159
|
+
jobs.kill(attached.id, owner, 'timed out during native preparation')
|
|
160
|
+
await jobs.wait(attached.id, 10000, owner)
|
|
161
|
+
throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`)
|
|
162
|
+
}
|
|
163
|
+
const read = jobs.read(attached.id, owner)
|
|
164
|
+
return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs,
|
|
165
|
+
output: ringDelta(read.chunks), encoding: request.output }
|
|
166
|
+
}
|
|
167
|
+
jobs.remove(attached.id, owner)
|
|
168
|
+
if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available')
|
|
169
|
+
return { kind: 'foreground', ...await attached.process().result() }
|
|
170
|
+
}
|
|
171
|
+
const proc = await executor.execute(request, exec)
|
|
172
|
+
return { kind: 'foreground', ...await proc.result() }
|
|
173
|
+
}
|
|
174
|
+
return deps.toolsModule.defineTool({ name: 'gitbash_native',
|
|
175
|
+
description: 'Choose this tool BEFORE execution for a Windows-native .exe that needs exact literal argv (for example /all or /c without MSYS conversion) or explicitly known independent stdout/stderr encodings. Use an offered shell tool instead for pipelines, redirection, globbing, shell expansion or POSIX scripts; this is direct typed argv through the host managed executor, not shell source. Supply an absolute executable, mark literal arguments unchanged and path arguments explicitly; do not put a whole shell command into argv. Prefer a producer UTF-8 option; declare gbk/utf16le only when known, raw for unknown or mixed bytes. In PTC call this offered SDK binding from inside run_code, not as a direct model tool call. Only actual danger-full-access standing policy is supported; missing/restricted policy is rejected before execution. A policy/access denial is a stop condition, never permission for another-tool retry. Decoding/storage failure preserves producer exit: never blindly repeat side effects. No user env, implicit scripts, .cmd/.bat wrappers, path guessing or encoding auto-detection. Known interpreters require explicit entrypoint:interpreter; common CRT/Node argv is supported, arbitrary interpreter quoting is not guaranteed.',
|
|
176
|
+
parameters: nativeParameters({ background, promote }),
|
|
177
|
+
output: { schema: NATIVE_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderNativeResult(value) }], presentationMeta: (_args, value) => ({ cwd: value.cwd }) },
|
|
178
|
+
execute,
|
|
179
|
+
presentCall: args => ({ card: 'generic', title: args.description, kind: 'execute', content: [{ type: 'text', text: args.executable.value }] }),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
let currentJobs
|
|
183
|
+
const refresh = registerExperimentalTool(ctx, () => definition(currentJobs), deps.experimentalEnabled, { scope: deps.scope, log: deps.log })
|
|
184
|
+
if (!enabled) return
|
|
185
|
+
ctx.inject(['jobs'], jobCtx => {
|
|
186
|
+
currentJobs = jobCtx.jobs
|
|
187
|
+
refresh()
|
|
188
|
+
jobCtx.effect(() => () => {
|
|
189
|
+
currentJobs = undefined
|
|
190
|
+
if (ctx.fiber?.state === 2) refresh()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|