@mario.andreschak/mcp-bash 3.44.0 → 3.45.1
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/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/resources.js +1 -1
- package/dist/shared.d.ts +0 -7
- package/dist/shared.js +0 -40
- package/dist/shared.js.map +1 -1
- package/dist/tools.d.ts +38 -0
- package/dist/tools.js +456 -370
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -12,16 +12,14 @@
|
|
|
12
12
|
* - orphan cleanup: every live session is force-killed on FLUJO process exit,
|
|
13
13
|
* and idle finished sessions are swept after a TTL.
|
|
14
14
|
*
|
|
15
|
-
* Confinement
|
|
15
|
+
* Confinement (issue #175): the `cwd` is confined to the same
|
|
16
16
|
* effective roots as the built-in `filesystem` server (a HARD CEILING from
|
|
17
17
|
* `FLUJO_BASH_ROOTS`, falling back to `FLUJO_FS_ROOTS`, plus UI-persisted roots
|
|
18
18
|
* that may only narrow within it). This is a best-effort guardrail — a shell can
|
|
19
19
|
* `cd` elsewhere or use absolute paths, so `FLUJO_FS_ROOTS`/`FLUJO_BASH_ROOTS`
|
|
20
|
-
* and OS permissions remain the real boundary. Spawned commands
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* full inheritance. Individual calls may pass explicit `env` overrides without
|
|
24
|
-
* enabling full inheritance.
|
|
20
|
+
* and OS permissions remain the real boundary. Spawned commands inherit the
|
|
21
|
+
* Bash server's complete host environment, just like a terminal opened by the
|
|
22
|
+
* same FLUJO process. Individual calls may still pass explicit `env` overrides.
|
|
25
23
|
*
|
|
26
24
|
* Every tool returns a machine-readable JSON envelope in a single text content
|
|
27
25
|
* block; failures come back as `isError: true` rather than thrown.
|
|
@@ -33,9 +31,9 @@
|
|
|
33
31
|
*/
|
|
34
32
|
import path from 'node:path';
|
|
35
33
|
import fs from 'node:fs';
|
|
36
|
-
import { spawn } from 'node:child_process';
|
|
34
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
37
35
|
import { spawn as spawnPty } from '@lydell/node-pty';
|
|
38
|
-
import {
|
|
36
|
+
import { createLogger, getDataDir, isInside, killProcessTree, loadEffectiveRoots, } from './shared.js';
|
|
39
37
|
const BASH_SERVER_NAME = 'bash';
|
|
40
38
|
export const BASH_TERMINAL_APP_URI = 'ui://bash/terminal';
|
|
41
39
|
const log = createLogger('backend/services/mcp/internal/bashTools');
|
|
@@ -226,12 +224,56 @@ function resolveBashExecutable() {
|
|
|
226
224
|
cachedBashPath = firstExistingFile(candidates);
|
|
227
225
|
return cachedBashPath;
|
|
228
226
|
}
|
|
227
|
+
function windowsStorePwshCandidates(programFiles) {
|
|
228
|
+
const windowsApps = path.join(programFiles, 'WindowsApps');
|
|
229
|
+
const packagePattern = /^Microsoft\.PowerShell_[^_]+_[^_]+__8wekyb3d8bbwe$/i;
|
|
230
|
+
const packageNames = [];
|
|
231
|
+
try {
|
|
232
|
+
packageNames.push(...fs.readdirSync(windowsApps, { withFileTypes: true })
|
|
233
|
+
.filter((entry) => entry.isDirectory() && packagePattern.test(entry.name))
|
|
234
|
+
.map((entry) => entry.name));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// WindowsApps is normally protected, even though executables in a known
|
|
238
|
+
// package directory remain runnable. Discover per-user packages below.
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
|
|
242
|
+
const regExe = systemRoot ? path.join(systemRoot, 'System32', 'reg.exe') : 'reg.exe';
|
|
243
|
+
const packageRepository = String.raw `HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages`;
|
|
244
|
+
const result = spawnSync(regExe, [
|
|
245
|
+
'query', packageRepository,
|
|
246
|
+
'/f', 'Microsoft.PowerShell_*__8wekyb3d8bbwe',
|
|
247
|
+
'/k', '/s',
|
|
248
|
+
], {
|
|
249
|
+
encoding: 'utf8',
|
|
250
|
+
windowsHide: true,
|
|
251
|
+
timeout: 3_000,
|
|
252
|
+
});
|
|
253
|
+
if (result.status === 0 && result.stdout) {
|
|
254
|
+
const matches = result.stdout.match(/Microsoft\.PowerShell_[^_\\\s]+_[^_\\\s]+__8wekyb3d8bbwe/gi);
|
|
255
|
+
if (matches)
|
|
256
|
+
packageNames.push(...matches);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Registry discovery is best-effort; MSI locations below remain available.
|
|
261
|
+
}
|
|
262
|
+
return [...new Set(packageNames)]
|
|
263
|
+
.sort((a, b) => {
|
|
264
|
+
const aVersion = a.split('_')[1] ?? '';
|
|
265
|
+
const bVersion = b.split('_')[1] ?? '';
|
|
266
|
+
return bVersion.localeCompare(aVersion, undefined, { numeric: true, sensitivity: 'base' });
|
|
267
|
+
})
|
|
268
|
+
.map((packageName) => path.join(windowsApps, packageName, 'pwsh.exe'));
|
|
269
|
+
}
|
|
229
270
|
function windowsPwshCandidates() {
|
|
230
271
|
const localAppData = getEnvCaseInsensitive('LocalAppData');
|
|
231
272
|
const programFiles = getEnvCaseInsensitive('ProgramFiles');
|
|
232
273
|
return [
|
|
233
274
|
...(localAppData ? [path.join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe')] : []),
|
|
234
275
|
...(programFiles ? [
|
|
276
|
+
...windowsStorePwshCandidates(programFiles),
|
|
235
277
|
path.join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
|
|
236
278
|
path.join(programFiles, 'PowerShell', '7-preview', 'pwsh.exe'),
|
|
237
279
|
] : []),
|
|
@@ -307,7 +349,7 @@ let cachedShellInfo;
|
|
|
307
349
|
export function collectShellInfo() {
|
|
308
350
|
if (cachedShellInfo)
|
|
309
351
|
return cachedShellInfo;
|
|
310
|
-
const defaultPlan =
|
|
352
|
+
const defaultPlan = resolveShellPlan('', 'default', 'command');
|
|
311
353
|
const shells = [
|
|
312
354
|
shellEntry('pwsh', resolvePwshExecutable()),
|
|
313
355
|
shellEntry('powershell', resolveWindowsPowerShellExecutable()),
|
|
@@ -325,7 +367,9 @@ export function collectShellInfo() {
|
|
|
325
367
|
const notes = [
|
|
326
368
|
'Tool output merges stdout and stderr into one "output" field; "isError" reflects the process exit code only.',
|
|
327
369
|
`The "default" shell on this machine is "${defaultPlan.effectiveShell}".`,
|
|
328
|
-
'Pass shell:"bash" for POSIX syntax
|
|
370
|
+
'Pass shell:"bash" for POSIX syntax; on Windows it launches the absolute Git Bash path reported above, never a bare bash lookup inside PowerShell.',
|
|
371
|
+
'A specific default-shell command may auto-select PowerShell 7 or Git Bash only for unquoted POSIX &&/|| chaining.',
|
|
372
|
+
'PowerShell programs use BOM-free encoded transport; write_stdin preserves a leading BOM unless bomPolicy:"strip-leading" is requested.',
|
|
329
373
|
];
|
|
330
374
|
if (!shells.find((entry) => entry.shell === 'pwsh')?.available
|
|
331
375
|
&& shells.find((entry) => entry.shell === 'powershell')?.available) {
|
|
@@ -663,15 +707,6 @@ async function resolveCwd(input, roots) {
|
|
|
663
707
|
const dataDir = getDataDir();
|
|
664
708
|
const raw = typeof input === 'string' ? input.trim() : '';
|
|
665
709
|
const resolved = raw ? (path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(dataDir, raw)) : dataDir;
|
|
666
|
-
// Optional defense-in-depth layer (issue #260). Configured roots win by
|
|
667
|
-
// default; users can opt into this stricter policy under Experimental Features.
|
|
668
|
-
if (await isProtectedPathsEnabled()) {
|
|
669
|
-
const prot = isProtected(resolved);
|
|
670
|
-
if (prot.denied) {
|
|
671
|
-
throw new Error(`cwd "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
|
|
672
|
-
`Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
710
|
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
676
711
|
throw new Error(`cwd "${resolved}" is outside the configured bash roots.`);
|
|
677
712
|
}
|
|
@@ -686,174 +721,12 @@ async function resolveOutputFile(input, cwd, roots) {
|
|
|
686
721
|
if (!raw)
|
|
687
722
|
throw new Error('"outputFile" must be a non-empty path.');
|
|
688
723
|
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw);
|
|
689
|
-
if (await isProtectedPathsEnabled()) {
|
|
690
|
-
const prot = isProtected(resolved);
|
|
691
|
-
if (prot.denied) {
|
|
692
|
-
throw new Error(`outputFile "${resolved}" is within a protected location (${prot.matchedRoot}).`);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
724
|
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
696
725
|
throw new Error(`outputFile "${resolved}" is outside the configured bash roots.`);
|
|
697
726
|
}
|
|
698
727
|
await fs.promises.mkdir(path.dirname(resolved), { recursive: true });
|
|
699
728
|
return resolved;
|
|
700
729
|
}
|
|
701
|
-
/**
|
|
702
|
-
* Best-effort advisory scan (issue #260, item 4) of a command string for
|
|
703
|
-
* absolute-looking paths that point OUTSIDE the configured roots or INTO a
|
|
704
|
-
* protected location. Returns human-readable warning strings; it NEVER blocks —
|
|
705
|
-
* a shell can reach anywhere regardless, so this is honest advice, not a
|
|
706
|
-
* boundary. Known limitation: shell variable expansions (e.g. `$HOME/AppData`)
|
|
707
|
-
* are not resolved.
|
|
708
|
-
*/
|
|
709
|
-
function maskGlobOptionValues(command) {
|
|
710
|
-
const chars = [...command];
|
|
711
|
-
const tokens = [...command.matchAll(/"(?:\\.|[^"\\])*"|'[^']*'|[^\s]+/g)];
|
|
712
|
-
let maskNext = false;
|
|
713
|
-
for (const match of tokens) {
|
|
714
|
-
const raw = match[0];
|
|
715
|
-
const start = match.index ?? 0;
|
|
716
|
-
const unquoted = raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))
|
|
717
|
-
? raw.slice(1, -1)
|
|
718
|
-
: raw;
|
|
719
|
-
let valueOffset = 0;
|
|
720
|
-
if (maskNext) {
|
|
721
|
-
maskNext = false;
|
|
722
|
-
}
|
|
723
|
-
else if (unquoted === '-g' || unquoted === '--glob' || unquoted === '--iglob') {
|
|
724
|
-
maskNext = true;
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
if (!/^(?:--glob|--iglob)=/.test(unquoted))
|
|
729
|
-
continue;
|
|
730
|
-
valueOffset = raw.indexOf('=') + 1;
|
|
731
|
-
}
|
|
732
|
-
for (let i = start + valueOffset; i < start + raw.length; i += 1)
|
|
733
|
-
chars[i] = ' ';
|
|
734
|
-
}
|
|
735
|
-
return chars.join('');
|
|
736
|
-
}
|
|
737
|
-
/**
|
|
738
|
-
* Windows utilities that take `/x`-style switches (issue #314, item D). A slash
|
|
739
|
-
* token is only demoted from "absolute path" to "switch" when one of these
|
|
740
|
-
* appears EARLIER IN THE SAME command segment, so `cd … && dir /b && rg …` and
|
|
741
|
-
* `echo dir /b` stay quiet while `echo /.git/config` keeps its advisory.
|
|
742
|
-
* Deliberately excludes names that are also common POSIX utilities (`find`,
|
|
743
|
-
* `sort`, `type`, `more`, `mkdir`, `rmdir`) — suppressing their arguments would
|
|
744
|
-
* silence genuine path advisories such as `find /etc -name passwd`.
|
|
745
|
-
*/
|
|
746
|
-
const WINDOWS_SWITCH_UTILITIES = new Set([
|
|
747
|
-
'dir', 'xcopy', 'robocopy', 'copy', 'move', 'del', 'erase', 'attrib', 'icacls', 'takeown',
|
|
748
|
-
'findstr', 'tasklist', 'taskkill', 'sc', 'reg', 'net', 'subst', 'where', 'ren', 'rename',
|
|
749
|
-
'md', 'rd', 'tree', 'fc', 'comp', 'cacls', 'schtasks', 'chkdsk', 'sfc', 'shutdown',
|
|
750
|
-
'wmic', 'diskpart', 'label', 'vol', 'mklink', 'assoc', 'ftype', 'gpupdate', 'powercfg',
|
|
751
|
-
]);
|
|
752
|
-
/**
|
|
753
|
-
* A cmd-style switch: `/b`, `/ad`, `/mir`, `/e:on`. Never matches a nested path
|
|
754
|
-
* (no interior separator), so `/etc/passwd` is untouched.
|
|
755
|
-
*/
|
|
756
|
-
const WINDOWS_SWITCH_RE = /^\/[A-Za-z][A-Za-z0-9?*-]{0,15}(?::[^\s"']*)?$/;
|
|
757
|
-
/** Text of the command segment (split on shell separators) preceding `index`. */
|
|
758
|
-
function segmentTextBefore(command, index) {
|
|
759
|
-
const separators = /&&|\|\||[|;&\n]/g;
|
|
760
|
-
let start = 0;
|
|
761
|
-
let match;
|
|
762
|
-
while ((match = separators.exec(command)) !== null) {
|
|
763
|
-
if (match.index >= index)
|
|
764
|
-
break;
|
|
765
|
-
start = match.index + match[0].length;
|
|
766
|
-
}
|
|
767
|
-
return command.slice(start, index);
|
|
768
|
-
}
|
|
769
|
-
/** True when a slash-switch utility heads the segment containing `index`. */
|
|
770
|
-
function windowsSwitchUtilityPrecedes(command, index) {
|
|
771
|
-
for (const word of segmentTextBefore(command, index).match(/[^\s"'<>()]+/g) ?? []) {
|
|
772
|
-
const name = word.replace(/^.*[\\/]/, '').toLowerCase().replace(/\.(?:exe|com|bat|cmd)$/, '');
|
|
773
|
-
if (WINDOWS_SWITCH_UTILITIES.has(name))
|
|
774
|
-
return true;
|
|
775
|
-
}
|
|
776
|
-
return false;
|
|
777
|
-
}
|
|
778
|
-
async function scanCommandForExternalPaths(command, cwd, roots) {
|
|
779
|
-
const warnings = [];
|
|
780
|
-
const protectedPathsEnabled = await isProtectedPathsEnabled();
|
|
781
|
-
const seen = new Set();
|
|
782
|
-
// Ripgrep glob values are patterns, not filesystem paths. Mask only option
|
|
783
|
-
// values so a real path elsewhere in the same command is still inspected.
|
|
784
|
-
const commandForPathScan = maskGlobOptionValues(command);
|
|
785
|
-
// Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
|
|
786
|
-
// POSIX (/foo), and ~-prefixed home paths.
|
|
787
|
-
const tokenRe = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|~\/[^\s"']*|(?<![\w.])\/[^\s"']+)/g;
|
|
788
|
-
const matches = [...commandForPathScan.matchAll(tokenRe)]
|
|
789
|
-
.filter((match) => {
|
|
790
|
-
if (process.platform !== 'win32')
|
|
791
|
-
return true;
|
|
792
|
-
// Two independent conditions must hold before a token is dropped: it must
|
|
793
|
-
// LOOK like a cmd switch, and a slash-switch utility must appear earlier
|
|
794
|
-
// in the SAME segment (`… && dir /b && …`). This keeps `/etc/passwd` and
|
|
795
|
-
// other genuine POSIX paths advisory.
|
|
796
|
-
if (!WINDOWS_SWITCH_RE.test(match[0]))
|
|
797
|
-
return true;
|
|
798
|
-
return !windowsSwitchUtilityPrecedes(commandForPathScan, match.index ?? 0);
|
|
799
|
-
})
|
|
800
|
-
.map((match) => match[0]);
|
|
801
|
-
const home = (() => {
|
|
802
|
-
try {
|
|
803
|
-
return getHomeDir();
|
|
804
|
-
}
|
|
805
|
-
catch {
|
|
806
|
-
return '';
|
|
807
|
-
}
|
|
808
|
-
})();
|
|
809
|
-
for (const rawToken of matches) {
|
|
810
|
-
let token = rawToken;
|
|
811
|
-
if (token.startsWith('~/') && home)
|
|
812
|
-
token = path.join(home, token.slice(2));
|
|
813
|
-
let resolved;
|
|
814
|
-
try {
|
|
815
|
-
resolved = path.isAbsolute(token) ? path.resolve(token) : path.resolve(cwd, token);
|
|
816
|
-
}
|
|
817
|
-
catch {
|
|
818
|
-
continue;
|
|
819
|
-
}
|
|
820
|
-
if (seen.has(resolved))
|
|
821
|
-
continue;
|
|
822
|
-
seen.add(resolved);
|
|
823
|
-
const prot = protectedPathsEnabled ? isProtected(resolved) : { denied: false };
|
|
824
|
-
if (prot.denied) {
|
|
825
|
-
warnings.push(`Command references "${rawToken}", which is inside a protected location (${prot.matchedRoot}).`);
|
|
826
|
-
}
|
|
827
|
-
else if (roots.length > 0 && !roots.some((root) => isInside(root, resolved))) {
|
|
828
|
-
warnings.push(`Command references "${rawToken}", which is outside the configured working roots.`);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return warnings;
|
|
832
|
-
}
|
|
833
|
-
/**
|
|
834
|
-
* A truthy env flag: "1", "true", "yes", "on" (case-insensitive).
|
|
835
|
-
*/
|
|
836
|
-
function isTruthyEnv(value) {
|
|
837
|
-
return typeof value === 'string' && /^(1|true|yes|on)$/i.test(value.trim());
|
|
838
|
-
}
|
|
839
|
-
/**
|
|
840
|
-
* Names of environment variables that are safe (and often necessary) to pass to
|
|
841
|
-
* a spawned shell. Anything not on this list — notably API keys / secrets — is
|
|
842
|
-
* withheld by default so a command like `env` / `printenv` / `Get-ChildItem Env:`
|
|
843
|
-
* cannot read them back (issue #175). `LC_*` locale vars are allowed by prefix.
|
|
844
|
-
*/
|
|
845
|
-
const ENV_ALLOWLIST = new Set([
|
|
846
|
-
'path',
|
|
847
|
-
'home', 'userprofile', 'homedrive', 'homepath',
|
|
848
|
-
'appdata', 'localappdata', 'programdata',
|
|
849
|
-
'tmp', 'temp', 'tmpdir',
|
|
850
|
-
'lang', 'lc_all', 'term', 'colorterm', 'no_color',
|
|
851
|
-
'shell', 'user', 'username', 'logname',
|
|
852
|
-
// Windows essentials so the default shell can even start.
|
|
853
|
-
'systemroot', 'windir', 'comspec', 'pathext', 'systemdrive',
|
|
854
|
-
'programfiles', 'programfiles(x86)',
|
|
855
|
-
'number_of_processors', 'processor_architecture',
|
|
856
|
-
]);
|
|
857
730
|
/**
|
|
858
731
|
* Returns the managed utilities directory path where Unix-like utilities
|
|
859
732
|
* (BusyBox applets on Windows) are installed. This directory is prepended to
|
|
@@ -892,15 +765,38 @@ function getBundledRipgrepDir() {
|
|
|
892
765
|
}
|
|
893
766
|
}
|
|
894
767
|
/**
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
|
|
768
|
+
* Read an env var case-insensitively, treating a blank value as absent. FLUJO
|
|
769
|
+
* launches this server with an explicit `env`, so vars can arrive defined but
|
|
770
|
+
* empty; an empty `ComSpec` is just as fatal as a missing one.
|
|
771
|
+
*/
|
|
772
|
+
function getNonEmptyEnv(name) {
|
|
773
|
+
const value = getEnvCaseInsensitive(name);
|
|
774
|
+
return value && value.trim() !== '' ? value : undefined;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Ensure a Windows env var the child toolchain needs is present exactly once.
|
|
778
|
+
* A spelling that already carries a value wins, so a script reading `%windir%`
|
|
779
|
+
* or `$SYSTEMROOT` keeps seeing the name it was given; blank spellings are
|
|
780
|
+
* deleted first, because Node de-duplicates Windows env keys case-insensitively
|
|
781
|
+
* and must not settle on the empty one.
|
|
782
|
+
*/
|
|
783
|
+
function ensureWindowsEnvDefault(target, key, value) {
|
|
784
|
+
if (!value)
|
|
785
|
+
return;
|
|
786
|
+
const spellings = Object.keys(target).filter((candidate) => candidate.toLowerCase() === key.toLowerCase());
|
|
787
|
+
if (spellings.some((candidate) => (target[candidate] ?? '').trim() !== ''))
|
|
788
|
+
return;
|
|
789
|
+
for (const candidate of spellings)
|
|
790
|
+
delete target[candidate];
|
|
791
|
+
target[key] = value;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Build the child process environment. A terminal is expected to behave like
|
|
795
|
+
* the host account that launched FLUJO, so inherit the complete server process
|
|
796
|
+
* environment and then apply explicit per-command overrides.
|
|
899
797
|
*/
|
|
900
798
|
function buildChildEnv(overrides = {}) {
|
|
901
|
-
const out =
|
|
902
|
-
? { ...process.env }
|
|
903
|
-
: Object.fromEntries(Object.entries(process.env).filter(([key]) => ENV_ALLOWLIST.has(key.toLowerCase()) || /^LC_/i.test(key)));
|
|
799
|
+
const out = { ...process.env };
|
|
904
800
|
const currentPath = getEnvCaseInsensitive('PATH') ?? '';
|
|
905
801
|
const utilityDirs = [getManagedUtilsDir(), getBundledRipgrepDir()]
|
|
906
802
|
.filter((dir) => Boolean(dir))
|
|
@@ -923,6 +819,20 @@ function buildChildEnv(overrides = {}) {
|
|
|
923
819
|
delete out[key];
|
|
924
820
|
}
|
|
925
821
|
out.PATHEXT = windowsExecutableExtensions().join(';');
|
|
822
|
+
// Windows essentials that must exist for the child to launch anything of
|
|
823
|
+
// its own. FLUJO passes stdio servers an explicit `env` and the MCP SDK's
|
|
824
|
+
// default Windows inherit list carries neither ComSpec nor windir, so these
|
|
825
|
+
// are absent here even though ENV_ALLOWLIST would have kept them.
|
|
826
|
+
// `npm run <script>` is the casualty: npm resolves its script shell from
|
|
827
|
+
// `process.env.ComSpec` with no fallback of its own, so a blank one makes it
|
|
828
|
+
// ask Node to spawn `undefined` and die with ERR_INVALID_ARG_TYPE — after
|
|
829
|
+
// printing its banner but before emitting one line of diagnostics, which is
|
|
830
|
+
// why `npm run test:mcp` looked like a silent, empty failure.
|
|
831
|
+
const systemRoot = getNonEmptyEnv('SystemRoot') ?? getNonEmptyEnv('windir') ?? 'C:\\Windows';
|
|
832
|
+
ensureWindowsEnvDefault(out, 'SystemRoot', systemRoot);
|
|
833
|
+
ensureWindowsEnvDefault(out, 'windir', systemRoot);
|
|
834
|
+
ensureWindowsEnvDefault(out, 'SystemDrive', getNonEmptyEnv('SystemDrive') ?? path.parse(systemRoot).root.replace(/[\\/]$/, ''));
|
|
835
|
+
ensureWindowsEnvDefault(out, 'ComSpec', resolveCmdExecutable() ?? path.join(systemRoot, 'System32', 'cmd.exe'));
|
|
926
836
|
}
|
|
927
837
|
// Deterministic text encoding and number formatting for children (issue #364):
|
|
928
838
|
// without this, interpreters emit locale-dependent decimal commas and non-UTF-8
|
|
@@ -983,100 +893,156 @@ const POWERSHELL_PRELUDE = [
|
|
|
983
893
|
+ ' [Threading.Thread]::CurrentThread.CurrentUICulture=[Globalization.CultureInfo]::InvariantCulture } catch {}',
|
|
984
894
|
].join('; ');
|
|
985
895
|
const POWERSHELL_EPILOGUE = 'exit $(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 })';
|
|
896
|
+
/** Remove exactly one caller-supplied leading U+FEFF from PowerShell source. */
|
|
897
|
+
export function normalizePowerShellSource(command) {
|
|
898
|
+
return command.charCodeAt(0) === 0xfeff ? command.slice(1) : command;
|
|
899
|
+
}
|
|
986
900
|
export function wrapPowerShellCommand(command) {
|
|
987
|
-
|
|
901
|
+
const normalized = normalizePowerShellSource(command);
|
|
902
|
+
return `${POWERSHELL_PRELUDE}\n${normalized}\n${POWERSHELL_EPILOGUE}`;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* PowerShell's encoded-command transport is UTF-16LE without a BOM. Keeping the
|
|
906
|
+
* complete program in one base64 argv value avoids Windows command-line quoting
|
|
907
|
+
* ambiguities around nested quotes and literal `-c` arguments.
|
|
908
|
+
*/
|
|
909
|
+
export function encodePowerShellCommand(command) {
|
|
910
|
+
return Buffer.from(wrapPowerShellCommand(command), 'utf16le').toString('base64');
|
|
988
911
|
}
|
|
989
912
|
/** `chcp 65001` forces UTF-8 output for cmd.exe children (issue #364). */
|
|
990
913
|
export function wrapCmdCommand(command) {
|
|
991
914
|
return `chcp 65001>nul & ${command}`;
|
|
992
915
|
}
|
|
993
916
|
function powerShellArgs(command) {
|
|
994
|
-
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-
|
|
917
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(command)];
|
|
995
918
|
}
|
|
996
|
-
/**
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
919
|
+
/** Resolve one shell request to the exact executable shared by every entry point. */
|
|
920
|
+
function resolveShellExecutable(shell, mode) {
|
|
921
|
+
if (shell === 'powershell') {
|
|
922
|
+
const file = resolveWindowsPowerShellExecutable();
|
|
923
|
+
return file
|
|
924
|
+
? { file, effectiveShell: 'powershell' }
|
|
925
|
+
: { file: '', effectiveShell: 'powershell', unavailableShell: 'powershell' };
|
|
926
|
+
}
|
|
1004
927
|
if (shell === 'pwsh') {
|
|
1005
|
-
const
|
|
1006
|
-
if (
|
|
1007
|
-
return { file
|
|
1008
|
-
}
|
|
928
|
+
const file = resolvePwshExecutable();
|
|
929
|
+
if (file)
|
|
930
|
+
return { file, effectiveShell: 'pwsh' };
|
|
1009
931
|
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
1010
932
|
if (windowsPowerShell) {
|
|
1011
933
|
return {
|
|
1012
934
|
file: windowsPowerShell,
|
|
1013
|
-
args: powerShellArgs(command),
|
|
1014
|
-
useShell: false,
|
|
1015
935
|
effectiveShell: 'powershell',
|
|
1016
936
|
shellSubstitution: {
|
|
1017
|
-
requested: 'pwsh',
|
|
937
|
+
requested: 'pwsh',
|
|
938
|
+
used: 'powershell',
|
|
1018
939
|
reason: 'PowerShell 7 (pwsh) is not installed on this machine.',
|
|
1019
940
|
},
|
|
1020
941
|
};
|
|
1021
942
|
}
|
|
1022
|
-
return { file: '',
|
|
943
|
+
return { file: '', effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
|
|
1023
944
|
}
|
|
1024
945
|
if (shell === 'bash') {
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
return { file: '', args: [], useShell: false, effectiveShell: 'bash', unavailableShell: 'bash' };
|
|
946
|
+
const file = resolveBashExecutable();
|
|
947
|
+
return file
|
|
948
|
+
? { file, effectiveShell: 'bash' }
|
|
949
|
+
: { file: '', effectiveShell: 'bash', unavailableShell: 'bash' };
|
|
1030
950
|
}
|
|
1031
951
|
if (shell === 'cmd') {
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
1037
|
-
useShell: false,
|
|
1038
|
-
effectiveShell: 'cmd',
|
|
1039
|
-
windowsVerbatimArguments: true,
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
return { file: '', args: [], useShell: false, effectiveShell: 'cmd', unavailableShell: 'cmd' };
|
|
952
|
+
const file = resolveCmdExecutable();
|
|
953
|
+
return file
|
|
954
|
+
? { file, effectiveShell: 'cmd' }
|
|
955
|
+
: { file: '', effectiveShell: 'cmd', unavailableShell: 'cmd' };
|
|
1043
956
|
}
|
|
1044
957
|
if (process.platform === 'win32') {
|
|
1045
958
|
const pwsh = resolvePwshExecutable();
|
|
1046
|
-
if (pwsh)
|
|
1047
|
-
return { file: pwsh,
|
|
1048
|
-
}
|
|
959
|
+
if (pwsh)
|
|
960
|
+
return { file: pwsh, effectiveShell: 'pwsh' };
|
|
1049
961
|
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
1050
|
-
if (windowsPowerShell)
|
|
1051
|
-
return { file: windowsPowerShell,
|
|
1052
|
-
}
|
|
962
|
+
if (windowsPowerShell)
|
|
963
|
+
return { file: windowsPowerShell, effectiveShell: 'powershell' };
|
|
1053
964
|
const cmd = resolveCmdExecutable();
|
|
1054
|
-
if (cmd)
|
|
1055
|
-
return {
|
|
1056
|
-
file: cmd,
|
|
1057
|
-
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
1058
|
-
useShell: false,
|
|
1059
|
-
effectiveShell: 'cmd',
|
|
1060
|
-
windowsVerbatimArguments: true,
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
965
|
+
if (cmd)
|
|
966
|
+
return { file: cmd, effectiveShell: 'cmd' };
|
|
1063
967
|
return {
|
|
1064
968
|
file: '',
|
|
1065
|
-
args: [],
|
|
1066
|
-
useShell: false,
|
|
1067
969
|
effectiveShell: 'cmd',
|
|
1068
970
|
startError: 'No usable PowerShell or cmd executable was found.',
|
|
1069
971
|
};
|
|
1070
972
|
}
|
|
973
|
+
if (mode === 'pty') {
|
|
974
|
+
const loginShell = getEnvCaseInsensitive('SHELL');
|
|
975
|
+
const bash = resolveBashExecutable();
|
|
976
|
+
const file = firstExistingFile([loginShell, bash, '/bin/sh', findExecutableOnPath('sh')]);
|
|
977
|
+
if (!file) {
|
|
978
|
+
return {
|
|
979
|
+
file: '',
|
|
980
|
+
effectiveShell: 'sh',
|
|
981
|
+
startError: 'No interactive POSIX shell was found.',
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
file,
|
|
986
|
+
effectiveShell: path.basename(file).toLowerCase().startsWith('bash') ? 'bash' : 'sh',
|
|
987
|
+
};
|
|
988
|
+
}
|
|
1071
989
|
const sh = firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
|
|
1072
|
-
|
|
1073
|
-
|
|
990
|
+
return sh
|
|
991
|
+
? { file: sh, effectiveShell: 'sh' }
|
|
992
|
+
: { file: '', effectiveShell: 'sh', startError: 'No POSIX /bin/sh executable was found.' };
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Resolve executable identity once, including the narrow default-shell
|
|
996
|
+
* auto-selection for unquoted POSIX chaining on Windows PowerShell 5.1.
|
|
997
|
+
*/
|
|
998
|
+
function resolveShellPlan(command, requestedShell, mode) {
|
|
999
|
+
let selectedShell = requestedShell;
|
|
1000
|
+
let autoSelected = false;
|
|
1001
|
+
let resolved = resolveShellExecutable(selectedShell, mode);
|
|
1002
|
+
if (mode === 'command'
|
|
1003
|
+
&& requestedShell === 'default'
|
|
1004
|
+
&& resolved.effectiveShell === 'powershell'
|
|
1005
|
+
&& commandUsesPosixChaining(command)) {
|
|
1006
|
+
if (resolvePwshExecutable())
|
|
1007
|
+
selectedShell = 'pwsh';
|
|
1008
|
+
else if (resolveBashExecutable())
|
|
1009
|
+
selectedShell = 'bash';
|
|
1010
|
+
if (selectedShell !== 'default') {
|
|
1011
|
+
autoSelected = true;
|
|
1012
|
+
resolved = resolveShellExecutable(selectedShell, mode);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return { requestedShell, selectedShell, autoSelected, ...resolved };
|
|
1016
|
+
}
|
|
1017
|
+
/** Test seam proving command and PTY entry points consume the same resolver. */
|
|
1018
|
+
export function _resolveShellPlanForTests(command, shell, mode = 'command') {
|
|
1019
|
+
return resolveShellPlan(command, shell, mode);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Build command-specific argv around a resolved executable. Child spawning
|
|
1023
|
+
* remains direct with `shell:false`.
|
|
1024
|
+
*/
|
|
1025
|
+
function buildSpawn(command, shell) {
|
|
1026
|
+
const plan = resolveShellPlan(command, shell, 'command');
|
|
1027
|
+
let args = [];
|
|
1028
|
+
let windowsVerbatimArguments;
|
|
1029
|
+
if (plan.file) {
|
|
1030
|
+
if (plan.effectiveShell === 'pwsh' || plan.effectiveShell === 'powershell') {
|
|
1031
|
+
args = powerShellArgs(command);
|
|
1032
|
+
}
|
|
1033
|
+
else if (plan.effectiveShell === 'bash' || plan.effectiveShell === 'sh') {
|
|
1034
|
+
args = ['-c', command];
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
args = ['/d', '/s', '/c', wrapCmdCommand(command)];
|
|
1038
|
+
windowsVerbatimArguments = true;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1074
1041
|
return {
|
|
1075
|
-
|
|
1076
|
-
args
|
|
1042
|
+
...plan,
|
|
1043
|
+
args,
|
|
1077
1044
|
useShell: false,
|
|
1078
|
-
|
|
1079
|
-
startError: 'No POSIX /bin/sh executable was found.',
|
|
1045
|
+
...(windowsVerbatimArguments ? { windowsVerbatimArguments } : {}),
|
|
1080
1046
|
};
|
|
1081
1047
|
}
|
|
1082
1048
|
function safeRequestedShellValue(input) {
|
|
@@ -1098,23 +1064,29 @@ function safeRequestedShellValue(input) {
|
|
|
1098
1064
|
function validateShell(input) {
|
|
1099
1065
|
if (input === undefined)
|
|
1100
1066
|
return { valid: true, shell: 'default' };
|
|
1101
|
-
if (input === 'default' || input === 'pwsh' || input === 'bash' || input === 'cmd') {
|
|
1067
|
+
if (input === 'default' || input === 'pwsh' || input === 'powershell' || input === 'bash' || input === 'cmd') {
|
|
1102
1068
|
return { valid: true, shell: input };
|
|
1103
1069
|
}
|
|
1104
1070
|
return { valid: false, requestedShell: safeRequestedShellValue(input) };
|
|
1105
1071
|
}
|
|
1106
1072
|
function invalidShellResult(requestedShell) {
|
|
1107
1073
|
return textResult({
|
|
1108
|
-
error: 'Invalid shell request. Expected one of: "default", "pwsh", "bash", or "cmd".',
|
|
1074
|
+
error: 'Invalid shell request. Expected one of: "default", "pwsh", "powershell", "bash", or "cmd".',
|
|
1109
1075
|
requestedShell,
|
|
1110
1076
|
}, true);
|
|
1111
1077
|
}
|
|
1112
1078
|
function startChild(command, cwd, shell, env) {
|
|
1113
|
-
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, } = buildSpawn(command, shell);
|
|
1079
|
+
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, autoSelected, } = buildSpawn(command, shell);
|
|
1080
|
+
const metadata = {
|
|
1081
|
+
effectiveShell,
|
|
1082
|
+
shellPath: file,
|
|
1083
|
+
shellSubstitution,
|
|
1084
|
+
shellAutoSelected: autoSelected,
|
|
1085
|
+
};
|
|
1114
1086
|
if (unavailableShell)
|
|
1115
|
-
return {
|
|
1087
|
+
return { ...metadata, unavailableShell };
|
|
1116
1088
|
if (startError)
|
|
1117
|
-
return {
|
|
1089
|
+
return { ...metadata, startError };
|
|
1118
1090
|
// POSIX: detached so killProcessTree can signal the whole group (see killProcessTree).
|
|
1119
1091
|
const detached = process.platform !== 'win32';
|
|
1120
1092
|
try {
|
|
@@ -1125,12 +1097,12 @@ function startChild(command, cwd, shell, env) {
|
|
|
1125
1097
|
detached,
|
|
1126
1098
|
...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
1127
1099
|
});
|
|
1128
|
-
return { child,
|
|
1100
|
+
return { child, ...metadata };
|
|
1129
1101
|
}
|
|
1130
1102
|
catch (err) {
|
|
1131
1103
|
return {
|
|
1132
1104
|
startError: err instanceof Error ? err.message : String(err),
|
|
1133
|
-
|
|
1105
|
+
...metadata,
|
|
1134
1106
|
};
|
|
1135
1107
|
}
|
|
1136
1108
|
}
|
|
@@ -1192,8 +1164,14 @@ const SHELL_BUILTINS = new Set([
|
|
|
1192
1164
|
'true', 'false', 'test', 'type', 'alias', 'function', 'return', 'shift', 'source', '.', 'dir', 'cls',
|
|
1193
1165
|
'copy', 'del', 'erase', 'md', 'mkdir', 'rd', 'rmdir', 'move', 'ren', 'rename', 'pushd', 'popd',
|
|
1194
1166
|
]);
|
|
1167
|
+
function commandAvailabilityName(head, shell) {
|
|
1168
|
+
const name = path.basename(head).toLowerCase();
|
|
1169
|
+
return shell === 'pwsh' || shell === 'powershell' || shell === 'cmd'
|
|
1170
|
+
? name.replace(/\.(?:com|exe|bat|cmd)$/i, '')
|
|
1171
|
+
: name;
|
|
1172
|
+
}
|
|
1195
1173
|
function isMissingCommandCandidate(head, shell) {
|
|
1196
|
-
const name =
|
|
1174
|
+
const name = commandAvailabilityName(head, shell);
|
|
1197
1175
|
if (!name || SHELL_BUILTINS.has(name) || /^[-/$]/.test(head) || /^[A-Za-z_][\w-]*=/.test(head))
|
|
1198
1176
|
return false;
|
|
1199
1177
|
if (/^[A-Za-z]+-[A-Za-z][A-Za-z-]*$/.test(head) && (shell === 'pwsh' || shell === 'powershell'))
|
|
@@ -1222,7 +1200,7 @@ export function detectDialectMismatch(command, shell, isAvailable = (name) => Bo
|
|
|
1222
1200
|
const posixShell = shell === 'bash' || shell === 'sh';
|
|
1223
1201
|
if (!posixShell) {
|
|
1224
1202
|
for (const head of new Set(heads)) {
|
|
1225
|
-
const name =
|
|
1203
|
+
const name = commandAvailabilityName(head, shell);
|
|
1226
1204
|
if (!isMissingCommandCandidate(head, shell) || isAvailable(name))
|
|
1227
1205
|
continue;
|
|
1228
1206
|
if (POSIX_ONLY_BINARIES.has(name)) {
|
|
@@ -1342,51 +1320,27 @@ export function createStreamDecoder(mode = 'auto') {
|
|
|
1342
1320
|
return text;
|
|
1343
1321
|
};
|
|
1344
1322
|
}
|
|
1345
|
-
/**
|
|
1323
|
+
/** Build PTY-specific argv around the same resolved executable used elsewhere. */
|
|
1346
1324
|
function buildPtySpawn(shell) {
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
? { file, args: ['/d', '/q'], effectiveShell: 'cmd' }
|
|
1363
|
-
: { file: '', args: [], effectiveShell: 'cmd', unavailableShell: 'cmd' };
|
|
1364
|
-
}
|
|
1365
|
-
if (process.platform === 'win32') {
|
|
1366
|
-
const pwsh = resolvePwshExecutable();
|
|
1367
|
-
if (pwsh)
|
|
1368
|
-
return { file: pwsh, args: ['-NoLogo'], effectiveShell: 'pwsh' };
|
|
1369
|
-
const powershell = resolveWindowsPowerShellExecutable();
|
|
1370
|
-
if (powershell)
|
|
1371
|
-
return { file: powershell, args: ['-NoLogo'], effectiveShell: 'powershell' };
|
|
1372
|
-
const cmd = resolveCmdExecutable();
|
|
1373
|
-
if (cmd)
|
|
1374
|
-
return { file: cmd, args: ['/d', '/q'], effectiveShell: 'cmd' };
|
|
1375
|
-
return { file: '', args: [], effectiveShell: 'cmd', startError: 'No usable PowerShell or cmd executable was found.' };
|
|
1325
|
+
const plan = resolveShellPlan('', shell, 'pty');
|
|
1326
|
+
let args = [];
|
|
1327
|
+
if (plan.file) {
|
|
1328
|
+
if (plan.effectiveShell === 'pwsh' || plan.effectiveShell === 'powershell') {
|
|
1329
|
+
args = ['-NoLogo'];
|
|
1330
|
+
}
|
|
1331
|
+
else if (plan.effectiveShell === 'bash') {
|
|
1332
|
+
args = ['--noprofile', '--norc', '-i'];
|
|
1333
|
+
}
|
|
1334
|
+
else if (plan.effectiveShell === 'cmd') {
|
|
1335
|
+
args = ['/d', '/q'];
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
args = ['-i'];
|
|
1339
|
+
}
|
|
1376
1340
|
}
|
|
1377
|
-
|
|
1378
|
-
const bash = resolveBashExecutable();
|
|
1379
|
-
const file = firstExistingFile([loginShell, bash, '/bin/sh', findExecutableOnPath('sh')]);
|
|
1380
|
-
if (!file)
|
|
1381
|
-
return { file: '', args: [], effectiveShell: 'sh', startError: 'No interactive POSIX shell was found.' };
|
|
1382
|
-
const isBash = path.basename(file).toLowerCase().startsWith('bash');
|
|
1383
|
-
return {
|
|
1384
|
-
file,
|
|
1385
|
-
args: isBash ? ['--noprofile', '--norc', '-i'] : ['-i'],
|
|
1386
|
-
effectiveShell: isBash ? 'bash' : 'sh',
|
|
1387
|
-
};
|
|
1341
|
+
return { ...plan, args };
|
|
1388
1342
|
}
|
|
1389
|
-
function createCommandProgressReporter(context) {
|
|
1343
|
+
function createCommandProgressReporter(context, heartbeatMs = 10_000) {
|
|
1390
1344
|
const report = context?.onProgress;
|
|
1391
1345
|
if (!report)
|
|
1392
1346
|
return { push: () => undefined, stop: async () => undefined };
|
|
@@ -1429,7 +1383,7 @@ function createCommandProgressReporter(context) {
|
|
|
1429
1383
|
flush();
|
|
1430
1384
|
else
|
|
1431
1385
|
deliver(`[command still running: ${Math.max(1, Math.round((Date.now() - startedAt) / 1000))}s]`);
|
|
1432
|
-
},
|
|
1386
|
+
}, heartbeatMs);
|
|
1433
1387
|
heartbeat.unref?.();
|
|
1434
1388
|
return {
|
|
1435
1389
|
push: (chunk) => {
|
|
@@ -1460,27 +1414,20 @@ function createCommandProgressReporter(context) {
|
|
|
1460
1414
|
stopped = true;
|
|
1461
1415
|
clearInterval(heartbeat);
|
|
1462
1416
|
flush();
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}),
|
|
1470
|
-
]);
|
|
1471
|
-
}
|
|
1472
|
-
finally {
|
|
1473
|
-
if (timer)
|
|
1474
|
-
clearTimeout(timer);
|
|
1475
|
-
}
|
|
1417
|
+
// The tool result must not overtake its final progress notification.
|
|
1418
|
+
// `chain` already catches reporter failures, so awaiting it cannot turn a
|
|
1419
|
+
// notification failure into a hung command. The previous one-second
|
|
1420
|
+
// escape hatch let the client tear down its progress handler first,
|
|
1421
|
+
// intermittently dropping the final output chunk (issue #457).
|
|
1422
|
+
await chain;
|
|
1476
1423
|
},
|
|
1477
1424
|
};
|
|
1478
1425
|
}
|
|
1479
1426
|
export function bashToolDefinitions() {
|
|
1480
1427
|
const shellProp = {
|
|
1481
1428
|
type: 'string',
|
|
1482
|
-
enum: ['default', 'pwsh', 'bash', 'cmd'],
|
|
1483
|
-
description: 'Command parser. "default" uses PowerShell on Windows and /bin/sh elsewhere.
|
|
1429
|
+
enum: ['default', 'pwsh', 'powershell', 'bash', 'cmd'],
|
|
1430
|
+
description: 'Command parser. "default" uses PowerShell on Windows and /bin/sh elsewhere. Explicit "bash" launches FLUJO\'s absolute resolved Git Bash path on Windows (WSL relay launchers are rejected). Explicit "pwsh" may report a compatibility substitution to Windows PowerShell 5.1; other unavailable explicit shells return an error.',
|
|
1484
1431
|
};
|
|
1485
1432
|
const cwdProp = { type: 'string', description: 'Working directory. Relative paths resolve from the FLUJO data directory; configured roots still apply.' };
|
|
1486
1433
|
const envProp = {
|
|
@@ -1504,7 +1451,7 @@ export function bashToolDefinitions() {
|
|
|
1504
1451
|
return [
|
|
1505
1452
|
{
|
|
1506
1453
|
name: 'shell_info',
|
|
1507
|
-
description: 'Describe this machine before running anything: platform,
|
|
1454
|
+
description: 'Describe this machine before running anything: platform, the effective "default" shell and absolute path, resolved paths for pwsh/powershell/bash/cmd/sh, and common interpreters. Explicit Bash execution uses the reported Git Bash path; call this instead of invoking a bare "bash" from PowerShell.',
|
|
1508
1455
|
inputSchema: { type: 'object', properties: {} },
|
|
1509
1456
|
},
|
|
1510
1457
|
{
|
|
@@ -1578,7 +1525,7 @@ export function bashToolDefinitions() {
|
|
|
1578
1525
|
{
|
|
1579
1526
|
name: 'run',
|
|
1580
1527
|
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. '
|
|
1581
|
-
+ 'On an unknown machine call "shell_info" first; prefer shell:"bash" for POSIX syntax (&&, pipes into head/grep).
|
|
1528
|
+
+ 'On an unknown machine call "shell_info" first; prefer shell:"bash" for POSIX syntax (&&, pipes into head/grep). Results include the absolute shellPath used. PowerShell programs use BOM-free encoded transport and invariant culture. '
|
|
1582
1529
|
+ 'Use start/status for a persistent background session.',
|
|
1583
1530
|
inputSchema: {
|
|
1584
1531
|
type: 'object',
|
|
@@ -1602,7 +1549,7 @@ export function bashToolDefinitions() {
|
|
|
1602
1549
|
},
|
|
1603
1550
|
{
|
|
1604
1551
|
name: 'start',
|
|
1605
|
-
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.',
|
|
1552
|
+
description: 'Start an independent background command and return its sessionId plus the effective shell and absolute shellPath. Output is stdout and stderr merged, decoded as UTF-8. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
|
|
1606
1553
|
inputSchema: {
|
|
1607
1554
|
type: 'object',
|
|
1608
1555
|
properties: {
|
|
@@ -1632,7 +1579,8 @@ export function bashToolDefinitions() {
|
|
|
1632
1579
|
},
|
|
1633
1580
|
{
|
|
1634
1581
|
name: 'wait',
|
|
1635
|
-
description: 'Wait
|
|
1582
|
+
description: 'Wait until a background session completes or the maximum timeout elapses, sending new output as live progress when supported. '
|
|
1583
|
+
+ 'The timeout does not kill the session. A finite wait that completes early reports the remaining duration and points to sleep for fixed delays.',
|
|
1636
1584
|
inputSchema: {
|
|
1637
1585
|
type: 'object',
|
|
1638
1586
|
properties: {
|
|
@@ -1641,19 +1589,44 @@ export function bashToolDefinitions() {
|
|
|
1641
1589
|
type: 'number',
|
|
1642
1590
|
description: 'Max seconds to wait (default 60; -1 waits until completion or cancellation; positive values are capped at 12 hours by default).',
|
|
1643
1591
|
},
|
|
1592
|
+
output: {
|
|
1593
|
+
type: 'string',
|
|
1594
|
+
enum: ['delta', 'full', 'none'],
|
|
1595
|
+
description: 'Output returned in the result: "delta" includes only output since the previous wait result, "full" includes all retained output (default), and "none" omits the output field. Every wait advances the delta boundary.',
|
|
1596
|
+
},
|
|
1644
1597
|
},
|
|
1645
1598
|
required: ['sessionId'],
|
|
1646
1599
|
},
|
|
1647
1600
|
},
|
|
1601
|
+
{
|
|
1602
|
+
name: 'sleep',
|
|
1603
|
+
description: 'Wait for a fixed duration independent of background-session state. Use this instead of wait when the full delay must elapse even if a command finishes early.',
|
|
1604
|
+
inputSchema: {
|
|
1605
|
+
type: 'object',
|
|
1606
|
+
properties: {
|
|
1607
|
+
seconds: {
|
|
1608
|
+
type: 'number',
|
|
1609
|
+
exclusiveMinimum: 0,
|
|
1610
|
+
description: 'Fixed positive number of seconds to sleep. Values above the configured Bash timeout ceiling are rejected rather than shortened.',
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
required: ['seconds'],
|
|
1614
|
+
},
|
|
1615
|
+
},
|
|
1648
1616
|
{
|
|
1649
1617
|
name: 'write_stdin',
|
|
1650
|
-
description: 'Write
|
|
1618
|
+
description: 'Write UTF-8 text to a running background session\'s stdin. Pass "newline": false to omit the trailing newline. Leading-BOM stripping is opt-in; raw input is preserved by default. Returns { sessionId, written }.',
|
|
1651
1619
|
inputSchema: {
|
|
1652
1620
|
type: 'object',
|
|
1653
1621
|
properties: {
|
|
1654
1622
|
sessionId: { type: 'string', description: 'The id returned by start.' },
|
|
1655
|
-
data: { type: 'string', description: 'Text to write to stdin.' },
|
|
1623
|
+
data: { type: 'string', description: 'Text to encode as UTF-8 and write to stdin.' },
|
|
1656
1624
|
newline: { type: 'boolean', description: 'Append a trailing newline (default true).' },
|
|
1625
|
+
bomPolicy: {
|
|
1626
|
+
type: 'string',
|
|
1627
|
+
enum: ['preserve', 'strip-leading'],
|
|
1628
|
+
description: 'Leading UTF-8 BOM policy. "preserve" is the default; "strip-leading" removes exactly one initial U+FEFF from this write.',
|
|
1629
|
+
},
|
|
1657
1630
|
},
|
|
1658
1631
|
required: ['sessionId', 'data'],
|
|
1659
1632
|
},
|
|
@@ -1682,24 +1655,6 @@ export function bashToolDefinitions() {
|
|
|
1682
1655
|
function maybeNormalize(text, normalize) {
|
|
1683
1656
|
return normalize ? text.replace(/\r\n?/g, '\n') : text;
|
|
1684
1657
|
}
|
|
1685
|
-
/**
|
|
1686
|
-
* Only when the caller said `shell: "default"` may we pick a different shell
|
|
1687
|
-
* (issue #364): a command chained with POSIX `&&`/`||` cannot even parse under
|
|
1688
|
-
* Windows PowerShell 5.1, so prefer a shell that can run it. An explicitly
|
|
1689
|
-
* requested shell is never overridden.
|
|
1690
|
-
*/
|
|
1691
|
-
function selectShell(command, requested) {
|
|
1692
|
-
if (requested !== 'default')
|
|
1693
|
-
return { shell: requested, autoSelected: false };
|
|
1694
|
-
const plan = buildSpawn(command, 'default');
|
|
1695
|
-
if (plan.effectiveShell === 'powershell' && commandUsesPosixChaining(command)) {
|
|
1696
|
-
if (resolvePwshExecutable())
|
|
1697
|
-
return { shell: 'pwsh', autoSelected: true };
|
|
1698
|
-
if (resolveBashExecutable())
|
|
1699
|
-
return { shell: 'bash', autoSelected: true };
|
|
1700
|
-
}
|
|
1701
|
-
return { shell: 'default', autoSelected: false };
|
|
1702
|
-
}
|
|
1703
1658
|
/** Optional per-call output limits shared by `run` and `start`. */
|
|
1704
1659
|
function resolveMaxOutputChars(input) {
|
|
1705
1660
|
return typeof input === 'number' && input > 0
|
|
@@ -1745,8 +1700,8 @@ function createOutputSpool(file) {
|
|
|
1745
1700
|
};
|
|
1746
1701
|
}
|
|
1747
1702
|
async function runTool(args, roots, context) {
|
|
1748
|
-
const command = String(args?.command ?? '')
|
|
1749
|
-
if (!command)
|
|
1703
|
+
const command = String(args?.command ?? '');
|
|
1704
|
+
if (!command.trim())
|
|
1750
1705
|
return textResult({ error: 'Provide "command": a shell command line to run.' }, true);
|
|
1751
1706
|
const shellValidation = validateShell(args.shell);
|
|
1752
1707
|
if (!shellValidation.valid)
|
|
@@ -1763,8 +1718,6 @@ async function runTool(args, roots, context) {
|
|
|
1763
1718
|
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1764
1719
|
}
|
|
1765
1720
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1766
|
-
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
1767
|
-
const warn = warnings.length ? { warnings } : {};
|
|
1768
1721
|
const normalize = args.normalizeNewlines === true;
|
|
1769
1722
|
const timeoutMs = resolveCommandTimeoutMs(args.timeout);
|
|
1770
1723
|
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
@@ -1777,7 +1730,6 @@ async function runTool(args, roots, context) {
|
|
|
1777
1730
|
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
1778
1731
|
}
|
|
1779
1732
|
}
|
|
1780
|
-
const selection = selectShell(command, requestedShell);
|
|
1781
1733
|
registerExitCleanup();
|
|
1782
1734
|
const startedAt = Date.now();
|
|
1783
1735
|
return await new Promise((resolve) => {
|
|
@@ -1790,10 +1742,10 @@ async function runTool(args, roots, context) {
|
|
|
1790
1742
|
const decodeStdout = createStreamDecoder(encodingMode);
|
|
1791
1743
|
const decodeStderr = createStreamDecoder(encodingMode);
|
|
1792
1744
|
const progress = createCommandProgressReporter(context);
|
|
1793
|
-
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd,
|
|
1745
|
+
const { child, startError, effectiveShell, shellPath, unavailableShell, shellSubstitution, shellAutoSelected, } = startChild(command, cwd, requestedShell, envValidation.env);
|
|
1794
1746
|
const dialectWarnings = detectDialectMismatch(command, effectiveShell);
|
|
1795
1747
|
const dialect = dialectWarnings.length ? { dialectWarnings } : {};
|
|
1796
|
-
const auto =
|
|
1748
|
+
const auto = shellAutoSelected ? { shellAutoSelected: true } : {};
|
|
1797
1749
|
const substitution = shellSubstitution ? { shellSubstitution } : {};
|
|
1798
1750
|
const spoolInfo = () => (outputFilePath
|
|
1799
1751
|
? { outputFile: outputFilePath, outputBytes: spool.bytes(), ...(spool.error() ? { outputFileError: spool.error() } : {}) }
|
|
@@ -1821,7 +1773,7 @@ async function runTool(args, roots, context) {
|
|
|
1821
1773
|
if (startError || !child) {
|
|
1822
1774
|
spool.close();
|
|
1823
1775
|
void progress.stop().then(() => {
|
|
1824
|
-
resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
|
|
1776
|
+
resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell, shellPath }, true));
|
|
1825
1777
|
});
|
|
1826
1778
|
return;
|
|
1827
1779
|
}
|
|
@@ -1837,6 +1789,7 @@ async function runTool(args, roots, context) {
|
|
|
1837
1789
|
cwd,
|
|
1838
1790
|
requestedShell,
|
|
1839
1791
|
shell: effectiveShell,
|
|
1792
|
+
shellPath,
|
|
1840
1793
|
exitCode: null,
|
|
1841
1794
|
output: finalOut,
|
|
1842
1795
|
...outputStats(),
|
|
@@ -1844,7 +1797,6 @@ async function runTool(args, roots, context) {
|
|
|
1844
1797
|
...dialect,
|
|
1845
1798
|
...substitution,
|
|
1846
1799
|
...auto,
|
|
1847
|
-
...warn,
|
|
1848
1800
|
}, true), true);
|
|
1849
1801
|
};
|
|
1850
1802
|
const finish = async (result, keepKillEscalation = false) => {
|
|
@@ -1870,6 +1822,7 @@ async function runTool(args, roots, context) {
|
|
|
1870
1822
|
cwd,
|
|
1871
1823
|
requestedShell,
|
|
1872
1824
|
shell: effectiveShell,
|
|
1825
|
+
shellPath,
|
|
1873
1826
|
exitCode: null,
|
|
1874
1827
|
timeoutMs,
|
|
1875
1828
|
elapsedMs: Date.now() - startedAt,
|
|
@@ -1883,7 +1836,6 @@ async function runTool(args, roots, context) {
|
|
|
1883
1836
|
...dialect,
|
|
1884
1837
|
...substitution,
|
|
1885
1838
|
...auto,
|
|
1886
|
-
...warn,
|
|
1887
1839
|
}, true), true);
|
|
1888
1840
|
}, timeoutMs);
|
|
1889
1841
|
}
|
|
@@ -1904,7 +1856,7 @@ async function runTool(args, roots, context) {
|
|
|
1904
1856
|
// ENOENT here means the resolved executable vanished between resolution and spawn.
|
|
1905
1857
|
foregroundChildren().delete(child);
|
|
1906
1858
|
append(`\n${err.message}`);
|
|
1907
|
-
void finish(textResult({ error: `Command failed to start (${effectiveShell}): ${err.message}`, cwd, shell: effectiveShell, output: maybeNormalize(output, normalize) }, true));
|
|
1859
|
+
void finish(textResult({ error: `Command failed to start (${effectiveShell}): ${err.message}`, cwd, shell: effectiveShell, shellPath, output: maybeNormalize(output, normalize) }, true));
|
|
1908
1860
|
});
|
|
1909
1861
|
child.on('close', (code) => {
|
|
1910
1862
|
foregroundChildren().delete(child);
|
|
@@ -1917,6 +1869,7 @@ async function runTool(args, roots, context) {
|
|
|
1917
1869
|
cwd,
|
|
1918
1870
|
requestedShell,
|
|
1919
1871
|
shell: effectiveShell,
|
|
1872
|
+
shellPath,
|
|
1920
1873
|
output: finalOut,
|
|
1921
1874
|
...outputStats(),
|
|
1922
1875
|
...spoolInfo(),
|
|
@@ -1924,7 +1877,6 @@ async function runTool(args, roots, context) {
|
|
|
1924
1877
|
...missingExecutableHint(code, dialectWarnings),
|
|
1925
1878
|
...substitution,
|
|
1926
1879
|
...auto,
|
|
1927
|
-
...warn,
|
|
1928
1880
|
}, code !== 0));
|
|
1929
1881
|
});
|
|
1930
1882
|
if (context?.signal?.aborted)
|
|
@@ -1941,8 +1893,8 @@ function scheduleReap(session) {
|
|
|
1941
1893
|
session.reapTimer.unref?.();
|
|
1942
1894
|
}
|
|
1943
1895
|
async function startTool(args, roots, ownerScope, context) {
|
|
1944
|
-
const command = String(args?.command ?? '')
|
|
1945
|
-
if (!command)
|
|
1896
|
+
const command = String(args?.command ?? '');
|
|
1897
|
+
if (!command.trim())
|
|
1946
1898
|
return textResult({ error: 'Provide "command": a shell command line to run.' }, true);
|
|
1947
1899
|
const shellValidation = validateShell(args.shell);
|
|
1948
1900
|
if (!shellValidation.valid)
|
|
@@ -1989,8 +1941,6 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1989
1941
|
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1990
1942
|
}
|
|
1991
1943
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1992
|
-
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
1993
|
-
const warn = warnings.length ? { warnings } : {};
|
|
1994
1944
|
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
1995
1945
|
let outputFilePath;
|
|
1996
1946
|
if (args.outputFile !== undefined) {
|
|
@@ -2001,8 +1951,7 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
2001
1951
|
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
2002
1952
|
}
|
|
2003
1953
|
}
|
|
2004
|
-
const
|
|
2005
|
-
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
|
|
1954
|
+
const { child, startError, effectiveShell, shellPath, unavailableShell, shellSubstitution, shellAutoSelected, } = startChild(command, cwd, requestedShell, envValidation.env);
|
|
2006
1955
|
if (unavailableShell) {
|
|
2007
1956
|
return textResult({
|
|
2008
1957
|
error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
|
|
@@ -2014,7 +1963,7 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
2014
1963
|
}, true);
|
|
2015
1964
|
}
|
|
2016
1965
|
if (startError || !child) {
|
|
2017
|
-
return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
|
|
1966
|
+
return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell, shellPath }, true);
|
|
2018
1967
|
}
|
|
2019
1968
|
const id = `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2020
1969
|
const session = {
|
|
@@ -2022,9 +1971,16 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
2022
1971
|
ownerScope,
|
|
2023
1972
|
command,
|
|
2024
1973
|
cwd,
|
|
1974
|
+
requestedShell,
|
|
1975
|
+
shell: effectiveShell,
|
|
1976
|
+
shellPath,
|
|
1977
|
+
...(shellSubstitution ? { shellSubstitution } : {}),
|
|
2025
1978
|
child,
|
|
2026
1979
|
output: '',
|
|
2027
1980
|
truncated: false,
|
|
1981
|
+
waitOutput: '',
|
|
1982
|
+
waitOutputTruncated: false,
|
|
1983
|
+
waitOutputLimit: maxOutputChars,
|
|
2028
1984
|
running: true,
|
|
2029
1985
|
exitCode: null,
|
|
2030
1986
|
startedAt: Date.now(),
|
|
@@ -2033,24 +1989,34 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
2033
1989
|
};
|
|
2034
1990
|
table.set(id, session);
|
|
2035
1991
|
const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; }, maxOutputChars);
|
|
1992
|
+
const appendSessionOutput = (chunk) => {
|
|
1993
|
+
append(chunk);
|
|
1994
|
+
if (!chunk)
|
|
1995
|
+
return;
|
|
1996
|
+
session.waitOutput += chunk;
|
|
1997
|
+
if (session.waitOutput.length > session.waitOutputLimit) {
|
|
1998
|
+
session.waitOutput = session.waitOutput.slice(-session.waitOutputLimit);
|
|
1999
|
+
session.waitOutputTruncated = true;
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2036
2002
|
const spool = createOutputSpool(outputFilePath);
|
|
2037
2003
|
const decodeStdout = createStreamDecoder(encodingMode);
|
|
2038
2004
|
const decodeStderr = createStreamDecoder(encodingMode);
|
|
2039
2005
|
child.stdout?.on('data', (d) => {
|
|
2040
2006
|
const chunk = decodeStdout(d);
|
|
2041
2007
|
touchSession(session);
|
|
2042
|
-
|
|
2008
|
+
appendSessionOutput(chunk);
|
|
2043
2009
|
spool.write(chunk);
|
|
2044
2010
|
});
|
|
2045
2011
|
child.stderr?.on('data', (d) => {
|
|
2046
2012
|
const chunk = decodeStderr(d);
|
|
2047
2013
|
session.stderrChars = (session.stderrChars ?? 0) + chunk.length;
|
|
2048
2014
|
touchSession(session);
|
|
2049
|
-
|
|
2015
|
+
appendSessionOutput(chunk);
|
|
2050
2016
|
spool.write(chunk);
|
|
2051
2017
|
});
|
|
2052
2018
|
child.on('error', (err) => {
|
|
2053
|
-
|
|
2019
|
+
appendSessionOutput(`\n${err.message}`);
|
|
2054
2020
|
session.running = false;
|
|
2055
2021
|
session.endedAt = Date.now();
|
|
2056
2022
|
scheduleReap(session);
|
|
@@ -2069,17 +2035,21 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
2069
2035
|
cwd,
|
|
2070
2036
|
requestedShell,
|
|
2071
2037
|
shell: effectiveShell,
|
|
2038
|
+
shellPath,
|
|
2072
2039
|
...(detached ? { detached: true } : {}),
|
|
2073
2040
|
...(shellSubstitution ? { shellSubstitution } : {}),
|
|
2074
|
-
...(
|
|
2041
|
+
...(shellAutoSelected ? { shellAutoSelected: true } : {}),
|
|
2075
2042
|
...(dialectWarnings.length ? { dialectWarnings } : {}),
|
|
2076
2043
|
...(outputFilePath ? { outputFile: outputFilePath } : {}),
|
|
2077
|
-
...warn,
|
|
2078
2044
|
});
|
|
2079
2045
|
}
|
|
2080
2046
|
function snapshot(session, extra = {}) {
|
|
2081
2047
|
return {
|
|
2082
2048
|
sessionId: session.id,
|
|
2049
|
+
requestedShell: session.requestedShell,
|
|
2050
|
+
shell: session.shell,
|
|
2051
|
+
shellPath: session.shellPath,
|
|
2052
|
+
...(session.shellSubstitution ? { shellSubstitution: session.shellSubstitution } : {}),
|
|
2083
2053
|
running: session.running,
|
|
2084
2054
|
exitCode: session.exitCode,
|
|
2085
2055
|
output: session.output,
|
|
@@ -2102,12 +2072,55 @@ async function waitTool(args, ownerScope, context) {
|
|
|
2102
2072
|
const session = ownedSession(id, ownerScope);
|
|
2103
2073
|
if (!session)
|
|
2104
2074
|
return textResult({ error: `No background session with id "${id}".` }, true);
|
|
2075
|
+
const outputMode = args.output === undefined ? 'full' : args.output;
|
|
2076
|
+
if (outputMode !== 'delta' && outputMode !== 'full' && outputMode !== 'none') {
|
|
2077
|
+
return textResult({
|
|
2078
|
+
error: 'Invalid wait output mode. Expected one of: "delta", "full", or "none".',
|
|
2079
|
+
requestedOutput: args.output,
|
|
2080
|
+
}, true);
|
|
2081
|
+
}
|
|
2082
|
+
const timeoutMs = resolveCommandTimeoutMs(args.timeout);
|
|
2083
|
+
const waitSnapshot = (extra) => {
|
|
2084
|
+
const delta = session.waitOutput;
|
|
2085
|
+
const deltaTruncated = session.waitOutputTruncated;
|
|
2086
|
+
session.waitOutput = '';
|
|
2087
|
+
session.waitOutputTruncated = false;
|
|
2088
|
+
const base = snapshot(session, extra);
|
|
2089
|
+
if (outputMode === 'none') {
|
|
2090
|
+
delete base.output;
|
|
2091
|
+
return { ...base, outputMode };
|
|
2092
|
+
}
|
|
2093
|
+
if (outputMode === 'delta') {
|
|
2094
|
+
return { ...base, output: delta, outputMode, deltaTruncated };
|
|
2095
|
+
}
|
|
2096
|
+
return { ...base, outputMode };
|
|
2097
|
+
};
|
|
2098
|
+
const timing = (outcome, waitedMs) => {
|
|
2099
|
+
const finite = timeoutMs !== undefined;
|
|
2100
|
+
const returnedEarly = finite && outcome === 'completed' && waitedMs < timeoutMs;
|
|
2101
|
+
const remainingSeconds = returnedEarly
|
|
2102
|
+
? Math.ceil(Math.max(0, timeoutMs - waitedMs)) / 1_000
|
|
2103
|
+
: 0;
|
|
2104
|
+
return {
|
|
2105
|
+
timedOut: outcome === 'timedOut',
|
|
2106
|
+
waitedMs,
|
|
2107
|
+
...(finite ? { requestedTimeoutMs: timeoutMs, returnedEarly } : {}),
|
|
2108
|
+
...(returnedEarly
|
|
2109
|
+
? {
|
|
2110
|
+
remainingSeconds,
|
|
2111
|
+
hint: '`wait.timeout` is a maximum, so wait returned when the background session completed. '
|
|
2112
|
+
+ `To wait the full requested duration, call sleep with {"seconds": ${remainingSeconds}}.`,
|
|
2113
|
+
}
|
|
2114
|
+
: {}),
|
|
2115
|
+
...(outcome === 'cancelled' ? { cancelled: true } : {}),
|
|
2116
|
+
};
|
|
2117
|
+
};
|
|
2105
2118
|
if (!session.running)
|
|
2106
|
-
return textResult(
|
|
2119
|
+
return textResult(waitSnapshot(timing('completed', 0)));
|
|
2107
2120
|
if (context?.signal?.aborted) {
|
|
2108
|
-
return textResult(
|
|
2121
|
+
return textResult(waitSnapshot(timing('cancelled', 0)), true);
|
|
2109
2122
|
}
|
|
2110
|
-
const
|
|
2123
|
+
const startedAt = Date.now();
|
|
2111
2124
|
const progress = createCommandProgressReporter(context);
|
|
2112
2125
|
const outcome = await new Promise((resolve) => {
|
|
2113
2126
|
let settled = false;
|
|
@@ -2125,8 +2138,9 @@ async function waitTool(args, ownerScope, context) {
|
|
|
2125
2138
|
context?.signal?.removeEventListener('abort', onAbort);
|
|
2126
2139
|
if (session.output.length > outputOffset)
|
|
2127
2140
|
progress.push(session.output.slice(outputOffset));
|
|
2141
|
+
const waitedMs = Date.now() - startedAt;
|
|
2128
2142
|
await progress.stop();
|
|
2129
|
-
resolve(value);
|
|
2143
|
+
resolve({ kind: value, waitedMs });
|
|
2130
2144
|
};
|
|
2131
2145
|
const onAbort = () => void finish('cancelled');
|
|
2132
2146
|
const poll = () => {
|
|
@@ -2146,10 +2160,54 @@ async function waitTool(args, ownerScope, context) {
|
|
|
2146
2160
|
context?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2147
2161
|
poll();
|
|
2148
2162
|
});
|
|
2149
|
-
return textResult(
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2163
|
+
return textResult(waitSnapshot(timing(outcome.kind, outcome.waitedMs)), outcome.kind === 'cancelled');
|
|
2164
|
+
}
|
|
2165
|
+
async function sleepTool(args, context) {
|
|
2166
|
+
const seconds = args.seconds;
|
|
2167
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
|
|
2168
|
+
return textResult({ error: 'Provide "seconds" as a finite number greater than 0.' }, true);
|
|
2169
|
+
}
|
|
2170
|
+
const requestedMs = seconds * 1_000;
|
|
2171
|
+
const ceilingMs = Math.min(commandMaxTimeoutMs(), 2 ** 31 - 1);
|
|
2172
|
+
if (requestedMs > ceilingMs) {
|
|
2173
|
+
return textResult({
|
|
2174
|
+
error: `Requested sleep exceeds the configured maximum of ${ceilingMs / 1_000} seconds.`,
|
|
2175
|
+
requestedSeconds: seconds,
|
|
2176
|
+
maxSeconds: ceilingMs / 1_000,
|
|
2177
|
+
}, true);
|
|
2178
|
+
}
|
|
2179
|
+
if (context?.signal?.aborted) {
|
|
2180
|
+
return textResult({ slept: false, cancelled: true, requestedSeconds: seconds, elapsedMs: 0 }, true);
|
|
2181
|
+
}
|
|
2182
|
+
const startedAt = Date.now();
|
|
2183
|
+
// A one-second heartbeat keeps finite MCP request timers alive even when the
|
|
2184
|
+
// requested fixed delay is longer than the caller's ordinary silent window.
|
|
2185
|
+
const progress = createCommandProgressReporter(context, 1_000);
|
|
2186
|
+
progress.push(`[sleeping for ${seconds}s]`);
|
|
2187
|
+
const outcome = await new Promise((resolve) => {
|
|
2188
|
+
let settled = false;
|
|
2189
|
+
const finish = (slept) => {
|
|
2190
|
+
if (settled)
|
|
2191
|
+
return;
|
|
2192
|
+
settled = true;
|
|
2193
|
+
clearTimeout(timer);
|
|
2194
|
+
context?.signal?.removeEventListener('abort', onAbort);
|
|
2195
|
+
resolve({ slept, elapsedMs: Date.now() - startedAt });
|
|
2196
|
+
};
|
|
2197
|
+
const onAbort = () => finish(false);
|
|
2198
|
+
const timer = setTimeout(() => finish(true), requestedMs);
|
|
2199
|
+
if (context?.signal?.aborted)
|
|
2200
|
+
onAbort();
|
|
2201
|
+
else
|
|
2202
|
+
context?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2203
|
+
});
|
|
2204
|
+
await progress.stop();
|
|
2205
|
+
return textResult({
|
|
2206
|
+
slept: outcome.slept,
|
|
2207
|
+
requestedSeconds: seconds,
|
|
2208
|
+
elapsedMs: outcome.elapsedMs,
|
|
2209
|
+
...(!outcome.slept ? { cancelled: true } : {}),
|
|
2210
|
+
}, !outcome.slept);
|
|
2153
2211
|
}
|
|
2154
2212
|
function writeStdinTool(args, ownerScope) {
|
|
2155
2213
|
const id = String(args?.sessionId ?? '');
|
|
@@ -2158,15 +2216,31 @@ function writeStdinTool(args, ownerScope) {
|
|
|
2158
2216
|
return textResult({ error: `No background session with id "${id}".` }, true);
|
|
2159
2217
|
if (!session.running)
|
|
2160
2218
|
return textResult({ error: `Session "${id}" has already exited.` }, true);
|
|
2219
|
+
const bomPolicy = args.bomPolicy === undefined ? 'preserve' : args.bomPolicy;
|
|
2220
|
+
if (bomPolicy !== 'preserve' && bomPolicy !== 'strip-leading') {
|
|
2221
|
+
return textResult({
|
|
2222
|
+
error: '"bomPolicy" must be one of "preserve" or "strip-leading".',
|
|
2223
|
+
requestedBomPolicy: args.bomPolicy,
|
|
2224
|
+
}, true);
|
|
2225
|
+
}
|
|
2161
2226
|
const data = typeof args.data === 'string' ? args.data : '';
|
|
2162
2227
|
const withNewline = args.newline === false ? data : `${data}\n`;
|
|
2228
|
+
let payload = Buffer.from(withNewline, 'utf8');
|
|
2229
|
+
if (bomPolicy === 'strip-leading'
|
|
2230
|
+
&& payload.length >= 3
|
|
2231
|
+
&& payload[0] === 0xef
|
|
2232
|
+
&& payload[1] === 0xbb
|
|
2233
|
+
&& payload[2] === 0xbf) {
|
|
2234
|
+
payload = payload.subarray(3);
|
|
2235
|
+
}
|
|
2163
2236
|
try {
|
|
2164
|
-
session.child.stdin?.write(
|
|
2237
|
+
session.child.stdin?.write(payload);
|
|
2238
|
+
touchSession(session);
|
|
2165
2239
|
}
|
|
2166
2240
|
catch (err) {
|
|
2167
2241
|
return textResult({ error: `Failed to write to stdin: ${err instanceof Error ? err.message : String(err)}` }, true);
|
|
2168
2242
|
}
|
|
2169
|
-
return textResult({ sessionId: id, written:
|
|
2243
|
+
return textResult({ sessionId: id, written: payload.length, bomPolicy });
|
|
2170
2244
|
}
|
|
2171
2245
|
function killTool(args, ownerScope) {
|
|
2172
2246
|
const id = String(args?.sessionId ?? '');
|
|
@@ -2185,6 +2259,9 @@ function listSessionsTool(ownerScope) {
|
|
|
2185
2259
|
.map((s) => ({
|
|
2186
2260
|
sessionId: s.id,
|
|
2187
2261
|
command: s.command,
|
|
2262
|
+
requestedShell: s.requestedShell,
|
|
2263
|
+
shell: s.shell,
|
|
2264
|
+
shellPath: s.shellPath,
|
|
2188
2265
|
running: s.running,
|
|
2189
2266
|
exitCode: s.exitCode,
|
|
2190
2267
|
detached: s.detached === true,
|
|
@@ -2212,7 +2289,10 @@ function releaseOwnerTool(ownerScope) {
|
|
|
2212
2289
|
function terminalSnapshot(session) {
|
|
2213
2290
|
return {
|
|
2214
2291
|
sessionId: session.id,
|
|
2292
|
+
requestedShell: session.requestedShell,
|
|
2215
2293
|
shell: session.shell,
|
|
2294
|
+
shellPath: session.shellPath,
|
|
2295
|
+
...(session.shellSubstitution ? { shellSubstitution: session.shellSubstitution } : {}),
|
|
2216
2296
|
cwd: session.cwd,
|
|
2217
2297
|
running: session.running,
|
|
2218
2298
|
exitCode: session.exitCode,
|
|
@@ -2292,6 +2372,7 @@ async function openTerminalTool(args, roots, ownerScope) {
|
|
|
2292
2372
|
error: `Failed to open pseudoterminal (${plan.effectiveShell}): ${error instanceof Error ? error.message : String(error)}`,
|
|
2293
2373
|
cwd,
|
|
2294
2374
|
shell: plan.effectiveShell,
|
|
2375
|
+
shellPath: plan.file,
|
|
2295
2376
|
}, true);
|
|
2296
2377
|
}
|
|
2297
2378
|
registerExitCleanup();
|
|
@@ -2299,7 +2380,10 @@ async function openTerminalTool(args, roots, ownerScope) {
|
|
|
2299
2380
|
const session = {
|
|
2300
2381
|
id,
|
|
2301
2382
|
ownerScope,
|
|
2383
|
+
requestedShell: shellValidation.shell,
|
|
2302
2384
|
shell: plan.effectiveShell,
|
|
2385
|
+
shellPath: plan.file,
|
|
2386
|
+
...(plan.shellSubstitution ? { shellSubstitution: plan.shellSubstitution } : {}),
|
|
2303
2387
|
cwd,
|
|
2304
2388
|
pty,
|
|
2305
2389
|
output: '',
|
|
@@ -2440,6 +2524,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
|
|
|
2440
2524
|
return statusTool(args, scope);
|
|
2441
2525
|
case 'wait':
|
|
2442
2526
|
return await waitTool(args, scope, context);
|
|
2527
|
+
case 'sleep':
|
|
2528
|
+
return await sleepTool(args, context);
|
|
2443
2529
|
case 'write_stdin':
|
|
2444
2530
|
return writeStdinTool(args, scope);
|
|
2445
2531
|
case 'kill':
|