@mario.andreschak/mcp-bash 3.42.0 → 3.43.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/dist/resources.js +242 -242
- package/dist/tools.d.ts +49 -0
- package/dist/tools.js +667 -44
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -150,9 +150,14 @@ function isWindowsWslBashLauncher(candidate) {
|
|
|
150
150
|
if (process.platform !== 'win32')
|
|
151
151
|
return false;
|
|
152
152
|
const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
const resolved = path.resolve(candidate).toLowerCase();
|
|
154
|
+
// The legacy WSL relay may be reached through System32, SysWOW64, or a
|
|
155
|
+
// SystemRoot-relative shim. It is not a usable POSIX interpreter without a
|
|
156
|
+
// provisioned distro, so never select it as the Bash MCP's `bash` shell.
|
|
157
|
+
if (systemRoot && resolved.startsWith(`${path.resolve(systemRoot).toLowerCase()}${path.sep}`)
|
|
158
|
+
&& path.basename(resolved) === 'bash.exe')
|
|
159
|
+
return true;
|
|
160
|
+
return /[\\/]WindowsApps[\\/].*wsl.*[\\/]bash\.exe$/i.test(resolved);
|
|
156
161
|
}
|
|
157
162
|
let cachedBashPath;
|
|
158
163
|
function resolveBashExecutable() {
|
|
@@ -211,11 +216,96 @@ function resolveCmdExecutable() {
|
|
|
211
216
|
path.join(getEnvCaseInsensitive('SystemRoot') ?? 'C:\\Windows', 'System32', 'cmd.exe'),
|
|
212
217
|
]);
|
|
213
218
|
}
|
|
219
|
+
function resolveShExecutable() {
|
|
220
|
+
if (process.platform === 'win32')
|
|
221
|
+
return findExecutableOnPath('sh');
|
|
222
|
+
return firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Interpreters and utilities probed by `shell_info` (issue #364) so a caller can
|
|
226
|
+
* discover the machine up front instead of rediscovering it by failed commands.
|
|
227
|
+
*/
|
|
228
|
+
const PROBED_BINARIES = [
|
|
229
|
+
'python', 'python3', 'node', 'npm', 'npx', 'git', 'rg',
|
|
230
|
+
'ffmpeg', 'ffprobe', 'curl', 'tar', 'grep', 'sed', 'awk', 'head', 'tail',
|
|
231
|
+
];
|
|
232
|
+
/**
|
|
233
|
+
* A zero-byte Windows Store app-execution alias (typically `python3.exe` under
|
|
234
|
+
* `WindowsApps`) resolves on PATH but only opens the Store when executed.
|
|
235
|
+
*/
|
|
236
|
+
function isWindowsStoreAliasStub(candidate) {
|
|
237
|
+
if (!candidate || process.platform !== 'win32')
|
|
238
|
+
return false;
|
|
239
|
+
if (!/[\\/]WindowsApps[\\/]/i.test(candidate))
|
|
240
|
+
return false;
|
|
241
|
+
try {
|
|
242
|
+
return fs.statSync(candidate).size === 0;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function shellEntry(shell, resolved) {
|
|
249
|
+
return { shell, available: Boolean(resolved), path: resolved };
|
|
250
|
+
}
|
|
251
|
+
let cachedShellInfo;
|
|
252
|
+
/** Describe the shells and interpreters actually available on this machine. */
|
|
253
|
+
export function collectShellInfo() {
|
|
254
|
+
if (cachedShellInfo)
|
|
255
|
+
return cachedShellInfo;
|
|
256
|
+
const defaultPlan = buildSpawn('', 'default');
|
|
257
|
+
const shells = [
|
|
258
|
+
shellEntry('pwsh', resolvePwshExecutable()),
|
|
259
|
+
shellEntry('powershell', resolveWindowsPowerShellExecutable()),
|
|
260
|
+
shellEntry('bash', resolveBashExecutable()),
|
|
261
|
+
shellEntry('cmd', resolveCmdExecutable()),
|
|
262
|
+
shellEntry('sh', resolveShExecutable()),
|
|
263
|
+
];
|
|
264
|
+
const binaries = PROBED_BINARIES.map((name) => {
|
|
265
|
+
const resolved = findExecutableOnPath(name);
|
|
266
|
+
if (isWindowsStoreAliasStub(resolved)) {
|
|
267
|
+
return { name, found: false, path: resolved, alias: true };
|
|
268
|
+
}
|
|
269
|
+
return { name, found: Boolean(resolved), path: resolved };
|
|
270
|
+
});
|
|
271
|
+
const notes = [
|
|
272
|
+
'Tool output merges stdout and stderr into one "output" field; "isError" reflects the process exit code only.',
|
|
273
|
+
`The "default" shell on this machine is "${defaultPlan.effectiveShell}".`,
|
|
274
|
+
'Pass shell:"bash" for POSIX syntax (&&, pipes into head/grep, $(…)); an unavailable explicit shell fails fast.',
|
|
275
|
+
];
|
|
276
|
+
if (!shells.find((entry) => entry.shell === 'pwsh')?.available
|
|
277
|
+
&& shells.find((entry) => entry.shell === 'powershell')?.available) {
|
|
278
|
+
notes.push('Only Windows PowerShell 5.1 is installed: "&&" and "||" are not valid statement separators there.');
|
|
279
|
+
}
|
|
280
|
+
cachedShellInfo = {
|
|
281
|
+
platform: process.platform,
|
|
282
|
+
defaultShell: defaultPlan.effectiveShell,
|
|
283
|
+
defaultShellPath: defaultPlan.file || null,
|
|
284
|
+
shells,
|
|
285
|
+
binaries,
|
|
286
|
+
notes,
|
|
287
|
+
};
|
|
288
|
+
return cachedShellInfo;
|
|
289
|
+
}
|
|
290
|
+
function availableShellNames() {
|
|
291
|
+
return collectShellInfo().shells.filter((entry) => entry.available).map((entry) => entry.shell);
|
|
292
|
+
}
|
|
293
|
+
function unavailableShellHint(shell) {
|
|
294
|
+
if (shell === 'bash' && process.platform === 'win32'
|
|
295
|
+
&& findExecutablesOnPath('bash').some(isWindowsWslBashLauncher)) {
|
|
296
|
+
return 'Only the WSL bash launcher was found and no Linux distribution provides /bin/bash. Install Git for Windows (Git Bash) or a WSL distro.';
|
|
297
|
+
}
|
|
298
|
+
return 'Call "shell_info" to see which shells and interpreters exist on this machine.';
|
|
299
|
+
}
|
|
300
|
+
function shellInfoTool() {
|
|
301
|
+
return textResult(collectShellInfo());
|
|
302
|
+
}
|
|
214
303
|
/** Test-only: forget cached shell-executable lookups. */
|
|
215
304
|
export function _resetBashShellCacheForTests() {
|
|
216
305
|
cachedBashPath = undefined;
|
|
217
306
|
cachedPwshPath = undefined;
|
|
218
307
|
cachedWindowsPowerShellPath = undefined;
|
|
308
|
+
cachedShellInfo = undefined;
|
|
219
309
|
}
|
|
220
310
|
function sessions() {
|
|
221
311
|
if (!global.__flujo_bash_sessions)
|
|
@@ -323,6 +413,27 @@ async function resolveCwd(input, roots) {
|
|
|
323
413
|
}
|
|
324
414
|
return resolved;
|
|
325
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Resolve an `outputFile` spool target under the same confinement rules as
|
|
418
|
+
* `cwd` (issue #364) and make sure its parent directory exists.
|
|
419
|
+
*/
|
|
420
|
+
async function resolveOutputFile(input, cwd, roots) {
|
|
421
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
422
|
+
if (!raw)
|
|
423
|
+
throw new Error('"outputFile" must be a non-empty path.');
|
|
424
|
+
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw);
|
|
425
|
+
if (await isProtectedPathsEnabled()) {
|
|
426
|
+
const prot = isProtected(resolved);
|
|
427
|
+
if (prot.denied) {
|
|
428
|
+
throw new Error(`outputFile "${resolved}" is within a protected location (${prot.matchedRoot}).`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
432
|
+
throw new Error(`outputFile "${resolved}" is outside the configured bash roots.`);
|
|
433
|
+
}
|
|
434
|
+
await fs.promises.mkdir(path.dirname(resolved), { recursive: true });
|
|
435
|
+
return resolved;
|
|
436
|
+
}
|
|
326
437
|
/**
|
|
327
438
|
* Best-effort advisory scan (issue #260, item 4) of a command string for
|
|
328
439
|
* absolute-looking paths that point OUTSIDE the configured roots or INTO a
|
|
@@ -359,6 +470,47 @@ function maskGlobOptionValues(command) {
|
|
|
359
470
|
}
|
|
360
471
|
return chars.join('');
|
|
361
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Windows utilities that take `/x`-style switches (issue #314, item D). A slash
|
|
475
|
+
* token is only demoted from "absolute path" to "switch" when one of these
|
|
476
|
+
* appears EARLIER IN THE SAME command segment, so `cd … && dir /b && rg …` and
|
|
477
|
+
* `echo dir /b` stay quiet while `echo /.git/config` keeps its advisory.
|
|
478
|
+
* Deliberately excludes names that are also common POSIX utilities (`find`,
|
|
479
|
+
* `sort`, `type`, `more`, `mkdir`, `rmdir`) — suppressing their arguments would
|
|
480
|
+
* silence genuine path advisories such as `find /etc -name passwd`.
|
|
481
|
+
*/
|
|
482
|
+
const WINDOWS_SWITCH_UTILITIES = new Set([
|
|
483
|
+
'dir', 'xcopy', 'robocopy', 'copy', 'move', 'del', 'erase', 'attrib', 'icacls', 'takeown',
|
|
484
|
+
'findstr', 'tasklist', 'taskkill', 'sc', 'reg', 'net', 'subst', 'where', 'ren', 'rename',
|
|
485
|
+
'md', 'rd', 'tree', 'fc', 'comp', 'cacls', 'schtasks', 'chkdsk', 'sfc', 'shutdown',
|
|
486
|
+
'wmic', 'diskpart', 'label', 'vol', 'mklink', 'assoc', 'ftype', 'gpupdate', 'powercfg',
|
|
487
|
+
]);
|
|
488
|
+
/**
|
|
489
|
+
* A cmd-style switch: `/b`, `/ad`, `/mir`, `/e:on`. Never matches a nested path
|
|
490
|
+
* (no interior separator), so `/etc/passwd` is untouched.
|
|
491
|
+
*/
|
|
492
|
+
const WINDOWS_SWITCH_RE = /^\/[A-Za-z][A-Za-z0-9?*-]{0,15}(?::[^\s"']*)?$/;
|
|
493
|
+
/** Text of the command segment (split on shell separators) preceding `index`. */
|
|
494
|
+
function segmentTextBefore(command, index) {
|
|
495
|
+
const separators = /&&|\|\||[|;&\n]/g;
|
|
496
|
+
let start = 0;
|
|
497
|
+
let match;
|
|
498
|
+
while ((match = separators.exec(command)) !== null) {
|
|
499
|
+
if (match.index >= index)
|
|
500
|
+
break;
|
|
501
|
+
start = match.index + match[0].length;
|
|
502
|
+
}
|
|
503
|
+
return command.slice(start, index);
|
|
504
|
+
}
|
|
505
|
+
/** True when a slash-switch utility heads the segment containing `index`. */
|
|
506
|
+
function windowsSwitchUtilityPrecedes(command, index) {
|
|
507
|
+
for (const word of segmentTextBefore(command, index).match(/[^\s"'<>()]+/g) ?? []) {
|
|
508
|
+
const name = word.replace(/^.*[\\/]/, '').toLowerCase().replace(/\.(?:exe|com|bat|cmd)$/, '');
|
|
509
|
+
if (WINDOWS_SWITCH_UTILITIES.has(name))
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
362
514
|
async function scanCommandForExternalPaths(command, cwd, roots) {
|
|
363
515
|
const warnings = [];
|
|
364
516
|
const protectedPathsEnabled = await isProtectedPathsEnabled();
|
|
@@ -369,10 +521,19 @@ async function scanCommandForExternalPaths(command, cwd, roots) {
|
|
|
369
521
|
// Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
|
|
370
522
|
// POSIX (/foo), and ~-prefixed home paths.
|
|
371
523
|
const tokenRe = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|~\/[^\s"']*|(?<![\w.])\/[^\s"']+)/g;
|
|
372
|
-
const matches =
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
524
|
+
const matches = [...commandForPathScan.matchAll(tokenRe)]
|
|
525
|
+
.filter((match) => {
|
|
526
|
+
if (process.platform !== 'win32')
|
|
527
|
+
return true;
|
|
528
|
+
// Two independent conditions must hold before a token is dropped: it must
|
|
529
|
+
// LOOK like a cmd switch, and a slash-switch utility must appear earlier
|
|
530
|
+
// in the SAME segment (`… && dir /b && …`). This keeps `/etc/passwd` and
|
|
531
|
+
// other genuine POSIX paths advisory.
|
|
532
|
+
if (!WINDOWS_SWITCH_RE.test(match[0]))
|
|
533
|
+
return true;
|
|
534
|
+
return !windowsSwitchUtilityPrecedes(commandForPathScan, match.index ?? 0);
|
|
535
|
+
})
|
|
536
|
+
.map((match) => match[0]);
|
|
376
537
|
const home = (() => {
|
|
377
538
|
try {
|
|
378
539
|
return getHomeDir();
|
|
@@ -499,6 +660,17 @@ function buildChildEnv(overrides = {}) {
|
|
|
499
660
|
}
|
|
500
661
|
out.PATHEXT = windowsExecutableExtensions().join(';');
|
|
501
662
|
}
|
|
663
|
+
// Deterministic text encoding and number formatting for children (issue #364):
|
|
664
|
+
// without this, interpreters emit locale-dependent decimal commas and non-UTF-8
|
|
665
|
+
// bytes that come back as mojibake. Explicit per-call `env` still wins below.
|
|
666
|
+
if (out.PYTHONIOENCODING === undefined)
|
|
667
|
+
out.PYTHONIOENCODING = 'utf-8';
|
|
668
|
+
if (process.platform !== 'win32') {
|
|
669
|
+
if (!out.LANG)
|
|
670
|
+
out.LANG = 'C.UTF-8';
|
|
671
|
+
if (!out.LC_ALL)
|
|
672
|
+
out.LC_ALL = 'C.UTF-8';
|
|
673
|
+
}
|
|
502
674
|
for (const [key, value] of Object.entries(overrides)) {
|
|
503
675
|
if (process.platform === 'win32') {
|
|
504
676
|
for (const existing of Object.keys(out)) {
|
|
@@ -528,6 +700,35 @@ function validateEnv(input) {
|
|
|
528
700
|
}
|
|
529
701
|
return { valid: true, env };
|
|
530
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* PowerShell prelude (issue #364). It makes three platform traps go away for
|
|
705
|
+
* every command we run:
|
|
706
|
+
* - stderr from a native binary no longer turns into a terminating error, so
|
|
707
|
+
* the reported exit code is the native process' own,
|
|
708
|
+
* - console output is forced to UTF-8 (otherwise logs come back as UTF-16 or
|
|
709
|
+
* OEM mojibake),
|
|
710
|
+
* - the culture is pinned to invariant, so numbers never use a decimal comma.
|
|
711
|
+
* The epilogue re-surfaces the native exit code (`$LASTEXITCODE`), falling back
|
|
712
|
+
* to `$?` when no native command ran.
|
|
713
|
+
*/
|
|
714
|
+
const POWERSHELL_PRELUDE = [
|
|
715
|
+
"$ErrorActionPreference='Continue'",
|
|
716
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
717
|
+
'try { [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8 } catch {}',
|
|
718
|
+
'try { [Threading.Thread]::CurrentThread.CurrentCulture=[Globalization.CultureInfo]::InvariantCulture;'
|
|
719
|
+
+ ' [Threading.Thread]::CurrentThread.CurrentUICulture=[Globalization.CultureInfo]::InvariantCulture } catch {}',
|
|
720
|
+
].join('; ');
|
|
721
|
+
const POWERSHELL_EPILOGUE = 'exit $(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 })';
|
|
722
|
+
export function wrapPowerShellCommand(command) {
|
|
723
|
+
return `${POWERSHELL_PRELUDE}\n${command}\n${POWERSHELL_EPILOGUE}`;
|
|
724
|
+
}
|
|
725
|
+
/** `chcp 65001` forces UTF-8 output for cmd.exe children (issue #364). */
|
|
726
|
+
export function wrapCmdCommand(command) {
|
|
727
|
+
return `chcp 65001>nul & ${command}`;
|
|
728
|
+
}
|
|
729
|
+
function powerShellArgs(command) {
|
|
730
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', wrapPowerShellCommand(command)];
|
|
731
|
+
}
|
|
531
732
|
/**
|
|
532
733
|
* Build the spawn arguments for the requested shell. Returns the command, argv
|
|
533
734
|
* and whether Node's `shell:true` wrapping applies. Shells are resolved
|
|
@@ -539,7 +740,20 @@ function buildSpawn(command, shell) {
|
|
|
539
740
|
if (shell === 'pwsh') {
|
|
540
741
|
const resolved = resolvePwshExecutable();
|
|
541
742
|
if (resolved) {
|
|
542
|
-
return { file: resolved, args:
|
|
743
|
+
return { file: resolved, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
|
|
744
|
+
}
|
|
745
|
+
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
746
|
+
if (windowsPowerShell) {
|
|
747
|
+
return {
|
|
748
|
+
file: windowsPowerShell,
|
|
749
|
+
args: powerShellArgs(command),
|
|
750
|
+
useShell: false,
|
|
751
|
+
effectiveShell: 'powershell',
|
|
752
|
+
shellSubstitution: {
|
|
753
|
+
requested: 'pwsh', used: 'powershell',
|
|
754
|
+
reason: 'PowerShell 7 (pwsh) is not installed on this machine.',
|
|
755
|
+
},
|
|
756
|
+
};
|
|
543
757
|
}
|
|
544
758
|
return { file: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
|
|
545
759
|
}
|
|
@@ -555,7 +769,7 @@ function buildSpawn(command, shell) {
|
|
|
555
769
|
if (resolved) {
|
|
556
770
|
return {
|
|
557
771
|
file: resolved,
|
|
558
|
-
args: ['/d', '/s', '/c', command],
|
|
772
|
+
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
559
773
|
useShell: false,
|
|
560
774
|
effectiveShell: 'cmd',
|
|
561
775
|
windowsVerbatimArguments: true,
|
|
@@ -566,17 +780,17 @@ function buildSpawn(command, shell) {
|
|
|
566
780
|
if (process.platform === 'win32') {
|
|
567
781
|
const pwsh = resolvePwshExecutable();
|
|
568
782
|
if (pwsh) {
|
|
569
|
-
return { file: pwsh, args:
|
|
783
|
+
return { file: pwsh, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
|
|
570
784
|
}
|
|
571
785
|
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
572
786
|
if (windowsPowerShell) {
|
|
573
|
-
return { file: windowsPowerShell, args:
|
|
787
|
+
return { file: windowsPowerShell, args: powerShellArgs(command), useShell: false, effectiveShell: 'powershell' };
|
|
574
788
|
}
|
|
575
789
|
const cmd = resolveCmdExecutable();
|
|
576
790
|
if (cmd) {
|
|
577
791
|
return {
|
|
578
792
|
file: cmd,
|
|
579
|
-
args: ['/d', '/s', '/c', command],
|
|
793
|
+
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
580
794
|
useShell: false,
|
|
581
795
|
effectiveShell: 'cmd',
|
|
582
796
|
windowsVerbatimArguments: true,
|
|
@@ -632,7 +846,7 @@ function invalidShellResult(requestedShell) {
|
|
|
632
846
|
}, true);
|
|
633
847
|
}
|
|
634
848
|
function startChild(command, cwd, shell, env) {
|
|
635
|
-
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, } = buildSpawn(command, shell);
|
|
849
|
+
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, } = buildSpawn(command, shell);
|
|
636
850
|
if (unavailableShell)
|
|
637
851
|
return { effectiveShell, unavailableShell };
|
|
638
852
|
if (startError)
|
|
@@ -647,7 +861,7 @@ function startChild(command, cwd, shell, env) {
|
|
|
647
861
|
detached,
|
|
648
862
|
...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
649
863
|
});
|
|
650
|
-
return { child, effectiveShell };
|
|
864
|
+
return { child, effectiveShell, shellSubstitution };
|
|
651
865
|
}
|
|
652
866
|
catch (err) {
|
|
653
867
|
return {
|
|
@@ -656,21 +870,212 @@ function startChild(command, cwd, shell, env) {
|
|
|
656
870
|
};
|
|
657
871
|
}
|
|
658
872
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
873
|
+
/**
|
|
874
|
+
* Bounded output accumulator (issue #364). Retention is head + tail rather than
|
|
875
|
+
* head-only, so the command echo at the start AND the final result at the end
|
|
876
|
+
* both survive an output flood (e.g. a 50k-line ffmpeg progress log); the
|
|
877
|
+
* omitted middle is reported explicitly.
|
|
878
|
+
*/
|
|
879
|
+
function makeAppender(get, set, maxChars = MAX_OUTPUT_CHARS) {
|
|
880
|
+
const limit = Math.max(1_000, Math.min(Math.floor(maxChars) || MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
|
|
881
|
+
const headLimit = Math.max(1, Math.floor(limit * 0.3));
|
|
882
|
+
const tailLimit = Math.max(1, limit - headLimit);
|
|
883
|
+
let head = '';
|
|
884
|
+
let tail = '';
|
|
885
|
+
let dropped = 0;
|
|
886
|
+
let total = 0;
|
|
887
|
+
const compose = () => (dropped > 0
|
|
888
|
+
? head + '\n…[' + dropped + ' characters of output omitted]…\n' + tail
|
|
889
|
+
: head + tail);
|
|
890
|
+
const append = ((chunk) => {
|
|
891
|
+
if (!chunk)
|
|
665
892
|
return;
|
|
893
|
+
total += chunk.length;
|
|
894
|
+
let rest = chunk;
|
|
895
|
+
if (head.length < headLimit) {
|
|
896
|
+
const take = Math.min(headLimit - head.length, rest.length);
|
|
897
|
+
head += rest.slice(0, take);
|
|
898
|
+
rest = rest.slice(take);
|
|
666
899
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
900
|
+
if (rest) {
|
|
901
|
+
tail += rest;
|
|
902
|
+
if (tail.length > tailLimit) {
|
|
903
|
+
const remove = tail.length - tailLimit;
|
|
904
|
+
tail = tail.slice(remove);
|
|
905
|
+
dropped += remove;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
set(compose(), dropped > 0);
|
|
909
|
+
});
|
|
910
|
+
append.totalChars = () => total;
|
|
911
|
+
append.droppedChars = () => dropped;
|
|
912
|
+
// `get` is retained for call-site symmetry with the previous signature.
|
|
913
|
+
void get;
|
|
914
|
+
return append;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Shell-dialect trap detection (issue #364). Commands are NEVER rewritten —
|
|
918
|
+
* translating shell text between dialects is far riskier than executing what
|
|
919
|
+
* the caller wrote — so every finding is returned as an advisory
|
|
920
|
+
* `dialectWarnings` entry with a concrete suggested fix.
|
|
921
|
+
*/
|
|
922
|
+
const POSIX_ONLY_BINARIES = new Set([
|
|
923
|
+
'head', 'tail', 'grep', 'sed', 'awk', 'wc', 'cat', 'cut', 'tr', 'uniq', 'sort', 'xargs', 'touch', 'which',
|
|
924
|
+
'rg', 'jq', 'find', 'ls', 'pwd', 'less', 'du', 'df',
|
|
925
|
+
]);
|
|
926
|
+
const SHELL_BUILTINS = new Set([
|
|
927
|
+
'cd', 'echo', 'exit', 'export', 'set', 'unset', 'if', 'then', 'else', 'fi', 'for', 'while', 'do', 'done',
|
|
928
|
+
'true', 'false', 'test', 'type', 'alias', 'function', 'return', 'shift', 'source', '.', 'dir', 'cls',
|
|
929
|
+
'copy', 'del', 'erase', 'md', 'mkdir', 'rd', 'rmdir', 'move', 'ren', 'rename', 'pushd', 'popd',
|
|
930
|
+
]);
|
|
931
|
+
function isMissingCommandCandidate(head, shell) {
|
|
932
|
+
const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
|
|
933
|
+
if (!name || SHELL_BUILTINS.has(name) || /^[-/$]/.test(head) || /^[A-Za-z_][\w-]*=/.test(head))
|
|
934
|
+
return false;
|
|
935
|
+
if (/^[A-Za-z]+-[A-Za-z][A-Za-z-]*$/.test(head) && (shell === 'pwsh' || shell === 'powershell'))
|
|
936
|
+
return false;
|
|
937
|
+
return !/[\\/]/.test(head) && !/^\.?\.?$/.test(head);
|
|
938
|
+
}
|
|
939
|
+
/** Blank out quoted spans so operators inside string literals never trip us. */
|
|
940
|
+
function stripQuotedSegments(command) {
|
|
941
|
+
return command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, (match) => ' '.repeat(match.length));
|
|
942
|
+
}
|
|
943
|
+
function commandSegmentHeads(stripped) {
|
|
944
|
+
return stripped
|
|
945
|
+
.split(/&&|\|\||[|;\n]/)
|
|
946
|
+
.map((segment) => segment.trim().split(/\s+/)[0] ?? '')
|
|
947
|
+
.map((head) => head.replace(/^[('"`]+/, ''))
|
|
948
|
+
.filter(Boolean);
|
|
949
|
+
}
|
|
950
|
+
/** True when the command relies on POSIX `&&` / `||` statement chaining. */
|
|
951
|
+
export function commandUsesPosixChaining(command) {
|
|
952
|
+
return /&&|\|\|/.test(stripQuotedSegments(command));
|
|
953
|
+
}
|
|
954
|
+
export function detectDialectMismatch(command, shell, isAvailable = (name) => Boolean(findExecutableOnPath(name))) {
|
|
955
|
+
const warnings = [];
|
|
956
|
+
const stripped = stripQuotedSegments(command);
|
|
957
|
+
const heads = commandSegmentHeads(stripped);
|
|
958
|
+
const posixShell = shell === 'bash' || shell === 'sh';
|
|
959
|
+
if (!posixShell) {
|
|
960
|
+
for (const head of new Set(heads)) {
|
|
961
|
+
const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
|
|
962
|
+
if (!isMissingCommandCandidate(head, shell) || isAvailable(name))
|
|
963
|
+
continue;
|
|
964
|
+
if (POSIX_ONLY_BINARIES.has(name)) {
|
|
965
|
+
warnings.push(`"${name}" is a POSIX utility and is not available as an executable on this machine; `
|
|
966
|
+
+ `under ${shell} it resolves to nothing (or to an unrelated alias). Pass shell:"bash" or use the native equivalent.`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
warnings.push(`"${name}" was not found on PATH; under ${shell} this will fail with a "command not found" error. `
|
|
970
|
+
+ 'Install it, use the native equivalent, or call "shell_info".');
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (shell === 'powershell' && /&&|\|\|/.test(stripped)) {
|
|
975
|
+
warnings.push('"&&" and "||" are not statement separators in Windows PowerShell 5.1 (they are a parse error). '
|
|
976
|
+
+ 'Use ";" for unconditional chaining, or pass shell:"bash" / shell:"pwsh".');
|
|
977
|
+
}
|
|
978
|
+
if (shell === 'pwsh' || shell === 'powershell') {
|
|
979
|
+
if (/\$\(\s*([A-Za-z_][\w-]*)/.test(stripped)) {
|
|
980
|
+
const inner = /\$\(\s*([A-Za-z_][\w-]*)/.exec(stripped)?.[1] ?? '';
|
|
981
|
+
if (POSIX_ONLY_BINARIES.has(inner.toLowerCase()) && !isAvailable(inner.toLowerCase())) {
|
|
982
|
+
warnings.push(`"$(${inner} …)" is POSIX command substitution; PowerShell evaluates it as a subexpression `
|
|
983
|
+
+ 'and cannot run that utility. Pass shell:"bash".');
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if ((stripped.match(/`/g) ?? []).length >= 2) {
|
|
987
|
+
warnings.push('Backticks are the line-continuation/escape character in PowerShell, not command substitution. '
|
|
988
|
+
+ 'Use "$(…)" for PowerShell, or pass shell:"bash".');
|
|
989
|
+
}
|
|
990
|
+
if (/2>&1/.test(stripped)) {
|
|
991
|
+
warnings.push('No "2>&1" redirection is needed: this tool already merges stdout and stderr into one "output" field, '
|
|
992
|
+
+ 'and "isError" is derived from the exit code only.');
|
|
672
993
|
}
|
|
673
|
-
|
|
994
|
+
}
|
|
995
|
+
if (shell === 'cmd') {
|
|
996
|
+
if (/'/.test(command.replace(/"(?:\\.|[^"\\])*"/g, ''))) {
|
|
997
|
+
warnings.push('cmd.exe does not treat single quotes as string delimiters; use double quotes.');
|
|
998
|
+
}
|
|
999
|
+
if (/\$\(|`/.test(stripped)) {
|
|
1000
|
+
warnings.push('Command substitution ($(…) / backticks) is not supported by cmd.exe. Pass shell:"bash" or shell:"pwsh".');
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (posixShell && /\b(?:Get|Set|New|Remove|Write|Select|Out|Format|Test|Invoke|Start|Stop)-[A-Z][A-Za-z]+\b/.test(stripped)) {
|
|
1004
|
+
warnings.push('This looks like a PowerShell cmdlet but the command is running under a POSIX shell. Pass shell:"pwsh".');
|
|
1005
|
+
}
|
|
1006
|
+
return warnings;
|
|
1007
|
+
}
|
|
1008
|
+
function missingExecutableHint(exitCode, warnings) {
|
|
1009
|
+
if ((exitCode === 9009 || exitCode === 255 || exitCode === 1)
|
|
1010
|
+
&& warnings.some((warning) => /not found on PATH|not available as an executable/.test(warning))) {
|
|
1011
|
+
return { hint: 'A referenced executable was not found — see dialectWarnings.' };
|
|
1012
|
+
}
|
|
1013
|
+
return {};
|
|
1014
|
+
}
|
|
1015
|
+
/** Commands that typically hang forever waiting on a pager or interactive prompt. */
|
|
1016
|
+
export function detectInteractiveHangRisk(command) {
|
|
1017
|
+
const hints = [];
|
|
1018
|
+
const stripped = stripQuotedSegments(command);
|
|
1019
|
+
if (/\bgit\b(?![^|;]*--no-pager)[^|;]*\b(?:log|diff|show|branch)\b/.test(stripped)) {
|
|
1020
|
+
hints.push('git paginates by default; use "git --no-pager …" (or append "| cat") for non-interactive runs.');
|
|
1021
|
+
}
|
|
1022
|
+
if (/(^|[|;\s])(?:less|more)(\s|$)/.test(stripped)) {
|
|
1023
|
+
hints.push('"less"/"more" are interactive pagers and will never exit here; drop them.');
|
|
1024
|
+
}
|
|
1025
|
+
if (/Format-(?:Table|List)\b/.test(stripped) && !/Out-String/.test(stripped)) {
|
|
1026
|
+
hints.push('Pipe Format-Table/Format-List into "| Out-String" so PowerShell does not wait on the host formatter.');
|
|
1027
|
+
}
|
|
1028
|
+
if (/(^|[|;\s])(?:npm|yarn|pnpm)\s+(?:init|login)(\s|$)/.test(stripped)) {
|
|
1029
|
+
hints.push('This command prompts interactively; add its non-interactive flag (e.g. "npm init -y").');
|
|
1030
|
+
}
|
|
1031
|
+
return hints;
|
|
1032
|
+
}
|
|
1033
|
+
function validateEncoding(input) {
|
|
1034
|
+
if (input === undefined)
|
|
1035
|
+
return 'auto';
|
|
1036
|
+
if (input === 'utf8' || input === 'utf16le' || input === 'auto')
|
|
1037
|
+
return input;
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
function looksLikeUtf16le(buf) {
|
|
1041
|
+
const len = Math.min(buf.length, 64);
|
|
1042
|
+
if (len < 4)
|
|
1043
|
+
return false;
|
|
1044
|
+
let nuls = 0;
|
|
1045
|
+
for (let i = 0; i < len; i += 1)
|
|
1046
|
+
if (buf[i] === 0)
|
|
1047
|
+
nuls += 1;
|
|
1048
|
+
return nuls / len > 0.3;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Decode child output deterministically (issue #364). Windows tooling happily
|
|
1052
|
+
* writes UTF-16LE (e.g. PowerShell redirection into a log) which the default
|
|
1053
|
+
* UTF-8 decode turns into NUL-riddled mojibake. `auto` sniffs a BOM or a high
|
|
1054
|
+
* NUL ratio in the first chunk and then stays with that choice.
|
|
1055
|
+
*/
|
|
1056
|
+
export function createStreamDecoder(mode = 'auto') {
|
|
1057
|
+
let resolved = mode === 'auto' ? null : mode;
|
|
1058
|
+
let first = true;
|
|
1059
|
+
return (chunk) => {
|
|
1060
|
+
if (typeof chunk === 'string')
|
|
1061
|
+
return chunk;
|
|
1062
|
+
let buf = chunk;
|
|
1063
|
+
if (first) {
|
|
1064
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
1065
|
+
if (resolved === null)
|
|
1066
|
+
resolved = 'utf16le';
|
|
1067
|
+
if (resolved === 'utf16le')
|
|
1068
|
+
buf = buf.subarray(2);
|
|
1069
|
+
}
|
|
1070
|
+
else if (resolved === null) {
|
|
1071
|
+
resolved = looksLikeUtf16le(buf) ? 'utf16le' : 'utf8';
|
|
1072
|
+
}
|
|
1073
|
+
first = false;
|
|
1074
|
+
}
|
|
1075
|
+
let text = buf.toString(resolved ?? 'utf8');
|
|
1076
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
1077
|
+
text = text.slice(1);
|
|
1078
|
+
return text;
|
|
674
1079
|
};
|
|
675
1080
|
}
|
|
676
1081
|
/** Resolve an interactive shell executable without wrapping a command string. */
|
|
@@ -817,7 +1222,25 @@ export function bashToolDefinitions() {
|
|
|
817
1222
|
additionalProperties: { type: 'string' },
|
|
818
1223
|
description: 'Environment variables to add or override for this command.',
|
|
819
1224
|
};
|
|
1225
|
+
const encodingProp = {
|
|
1226
|
+
type: 'string',
|
|
1227
|
+
enum: ['auto', 'utf8', 'utf16le'],
|
|
1228
|
+
description: 'How to decode child output. "auto" (default) detects UTF-16LE (BOM or NUL-heavy) and otherwise uses UTF-8.',
|
|
1229
|
+
};
|
|
1230
|
+
const maxOutputCharsProp = {
|
|
1231
|
+
type: 'number',
|
|
1232
|
+
description: 'Cap the returned output (default and hard maximum 100000 characters). When exceeded, the head and tail are kept and the omitted middle is reported.',
|
|
1233
|
+
};
|
|
1234
|
+
const outputFileProp = {
|
|
1235
|
+
type: 'string',
|
|
1236
|
+
description: 'Spool the full merged output to this file (confined to the configured roots) while the result stays bounded.',
|
|
1237
|
+
};
|
|
820
1238
|
return [
|
|
1239
|
+
{
|
|
1240
|
+
name: 'shell_info',
|
|
1241
|
+
description: 'Describe this machine before running anything: platform, which shell "default" resolves to, which of pwsh/powershell/bash/cmd/sh are available, and whether common interpreters (python, node, git, ffmpeg, …) are on PATH. Call this instead of discovering the environment through failed commands.',
|
|
1242
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1243
|
+
},
|
|
821
1244
|
{
|
|
822
1245
|
name: 'open_terminal',
|
|
823
1246
|
description: 'Open a real interactive pseudoterminal (PTY/ConPTY) and display its MCP App. Returns an owner-scoped terminal sessionId.',
|
|
@@ -884,7 +1307,9 @@ export function bashToolDefinitions() {
|
|
|
884
1307
|
},
|
|
885
1308
|
{
|
|
886
1309
|
name: 'run',
|
|
887
|
-
description: 'Run one command to completion.
|
|
1310
|
+
description: 'Run one command to completion. "output" is stdout and stderr MERGED (no 2>&1 needed) and "isError" reflects the process exit code only — text on stderr is not a failure. '
|
|
1311
|
+
+ 'On an unknown machine call "shell_info" first; prefer shell:"bash" for POSIX syntax (&&, pipes into head/grep). Output is UTF-8 decoded and PowerShell runs with invariant culture, so numbers and text are stable. '
|
|
1312
|
+
+ 'Use start/status for a persistent background session.',
|
|
888
1313
|
inputSchema: {
|
|
889
1314
|
type: 'object',
|
|
890
1315
|
properties: {
|
|
@@ -892,15 +1317,18 @@ export function bashToolDefinitions() {
|
|
|
892
1317
|
cwd: cwdProp,
|
|
893
1318
|
shell: shellProp,
|
|
894
1319
|
env: envProp,
|
|
895
|
-
timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600).' },
|
|
1320
|
+
timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600). On timeout the whole process tree is killed and elapsedMs/timeoutMs are reported.' },
|
|
896
1321
|
normalizeNewlines: { type: 'boolean', description: 'If true, CRLF/CR in the captured output are normalized to LF.' },
|
|
1322
|
+
encoding: encodingProp,
|
|
1323
|
+
maxOutputChars: maxOutputCharsProp,
|
|
1324
|
+
outputFile: outputFileProp,
|
|
897
1325
|
},
|
|
898
1326
|
required: ['command'],
|
|
899
1327
|
},
|
|
900
1328
|
},
|
|
901
1329
|
{
|
|
902
1330
|
name: 'start',
|
|
903
|
-
description: 'Start an independent background command and return its sessionId. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
|
|
1331
|
+
description: 'Start an independent background command and return its sessionId. Output is stdout and stderr merged, decoded as UTF-8. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
|
|
904
1332
|
inputSchema: {
|
|
905
1333
|
type: 'object',
|
|
906
1334
|
properties: {
|
|
@@ -908,6 +1336,9 @@ export function bashToolDefinitions() {
|
|
|
908
1336
|
cwd: cwdProp,
|
|
909
1337
|
shell: shellProp,
|
|
910
1338
|
env: envProp,
|
|
1339
|
+
encoding: encodingProp,
|
|
1340
|
+
maxOutputChars: maxOutputCharsProp,
|
|
1341
|
+
outputFile: outputFileProp,
|
|
911
1342
|
},
|
|
912
1343
|
required: ['command'],
|
|
913
1344
|
},
|
|
@@ -965,6 +1396,68 @@ export function bashToolDefinitions() {
|
|
|
965
1396
|
function maybeNormalize(text, normalize) {
|
|
966
1397
|
return normalize ? text.replace(/\r\n?/g, '\n') : text;
|
|
967
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Only when the caller said `shell: "default"` may we pick a different shell
|
|
1401
|
+
* (issue #364): a command chained with POSIX `&&`/`||` cannot even parse under
|
|
1402
|
+
* Windows PowerShell 5.1, so prefer a shell that can run it. An explicitly
|
|
1403
|
+
* requested shell is never overridden.
|
|
1404
|
+
*/
|
|
1405
|
+
function selectShell(command, requested) {
|
|
1406
|
+
if (requested !== 'default')
|
|
1407
|
+
return { shell: requested, autoSelected: false };
|
|
1408
|
+
const plan = buildSpawn(command, 'default');
|
|
1409
|
+
if (plan.effectiveShell === 'powershell' && commandUsesPosixChaining(command)) {
|
|
1410
|
+
if (resolvePwshExecutable())
|
|
1411
|
+
return { shell: 'pwsh', autoSelected: true };
|
|
1412
|
+
if (resolveBashExecutable())
|
|
1413
|
+
return { shell: 'bash', autoSelected: true };
|
|
1414
|
+
}
|
|
1415
|
+
return { shell: 'default', autoSelected: false };
|
|
1416
|
+
}
|
|
1417
|
+
/** Optional per-call output limits shared by `run` and `start`. */
|
|
1418
|
+
function resolveMaxOutputChars(input) {
|
|
1419
|
+
return typeof input === 'number' && input > 0
|
|
1420
|
+
? Math.min(Math.floor(input), MAX_OUTPUT_CHARS)
|
|
1421
|
+
: MAX_OUTPUT_CHARS;
|
|
1422
|
+
}
|
|
1423
|
+
/** Spool the full merged output to a file while the tool result stays bounded. */
|
|
1424
|
+
function createOutputSpool(file) {
|
|
1425
|
+
if (!file)
|
|
1426
|
+
return { write: () => undefined, close: () => undefined, bytes: () => 0, error: () => undefined };
|
|
1427
|
+
let bytes = 0;
|
|
1428
|
+
let failure;
|
|
1429
|
+
let stream;
|
|
1430
|
+
try {
|
|
1431
|
+
stream = fs.createWriteStream(file, { encoding: 'utf8' });
|
|
1432
|
+
stream.on('error', (err) => { failure = err instanceof Error ? err.message : String(err); });
|
|
1433
|
+
}
|
|
1434
|
+
catch (err) {
|
|
1435
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
write: (chunk) => {
|
|
1439
|
+
if (!stream || failure || !chunk)
|
|
1440
|
+
return;
|
|
1441
|
+
try {
|
|
1442
|
+
stream.write(chunk);
|
|
1443
|
+
bytes += Buffer.byteLength(chunk, 'utf8');
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
close: () => {
|
|
1450
|
+
try {
|
|
1451
|
+
stream?.end();
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
/* best-effort */
|
|
1455
|
+
}
|
|
1456
|
+
},
|
|
1457
|
+
bytes: () => bytes,
|
|
1458
|
+
error: () => failure,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
968
1461
|
async function runTool(args, roots, context) {
|
|
969
1462
|
const command = String(args?.command ?? '').trim();
|
|
970
1463
|
if (!command)
|
|
@@ -979,27 +1472,69 @@ async function runTool(args, roots, context) {
|
|
|
979
1472
|
if (context?.signal?.aborted) {
|
|
980
1473
|
return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
|
|
981
1474
|
}
|
|
1475
|
+
const encodingMode = validateEncoding(args.encoding);
|
|
1476
|
+
if (!encodingMode) {
|
|
1477
|
+
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1478
|
+
}
|
|
982
1479
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
983
1480
|
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
984
1481
|
const warn = warnings.length ? { warnings } : {};
|
|
985
1482
|
const normalize = args.normalizeNewlines === true;
|
|
986
1483
|
const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
|
|
987
1484
|
const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
|
|
1485
|
+
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
1486
|
+
let outputFilePath;
|
|
1487
|
+
if (args.outputFile !== undefined) {
|
|
1488
|
+
try {
|
|
1489
|
+
outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
|
|
1490
|
+
}
|
|
1491
|
+
catch (err) {
|
|
1492
|
+
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const selection = selectShell(command, requestedShell);
|
|
988
1496
|
registerExitCleanup();
|
|
1497
|
+
const startedAt = Date.now();
|
|
989
1498
|
return await new Promise((resolve) => {
|
|
990
1499
|
let output = '';
|
|
991
1500
|
let truncated = false;
|
|
992
1501
|
let settled = false;
|
|
993
|
-
|
|
1502
|
+
let stderrChars = 0;
|
|
1503
|
+
const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; }, maxOutputChars);
|
|
1504
|
+
const spool = createOutputSpool(outputFilePath);
|
|
1505
|
+
const decodeStdout = createStreamDecoder(encodingMode);
|
|
1506
|
+
const decodeStderr = createStreamDecoder(encodingMode);
|
|
994
1507
|
const progress = createCommandProgressReporter(context);
|
|
995
|
-
const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd,
|
|
1508
|
+
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
|
|
1509
|
+
const dialectWarnings = detectDialectMismatch(command, effectiveShell);
|
|
1510
|
+
const dialect = dialectWarnings.length ? { dialectWarnings } : {};
|
|
1511
|
+
const auto = selection.autoSelected ? { shellAutoSelected: true } : {};
|
|
1512
|
+
const substitution = shellSubstitution ? { shellSubstitution } : {};
|
|
1513
|
+
const spoolInfo = () => (outputFilePath
|
|
1514
|
+
? { outputFile: outputFilePath, outputBytes: spool.bytes(), ...(spool.error() ? { outputFileError: spool.error() } : {}) }
|
|
1515
|
+
: {});
|
|
1516
|
+
const outputStats = () => ({
|
|
1517
|
+
truncated,
|
|
1518
|
+
stderrChars,
|
|
1519
|
+
outputChars: append.totalChars(),
|
|
1520
|
+
...(append.droppedChars() > 0 ? { omittedChars: append.droppedChars() } : {}),
|
|
1521
|
+
});
|
|
996
1522
|
if (unavailableShell) {
|
|
1523
|
+
spool.close();
|
|
997
1524
|
void progress.stop().then(() => {
|
|
998
|
-
resolve(textResult({
|
|
1525
|
+
resolve(textResult({
|
|
1526
|
+
error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
|
|
1527
|
+
cwd,
|
|
1528
|
+
requestedShell,
|
|
1529
|
+
shell: unavailableShell,
|
|
1530
|
+
availableShells: availableShellNames(),
|
|
1531
|
+
hint: unavailableShellHint(unavailableShell),
|
|
1532
|
+
}, true));
|
|
999
1533
|
});
|
|
1000
1534
|
return;
|
|
1001
1535
|
}
|
|
1002
1536
|
if (startError || !child) {
|
|
1537
|
+
spool.close();
|
|
1003
1538
|
void progress.stop().then(() => {
|
|
1004
1539
|
resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
|
|
1005
1540
|
});
|
|
@@ -1018,8 +1553,12 @@ async function runTool(args, roots, context) {
|
|
|
1018
1553
|
requestedShell,
|
|
1019
1554
|
shell: effectiveShell,
|
|
1020
1555
|
exitCode: null,
|
|
1021
|
-
truncated,
|
|
1022
1556
|
output: finalOut,
|
|
1557
|
+
...outputStats(),
|
|
1558
|
+
...spoolInfo(),
|
|
1559
|
+
...dialect,
|
|
1560
|
+
...substitution,
|
|
1561
|
+
...auto,
|
|
1023
1562
|
...warn,
|
|
1024
1563
|
}, true), true);
|
|
1025
1564
|
};
|
|
@@ -1032,31 +1571,46 @@ async function runTool(args, roots, context) {
|
|
|
1032
1571
|
context?.signal?.removeEventListener('abort', onAbort);
|
|
1033
1572
|
if (!keepKillEscalation)
|
|
1034
1573
|
cancelEscalation?.();
|
|
1574
|
+
spool.close();
|
|
1035
1575
|
await progress.stop();
|
|
1036
1576
|
resolve(result);
|
|
1037
1577
|
};
|
|
1038
1578
|
timer = setTimeout(() => {
|
|
1039
1579
|
cancelEscalation = killProcessTree(child);
|
|
1040
1580
|
const finalOut = maybeNormalize(output, normalize);
|
|
1581
|
+
const hangHints = detectInteractiveHangRisk(command);
|
|
1041
1582
|
void finish(textResult({
|
|
1042
1583
|
timedOut: true,
|
|
1043
1584
|
cwd,
|
|
1044
1585
|
requestedShell,
|
|
1045
1586
|
shell: effectiveShell,
|
|
1046
1587
|
exitCode: null,
|
|
1047
|
-
|
|
1588
|
+
timeoutMs,
|
|
1589
|
+
elapsedMs: Date.now() - startedAt,
|
|
1590
|
+
killedProcessTree: true,
|
|
1591
|
+
suggestion: `Command exceeded ${timeoutMs / 1000}s and its process tree was killed. `
|
|
1592
|
+
+ 'Raise "timeout" (max 600s) or run it via "start" + "wait" for long jobs.',
|
|
1048
1593
|
output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
|
|
1594
|
+
...outputStats(),
|
|
1595
|
+
...spoolInfo(),
|
|
1596
|
+
...(hangHints.length ? { hangHints } : {}),
|
|
1597
|
+
...dialect,
|
|
1598
|
+
...substitution,
|
|
1599
|
+
...auto,
|
|
1049
1600
|
...warn,
|
|
1050
1601
|
}, true), true);
|
|
1051
1602
|
}, timeoutMs);
|
|
1052
1603
|
child.stdout?.on('data', (d) => {
|
|
1053
|
-
const chunk = d
|
|
1604
|
+
const chunk = decodeStdout(d);
|
|
1054
1605
|
append(chunk);
|
|
1606
|
+
spool.write(chunk);
|
|
1055
1607
|
progress.push(maybeNormalize(chunk, normalize));
|
|
1056
1608
|
});
|
|
1057
1609
|
child.stderr?.on('data', (d) => {
|
|
1058
|
-
const chunk = d
|
|
1610
|
+
const chunk = decodeStderr(d);
|
|
1611
|
+
stderrChars += chunk.length;
|
|
1059
1612
|
append(chunk);
|
|
1613
|
+
spool.write(chunk);
|
|
1060
1614
|
progress.push(maybeNormalize(chunk, normalize));
|
|
1061
1615
|
});
|
|
1062
1616
|
child.on('error', (err) => {
|
|
@@ -1071,7 +1625,20 @@ async function runTool(args, roots, context) {
|
|
|
1071
1625
|
if (settled)
|
|
1072
1626
|
return;
|
|
1073
1627
|
const finalOut = maybeNormalize(output, normalize);
|
|
1074
|
-
void finish(textResult({
|
|
1628
|
+
void finish(textResult({
|
|
1629
|
+
exitCode: code,
|
|
1630
|
+
cwd,
|
|
1631
|
+
requestedShell,
|
|
1632
|
+
shell: effectiveShell,
|
|
1633
|
+
output: finalOut,
|
|
1634
|
+
...outputStats(),
|
|
1635
|
+
...spoolInfo(),
|
|
1636
|
+
...dialect,
|
|
1637
|
+
...missingExecutableHint(code, dialectWarnings),
|
|
1638
|
+
...substitution,
|
|
1639
|
+
...auto,
|
|
1640
|
+
...warn,
|
|
1641
|
+
}, code !== 0));
|
|
1075
1642
|
});
|
|
1076
1643
|
if (context?.signal?.aborted)
|
|
1077
1644
|
onAbort();
|
|
@@ -1113,12 +1680,34 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1113
1680
|
return textResult({ error: `Too many active background sessions (max ${MAX_SESSIONS}). Kill some first.` }, true);
|
|
1114
1681
|
}
|
|
1115
1682
|
registerExitCleanup();
|
|
1683
|
+
const encodingMode = validateEncoding(args.encoding);
|
|
1684
|
+
if (!encodingMode) {
|
|
1685
|
+
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1686
|
+
}
|
|
1116
1687
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1117
1688
|
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
1118
1689
|
const warn = warnings.length ? { warnings } : {};
|
|
1119
|
-
const
|
|
1690
|
+
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
1691
|
+
let outputFilePath;
|
|
1692
|
+
if (args.outputFile !== undefined) {
|
|
1693
|
+
try {
|
|
1694
|
+
outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const selection = selectShell(command, requestedShell);
|
|
1701
|
+
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
|
|
1120
1702
|
if (unavailableShell) {
|
|
1121
|
-
return textResult({
|
|
1703
|
+
return textResult({
|
|
1704
|
+
error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
|
|
1705
|
+
cwd,
|
|
1706
|
+
requestedShell,
|
|
1707
|
+
shell: unavailableShell,
|
|
1708
|
+
availableShells: availableShellNames(),
|
|
1709
|
+
hint: unavailableShellHint(unavailableShell),
|
|
1710
|
+
}, true);
|
|
1122
1711
|
}
|
|
1123
1712
|
if (startError || !child) {
|
|
1124
1713
|
return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
|
|
@@ -1137,9 +1726,21 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1137
1726
|
startedAt: Date.now(),
|
|
1138
1727
|
};
|
|
1139
1728
|
table.set(id, session);
|
|
1140
|
-
const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; });
|
|
1141
|
-
|
|
1142
|
-
|
|
1729
|
+
const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; }, maxOutputChars);
|
|
1730
|
+
const spool = createOutputSpool(outputFilePath);
|
|
1731
|
+
const decodeStdout = createStreamDecoder(encodingMode);
|
|
1732
|
+
const decodeStderr = createStreamDecoder(encodingMode);
|
|
1733
|
+
child.stdout?.on('data', (d) => {
|
|
1734
|
+
const chunk = decodeStdout(d);
|
|
1735
|
+
append(chunk);
|
|
1736
|
+
spool.write(chunk);
|
|
1737
|
+
});
|
|
1738
|
+
child.stderr?.on('data', (d) => {
|
|
1739
|
+
const chunk = decodeStderr(d);
|
|
1740
|
+
session.stderrChars = (session.stderrChars ?? 0) + chunk.length;
|
|
1741
|
+
append(chunk);
|
|
1742
|
+
spool.write(chunk);
|
|
1743
|
+
});
|
|
1143
1744
|
child.on('error', (err) => {
|
|
1144
1745
|
append(`\n${err.message}`);
|
|
1145
1746
|
session.running = false;
|
|
@@ -1150,9 +1751,22 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1150
1751
|
session.running = false;
|
|
1151
1752
|
session.exitCode = code;
|
|
1152
1753
|
session.endedAt = Date.now();
|
|
1754
|
+
spool.close();
|
|
1153
1755
|
scheduleReap(session);
|
|
1154
1756
|
});
|
|
1155
|
-
|
|
1757
|
+
const dialectWarnings = detectDialectMismatch(command, effectiveShell);
|
|
1758
|
+
session.missingExecutableWarning = dialectWarnings.some((warning) => /not found on PATH|not available as an executable/.test(warning));
|
|
1759
|
+
return textResult({
|
|
1760
|
+
sessionId: id,
|
|
1761
|
+
cwd,
|
|
1762
|
+
requestedShell,
|
|
1763
|
+
shell: effectiveShell,
|
|
1764
|
+
...(shellSubstitution ? { shellSubstitution } : {}),
|
|
1765
|
+
...(selection.autoSelected ? { shellAutoSelected: true } : {}),
|
|
1766
|
+
...(dialectWarnings.length ? { dialectWarnings } : {}),
|
|
1767
|
+
...(outputFilePath ? { outputFile: outputFilePath } : {}),
|
|
1768
|
+
...warn,
|
|
1769
|
+
});
|
|
1156
1770
|
}
|
|
1157
1771
|
function snapshot(session, extra = {}) {
|
|
1158
1772
|
return {
|
|
@@ -1161,6 +1775,9 @@ function snapshot(session, extra = {}) {
|
|
|
1161
1775
|
exitCode: session.exitCode,
|
|
1162
1776
|
output: session.output,
|
|
1163
1777
|
truncated: session.truncated,
|
|
1778
|
+
stderrChars: session.stderrChars ?? 0,
|
|
1779
|
+
...(session.missingExecutableWarning && (session.exitCode === 9009 || session.exitCode === 255 || session.exitCode === 1)
|
|
1780
|
+
? { hint: 'A referenced executable was not found — see dialectWarnings.' } : {}),
|
|
1164
1781
|
...extra,
|
|
1165
1782
|
};
|
|
1166
1783
|
}
|
|
@@ -1304,7 +1921,11 @@ async function openTerminalTool(args, roots, ownerScope) {
|
|
|
1304
1921
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1305
1922
|
const plan = buildPtySpawn(shellValidation.shell);
|
|
1306
1923
|
if (plan.unavailableShell) {
|
|
1307
|
-
return textResult({
|
|
1924
|
+
return textResult({
|
|
1925
|
+
error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.`,
|
|
1926
|
+
availableShells: availableShellNames(),
|
|
1927
|
+
hint: 'Call "shell_info" to see which shells and interpreters exist on this machine.',
|
|
1928
|
+
}, true);
|
|
1308
1929
|
}
|
|
1309
1930
|
if (plan.startError)
|
|
1310
1931
|
return textResult({ error: plan.startError }, true);
|
|
@@ -1456,6 +2077,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
|
|
|
1456
2077
|
return closeTerminalTool(args, scope);
|
|
1457
2078
|
case 'terminal_list':
|
|
1458
2079
|
return listTerminalsTool(scope);
|
|
2080
|
+
case 'shell_info':
|
|
2081
|
+
return shellInfoTool();
|
|
1459
2082
|
case 'run': {
|
|
1460
2083
|
const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
|
|
1461
2084
|
return await runTool(args, roots, context);
|