// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
namespace Microsoft.Agents.AI.Tools.Shell;
///
/// Resolves which shell binary and which argv to launch for the current OS.
///
///
/// Resolution order:
///
/// - Windows: prefer pwsh, fall back to powershell.exe, then cmd.exe.
/// - Linux / macOS: prefer /bin/bash, fall back to /bin/sh.
/// - Override via the constructor argument or the AGENT_FRAMEWORK_SHELL environment variable.
///
///
internal static class ShellResolver
{
///
/// The environment variable consulted by to override
/// the default shell selection (e.g. AGENT_FRAMEWORK_SHELL=/usr/bin/bash).
///
public const string EnvVarName = "AGENT_FRAMEWORK_SHELL";
/// Resolve the shell binary and the per-command argv prefix.
public static ResolvedShell Resolve(string? overrideShell = null)
{
var requested = overrideShell ?? Environment.GetEnvironmentVariable(EnvVarName);
if (!string.IsNullOrWhiteSpace(requested))
{
return ClassifyExplicit(requested!);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (TryFindOnPath("pwsh", out var pwsh))
{
return new ResolvedShell(pwsh, ShellKind.PowerShell);
}
if (TryFindOnPath("powershell", out var winps))
{
return new ResolvedShell(winps, ShellKind.PowerShell);
}
return new ResolvedShell(Path.Combine(SystemRoot(), "System32", "cmd.exe"), ShellKind.Cmd);
}
if (File.Exists("/bin/bash"))
{
return new ResolvedShell("/bin/bash", ShellKind.Bash);
}
return new ResolvedShell("/bin/sh", ShellKind.Sh);
}
///
/// Resolve from an explicit argv list. The first element is treated as
/// the binary; the rest are passed as a launch-time prefix preceding
/// the standard -c / -Command / persistent suffix.
///
public static ResolvedShell ResolveArgv(IReadOnlyList shellArgv)
{
if (shellArgv is null)
{
throw new ArgumentNullException(nameof(shellArgv));
}
if (shellArgv.Count == 0)
{
throw new ArgumentException("shellArgv must contain at least the binary path.", nameof(shellArgv));
}
var binary = shellArgv[0];
var kind = ClassifyKind(binary);
var extra = shellArgv.Count > 1 ? new string[shellArgv.Count - 1] : Array.Empty();
for (var i = 1; i < shellArgv.Count; i++)
{
extra[i - 1] = shellArgv[i];
}
return new ResolvedShell(binary, kind, ExtraArgv: extra);
}
private static ResolvedShell ClassifyExplicit(string path) =>
new(path, ClassifyKind(path));
private static ShellKind ClassifyKind(string path)
{
var name = Path.GetFileNameWithoutExtension(path).ToUpperInvariant();
return name switch
{
"PWSH" or "POWERSHELL" => ShellKind.PowerShell,
"CMD" => ShellKind.Cmd,
"BASH" => ShellKind.Bash,
// All other POSIX shells (sh, zsh, dash, ash, ksh, busybox, ...)
// are launched as plain sh so we don't pass bash-only flags like
// --noprofile / --norc, which zsh and dash reject.
_ => ShellKind.Sh,
};
}
private static bool TryFindOnPath(string name, out string fullPath)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathEnv))
{
fullPath = string.Empty;
return false;
}
var exts = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new[] { ".exe", ".cmd", ".bat", string.Empty }
: new[] { string.Empty };
foreach (var dir in pathEnv!.Split(Path.PathSeparator))
{
if (string.IsNullOrEmpty(dir))
{
continue;
}
foreach (var ext in exts)
{
var candidate = Path.Combine(dir, name + ext);
if (File.Exists(candidate))
{
fullPath = candidate;
return true;
}
}
}
fullPath = string.Empty;
return false;
}
private static string SystemRoot() =>
Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows";
}
/// Identifies the dialect of the resolved shell.
internal enum ShellKind
{
/// POSIX bash; supports --noprofile / --norc.
Bash,
/// PowerShell (pwsh or Windows PowerShell).
PowerShell,
/// Windows cmd.exe.
Cmd,
/// Generic POSIX shell (sh, zsh, dash, ash, ksh, busybox) — bash-only flags are not passed.
Sh,
}
internal readonly record struct ResolvedShell(string Binary, ShellKind Kind, IReadOnlyList? ExtraArgv = null)
{
public IReadOnlyList StatelessArgvForCommand(string command)
{
var extra = this.ExtraArgv ?? Array.Empty();
var suffix = this.Kind switch
{
ShellKind.PowerShell => new[]
{
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
command,
},
ShellKind.Cmd => new[] { "/d", "/c", command },
ShellKind.Sh => new[] { "-c", command },
_ => new[] { "--noprofile", "--norc", "-c", command },
};
if (extra.Count == 0)
{
return suffix;
}
var combined = new string[extra.Count + suffix.Length];
for (var i = 0; i < extra.Count; i++) { combined[i] = extra[i]; }
for (var i = 0; i < suffix.Length; i++) { combined[extra.Count + i] = suffix[i]; }
return combined;
}
///
/// Argv for launching a long-lived shell that reads commands from stdin.
///
public IReadOnlyList PersistentArgv()
{
var extra = this.ExtraArgv ?? Array.Empty();
var suffix = this.Kind switch
{
ShellKind.PowerShell => new[]
{
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
"-",
},
ShellKind.Cmd => throw new NotSupportedException(
"Persistent mode is not supported for cmd.exe — use pwsh, powershell, or a POSIX shell."),
ShellKind.Sh => Array.Empty(),
_ => new[] { "--noprofile", "--norc" },
};
if (extra.Count == 0)
{
return suffix;
}
var combined = new string[extra.Count + suffix.Length];
for (var i = 0; i < extra.Count; i++) { combined[i] = extra[i]; }
for (var i = 0; i < suffix.Length; i++) { combined[extra.Count + i] = suffix[i]; }
return combined;
}
}