fix(skills): retry pip installs without --break-system-packages when unsupported

pip >= 23.0 added the PEP 668 --break-system-packages flag. On older pip
builds (e.g. macOS Command Line Tools Python) and on the pip `list`
subcommand of every version, the flag is rejected with
"no such option: --break-system-packages", breaking skill dependency
install/check paths entirely.

- dep_installer: route pip installs through pipRunInstall, which retries
  once without the flag when pip rejects it (issue #956)
- pip_update_executor: same retry for the upgrade path
- pip_update_checker: drop the flag from `pip3 list --outdated` - list is
  read-only, PEP 668 never applies, and no pip accepts the flag there
- add pip_flags.go helpers + unit tests, and legacy-pip integration tests
  reproducing the original failure

Fixes #956
This commit is contained in:
fchengyan committed 2026-09-03 10:18:12 +08:00
1 parent 8331211abf
commit a1e550f0f0
5 files changed
+248 -16

No files matched your search

+6 -4
View File
@@ -106,8 +106,9 @@ func InstallSingleDep(ctx context.Context, dep string) (bool, string) {
}
defer release()
}
cmd := exec.CommandContext(ctx, "pip3", "install", "--no-cache-dir", "--break-system-packages", pkg)
out, err := cmd.CombinedOutput()
// pipRunInstall retries without the PEP 668 flag when pip rejects it
// (pip < 23.0, see issue #956).
out, err := pipRunInstall(ctx, []string{"install", "--no-cache-dir", pipBreakSystemPackagesFlag, pkg})
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("skills: dep install failed", "dep", dep, "error", msg)
@@ -183,8 +184,9 @@ func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string)
slog.Info("skills: installing pip packages", "pkgs", pipPkgs)
var successful []string
for _, pkg := range pipPkgs {
cmd := exec.CommandContext(ctx, "pip3", "install", "--no-cache-dir", "--break-system-packages", pkg)
if out, err := cmd.CombinedOutput(); err != nil {
// pipRunInstall retries without the PEP 668 flag when pip rejects it.
out, err := pipRunInstall(ctx, []string{"install", "--no-cache-dir", pipBreakSystemPackagesFlag, pkg})
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("pip %s: %s (%v)", pkg, strings.TrimSpace(string(out)), err))
if hint := pipBuildFailHint(pkg, string(out)); hint != "" {
slog.Warn("skills: dep install hint", "pkg", pkg, "hint", hint)
+49
View File
@@ -0,0 +1,49 @@
package skills
import (
"bytes"
"context"
"os/exec"
)
// pipBreakSystemPackagesFlag is the PEP 668 opt-out flag accepted by pip >= 23.0.
// Older pip builds (and the `list` subcommand on every pip) reject it with
// "no such option: --break-system-packages", which breaks skill dependency
// installs on macOS Command Line Tools Python and other legacy environments.
const pipBreakSystemPackagesFlag = "--break-system-packages"
// pipRejectsBSPFlag reports whether pip's combined output indicates the
// --break-system-packages flag was rejected as an unknown option. pip < 23.0
// predates PEP 668 and emits exactly this error (see issue #956).
func pipRejectsBSPFlag(combinedOut []byte) bool {
return bytes.Contains(combinedOut, []byte("no such option: "+pipBreakSystemPackagesFlag))
}
// dropPipBSPFlag returns args with every --break-system-packages occurrence
// removed, preserving order of the remaining tokens. Used to retry an install
// on pip builds that do not support the flag.
func dropPipBSPFlag(args []string) []string {
out := make([]string, 0, len(args))
for _, a := range args {
if a == pipBreakSystemPackagesFlag {
continue
}
out = append(out, a)
}
return out
}
// pipRunInstall runs `pip3 <args...>` where args includes the PEP 668
// --break-system-packages flag. If pip rejects the flag as an unknown option
// (pip < 23.0, issue #956), the command is retried once without it. Returns
// the combined output of the final attempt. Success-path overhead is zero
// extra subprocesses.
func pipRunInstall(ctx context.Context, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, pipBinary, args...)
out, err := cmd.CombinedOutput()
if err != nil && pipRejectsBSPFlag(out) {
cmd = exec.CommandContext(ctx, pipBinary, dropPipBSPFlag(args)...)
out, err = cmd.CombinedOutput()
}
return out, err
}
+166
View File
@@ -0,0 +1,166 @@
package skills
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestPipRejectsBSPFlag(t *testing.T) {
cases := []struct {
name string
out string
want bool
}{
{"exact error", "no such option: --break-system-packages", true},
{"error with context", "Usage: pip3 install\n\nno such option: --break-system-packages", true},
{"unrelated error", "ERROR: Could not find a version that satisfies the requirement", false},
{"empty", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := pipRejectsBSPFlag([]byte(c.out)); got != c.want {
t.Errorf("pipRejectsBSPFlag(%q) = %v, want %v", c.out, got, c.want)
}
})
}
}
func TestDropPipBSPFlag(t *testing.T) {
in := []string{"install", "--upgrade", "--no-cache-dir", "--break-system-packages", "--upgrade-strategy", "only-if-needed", "--pre", "pkg"}
got := dropPipBSPFlag(in)
want := []string{"install", "--upgrade", "--no-cache-dir", "--upgrade-strategy", "only-if-needed", "--pre", "pkg"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("dropPipBSPFlag() = %v, want %v", got, want)
}
}
// writeLegacyPipScript writes a fake `pip3` that behaves like pip < 23.0: it
// rejects --break-system-packages with the exact error from issue #956 and
// succeeds without it. Every successful non-flag invocation appends its args
// to argsFile so tests can assert the flag was not passed on the retry.
func writeLegacyPipScript(t *testing.T, argsFile string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "pip3")
body := "#!/bin/sh\n" +
"for a in \"$@\"; do\n" +
" if [ \"$a\" = \"--break-system-packages\" ]; then\n" +
" echo \"no such option: --break-system-packages\" >&2\n" +
" exit 2\n" +
" fi\n" +
"done\n" +
"case \"$1\" in\n" +
" install|cache) echo \"$@\" >> \"" + argsFile + "\"; exit 0 ;;\n" +
"esac\n" +
"exit 0\n"
if runtime.GOOS == "windows" {
path += ".cmd"
body = "@echo off\r\n" +
"setlocal\r\n" +
"for %%A in (%*) do (\r\n" +
" if \"%%~A\"==\"--break-system-packages\" (\r\n" +
" echo no such option: --break-system-packages 1>&2\r\n" +
" exit /b 2\r\n" +
" )\r\n" +
")\r\n" +
"if \"%~1\"==\"install\" (\r\n" +
" echo %* >> \"" + argsFile + "\"\r\n" +
" exit /b 0\r\n" +
")\r\n" +
"if \"%~1\"==\"cache\" (\r\n" +
" echo %* >> \"" + argsFile + "\"\r\n" +
" exit /b 0\r\n" +
")\r\n" +
"exit /b 0\r\n"
}
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write legacy pip script: %v", err)
}
return path
}
// setupLegacyPip points pipBinary/pipLookPath at a legacy pip script that
// rejects --break-system-packages.
func setupLegacyPip(t *testing.T, argsFile string) {
t.Helper()
scriptPath := writeLegacyPipScript(t, argsFile)
origBinary := pipBinary
origLookPath := pipLookPath
pipBinary = scriptPath
pipLookPath = func(string) (string, error) { return scriptPath, nil }
t.Cleanup(func() {
pipBinary = origBinary
pipLookPath = origLookPath
})
}
// TestInstallSingleDepPip_LegacyPipNoFlag reproduces issue #956: on a pip that
// rejects --break-system-packages, installing a pip dep must still succeed via
// the automatic retry without the flag.
func TestInstallSingleDepPip_LegacyPipNoFlag(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "captured-args.txt")
setupLegacyPip(t, argsFile)
ok, msg := InstallSingleDep(context.Background(), "pip:requests")
if !ok {
t.Fatalf("InstallSingleDep failed on legacy pip: %s", msg)
}
captured, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("args file not written: %v", err)
}
if strings.Contains(string(captured), "--break-system-packages") {
t.Fatalf("legacy pip received --break-system-packages: %q", string(captured))
}
}
// TestInstallDepsPip_LegacyPipNoFlag covers the batch path (InstallDeps) with
// the same legacy pip fixture.
func TestInstallDepsPip_LegacyPipNoFlag(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "captured-args.txt")
setupLegacyPip(t, argsFile)
res, err := InstallDeps(context.Background(), &SkillManifest{}, []string{"pip:requests", "pip:numpy"})
if err != nil {
t.Fatalf("InstallDeps returned error: %v", err)
}
if len(res.Errors) != 0 {
t.Fatalf("InstallDeps errors = %v, want none", res.Errors)
}
if len(res.Pip) != 2 {
t.Fatalf("InstallDeps installed = %v, want 2 packages", res.Pip)
}
captured, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("args file not written: %v", err)
}
if strings.Contains(string(captured), "--break-system-packages") {
t.Fatalf("legacy pip received --break-system-packages: %q", string(captured))
}
}
// TestPipUpdateExecutor_LegacyPipNoFlag verifies the upgrade path also retries
// without the flag on legacy pip.
func TestPipUpdateExecutor_LegacyPipNoFlag(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "captured-args.txt")
setupLegacyPip(t, argsFile)
e := NewPipUpdateExecutor()
if err := e.Update(context.Background(), "requests", "2.31.0", nil); err != nil {
t.Fatalf("Update failed on legacy pip: %v", err)
}
captured, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("args file not written: %v", err)
}
if strings.Contains(string(captured), "--break-system-packages") {
t.Fatalf("legacy pip received --break-system-packages: %q", string(captured))
}
}
+5 -1
View File
@@ -111,7 +111,11 @@ func (c *PipUpdateChecker) runOutdated(ctx context.Context, includePre bool) ([]
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
args := []string{"list", "--outdated", "--format", "json", "--break-system-packages"}
// `list` is a read-only query — PEP 668 (externally-managed environments)
// only constrains commands that write into site-packages, and pip rejects
// --break-system-packages on `list` on every version (verified on pip 24).
// Passing it here makes the outdated check fail unconditionally.
args := []string{"list", "--outdated", "--format", "json"}
if includePre {
args = append(args, "--pre")
}
+22 -11
View File
@@ -36,9 +36,8 @@ func (e *PipUpdateExecutor) Update(ctx context.Context, name, toVersion string,
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
args := []string{
"install", "--upgrade",
"--no-cache-dir", "--break-system-packages",
args := []string{"install", "--upgrade",
"--no-cache-dir", pipBreakSystemPackagesFlag,
"--upgrade-strategy", "only-if-needed",
}
@@ -57,18 +56,17 @@ func (e *PipUpdateExecutor) Update(ctx context.Context, name, toVersion string,
}
args = append(args, name)
cmd := exec.CommandContext(cctx, pipBinary, args...)
cmd.WaitDelay = 2 * time.Second
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
runErr := cmd.Run()
stderr, runErr := runPipUpdateCommand(cctx, args)
// If pip rejects the PEP 668 flag (pip < 23.0, issue #956), retry once
// without it so updates keep working on legacy environments.
if runErr != nil && pipRejectsBSPFlag([]byte(stderr)) {
stderr, runErr = runPipUpdateCommand(cctx, dropPipBSPFlag(args))
}
durationMs := time.Since(start).Milliseconds()
if runErr != nil {
sentinel, reason := ClassifyPipStderr(stderr.String())
sentinel, reason := ClassifyPipStderr(stderr)
if sentinel == nil {
sentinel = fmt.Errorf("pip install failed: %w", runErr)
}
@@ -91,3 +89,16 @@ func (e *PipUpdateExecutor) Update(ctx context.Context, name, toVersion string,
"duration_ms", durationMs)
return nil
}
// runPipUpdateCommand runs `pip3 <args...>` and returns stderr only (it feeds
// ClassifyPipStderr); stdout is captured and discarded. Command-level
// WaitDelay mirrors the previous inline exec.
func runPipUpdateCommand(ctx context.Context, args []string) (stderr string, runErr error) {
cmd := exec.CommandContext(ctx, pipBinary, args...)
cmd.WaitDelay = 2 * time.Second
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
runErr = cmd.Run()
return errBuf.String(), runErr
}