@mario.andreschak/mcp-bash 3.45.0 → 3.45.2

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/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 + env hygiene (issue #175): the `cwd` is confined to the same
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 also DO NOT
21
- * inherit the full backend `process.env` by default (which would leak secrets);
22
- * only a minimal allow-list is passed. Set `FLUJO_BASH_INHERIT_ENV=1` to restore
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 { ALLOW_PROTECTED_PATHS_ENV, createLogger, getDataDir, getHomeDir, isInside, isProtected, isProtectedPathsEnabled, killProcessTree, loadEffectiveRoots, } from './shared.js';
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 = buildSpawn('', 'default');
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 (&&, pipes into head/grep, $(…)); an unavailable explicit shell fails fast.',
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
- * Build the child process environment. By default only the minimal allow-list is
896
- * inherited (secrets never leave the backend). Explicit per-command overrides
897
- * are then applied. Setting `FLUJO_BASH_INHERIT_ENV` to a truthy value restores
898
- * full `process.env` inheritance for power users.
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 = isTruthyEnv(process.env.FLUJO_BASH_INHERIT_ENV)
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
- return `${POWERSHELL_PRELUDE}\n${command}\n${POWERSHELL_EPILOGUE}`;
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', '-Command', wrapPowerShellCommand(command)];
917
+ return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(command)];
995
918
  }
996
- /**
997
- * Build the spawn arguments for the requested shell. Returns the command, argv
998
- * and whether Node's `shell:true` wrapping applies. Shells are resolved
999
- * to a concrete executable path up front (checking Git for Windows' well-known
1000
- * install locations for `bash` when it isn't on `PATH`). An unavailable
1001
- * explicitly requested shell is reported before any user command is executed.
1002
- */
1003
- function buildSpawn(command, shell) {
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 resolved = resolvePwshExecutable();
1006
- if (resolved) {
1007
- return { file: resolved, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
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', used: 'powershell',
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: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
943
+ return { file: '', effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
1023
944
  }
1024
945
  if (shell === 'bash') {
1025
- const resolved = resolveBashExecutable();
1026
- if (resolved) {
1027
- return { file: resolved, args: ['-c', command], useShell: false, effectiveShell: 'bash' };
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 resolved = resolveCmdExecutable();
1033
- if (resolved) {
1034
- return {
1035
- file: resolved,
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, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
1048
- }
959
+ if (pwsh)
960
+ return { file: pwsh, effectiveShell: 'pwsh' };
1049
961
  const windowsPowerShell = resolveWindowsPowerShellExecutable();
1050
- if (windowsPowerShell) {
1051
- return { file: windowsPowerShell, args: powerShellArgs(command), useShell: false, effectiveShell: 'powershell' };
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
- if (sh)
1073
- return { file: sh, args: ['-c', command], useShell: false, effectiveShell: 'sh' };
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
- file: '',
1076
- args: [],
1042
+ ...plan,
1043
+ args,
1077
1044
  useShell: false,
1078
- effectiveShell: 'sh',
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 { effectiveShell, unavailableShell };
1087
+ return { ...metadata, unavailableShell };
1116
1088
  if (startError)
1117
- return { effectiveShell, startError };
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, effectiveShell, shellSubstitution };
1100
+ return { child, ...metadata };
1129
1101
  }
1130
1102
  catch (err) {
1131
1103
  return {
1132
1104
  startError: err instanceof Error ? err.message : String(err),
1133
- effectiveShell,
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 = path.basename(head).toLowerCase().replace(/\.exe$/, '');
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 = path.basename(head).toLowerCase().replace(/\.exe$/, '');
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
- /** Resolve an interactive shell executable without wrapping a command string. */
1323
+ /** Build PTY-specific argv around the same resolved executable used elsewhere. */
1346
1324
  function buildPtySpawn(shell) {
1347
- if (shell === 'pwsh') {
1348
- const file = resolvePwshExecutable();
1349
- return file
1350
- ? { file, args: ['-NoLogo'], effectiveShell: 'pwsh' }
1351
- : { file: '', args: [], effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
1352
- }
1353
- if (shell === 'bash') {
1354
- const file = resolveBashExecutable();
1355
- return file
1356
- ? { file, args: ['--noprofile', '--norc', '-i'], effectiveShell: 'bash' }
1357
- : { file: '', args: [], effectiveShell: 'bash', unavailableShell: 'bash' };
1358
- }
1359
- if (shell === 'cmd') {
1360
- const file = resolveCmdExecutable();
1361
- return file
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
- const loginShell = getEnvCaseInsensitive('SHELL');
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
- }, 10_000);
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
- let timer;
1464
- try {
1465
- await Promise.race([
1466
- chain,
1467
- new Promise((resolve) => {
1468
- timer = setTimeout(resolve, 1_000);
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. Use "pwsh", "bash", or "cmd" for explicit syntax; unavailable explicit shells return an error.',
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, 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.',
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). Output is UTF-8 decoded and PowerShell runs with invariant culture, so numbers and text are stable. '
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 for a background session, sending new output as live progress when supported. The wait timeout does not kill the session.',
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 a string to a running background session\'s stdin. Pass "newline": false to omit the trailing newline. Returns { sessionId, written }.',
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 ?? '').trim();
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, selection.shell, envValidation.env);
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 = selection.autoSelected ? { shellAutoSelected: true } : {};
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 ?? '').trim();
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 selection = selectShell(command, requestedShell);
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
- append(chunk);
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
- append(chunk);
2015
+ appendSessionOutput(chunk);
2050
2016
  spool.write(chunk);
2051
2017
  });
2052
2018
  child.on('error', (err) => {
2053
- append(`\n${err.message}`);
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
- ...(selection.autoSelected ? { shellAutoSelected: true } : {}),
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(snapshot(session, { timedOut: false }));
2119
+ return textResult(waitSnapshot(timing('completed', 0)));
2107
2120
  if (context?.signal?.aborted) {
2108
- return textResult(snapshot(session, { timedOut: false, cancelled: true }), true);
2121
+ return textResult(waitSnapshot(timing('cancelled', 0)), true);
2109
2122
  }
2110
- const timeoutMs = resolveCommandTimeoutMs(args.timeout);
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(snapshot(session, {
2150
- timedOut: outcome === 'timedOut',
2151
- ...(outcome === 'cancelled' ? { cancelled: true } : {}),
2152
- }), outcome === 'cancelled');
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(withNewline);
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: Buffer.byteLength(withNewline, 'utf8') });
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':