From fb9966e9c614e950a8b769078d332f309920b759 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Wed, 8 Jul 2026 13:53:44 -0400
Subject: [PATCH 1/7] Python script only packages BE support (#48942)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #48393
Adds `.py` as an accepted script-only software package on the server —
mirroring `.sh`, assigned the new `py_packages` source and installable
on macOS and Linux hosts.
# Checklist for submitter
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
## Testing
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
---
cmd/fleetctl/fleetctl/generate_gitops_test.go | 56 ++++-
.../integrationtest/gitops/software_test.go | 4 +-
ee/server/service/software_installers.go | 12 +-
ee/server/service/software_installers_test.go | 216 ++++++++++++++++++
server/fleet/software_installer.go | 8 +-
server/fleet/software_installer_test.go | 7 +
server/service/integration_enterprise_test.go | 123 ++++++++++
.../testdata/software-installers/script.py | 3 +
8 files changed, 417 insertions(+), 12 deletions(-)
create mode 100644 server/service/testdata/software-installers/script.py
diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go
index 023dd9f4468..5005d7ffc59 100644
--- a/cmd/fleetctl/fleetctl/generate_gitops_test.go
+++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go
@@ -1812,10 +1812,10 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
packages, ok := software["packages"].([]interface{})
require.True(t, ok, "packages should be an array")
- require.Len(t, packages, 3, "should have 3 packages: 1 regular + 2 scripts (.sh and .ps1)")
+ require.Len(t, packages, 4, "should have 4 packages: 1 regular + 3 scripts (.sh, .ps1, and .py)")
// Identify by URL since hash_sha256 includes comment tokens
- var shScriptPkg, ps1ScriptPkg, regularPkg map[string]interface{}
+ var shScriptPkg, ps1ScriptPkg, pyScriptPkg, regularPkg map[string]any
for _, pkg := range packages {
p := pkg.(map[string]interface{})
url, ok := p["url"].(string)
@@ -1827,6 +1827,8 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
shScriptPkg = p
case "https://example.com/download/setup.ps1":
ps1ScriptPkg = p
+ case "https://example.com/download/install.py":
+ pyScriptPkg = p
case "https://example.com/download/regular-package.deb":
regularPkg = p
}
@@ -1834,6 +1836,7 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
require.NotNil(t, shScriptPkg, ".sh script package should exist")
require.NotNil(t, ps1ScriptPkg, ".ps1 script package should exist")
+ require.NotNil(t, pyScriptPkg, ".py script package should exist")
require.NotNil(t, regularPkg, "regular package should exist")
_, hasInstallScript := shScriptPkg["install_script"]
@@ -1860,10 +1863,24 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
_, hasPreInstallQuery = ps1ScriptPkg["pre_install_query"]
require.False(t, hasPreInstallQuery, ".ps1 script package should NOT have pre_install_query in YAML output")
+ _, hasInstallScript = pyScriptPkg["install_script"]
+ require.False(t, hasInstallScript, ".py script package should NOT have install_script in YAML output")
+
+ _, hasPostInstallScript = pyScriptPkg["post_install_script"]
+ require.False(t, hasPostInstallScript, ".py script package should NOT have post_install_script in YAML output")
+
+ _, hasUninstallScript = pyScriptPkg["uninstall_script"]
+ require.False(t, hasUninstallScript, ".py script package should NOT have uninstall_script in YAML output")
+
+ _, hasPreInstallQuery = pyScriptPkg["pre_install_query"]
+ require.False(t, hasPreInstallQuery, ".py script package should NOT have pre_install_query in YAML output")
+
require.Contains(t, shScriptPkg, "url", ".sh script package should have url")
require.Contains(t, shScriptPkg, "hash_sha256", ".sh script package should have hash_sha256")
require.Contains(t, ps1ScriptPkg, "url", ".ps1 script package should have url")
require.Contains(t, ps1ScriptPkg, "hash_sha256", ".ps1 script package should have hash_sha256")
+ require.Contains(t, pyScriptPkg, "url", ".py script package should have url")
+ require.Contains(t, pyScriptPkg, "hash_sha256", ".py script package should have hash_sha256")
require.Contains(t, regularPkg, "install_script", "regular package should have install_script")
require.Contains(t, regularPkg, "post_install_script", "regular package should have post_install_script")
@@ -1881,6 +1898,7 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
}
require.NotContains(t, commentFor("my-script.sh"), "version", ".sh script package comment should not mention version")
require.NotContains(t, commentFor("setup.ps1"), "version", ".ps1 script package comment should not mention version")
+ require.NotContains(t, commentFor("install.py"), "version", ".py script package comment should not mention version")
require.Contains(t, commentFor("regular-package.deb"), "version", "regular package comment should still mention version")
for filename := range cmd.FilesToWrite {
@@ -1893,6 +1911,11 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
require.NotContains(t, filename, "powershell-script-windows-postinstall", "should not write post-install script file for .ps1 script package")
require.NotContains(t, filename, "powershell-script-windows-uninstall", "should not write uninstall script file for .ps1 script package")
require.NotContains(t, filename, "powershell-script-windows-preinstallquery", "should not write pre-install query file for .ps1 script package")
+
+ require.NotContains(t, filename, "python-script-linux-install", "should not write install script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-postinstall", "should not write post-install script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-uninstall", "should not write uninstall script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-preinstallquery", "should not write pre-install query file for .py script package")
}
}
@@ -1934,6 +1957,16 @@ func (c *MockClientWithScriptPackage) ListSoftwareTitles(query string) ([]fleet.
Version: "1.5",
},
},
+ {
+ ID: 6,
+ Name: "Python Script",
+ HashSHA256: new("py-script-hash"),
+ SoftwarePackage: &fleet.SoftwarePackageOrApp{
+ Name: "install.py",
+ Platform: "linux",
+ Version: "1.2",
+ },
+ },
}, nil
default:
return c.MockClient.ListSoftwareTitles(query)
@@ -1997,6 +2030,25 @@ func (c *MockClientWithScriptPackage) GetSoftwareTitleByID(id uint, teamID *uint
Name: "setup.ps1",
},
}, nil
+ case 6:
+ if *teamID != 2 {
+ return nil, errors.New("team ID mismatch")
+ }
+ // InstallScript is populated internally from file contents, but these fields
+ // should NOT be output in GitOps YAML
+ return &fleet.SoftwareTitle{
+ ID: 6,
+ SoftwarePackage: &fleet.SoftwareInstaller{
+ InstallScript: "#!/usr/bin/env python3\nprint('This is the Python script content')",
+ PostInstallScript: "",
+ UninstallScript: "",
+ PreInstallQuery: "",
+ SelfService: true,
+ Platform: "linux",
+ URL: "https://example.com/download/install.py",
+ Name: "install.py",
+ },
+ }, nil
default:
return c.MockClient.GetSoftwareTitleByID(id, teamID)
}
diff --git a/cmd/fleetctl/integrationtest/gitops/software_test.go b/cmd/fleetctl/integrationtest/gitops/software_test.go
index 8d483481e9e..ca713bf9313 100644
--- a/cmd/fleetctl/integrationtest/gitops/software_test.go
+++ b/cmd/fleetctl/integrationtest/gitops/software_test.go
@@ -35,7 +35,7 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) {
}{
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/team_software_installer_install_script_secret.yml", "environment variable \"FLEET_SECRET_NAME\" not set"},
- {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."},
+ {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_subdir.yml", ""},
@@ -427,7 +427,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
- {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."},
+ {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_subdir.yml", ""},
diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go
index 820bccf4ba0..4ed6a55e2b3 100644
--- a/ee/server/service/software_installers.go
+++ b/ee/server/service/software_installers.go
@@ -1799,8 +1799,8 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host
}
if host.FleetPlatform() != requiredPlatform {
- // Allow .sh scripts for any unix-like platform (linux and darwin)
- if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) {
+ // Allow .sh and .py scripts for any unix-like platform (linux and darwin)
+ if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.NewWithData(
@@ -2097,7 +2097,7 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
return "", &fleet.BadRequestError{
- Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1.",
+ Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
@@ -2271,6 +2271,8 @@ func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet
payload.Source = "sh_packages"
case "ps1":
payload.Source = "ps1_packages"
+ case "py":
+ payload.Source = "py_packages"
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension)
@@ -2956,7 +2958,7 @@ func (svc *Service) softwareBatchUpload(
ext = strings.TrimPrefix(ext, ".")
if !fleet.IsScriptPackage(ext) {
- return fmt.Errorf("script:// URL must reference a .sh or .ps1 file, got: %s", filename)
+ return fmt.Errorf("script:// URL must reference a .sh, .py, or .ps1 file, got: %s", filename)
}
if p.InstallScript == "" {
@@ -3728,7 +3730,7 @@ func packageExtensionToPlatform(ext string) string {
requiredPlatform = "windows"
case ".pkg", ".dmg":
requiredPlatform = "darwin"
- case ".deb", ".rpm", ".gz", ".tgz", ".sh":
+ case ".deb", ".rpm", ".gz", ".tgz", ".sh", ".py":
requiredPlatform = "linux"
default:
return ""
diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go
index ad2a29ae722..a1dcac0ffac 100644
--- a/ee/server/service/software_installers_test.go
+++ b/ee/server/service/software_installers_test.go
@@ -803,6 +803,82 @@ func TestAddScriptPackageMetadata(t *testing.T) {
require.NotEmpty(t, payload.StorageID)
})
+ t.Run("valid python script", func(t *testing.T) {
+ scriptContents := "#!/usr/bin/env python3\nprint('Installing software')\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "install-app.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.NoError(t, err)
+ require.Equal(t, "install-app", payload.Title)
+ require.Empty(t, payload.Version)
+ require.Equal(t, scriptContents, payload.InstallScript)
+ require.Equal(t, "linux", payload.Platform)
+ require.Equal(t, "py_packages", payload.Source)
+ require.Empty(t, payload.BundleIdentifier)
+ require.Empty(t, payload.PackageIDs)
+ require.NotEmpty(t, payload.StorageID)
+ require.Equal(t, "py", payload.Extension)
+ })
+
+ t.Run("python script without shebang", func(t *testing.T) {
+ scriptContents := "print('hello')\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "test.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Script validation failed")
+ require.Contains(t, err.Error(), "python shebang")
+ })
+
+ t.Run("python script with shell shebang", func(t *testing.T) {
+ scriptContents := "#!/bin/bash\necho 'hello'\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "test.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Script validation failed")
+ require.Contains(t, err.Error(), "python shebang")
+ })
+
t.Run("invalid shebang", func(t *testing.T) {
scriptContents := "#!/usr/bin/python\nprint('hello')\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
@@ -950,6 +1026,32 @@ func TestAddScriptPackageMetadataLargeScript(t *testing.T) {
require.NoError(t, err)
require.Equal(t, scriptContents, payload.InstallScript)
})
+
+ t.Run("large python script within saved limit", func(t *testing.T) {
+ t.Parallel()
+ scriptContents := "#!/usr/bin/env python3\n" + strings.Repeat("print('line')\n", 1000)
+ require.Greater(t, len(scriptContents), fleet.UnsavedScriptMaxRuneLen)
+ require.Less(t, len(scriptContents), fleet.SavedScriptMaxRuneLen)
+
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "large-install.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.NoError(t, err)
+ require.Equal(t, scriptContents, payload.InstallScript)
+ })
}
// TestInstallShScriptOnDarwin tests that .sh scripts (stored as platform='linux')
@@ -1229,6 +1331,120 @@ func TestInstallShScriptOnWindowsFails(t *testing.T) {
require.Contains(t, bre.Message, "can be installed only on linux hosts")
}
+// .py packages are stored with platform='linux', but the unix-like exception
+// must still let them install on darwin hosts.
+func TestInstallPyScriptOnUnixLike(t *testing.T) {
+ t.Parallel()
+
+ for _, platform := range []string{"linux", "darwin"} {
+ t.Run(platform, func(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: platform,
+ TeamID: new(uint(1)),
+ }, nil
+ }
+
+ ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
+ return nil, nil
+ }
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: false,
+ }, nil
+ }
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
+ return nil, nil
+ }
+
+ ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error {
+ return nil
+ }
+
+ ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
+ return "install-uuid", nil
+ }
+
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{
+ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)},
+ })
+
+ err := svc.InstallSoftwareTitle(ctx, 1, 100)
+ require.NoError(t, err, ".py install on %s should succeed", platform)
+ require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created")
+ })
+ }
+}
+
+func TestInstallPyScriptOnWindowsFails(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: "windows",
+ TeamID: new(uint(1)),
+ }, nil
+ }
+
+ ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
+ return nil, nil
+ }
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: false,
+ }, nil
+ }
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
+ return nil, nil
+ }
+
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{
+ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)},
+ })
+
+ err := svc.InstallSoftwareTitle(ctx, 1, 100)
+ require.Error(t, err, ".py install on windows should fail")
+
+ var bre *fleet.BadRequestError
+ require.ErrorAs(t, err, &bre, "error should be BadRequestError")
+ require.NotNil(t, bre)
+ require.Contains(t, bre.Message, "can be installed only on linux hosts")
+}
+
func TestSelfServiceInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index d1827410059..b9128260b36 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -736,6 +736,8 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
return "sh_packages", nil
case "ps1":
return "ps1_packages", nil
+ case "py":
+ return "py_packages", nil
default:
return "", fmt.Errorf("unsupported file type: %s", ext)
}
@@ -744,7 +746,7 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
- case "deb", "rpm", "tar.gz", "sh":
+ case "deb", "rpm", "tar.gz", "sh", "py":
return "linux", nil
case "exe", "msi", "ps1", "zip":
return "windows", nil
@@ -758,10 +760,10 @@ func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
}
// IsScriptPackage returns true if the extension represents a script package
-// (.sh or .ps1 files where the file contents become the install script).
+// (.sh, .ps1, or .py files where the file contents become the install script).
func IsScriptPackage(ext string) bool {
ext = strings.TrimPrefix(ext, ".")
- return ext == "sh" || ext == "ps1"
+ return ext == "sh" || ext == "ps1" || ext == "py"
}
// HostSoftwareWithInstaller represents the list of software installed on a
diff --git a/server/fleet/software_installer_test.go b/server/fleet/software_installer_test.go
index d8637d96b03..a3b500ca892 100644
--- a/server/fleet/software_installer_test.go
+++ b/server/fleet/software_installer_test.go
@@ -160,6 +160,8 @@ func TestSoftwareInstallerPlatformFromExtension(t *testing.T) {
{"sh", "linux", false},
{".ps1", "windows", false},
{"ps1", "windows", false},
+ {".py", "linux", false},
+ {"py", "linux", false},
// Unsupported extensions (msix is fleet-maintained only, not custom upload)
{".msix", "", true},
@@ -212,6 +214,8 @@ func TestSofwareInstallerSourceFromExtensionAndName(t *testing.T) {
{"sh", "setup.sh", "sh_packages", false},
{".ps1", "script.ps1", "ps1_packages", false},
{"ps1", "setup.ps1", "ps1_packages", false},
+ {".py", "script.py", "py_packages", false},
+ {"py", "setup.py", "py_packages", false},
// Unsupported extensions (msix is fleet-maintained only, not custom upload)
{".msix", "app.msix", "", true},
@@ -244,6 +248,8 @@ func TestIsScriptPackage(t *testing.T) {
{"sh", true},
{".ps1", true},
{"ps1", true},
+ {".py", true},
+ {"py", true},
// Non-script extensions - should return false
{".pkg", false},
@@ -263,6 +269,7 @@ func TestIsScriptPackage(t *testing.T) {
{"", false},
{".SH", false}, // Case sensitive
{".PS1", false}, // Case sensitive
+ {".PY", false}, // Case sensitive
{".bash", false}, // Not recognized
}
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index bde0bdab2f6..6e9bccae1d6 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -17118,6 +17118,77 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() {
return sqlx.GetContext(ctx, q, &storedURL, `SELECT url FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, &team.ID, "hello world.sh")
})
require.Empty(t, storedURL, "cache-hit re-apply must drop the placeholder url too")
+
+ pyContent := "#!/usr/bin/env python3\nprint('Installing...')\n"
+ pyFile, err := fleet.NewTempFileReader(strings.NewReader(pyContent), func() string { return t.TempDir() })
+ require.NoError(t, err)
+ defer pyFile.Close()
+
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "install-app.py",
+ TeamID: &team.ID,
+ AutomaticInstall: true,
+ InstallerFile: pyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Fleet can't create a policy to detect existing installations for .py packages.")
+
+ err = pyFile.Rewind()
+ require.NoError(t, err)
+
+ badPyContent := "print('no shebang')\n"
+ badPyFile, err := fleet.NewTempFileReader(strings.NewReader(badPyContent), func() string { return t.TempDir() })
+ require.NoError(t, err)
+ defer badPyFile.Close()
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "no-shebang.py",
+ TeamID: &team.ID,
+ InstallerFile: badPyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Script validation failed")
+
+ // install_script is derived from the file, so any install_script param is ignored.
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "install-app.py",
+ Title: "install-app.py",
+ TeamID: &team.ID,
+ InstallScript: "this should be ignored",
+ UninstallScript: "echo 'uninstall py'",
+ PostInstallScript: "echo 'post py'",
+ PreInstallQuery: "SELECT 1;",
+ InstallerFile: pyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
+
+ var pyStored struct {
+ Source string `db:"source"`
+ InstallScript string `db:"install_script"`
+ UninstallScript string `db:"uninstall_script"`
+ PostInstallScript string `db:"post_install_script"`
+ PreInstallQuery string `db:"pre_install_query"`
+ Platform string `db:"platform"`
+ }
+ mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(context.Background(), q, &pyStored, `
+ SELECT
+ st.source,
+ COALESCE(inst.contents, '') AS install_script,
+ COALESCE(uninst.contents, '') AS uninstall_script,
+ COALESCE(postinst.contents, '') AS post_install_script,
+ si.pre_install_query,
+ si.platform
+ FROM software_installers si
+ JOIN software_titles st ON st.id = si.title_id
+ LEFT JOIN script_contents inst ON inst.id = si.install_script_content_id
+ LEFT JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id
+ LEFT JOIN script_contents postinst ON postinst.id = si.post_install_script_content_id
+ WHERE si.global_or_team_id = ? AND si.filename = ?`, team.ID, payload.Filename)
+ })
+ require.Equal(t, "py_packages", pyStored.Source, "py package should be stored with py_packages source")
+ require.Equal(t, pyContent, pyStored.InstallScript, "install_script should be the .py file contents, not the ignored param")
+ require.Equal(t, "echo 'uninstall py'", pyStored.UninstallScript)
+ require.Equal(t, "echo 'post py'", pyStored.PostInstallScript)
+ require.Equal(t, "SELECT 1;", pyStored.PreInstallQuery)
+ require.Equal(t, "linux", pyStored.Platform, ".py packages are stored with the linux platform")
}
// 1. host reports software
@@ -22159,6 +22230,11 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploadValidation() {
err = os.WriteFile(ps1ScriptPath, ps1ScriptContent, 0o644)
require.NoError(t, err)
+ pyScriptPath := filepath.Join(tmpDir, "test-script.py")
+ pyScriptContent := []byte("#!/usr/bin/env python3\nprint('Installing...')\n")
+ err = os.WriteFile(pyScriptPath, pyScriptContent, 0o644)
+ require.NoError(t, err)
+
t.Run("sh script package preserves advanced options", func(t *testing.T) {
installerFile, err := fleet.NewKeepFileReader(shScriptPath)
require.NoError(t, err)
@@ -22250,6 +22326,53 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploadValidation() {
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, 204, "team_id", "0")
})
+
+ t.Run("py script package preserves advanced options", func(t *testing.T) {
+ installerFile, err := fleet.NewKeepFileReader(pyScriptPath)
+ require.NoError(t, err)
+ defer installerFile.Close()
+
+ // install_script is ignored (the file is the install script); the rest persist.
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "print('install_script is ignored')",
+ PostInstallScript: "echo 'post-install'",
+ UninstallScript: "echo 'uninstall'",
+ PreInstallQuery: "SELECT 1",
+ Filename: "test-script.py",
+ InstallerFile: installerFile,
+ }
+
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
+
+ var listResp listSoftwareTitlesResponse
+ s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listResp, "team_id", "0", "available_for_install", "true")
+
+ var found bool
+ var titleID uint
+ for _, sw := range listResp.SoftwareTitles {
+ if sw.SoftwarePackage != nil && sw.SoftwarePackage.Name == "test-script.py" {
+ found = true
+ titleID = sw.ID
+ break
+ }
+ }
+ require.True(t, found, "Script package should be created")
+
+ var titleResp getSoftwareTitleResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0")
+
+ require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage)
+ installer := titleResp.SoftwareTitle.SoftwarePackage
+
+ require.Equal(t, "py_packages", titleResp.SoftwareTitle.Source, ".py script package should have py_packages source")
+ require.Equal(t, string(pyScriptContent), installer.InstallScript, ".py script package should have install_script from file contents")
+ require.NotEqual(t, "print('install_script is ignored')", installer.InstallScript, "user-provided install_script should be overwritten")
+ require.Equal(t, "echo 'post-install'", installer.PostInstallScript, ".py script package should persist post_install_script")
+ require.Equal(t, "echo 'uninstall'", installer.UninstallScript, ".py script package should persist uninstall_script")
+ require.Equal(t, "SELECT 1", installer.PreInstallQuery, ".py script package should persist pre_install_query")
+
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, 204, "team_id", "0")
+ })
}
func (s *integrationEnterpriseTestSuite) TestBatchSoftwareUploadWithSHAs() {
diff --git a/server/service/testdata/software-installers/script.py b/server/service/testdata/software-installers/script.py
new file mode 100644
index 00000000000..9414dafe61d
--- /dev/null
+++ b/server/service/testdata/software-installers/script.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+print("script")
From c4e7e7e7786bf7b5671976d9e380c0e289d84ba9 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:07:57 -0400
Subject: [PATCH 2/7] Python script only packages FE support (#48946)
**Related issue:** Resolves #48394
Adds `.py` to the custom-package upload form and software details page,
accepted and detected as a script-only package (advanced options follow
`.sh`/`.ps1`), shown with the Python icon and per-platform file-type
tooltips, and mapped to the `py_packages` source that renders as
"Script-only package (macOS & Linux)".
# Checklist for submitter
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
## Manual QA
- [x] Verified live in the browser against a dev server
---
.../SoftwareScriptDetailsModal.tsx | 2 +-
frontend/interfaces/package_type.ts | 2 +-
frontend/interfaces/setup.ts | 4 +-
frontend/interfaces/software.ts | 8 ++-
.../GlobalActivityItem.tests.tsx | 54 +++++++++++++++++++
.../LibraryItemAccordion.tsx | 7 ++-
.../InstallerDetailsWidget.tests.tsx | 12 +++++
.../InstallerDetailsWidget.tsx | 8 ++-
.../SoftwareTitleDetailsPage.tsx | 1 +
.../SoftwareTitleDetailsPage/helpers.tests.ts | 37 +++++++++++++
.../SoftwareDetailsSummary.tsx | 4 +-
.../forms/PackageForm/PackageForm.tsx | 23 ++++----
.../details/DeviceUserPage/helpers.tests.ts | 25 +++++++++
.../hosts/details/DeviceUserPage/helpers.ts | 5 +-
frontend/utilities/file/fileUtils.tests.tsx | 6 +++
frontend/utilities/file/fileUtils.tsx | 1 +
.../utilities/software_install_scripts.ts | 1 +
.../utilities/software_uninstall_scripts.ts | 1 +
18 files changed, 181 insertions(+), 20 deletions(-)
create mode 100644 frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
index 33fb4d4c7cd..271f4572bba 100644
--- a/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
+++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
@@ -1,5 +1,5 @@
/** This component is intentionally separate from SoftwareInstallDetailsModal
- * because it handles script-only package installs (e.g. sh_packages or ps1_packages)
+ * because it handles script-only package installs (e.g. sh_packages, ps1_packages, or py_packages)
*
* Key differences from SoftwareInstallDetailsModal:
* - Uses Script/Run/Rerun language in UI instead of Install/Retry.
diff --git a/frontend/interfaces/package_type.ts b/frontend/interfaces/package_type.ts
index b66813200b0..9049a8d1149 100644
--- a/frontend/interfaces/package_type.ts
+++ b/frontend/interfaces/package_type.ts
@@ -1,7 +1,7 @@
const fleetMaintainedPackageTypes = ["dmg", "zip"] as const;
const unixPackageTypes = ["pkg", "deb", "rpm", "dmg", "zip", "tar.gz"] as const;
const windowsPackageTypes = ["msi", "exe", "zip"] as const;
-const scriptOnlyPackageTypes = ["sh", "ps1"] as const;
+const scriptOnlyPackageTypes = ["sh", "ps1", "py"] as const;
const iosIpadosPackageTypes = ["ipa"] as const;
export const packageTypes = [
...unixPackageTypes,
diff --git a/frontend/interfaces/setup.ts b/frontend/interfaces/setup.ts
index e5f5fb250d7..95e245418f9 100644
--- a/frontend/interfaces/setup.ts
+++ b/frontend/interfaces/setup.ts
@@ -13,7 +13,7 @@ export type SetupStepStatus = typeof SETUP_STEP_STATUSES[number];
/** These type extends onto API returned software steps */
export const SETUP_STEP_TYPES = [
"software_install", // API key: software
- "software_script_run", // API key: software, detected via source === "sh_packages" || "ps1_packages"
+ "software_script_run", // API key: software, detected via a script package source (see SCRIPT_PACKAGE_SOURCES)
"script_run", // API key: scripts
];
@@ -24,7 +24,7 @@ export interface ISetupStep {
status: SetupStepStatus;
type: SetupStepType;
error?: string | null;
- source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "apps")
+ source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "py_packages", "apps")
display_name?: string | null;
icon_url?: string | null;
}
diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts
index 86d290bde1a..106dfdf6f30 100644
--- a/frontend/interfaces/software.ts
+++ b/frontend/interfaces/software.ts
@@ -299,6 +299,7 @@ export const SOURCE_TYPE_CONVERSION = {
vscode_extensions: "IDE extension", // vscode_extensions can include any vscode-based editor (e.g., Cursor, Trae, Windsurf), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
sh_packages: "Script-only package (macOS & Linux)",
ps1_packages: "Script-only package (Windows)",
+ py_packages: "Script-only package (macOS & Linux)",
jetbrains_plugins: "IDE extension", // jetbrains_plugins can include any JetBrains IDE (e.g., IntelliJ, PyCharm, WebStorm), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
go_binaries: "Binary (Go)",
} as const;
@@ -332,11 +333,16 @@ export const INSTALLABLE_SOURCE_PLATFORM_CONVERSION = {
vscode_extensions: null,
sh_packages: "linux", // 4.76 Added support for Linux hosts only
ps1_packages: "windows",
+ py_packages: "linux", // stored as linux; also runs on macOS via the unix-like install exception
jetbrains_plugins: null,
go_binaries: null,
} as const;
-export const SCRIPT_PACKAGE_SOURCES = ["sh_packages", "ps1_packages"];
+export const SCRIPT_PACKAGE_SOURCES = [
+ "sh_packages",
+ "ps1_packages",
+ "py_packages",
+];
/** Sources that don't map cleanly to versions or hosts in software inventory.
* UI behavior for these sources:
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
index 39b1c4dbe4d..d3ed83cefe0 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
@@ -1817,6 +1817,60 @@ describe("Activity Feed", () => {
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
});
+ it("renders py script package ran status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "installed",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/ran/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
+ it("renders py script package pending run status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "pending_install",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/told Fleet to run/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
+ it("renders py script package failed run status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "failed_install",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/failed to run/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
it("renders addedNdesScepProxy activity correctly", () => {
const activity = createMockActivity({
type: ActivityType.AddedNdesScepProxy,
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
index dd631847c3a..79484c89092 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
@@ -11,7 +11,7 @@ import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { IconNames } from "components/icons";
import { ILabelSoftwareTitle } from "interfaces/label";
-import { InstallerType } from "interfaces/software";
+import { InstallerType, SoftwareSource } from "interfaces/software";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
const baseClass = "library-item-accordion";
@@ -48,6 +48,9 @@ export interface ILibraryItemAccordionProps {
isLatestFmaVersion?: boolean;
/** Hide the version entirely (script-only packages). */
isScriptPackage?: boolean;
+ /** Software source, threaded to the installer widget to pick the file icon
+ * (e.g. `file-py` for `py_packages`). */
+ source?: SoftwareSource;
isTarballPackage?: boolean;
/** Apple App Store app whose platform is iOS or iPadOS. Drops the
* "policy automation" leg from the info-icon tooltip — `automatic_install`
@@ -119,6 +122,7 @@ const LibraryItemAccordion = ({
isFma = false,
isLatestFmaVersion,
isScriptPackage = false,
+ source,
isTarballPackage = false,
isIosOrIpadosApp = false,
isActive,
@@ -523,6 +527,7 @@ const LibraryItemAccordion = ({
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
+ source={source}
androidPlayStoreId={androidPlayStoreId}
hideInstallerType
// Inactive rows surface a single hover tooltip (the rollback hint);
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
index f13ef1a7fb0..356ce2aeb15 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
@@ -34,6 +34,18 @@ describe("InstallerDetailsWidget", () => {
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
});
+ it("renders the Python icon for a py_packages script package", () => {
+ render();
+ expect(screen.queryByTestId("file-py-graphic")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-pkg-graphic")).not.toBeInTheDocument();
+ });
+
+ it("renders the generic package icon for other script sources", () => {
+ render();
+ expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-py-graphic")).not.toBeInTheDocument();
+ });
+
it("renders the software name", () => {
render();
expect(screen.getByText("Test Software")).toBeInTheDocument();
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
index fbaa1a3aa4d..b4149f1334b 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
@@ -8,7 +8,7 @@ import { internationalTimeFormat } from "utilities/helpers";
import { addedFromNow } from "utilities/date_format";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
-import { InstallerType } from "interfaces/software";
+import { InstallerType, SoftwareSource } from "interfaces/software";
import { isAndroidWebApp } from "pages/SoftwarePage/helpers";
@@ -74,6 +74,8 @@ interface IInstallerDetailsWidgetProps {
isFma: boolean;
isLatestFmaVersion?: boolean;
isScriptPackage: boolean;
+ /** Software source, used to pick the file icon (e.g. `file-py` for `py_packages`). */
+ source?: SoftwareSource;
androidPlayStoreId?: string;
customDetails?: string;
/** Suppress the leading installer-type label ("Custom package", "App Store (VPP)",
@@ -99,6 +101,7 @@ const InstallerDetailsWidget = ({
isFma,
isLatestFmaVersion = false,
isScriptPackage,
+ source,
androidPlayStoreId,
customDetails,
hideInstallerType = false,
@@ -113,6 +116,9 @@ const InstallerDetailsWidget = ({
}
return ;
}
+ if (source === "py_packages") {
+ return
;
+ }
return
;
};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
index 4b00fc57282..4cd69f44f53 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
@@ -305,6 +305,7 @@ const SoftwareTitleDetailsPage = ({
isFma={isFma}
isLatestFmaVersion={row.isActive && isLatestFmaVersion}
isScriptPackage={isScriptPackage}
+ source={title.source}
isTarballPackage={title.source === "tgz_packages"}
isIosOrIpadosApp={isIpadOrIphoneSoftwareSource(title.source)}
isActive={row.isActive}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
index 57214dfe812..337c12e823c 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
@@ -138,6 +138,43 @@ describe("SoftwareTitleDetailsPage helpers", () => {
isSelfService: true,
});
});
+ it("marks a py_packages title as a script package", () => {
+ const softwareTitle: ISoftwareTitleDetails = {
+ id: 1,
+ name: "Test Script",
+ icon_url: null,
+ versions: [],
+ software_package: {
+ labels_include_any: null,
+ labels_exclude_any: null,
+ labels_include_all: null,
+ name: "install.py",
+ title_id: 2,
+ version: "",
+ self_service: false,
+ uploaded_at: "2021-01-01T00:00:00Z",
+ status: {
+ installed: 1,
+ pending_install: 0,
+ pending_uninstall: 0,
+ failed_install: 0,
+ failed_uninstall: 0,
+ },
+ install_script: "#!/usr/bin/env python3\nprint('hi')",
+ uninstall_script: "",
+ icon_url: null,
+ automatic_install_policies: [],
+ url: "",
+ },
+ app_store_app: null,
+ source: "py_packages",
+ hosts_count: 0,
+ };
+ const packageCardInfo = getInstallerCardInfo(softwareTitle);
+ expect(packageCardInfo.source).toEqual("py_packages");
+ expect(packageCardInfo.isScriptPackage).toBe(true);
+ expect(packageCardInfo.name).toEqual("install.py");
+ });
it("returns the correct data for an app store app (and with a custom display name)", () => {
const softwareTitle: ISoftwareTitleDetails = {
id: 1,
diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
index 9cb19a012be..60c76dd72c6 100644
--- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
+++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
@@ -256,8 +256,8 @@ const SoftwareDetailsSummary = ({
}
};
- // Remove host count for tgz_packages, sh_packages, and ps1_packages only
- // or if viewing details summary from edit icon preview modal
+ // Remove host count for sources without version/host data (tgz and script
+ // packages) or if viewing details summary from edit icon preview modal
const showHostCount =
!!hostCount && !NO_VERSION_OR_HOST_DATA_SOURCES.includes(source || "");
diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
index 77ace51a52d..741e136de5b 100644
--- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
+++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
@@ -65,6 +65,8 @@ const getGraphicName = (ext: string) => {
return "file-sh";
} else if (ext === "ps1") {
return "file-ps1";
+ } else if (ext === "py") {
+ return "file-py";
}
return "file-pkg";
};
@@ -90,14 +92,17 @@ const renderSoftwareDeployWarningBanner = () => (
const renderFileTypeMessage = () => {
return (
<>
- macOS (.pkg,{" "}
- .sh),
- iOS/iPadOS (.ipa),
-
- Windows (.msi, .exe,{" "}
- .ps1),
- or Linux (.deb, .rpm, .tar.gz,{" "}
- .sh)
+
+ macOS
+
+ , iOS/iPadOS,{" "}
+
+ Windows
+
+ , or{" "}
+
+ Linux
+
>
);
};
@@ -126,7 +131,7 @@ interface IPackageFormProps {
}
// application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly
const ACCEPTED_EXTENSIONS =
- ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.ipa";
+ ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.py,.ipa";
const PackageForm = ({
labels,
diff --git a/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts b/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
new file mode 100644
index 00000000000..8e838547a30
--- /dev/null
+++ b/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
@@ -0,0 +1,25 @@
+import { ISetupStep } from "interfaces/setup";
+import { isSoftwareScriptSetup } from "./helpers";
+
+const setupStep = (source?: ISetupStep["source"]): ISetupStep => ({
+ name: "test",
+ status: "success",
+ type: "software_script_run",
+ source,
+});
+
+describe("DeviceUserPage helpers - isSoftwareScriptSetup", () => {
+ it("returns true for script package sources (sh, ps1, py)", () => {
+ expect(isSoftwareScriptSetup(setupStep("sh_packages"))).toBe(true);
+ expect(isSoftwareScriptSetup(setupStep("ps1_packages"))).toBe(true);
+ expect(isSoftwareScriptSetup(setupStep("py_packages"))).toBe(true);
+ });
+
+ it("returns false for non-script sources", () => {
+ expect(isSoftwareScriptSetup(setupStep("apps"))).toBe(false);
+ });
+
+ it("returns false when source is missing", () => {
+ expect(isSoftwareScriptSetup(setupStep(undefined))).toBe(false);
+ });
+});
diff --git a/frontend/pages/hosts/details/DeviceUserPage/helpers.ts b/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
index 289cba399c0..d902ec11d73 100644
--- a/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
+++ b/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
@@ -1,4 +1,5 @@
import { ISetupStep } from "interfaces/setup";
+import { SCRIPT_PACKAGE_SOURCES } from "interfaces/software";
const DEFAULT_ERROR_MESSAGE = "refetch error.";
@@ -39,12 +40,12 @@ export const getFailedSoftwareInstall = (
return firstWithError ?? failedSoftware[0];
};
-/** Checks if the software is a script-only package (sh or ps1)
+/** Checks if the software is a script-only package (sh, ps1, or py)
* by examining the source field from the API */
export const isSoftwareScriptSetup = (s: ISetupStep) => {
if (!s.source) return false;
- return s.source === "sh_packages" || s.source === "ps1_packages";
+ return SCRIPT_PACKAGE_SOURCES.includes(s.source);
};
// Hosts after enrollment during which we suppress the "host is offline" banner.
diff --git a/frontend/utilities/file/fileUtils.tests.tsx b/frontend/utilities/file/fileUtils.tests.tsx
index a80f44bba80..ee1c492a986 100644
--- a/frontend/utilities/file/fileUtils.tests.tsx
+++ b/frontend/utilities/file/fileUtils.tests.tsx
@@ -17,6 +17,7 @@ describe("fileUtils", () => {
{ fileName: "test.deb", expectedExtension: "deb" },
{ fileName: "test.rpm", expectedExtension: "rpm" },
{ fileName: "test.tar", expectedExtension: "tar" },
+ { fileName: "test.py", expectedExtension: "py" },
// Compound extensions
{ fileName: "test.tar.gz", expectedExtension: "tar.gz" },
@@ -55,6 +56,10 @@ describe("fileUtils", () => {
fileName: "test.tar.gz",
expectedDetails: { name: "test.tar.gz", description: "Linux" },
},
+ {
+ fileName: "test.py",
+ expectedDetails: { name: "test.py", description: "macOS & Linux" },
+ },
{
fileName: "unknown.file",
expectedDetails: { name: "unknown.file", description: undefined },
@@ -79,6 +84,7 @@ describe("fileUtils", () => {
{ extension: "xml", platform: "Windows" },
{ extension: "deb", platform: "Linux" },
{ extension: "tar.gz", platform: "Linux" },
+ { extension: "py", platform: "macOS & Linux" },
{ extension: undefined, platform: undefined }, // no extension
{ extension: "unknown_ext", platform: undefined }, // unmapped extension
];
diff --git a/frontend/utilities/file/fileUtils.tsx b/frontend/utilities/file/fileUtils.tsx
index cbc154c9156..62ed63a6adf 100644
--- a/frontend/utilities/file/fileUtils.tsx
+++ b/frontend/utilities/file/fileUtils.tsx
@@ -24,6 +24,7 @@ export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record<
"tar.gz": "Linux",
sh: "macOS & Linux",
ps1: "Windows",
+ py: "macOS & Linux",
ipa: "iOS/iPadOS",
};
diff --git a/frontend/utilities/software_install_scripts.ts b/frontend/utilities/software_install_scripts.ts
index 196634d448f..6a958f1b5cf 100644
--- a/frontend/utilities/software_install_scripts.ts
+++ b/frontend/utilities/software_install_scripts.ts
@@ -30,6 +30,7 @@ const getDefaultInstallScript = (fileName: string): string => {
case "tar.gz":
case "sh":
case "ps1":
+ case "py":
case "ipa":
return "";
default:
diff --git a/frontend/utilities/software_uninstall_scripts.ts b/frontend/utilities/software_uninstall_scripts.ts
index 35432ce5ffe..efb6019592a 100644
--- a/frontend/utilities/software_uninstall_scripts.ts
+++ b/frontend/utilities/software_uninstall_scripts.ts
@@ -29,6 +29,7 @@ const getDefaultUninstallScript = (fileName: string): string => {
case "tar.gz":
case "sh":
case "ps1":
+ case "py":
case "ipa":
return "";
default:
From b20862b4de6d5c307f94e289d977bc811cc129d9 Mon Sep 17 00:00:00 2001
From: Carlo DiCelico
Date: Thu, 9 Jul 2026 15:18:26 -0400
Subject: [PATCH 3/7] add changes file
---
changes/41470-python-script-only-packages | 1 +
1 file changed, 1 insertion(+)
create mode 100644 changes/41470-python-script-only-packages
diff --git a/changes/41470-python-script-only-packages b/changes/41470-python-script-only-packages
new file mode 100644
index 00000000000..12d43d136c1
--- /dev/null
+++ b/changes/41470-python-script-only-packages
@@ -0,0 +1 @@
+- Added support for Python (`.py`) script-only software packages, which can be uploaded as custom packages (the file contents become the install script) and installed on macOS and Linux hosts, via the UI, REST API, and GitOps.
From 88710fdedacee3fd2cac343824823c97dc41b7b2 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:33:14 -0400
Subject: [PATCH 4/7] Fix self-service .py install on macOS hosts (#49383)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #49369
Self-service install of a `.py` script-only package was rejected on
macOS hosts (`Package (.py) can be installed only on linux hosts.`)
because `SelfServiceInstallSoftwareTitle` only allowed `.sh` in the
unix-like (linux + darwin) exception. Now allows `.py` too, matching the
admin install path (`installSoftwareTitleUsingInstaller`).
# Checklist for submitter
- [x] Changes file: N/A — unreleased fix on the `#41470` feature branch;
the feature PR (#49070) carries the changes file.
- [x] Input data is properly validated (platform-gate fix only; no new
input handling, SQL, or shell interpolation).
## Testing
- [x] Added/updated automated tests —
`TestSelfServiceInstallPyScriptOnUnixLike` (linux + darwin); confirmed
it fails without the fix and passes with it.
- [x] QA'd all new/changed functionality manually — live on a macOS
host: self-service `.py` install went from HTTP 400 → 202 → `installed`
(script executed), with no regression to `.sh`/Linux/Windows behavior.
---
ee/server/service/software_installers.go | 4 +-
ee/server/service/software_installers_test.go | 49 +++++++++++++++++++
2 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go
index 4ed6a55e2b3..2a8bbf42e19 100644
--- a/ee/server/service/software_installers.go
+++ b/ee/server/service/software_installers.go
@@ -3545,8 +3545,8 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f
}
if host.FleetPlatform() != requiredPlatform {
- // Allow .sh scripts for any unix-like platform (linux and darwin)
- if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) {
+ // Allow .sh and .py scripts for any unix-like platform (linux and darwin)
+ if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.WrapWithData(
diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go
index a1dcac0ffac..dfc50e19703 100644
--- a/ee/server/service/software_installers_test.go
+++ b/ee/server/service/software_installers_test.go
@@ -1445,6 +1445,55 @@ func TestInstallPyScriptOnWindowsFails(t *testing.T) {
require.Contains(t, bre.Message, "can be installed only on linux hosts")
}
+// .py packages are stored with platform='linux'; the self-service install path
+// must still allow them on darwin hosts via the unix-like exception.
+func TestSelfServiceInstallPyScriptOnUnixLike(t *testing.T) {
+ t.Parallel()
+
+ for _, platform := range []string{"linux", "darwin"} {
+ t.Run(platform, func(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: true,
+ }, nil
+ }
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error {
+ return nil
+ }
+
+ ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
+ return "install-uuid", nil
+ }
+
+ host := &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: platform,
+ TeamID: new(uint(1)),
+ }
+
+ err := svc.SelfServiceInstallSoftwareTitle(context.Background(), host, 100)
+ require.NoError(t, err, ".py self-service install on %s should succeed", platform)
+ require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created")
+ })
+ }
+}
+
func TestSelfServiceInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
From 0ee839c2d99d5f02f5f19e2e19586f5ec673bb28 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:33:57 -0400
Subject: [PATCH 5/7] Accept .py path-referenced packages in GitOps (#49387)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #49370
`fleetctl gitops` rejected a local `path:`-referenced `.py` software
package client-side with `unsupported extension ".py"`, even though a
`url:`-referenced `.py` worked end-to-end. The package-path `switch` and
the two `HydrateToPackageLevel` gates (team-level advanced options +
icon) only recognized `.sh`/`.ps1`. They now use
`fleet.IsScriptPackage`, so `.py` is accepted — and future script types
won't drift out of sync.
# Checklist for submitter
- [x] Changes file: N/A — unreleased fix on the `#41470` feature branch;
the feature PR (#49070) carries the changes file.
- [x] Input data is properly validated (extension-gating fix only; no
new input handling, SQL, or shell interpolation).
## Testing
- [x] Added/updated automated tests — `TestScriptOnlyPackagesPathPy`
(path `.py` with team-level self-service, uninstall/post-install
scripts, pre-install query, and icon); confirmed it fails without the
fix and passes with it. Also updated a sibling test's error-string
assertion.
- [x] QA'd all new/changed functionality manually — live before/after
with the real `fleetctl` binary: unfixed → `unsupported extension
".py"`; fixed → `gitops dry run succeeded`, and a real apply persisted
the package with `source: py_packages`.
---
pkg/spec/gitops.go | 12 ++---
pkg/spec/gitops_test.go | 55 ++++++++++++++++++++++-
pkg/spec/testdata/software/script-only.py | 3 ++
3 files changed, 63 insertions(+), 7 deletions(-)
create mode 100644 pkg/spec/testdata/software/script-only.py
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index 5def5535eef..3604b1cb055 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -283,7 +283,7 @@ type SoftwarePackage struct {
}
func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePackageSpec, ext string) (fleet.SoftwarePackageSpec, error) {
- isScript := ext == ".sh" || ext == ".ps1"
+ isScript := fleet.IsScriptPackage(ext)
// Script-only packages are configured inline in the team YAML, so their
// uninstall/post-install scripts and pre-install query are allowed here;
@@ -299,7 +299,7 @@ func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePac
// Icon should be allowed at the team level yaml for script packages which must be specified as a path
if spec.Icon.Path != "" {
- if ext != ".sh" && ext != ".ps1" {
+ if !fleet.IsScriptPackage(ext) {
return packageLevel, fmt.Errorf("the software package defined in %s must not have icons, scripts, queries, URL, or hash specified at the team level", *spec.Path)
}
}
@@ -2209,8 +2209,8 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
}
ext := strings.ToLower(filepath.Ext(resolvedPath))
- switch ext {
- case ".sh", ".ps1":
+ switch {
+ case fleet.IsScriptPackage(ext):
// Script files: only gather FLEET_SECRET_ variables, don't expand
// regular env vars (they are shell variables meant for the endpoint).
if err := gatherFileSecrets(result, resolvedPath); err != nil {
@@ -2238,7 +2238,7 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
}
softwarePackageSpecs = append(softwarePackageSpecs, &scriptSpec)
- case ".yml", ".yaml":
+ case ext == ".yml" || ext == ".yaml":
// Replace $var and ${var} with env values in YAML files only.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
@@ -2282,7 +2282,7 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
}
default:
- multiError = multierror.Append(multiError, fmt.Errorf("software package path %s has unsupported extension %q; only .yml, .yaml, .sh, or .ps1 files are supported", *teamLevelPackage.Path, ext))
+ multiError = multierror.Append(multiError, fmt.Errorf("software package path %s has unsupported extension %q; only .yml, .yaml, .sh, .ps1, or .py files are supported", *teamLevelPackage.Path, ext))
continue
}
} else {
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index 45b3c66be42..486db305357 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -2246,6 +2246,59 @@ software:
assert.Equal(t, filepath.Join(basePath, "foo", "bar.png"), gitops.Software.Packages[0].Icon.Path)
}
+// A path-referenced .py is a script-only package: it must be accepted and
+// treated like .sh/.ps1, not rejected as an unsupported extension.
+func TestScriptOnlyPackagesPathPy(t *testing.T) {
+ t.Parallel()
+ config := getTeamConfig([]string{"software"})
+ config += `
+software:
+ packages:
+ - path: software/script-only.py
+ self_service: true
+ icon:
+ path: ./foo/bar.png
+ uninstall_script:
+ path: software/uninstall.sh
+ post_install_script:
+ path: software/post-install.sh
+ pre_install_query:
+ path: software/preinstall-query.yml
+`
+
+ path, basePath := createTempFile(t, "", config)
+
+ copies := []struct{ src, dst string }{
+ {filepath.Join("testdata", "software", "script-only.py"), filepath.Join(basePath, "software", "script-only.py")},
+ {filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "uninstall.sh")},
+ {filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "post-install.sh")},
+ }
+ for _, c := range copies {
+ require.NoError(t, file.Copy(c.src, c.dst, os.FileMode(0o755)))
+ }
+ require.NoError(t, file.Copy(
+ filepath.Join("testdata", "lib", "preinstall-query.yml"),
+ filepath.Join(basePath, "software", "preinstall-query.yml"),
+ os.FileMode(0o644),
+ ))
+
+ appConfig := fleet.EnrichedAppConfig{}
+ appConfig.License = &fleet.LicenseInfo{
+ Tier: fleet.TierPremium,
+ }
+ gitops, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf)
+ require.NoError(t, err)
+ require.Len(t, gitops.Software.Packages, 1)
+
+ pkg := gitops.Software.Packages[0]
+ assert.Equal(t, filepath.Join(basePath, "software", "script-only.py"), pkg.InstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "foo", "bar.png"), pkg.Icon.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "uninstall.sh"), pkg.UninstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "post-install.sh"), pkg.PostInstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "preinstall-query.yml"), pkg.PreInstallQuery.Path)
+ assert.True(t, pkg.SelfService)
+}
+
func TestScriptOnlyPackagesWithAdvancedOptions(t *testing.T) {
t.Parallel()
config := getTeamConfig([]string{"software"})
@@ -4488,7 +4541,7 @@ software:
_, err = GitOpsFromFile(path, basePath, appConfig, nopLogf)
assert.ErrorContains(t, err, "unsupported extension")
- assert.ErrorContains(t, err, "only .yml, .yaml, .sh, or .ps1 files are supported")
+ assert.ErrorContains(t, err, "only .yml, .yaml, .sh, .ps1, or .py files are supported")
})
t.Run("script_with_team_options", func(t *testing.T) {
diff --git a/pkg/spec/testdata/software/script-only.py b/pkg/spec/testdata/software/script-only.py
new file mode 100644
index 00000000000..0485176deb2
--- /dev/null
+++ b/pkg/spec/testdata/software/script-only.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+print("hello world")
From 8814a6f7d483ed56202f297225c02bf979ccd653 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:37:04 -0400
Subject: [PATCH 6/7] Add py/python keywords to Add custom package palette
entry (#49389)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #49371
Typing `py` (or `python`) in the command palette didn't surface **Add
custom package**, even though `.py` is now an accepted custom-package
upload type. The `add-custom-package` entry's `keywords` list enumerated
every other extension (`pkg`, `ipa`, `msi`, `exe`, `ps1`, `deb`, `rpm`,
`tar.gz`, `sh`) but omitted `py`. Added `py` and `python`.
# Checklist for submitter
- [x] Changes file: N/A — unreleased fix on the `#41470` feature branch;
the feature PR (#49070) carries the changes file.
## Testing
- [x] Added/updated automated tests — new `helpers.tests.ts` case
asserting the `add-custom-package` entry surfaces on `py`/`python`;
confirmed it fails without the fix and passes with it.
- [x] QA'd all new/changed functionality manually — live in the browser:
with a team selected, searching `py` in the command palette now lists
**Add custom package**.
---
.../components/CommandPalette/groups/commands.ts | 2 ++
frontend/components/CommandPalette/helpers.tests.ts | 13 +++++++++++++
2 files changed, 15 insertions(+)
diff --git a/frontend/components/CommandPalette/groups/commands.ts b/frontend/components/CommandPalette/groups/commands.ts
index e1cf1a5c321..f21d4e330b4 100644
--- a/frontend/components/CommandPalette/groups/commands.ts
+++ b/frontend/components/CommandPalette/groups/commands.ts
@@ -291,6 +291,8 @@ const buildCommandsItems = (
"tar.gz",
"tarballs",
"sh",
+ "py",
+ "python",
],
},
{
diff --git a/frontend/components/CommandPalette/helpers.tests.ts b/frontend/components/CommandPalette/helpers.tests.ts
index 862a4130469..4b54b312454 100644
--- a/frontend/components/CommandPalette/helpers.tests.ts
+++ b/frontend/components/CommandPalette/helpers.tests.ts
@@ -327,6 +327,19 @@ describe("CommandPalette helpers", () => {
expect(keywords).not.toContain("sso");
});
+ it("surfaces Add custom package when searching py / python (.py is an accepted upload type)", () => {
+ const items = buildPaletteItems({
+ ...BASE_CONTEXT,
+ hasTeamSelected: true,
+ currentTeam: { id: 1, name: "Engineering" },
+ });
+ const addCustomPackage = items.find((i) => i.id === "add-custom-package");
+ expect(addCustomPackage).toBeDefined();
+ const keywords = addCustomPackage?.keywords ?? [];
+ expect(keywords).toContain("py");
+ expect(keywords).toContain("python");
+ });
+
it("hides calendar keywords on Unassigned (Calendar section is disabled there) but keeps conditional access", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
From 87d13fb5d5c0f0ec7558285dfc84b5628c626b62 Mon Sep 17 00:00:00 2001
From: Carlo DiCelico
Date: Thu, 16 Jul 2026 12:40:17 -0400
Subject: [PATCH 7/7] fix tests
---
ee/server/service/software_installers_test.go | 3 +++
.../AddPackageModal/AddPackageModal.tests.tsx | 8 ++++++--
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go
index 3bed86c7d4c..759dfb874e9 100644
--- a/ee/server/service/software_installers_test.go
+++ b/ee/server/service/software_installers_test.go
@@ -1808,6 +1808,7 @@ func TestInstallPyScriptOnUnixLike(t *testing.T) {
SelfService: false,
}, nil
}
+ mockSoftwarePackagesFromMetadata(ds)
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
return true, nil
@@ -1865,6 +1866,7 @@ func TestInstallPyScriptOnWindowsFails(t *testing.T) {
SelfService: false,
}, nil
}
+ mockSoftwarePackagesFromMetadata(ds)
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
return true, nil
@@ -1909,6 +1911,7 @@ func TestSelfServiceInstallPyScriptOnUnixLike(t *testing.T) {
SelfService: true,
}, nil
}
+ mockSoftwarePackagesFromMetadata(ds)
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
return true, nil
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
index 667a83e803b..f19d0d45383 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
@@ -74,8 +74,12 @@ describe("AddPackageModal", () => {
it("falls back to the all-platforms file-type message when the existing name has no recognized extension", () => {
renderModal({ existingPackageName: "no-extension" });
- // PackageForm's default message lists every supported platform.
- expect(screen.getByText(/macOS \(.pkg,/)).toBeInTheDocument();
+ // PackageForm's default message lists every supported platform as
+ // tooltip triggers (extensions live in the tooltips, not the label text).
+ expect(screen.getByText("macOS")).toBeInTheDocument();
+ expect(screen.getByText("iOS/iPadOS")).toBeInTheDocument();
+ expect(screen.getByText("Windows")).toBeInTheDocument();
+ expect(screen.getByText("Linux")).toBeInTheDocument();
});
it("renders the form's Save button as 'Save' (not 'Add software')", async () => {