File: Agents\Playwright\PlaywrightCliInstaller.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Security.Cryptography;
using Aspire.Cli.Interaction;
using Aspire.Cli.Npm;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Agents.Playwright;
 
/// <summary>
/// Describes the outcome of a Playwright CLI installation attempt.
/// </summary>
internal enum PlaywrightInstallStatus
{
    /// <summary>
    /// Installation completed successfully.
    /// </summary>
    Installed,
 
    /// <summary>
    /// Installation completed but some post-install steps (e.g., mirroring) had warnings.
    /// </summary>
    InstalledWithWarnings,
 
    /// <summary>
    /// Installation was skipped because a prerequisite (npm) is not available.
    /// </summary>
    Skipped,
 
    /// <summary>
    /// Installation failed.
    /// </summary>
    Failed
}
 
/// <summary>
/// Orchestrates secure installation of the Playwright CLI with supply chain verification.
/// </summary>
internal sealed class PlaywrightCliInstaller(
    INpmRunner npmRunner,
    INpmProvenanceChecker provenanceChecker,
    IPlaywrightCliRunner playwrightCliRunner,
    IInteractionService interactionService,
    IConfiguration configuration,
    ILogger<PlaywrightCliInstaller> logger)
{
    /// <summary>
    /// The npm package name for the Playwright CLI.
    /// </summary>
    internal const string PackageName = "@playwright/cli";
 
    /// <summary>
    /// The version range to resolve. Accepts any version from 0.1.3 onwards.
    /// </summary>
    internal const string VersionRange = ">=0.1.3";
 
    /// <summary>
    /// The expected source repository for provenance verification.
    /// </summary>
    internal const string ExpectedSourceRepository = "https://github.com/microsoft/playwright-cli";
 
    /// <summary>
    /// The expected workflow file path in the source repository.
    /// </summary>
    internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml";
 
    /// <summary>
    /// The expected SLSA build type, which identifies GitHub Actions as the CI system
    /// and implicitly confirms the OIDC token issuer is <c>https://token.actions.githubusercontent.com</c>.
    /// </summary>
    internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
 
    /// <summary>
    /// The name of the playwright-cli skill directory.
    /// </summary>
    internal const string PlaywrightCliSkillName = "playwright-cli";
 
    /// <summary>
    /// The primary skill base directory where playwright-cli installs skills.
    /// This must match the directory that the playwright-cli binary actually writes to.
    /// See: https://github.com/microsoft/playwright-cli/issues/294
    /// </summary>
    internal static readonly string s_primarySkillBaseDirectory = Path.Combine(".claude", "skills");
 
    /// <summary>
    /// Configuration key that disables package validation when set to "true".
    /// This is a break-glass mechanism for debugging npm service issues and must never be the default.
    /// </summary>
    internal const string DisablePackageValidationKey = "disablePlaywrightCliPackageValidation";
 
    /// <summary>
    /// Configuration key that overrides the version to install. When set, the specified
    /// exact version is used instead of resolving the latest from the version range.
    /// </summary>
    internal const string VersionOverrideKey = "playwrightCliVersion";
 
    /// <summary>
    /// Installs the Playwright CLI with supply chain verification and generates skill files.
    /// </summary>
    /// <param name="repoRoot">The workspace/repository root directory.</param>
    /// <param name="selectedSkillDirectories">The skill directories the user explicitly selected.</param>
    /// <param name="cancellationToken">A token to cancel the operation.</param>
    /// <returns>A tuple where <c>Status</c> indicates the outcome (installed, skipped, or failed) and <c>Message</c> contains additional details when applicable.</returns>
    public async Task<(PlaywrightInstallStatus Status, string? Message)> InstallAsync(string repoRoot, IReadOnlySet<string> selectedSkillDirectories, CancellationToken cancellationToken)
    {
        return await interactionService.ShowStatusAsync(
            AgentCommandStrings.PlaywrightCliInstaller_InstallingStatus,
            () => InstallCoreAsync(repoRoot, selectedSkillDirectories, cancellationToken));
    }
 
    private async Task<(PlaywrightInstallStatus Status, string? Message)> InstallCoreAsync(string repoRoot, IReadOnlySet<string> selectedSkillDirectories, CancellationToken cancellationToken)
    {
        // Early exit if npm is not available — playwright-cli requires npm.
        if (!npmRunner.IsAvailable)
        {
            logger.LogDebug("npm is not available on PATH, skipping Playwright CLI installation.");
            return (PlaywrightInstallStatus.Skipped, null);
        }
 
        // Step 1: Resolve the target version from the public npm registry.
        var versionOverride = configuration[VersionOverrideKey];
        string effectiveRange;
 
        if (!string.IsNullOrEmpty(versionOverride))
        {
            // The override is forwarded directly to npm as an exact version specifier, so reject
            // anything that is not a strict SemVer 2.0 version (e.g. ranges like ">=1.0.0", npm
            // dist-tags like "latest", or arbitrary strings). This prevents a malformed config
            // value from being interpreted by npm in unexpected ways and gives the user a clear
            // error rather than a generic resolve failure.
            // See https://semver.org/spec/v2.0.0.html for the accepted shape.
            if (!SemVersion.TryParse(versionOverride, SemVersionStyles.Strict, out _))
            {
                return (PlaywrightInstallStatus.Failed, string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.PlaywrightCliInstaller_InvalidVersionOverride, VersionOverrideKey, versionOverride));
            }
 
            effectiveRange = versionOverride;
            logger.LogDebug("Using version override from '{ConfigKey}': {Version}", VersionOverrideKey, versionOverride);
        }
        else
        {
            effectiveRange = VersionRange;
        }
 
        logger.LogDebug("Resolving {Package}@{Range} from the public npm registry.", PackageName, effectiveRange);
        var packageInfo = await npmRunner.ResolvePackageAsync(PackageName, effectiveRange, cancellationToken);
 
        if (packageInfo is null)
        {
            return (PlaywrightInstallStatus.Failed, string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.PlaywrightCliInstaller_FailedToResolvePackage, NpmPackageInfo.FormatPackageSpecifier(PackageName, effectiveRange)));
        }
 
        logger.LogDebug("Resolved {PackageSpecifier}.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version));
 
        // Step 2: Check if a suitable version is already installed.
        var installedVersion = await playwrightCliRunner.GetVersionAsync(cancellationToken);
        if (installedVersion is not null)
        {
            var comparison = SemVersion.ComparePrecedence(installedVersion, packageInfo.Version);
            if (comparison >= 0)
            {
                logger.LogDebug(
                    "playwright-cli {InstalledVersion} is already installed (target: {TargetVersion}), skipping installation.",
                    installedVersion,
                    packageInfo.Version);
 
                // Still install skills in case they're missing.
                return await InstallAndMirrorSkillsAsync(repoRoot, selectedSkillDirectories, cancellationToken);
            }
 
            logger.LogDebug(
                "Upgrading playwright-cli from {InstalledVersion} to {TargetVersion}.",
                installedVersion,
                packageInfo.Version);
        }
 
        // Check break-glass configuration to bypass package validation.
        var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase);
        if (validationDisabled)
        {
            logger.LogWarning(
                "Package validation is disabled via '{ConfigKey}'. " +
                "Sigstore attestation, provenance, and integrity checks will be skipped. " +
                "This should only be used for debugging npm service issues.",
                DisablePackageValidationKey);
        }
 
        // Step 3: Download the tarball via npm pack.
        var tempDir = Directory.CreateTempSubdirectory("aspire-playwright-").FullName;
 
        try
        {
            logger.LogDebug("Downloading {PackageSpecifier} to {TempDir}.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version), tempDir);
            var tarballPath = await npmRunner.PackAsync(PackageName, packageInfo.Version.ToString(), tempDir, cancellationToken);
 
            if (tarballPath is null)
            {
                logger.LogWarning("Failed to download {PackageSpecifier}.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version));
                return (PlaywrightInstallStatus.Failed, string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.PlaywrightCliInstaller_FailedToDownload, NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version)));
            }
 
            if (!validationDisabled)
            {
                // Step 4: Verify provenance via Sigstore bundle verification and SLSA attestation checks.
                // The digest is computed from the downloaded archive so verification binds the
                // exact package bytes to the signed provenance statement.
                var tarballIntegrity = ComputeIntegrity(tarballPath);
                logger.LogDebug("Verifying provenance for {PackageSpecifier}.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version));
                var provenanceResult = await provenanceChecker.VerifyProvenanceAsync(
                    PackageName,
                    packageInfo.Version.ToString(),
                    ExpectedSourceRepository,
                    ExpectedWorkflowPath,
                    ExpectedBuildType,
                    refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) &&
                               (string.Equals(refInfo.Name, $"{packageInfo.Version}", StringComparison.Ordinal) ||
                                string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal)),
                    tarballIntegrity,
                    cancellationToken);
 
                if (!provenanceResult.IsVerified)
                {
                    logger.LogWarning(
                        "Provenance verification failed for {PackageSpecifier}: {Outcome}. Expected source repository: {ExpectedRepo}",
                        NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version),
                        provenanceResult.Outcome,
                        ExpectedSourceRepository);
                    return (PlaywrightInstallStatus.Failed, string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.PlaywrightCliInstaller_ProvenanceVerificationFailed, NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version), provenanceResult.Outcome));
                }
 
                logger.LogDebug(
                    "Provenance verification passed for {PackageSpecifier} (source: {SourceRepo})",
                    NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version),
                    provenanceResult.Provenance?.SourceRepository);
            }
 
            // Step 5: Install globally from the verified tarball.
            logger.LogDebug("Installing {PackageSpecifier} globally.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version));
            var installSuccess = await npmRunner.InstallGlobalAsync(tarballPath, cancellationToken);
 
            if (!installSuccess)
            {
                logger.LogWarning("Failed to install {PackageSpecifier} globally.", NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version));
                return (PlaywrightInstallStatus.Failed, string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.PlaywrightCliInstaller_FailedToInstallGlobally, NpmPackageInfo.FormatPackageSpecifier(PackageName, packageInfo.Version)));
            }
 
            // Step 6: Generate skill files and mirror to selected locations.
            return await InstallAndMirrorSkillsAsync(repoRoot, selectedSkillDirectories, cancellationToken);
        }
        finally
        {
            // Clean up temporary directory.
            try
            {
                if (Directory.Exists(tempDir))
                {
                    Directory.Delete(tempDir, recursive: true);
                }
            }
            catch (IOException ex)
            {
                logger.LogDebug(ex, "Failed to clean up temporary directory: {TempDir}", tempDir);
            }
        }
    }
 
    /// <summary>
    /// Runs <c>playwright-cli install --skills</c>, then mirrors the generated files
    /// to the user-selected locations and cleans up any unselected locations that
    /// playwright-cli created during this run.
    /// </summary>
    private async Task<(PlaywrightInstallStatus Status, string? Message)> InstallAndMirrorSkillsAsync(
        string repoRoot,
        IReadOnlySet<string> selectedSkillDirectories,
        CancellationToken cancellationToken)
    {
        logger.LogDebug("Generating Playwright CLI skill files.");
        var preExisting = SnapshotPlaywrightSkillDirs(repoRoot);
        var skillsInstalled = await playwrightCliRunner.InstallSkillsAsync(repoRoot, cancellationToken);
        if (!skillsInstalled)
        {
            return (PlaywrightInstallStatus.Failed, AgentCommandStrings.PlaywrightCliInstaller_FailedToGenerateSkillFiles);
        }
 
        try
        {
            MirrorSkillFiles(repoRoot, selectedSkillDirectories, preExisting);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogWarning(ex, "Failed to mirror Playwright CLI skill files to some locations.");
            return (PlaywrightInstallStatus.InstalledWithWarnings, AgentCommandStrings.PlaywrightCliInstaller_InstalledWithMirrorWarnings);
        }
 
        return (PlaywrightInstallStatus.Installed, null);
    }
 
    /// <summary>
    /// Snapshots which playwright-cli skill directories already exist across all
    /// known skill locations so we can tell what was created during this run.
    /// </summary>
    private static HashSet<string> SnapshotPlaywrightSkillDirs(string repoRoot)
    {
        var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var location in SkillLocation.All)
        {
            var dir = Path.Combine(repoRoot, location.RelativeSkillDirectory, PlaywrightCliSkillName);
            if (Directory.Exists(dir))
            {
                existing.Add(location.RelativeSkillDirectory);
            }
        }
        return existing;
    }
 
    /// <summary>
    /// Mirrors the playwright-cli skill directory from the primary location to all
    /// user-selected skill directories, then cleans up any directories that
    /// playwright-cli created in unselected locations during this run.
    /// </summary>
    private void MirrorSkillFiles(string repoRoot, IReadOnlySet<string> selectedSkillDirectories, HashSet<string> preExistingLocations)
    {
        var primarySkillDir = Path.Combine(repoRoot, s_primarySkillBaseDirectory, PlaywrightCliSkillName);
 
        if (!Directory.Exists(primarySkillDir))
        {
            logger.LogDebug("Primary skill directory does not exist: {PrimarySkillDir}", primarySkillDir);
            return;
        }
 
        // Mirror to each user-selected location (skip the primary — it's the source).
        foreach (var skillBaseDir in selectedSkillDirectories)
        {
            if (string.Equals(skillBaseDir, s_primarySkillBaseDirectory, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            var targetSkillDir = Path.Combine(repoRoot, skillBaseDir, PlaywrightCliSkillName);
 
            try
            {
                SyncDirectory(primarySkillDir, targetSkillDir);
                logger.LogDebug("Mirrored playwright-cli skills to {TargetDir}.", targetSkillDir);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogWarning(ex, "Failed to mirror playwright-cli skills to {TargetDir}.", targetSkillDir);
            }
        }
 
        // Clean up playwright-cli directories that were created during this run
        // in locations the user didn't select. We only remove directories that
        // didn't exist before install — pre-existing content is never touched.
        foreach (var location in SkillLocation.All)
        {
            if (selectedSkillDirectories.Contains(location.RelativeSkillDirectory))
            {
                continue; // User selected this location — keep it
            }
 
            if (preExistingLocations.Contains(location.RelativeSkillDirectory))
            {
                continue; // Was already there before this run — leave it alone
            }
 
            var skillDir = Path.Combine(repoRoot, location.RelativeSkillDirectory, PlaywrightCliSkillName);
            if (!Directory.Exists(skillDir))
            {
                continue;
            }
 
            try
            {
                Directory.Delete(skillDir, recursive: true);
                logger.LogDebug("Removed playwright-cli skills from unselected location: {SkillDir}", skillDir);
 
                RemoveEmptyParentDirectories(skillDir, repoRoot, location.RelativeSkillDirectory, logger);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogDebug(ex, "Failed to remove playwright-cli skills from {SkillDir}.", skillDir);
            }
        }
    }
 
    /// <summary>
    /// Walks up from <paramref name="startDir"/> and removes empty parent directories,
    /// stopping at <paramref name="stopDir"/> (never deleted). The number of levels walked
    /// is bounded by the segment count in <paramref name="relativeSkillDirectory"/> + 1
    /// as an additional safeguard against unintended recursion.
    /// </summary>
    internal static void RemoveEmptyParentDirectories(string startDir, string stopDir, string relativeSkillDirectory, ILogger? logger = null)
    {
        var maxDepth = relativeSkillDirectory.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length + 1;
        var depth = 0;
        var parent = Path.GetDirectoryName(startDir);
        while (parent is not null
            && ++depth <= maxDepth
            && !string.Equals(parent, stopDir, StringComparison.OrdinalIgnoreCase)
            && Directory.Exists(parent)
            && Directory.GetFileSystemEntries(parent).Length == 0)
        {
            Directory.Delete(parent);
            logger?.LogDebug("Removed empty directory: {Dir}", parent);
            parent = Path.GetDirectoryName(parent);
        }
    }
 
    /// <summary>
    /// Synchronizes the contents of the source directory to the target directory,
    /// creating, updating, and removing files so the target matches the source exactly.
    /// </summary>
    internal static void SyncDirectory(string sourceDir, string targetDir)
    {
        Directory.CreateDirectory(targetDir);
 
        // Copy all files from source to target
        foreach (var sourceFile in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
        {
            var relativePath = Path.GetRelativePath(sourceDir, sourceFile);
            var targetFile = Path.Combine(targetDir, relativePath);
 
            var targetFileDir = Path.GetDirectoryName(targetFile);
            if (!string.IsNullOrEmpty(targetFileDir))
            {
                Directory.CreateDirectory(targetFileDir);
            }
 
            File.Copy(sourceFile, targetFile, overwrite: true);
        }
 
        // Remove files in target that don't exist in source
        if (Directory.Exists(targetDir))
        {
            foreach (var targetFile in Directory.GetFiles(targetDir, "*", SearchOption.AllDirectories))
            {
                var relativePath = Path.GetRelativePath(targetDir, targetFile);
                var sourceFile = Path.Combine(sourceDir, relativePath);
 
                if (!File.Exists(sourceFile))
                {
                    File.Delete(targetFile);
                }
            }
 
            // Remove empty directories in target
            foreach (var dir in Directory.GetDirectories(targetDir, "*", SearchOption.AllDirectories)
                .OrderByDescending(d => d.Length))
            {
                if (Directory.Exists(dir) && Directory.GetFileSystemEntries(dir).Length == 0)
                {
                    Directory.Delete(dir);
                }
            }
        }
    }
 
    /// <summary>
    /// Computes the SHA-512 SRI integrity value for a file.
    /// </summary>
    internal static string ComputeIntegrity(string filePath)
    {
        using var stream = File.OpenRead(filePath);
        return $"sha512-{Convert.ToBase64String(SHA512.HashData(stream))}";
    }
 
}