Files
fchengyan a1e550f0f0 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
2026-09-03 10:18:12 +08:00

473 lines
16 KiB
Go

package skills
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
// sharedLocker is the package-level PackageLocker injected by gateway wiring.
// It serializes concurrent pip/npm install+update operations on the same package.
// If nil (default), pip/npm branches run lock-free — backward-compatible for
// tests and callers that don't wire a locker.
var sharedLocker atomic.Pointer[PackageLocker]
// SetSharedPackageLocker installs the package-level locker used by
// InstallSingleDep for pip and npm operations. Wiring MUST call this before
// the first install/update; otherwise pip/npm paths run lock-free.
// GitHub installs lock independently via GitHubInstaller.Locker.
func SetSharedPackageLocker(l *PackageLocker) { sharedLocker.Store(l) }
// sharedPackageLocker returns the current shared PackageLocker, or nil if none
// was installed via SetSharedPackageLocker.
func sharedPackageLocker() *PackageLocker { return sharedLocker.Load() }
// InstallTimeout is the wall-clock cap applied to a single package install.
// Exported so HTTP handlers that bypass InstallSingleDep (e.g. the github:
// fast path) can wrap their context with the same deadline.
const InstallTimeout = 5 * time.Minute
// pkgHelperSocket is the Unix socket path for the root-privileged pkg-helper.
const pkgHelperSocket = "/tmp/pkg.sock"
// pkgHelperBundledPath is where the Docker image copies the helper binary.
// /app is not part of PATH, so direct-exec fallback must check it explicitly.
const pkgHelperBundledPath = "/app/pkg-helper"
// apkHelperCallFunc is the package-level hook for apkHelperCall, allowing tests
// to inject a stub without starting a real Unix socket server. Production code
// always uses the default value (apkHelperCall). Tests replace it per-case and
// restore via t.Cleanup.
var apkHelperCallFunc = apkHelperCall
// InstallResult holds per-category install outcomes.
type InstallResult struct {
System []string `json:"system,omitempty"`
Pip []string `json:"pip,omitempty"`
Npm []string `json:"npm,omitempty"`
GitHub []string `json:"github,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// AggregateMissingDeps scans all provided skill directories, merges their manifests,
// then checks which dependencies are missing.
// skillDirs is map[slug]->dir.
func AggregateMissingDeps(skillDirs map[string]string) (*SkillManifest, []string) {
var merged *SkillManifest
for _, dir := range skillDirs {
m := ScanSkillDeps(dir)
if m != nil {
merged = MergeDeps(merged, m)
}
}
if merged == nil || merged.IsEmpty() {
return nil, nil
}
_, missing := CheckSkillDeps(merged)
return merged, missing
}
// InstallSingleDep installs one dependency (format: "pip:pkg", "npm:pkg", or plain binary name).
// Returns (ok, errorMessage). Logs progress via slog so the Log page can show install status.
func InstallSingleDep(ctx context.Context, dep string) (bool, string) {
ctx, cancel := context.WithTimeout(ctx, InstallTimeout)
defer cancel()
slog.Info("skills: installing dep", "dep", dep)
switch {
case strings.HasPrefix(dep, "github:"):
gh := DefaultGitHubInstaller()
if gh == nil {
return false, "github installer not configured"
}
if _, err := gh.Install(ctx, dep); err != nil {
slog.Error("skills: github install failed", "dep", dep, "error", err)
return false, err.Error()
}
slog.Info("skills: dep installed", "dep", dep)
return true, ""
case strings.HasPrefix(dep, "pip:"):
pkg := strings.TrimPrefix(dep, "pip:")
if l := sharedPackageLocker(); l != nil {
release, lerr := l.Acquire(ctx, "pip", pkg)
if lerr != nil {
return false, fmt.Sprintf("lock acquire: %v", lerr)
}
defer release()
}
// 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)
if hint := pipBuildFailHint(pkg, string(out)); hint != "" {
slog.Warn("skills: dep install hint", "dep", dep, "hint", hint)
}
return false, msg
}
case strings.HasPrefix(dep, "npm:"):
pkg := strings.TrimPrefix(dep, "npm:")
if l := sharedPackageLocker(); l != nil {
release, lerr := l.Acquire(ctx, "npm", pkg)
if lerr != nil {
return false, fmt.Sprintf("lock acquire: %v", lerr)
}
defer release()
}
out, err := installNpmPackage(ctx, pkg)
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("skills: dep install failed", "dep", dep, "error", msg)
return false, msg
}
default:
ok, errMsg := installSystemPackage(ctx, dep)
if !ok {
return false, errMsg
}
}
slog.Info("skills: dep installed", "dep", dep)
cleanCaches(ctx)
return true, ""
}
// InstallDeps installs missing packages by category.
// Uses PIP_TARGET and NPM_CONFIG_PREFIX from env (set by docker-entrypoint.sh).
func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string) (*InstallResult, error) {
ctx, cancel := context.WithTimeout(ctx, InstallTimeout)
defer cancel()
result := &InstallResult{}
var sysPkgs, pipPkgs, npmPkgs []string
for _, dep := range missing {
switch {
case strings.HasPrefix(dep, "pip:"):
pipPkgs = append(pipPkgs, strings.TrimPrefix(dep, "pip:"))
case strings.HasPrefix(dep, "npm:"):
npmPkgs = append(npmPkgs, strings.TrimPrefix(dep, "npm:"))
default:
sysPkgs = append(sysPkgs, dep)
}
}
// System packages: install one by one via pkg-helper.
if len(sysPkgs) > 0 {
slog.Info("skills: installing system packages", "pkgs", sysPkgs)
var successful []string
for _, pkg := range sysPkgs {
ok, errMsg := installSystemPackage(ctx, pkg)
if !ok {
result.Errors = append(result.Errors, fmt.Sprintf("system %s: %s", pkg, errMsg))
} else {
successful = append(successful, pkg)
}
}
result.System = successful
}
// Pip packages: install one by one for partial-success resilience.
if len(pipPkgs) > 0 {
slog.Info("skills: installing pip packages", "pkgs", pipPkgs)
var successful []string
for _, pkg := range pipPkgs {
// 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)
}
} else {
successful = append(successful, pkg)
}
}
result.Pip = successful
}
// Npm packages: install one by one for partial-success resilience.
if len(npmPkgs) > 0 {
slog.Info("skills: installing npm packages", "pkgs", npmPkgs)
if err := os.MkdirAll(npmGlobalPrefix(), 0o750); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("npm prefix setup: %v", err))
cleanCaches(ctx)
return result, nil
}
ensureNpmGlobalEnv()
var successful []string
for _, pkg := range npmPkgs {
if out, err := installNpmPackage(ctx, pkg); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("npm %s: %s (%v)", pkg, strings.TrimSpace(string(out)), err))
} else {
successful = append(successful, pkg)
}
}
result.Npm = successful
}
cleanCaches(ctx)
return result, nil
}
// UninstallPackage removes one package (format: "pip:pkg", "npm:pkg", or plain apk name).
// Returns (ok, errorMessage).
func UninstallPackage(ctx context.Context, dep string) (bool, string) {
ctx, cancel := context.WithTimeout(ctx, InstallTimeout)
defer cancel()
slog.Info("skills: uninstalling package", "dep", dep)
switch {
case strings.HasPrefix(dep, "github:"):
gh := DefaultGitHubInstaller()
if gh == nil {
return false, "github installer not configured"
}
// Accept either "github:name" (manifest name only) or the full
// "github:owner/repo[@tag]". For the full form we look up the manifest
// entry by owner/repo so packages whose binary name differs from the
// repo name (e.g. cli/cli → gh) can still be uninstalled via spec.
name := strings.TrimPrefix(dep, "github:")
if spec, err := ParseGitHubSpec(dep); err == nil {
name = spec.Repo
if entries, lerr := gh.List(); lerr == nil {
want := spec.Owner + "/" + spec.Repo
for _, e := range entries {
if strings.EqualFold(e.Repo, want) {
name = e.Name
break
}
}
}
} else if slash := strings.Index(name, "/"); slash >= 0 {
// Tolerate bare "owner/repo" without the scheme prefix.
name = name[slash+1:]
if at := strings.IndexByte(name, '@'); at >= 0 {
name = name[:at]
}
}
if err := gh.Uninstall(ctx, name); err != nil {
slog.Error("skills: github uninstall failed", "dep", dep, "error", err)
return false, err.Error()
}
slog.Info("skills: package uninstalled", "dep", dep)
return true, ""
case strings.HasPrefix(dep, "pip:"):
pkg := strings.TrimPrefix(dep, "pip:")
cmd := exec.CommandContext(ctx, "pip3", "uninstall", "-y", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("skills: uninstall failed", "dep", dep, "error", msg)
return false, msg
}
case strings.HasPrefix(dep, "npm:"):
pkg := strings.TrimPrefix(dep, "npm:")
ensureNpmGlobalEnv()
cmd := exec.CommandContext(ctx, npmBinary, "uninstall", "-g", pkg)
cmd.Env = npmCommandEnv()
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("skills: uninstall failed", "dep", dep, "error", msg)
return false, msg
}
default:
ok, errMsg := uninstallSystemPackage(ctx, dep)
if !ok {
return false, errMsg
}
}
slog.Info("skills: package uninstalled", "dep", dep)
return true, ""
}
// apkHelperCall dials the pkg-helper v2 Unix socket and invokes action for pkg.
// Package may be empty for read-only actions (update-index, list-outdated).
//
// Return values:
// - ok: resp.OK from helper
// - code: resp.Code (error classification); "helper_unavailable" on dial fail,
// "helper_error" on send/recv/parse failure, "system_error" if helper omits code
// - data: resp.Data (stdout payload for list-outdated / update-index)
// - errMsg: resp.Error (human-readable reason)
//
// Scanner buffer: 64KB initial / 1MB max (CONTRACT). list-outdated output on
// full-skills images can approach this limit. Any NEW action returning >1MB MUST
// raise this ceiling AND the matching helper-side write, or split into multiple
// JSON lines. Violating silently yields helper_error "bufio.Scanner: token too long".
func apkHelperCall(ctx context.Context, action, pkg string) (ok bool, code, data, errMsg string) {
conn, err := net.DialTimeout("unix", pkgHelperSocket, 5*time.Second)
if err != nil {
if path, found := findPkgHelperBinary(); found {
return apkHelperCallFallback(ctx, path, action, pkg)
}
return false, "helper_unavailable", "", fmt.Sprintf("pkg-helper unavailable: %v", err)
}
defer conn.Close()
// Bind connection lifetime to caller's context deadline (primary per-op timeout).
// The helper also enforces a 10-min safety ceiling independently.
if deadline, hasDeadline := ctx.Deadline(); hasDeadline {
conn.SetDeadline(deadline) //nolint:errcheck
}
// Send request as a newline-delimited JSON line.
req := map[string]string{"action": action, "package": pkg}
if err := json.NewEncoder(conn).Encode(req); err != nil {
return false, "helper_error", "", fmt.Sprintf("pkg-helper send failed: %v", err)
}
// Read single-line JSON response.
// Buffer ceiling documented above as a client contract.
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
if !scanner.Scan() {
scanErr := scanner.Err()
if scanErr != nil {
return false, "helper_error", "", fmt.Sprintf("pkg-helper: read error: %v", scanErr)
}
return false, "helper_error", "", "pkg-helper: no response"
}
resp, err := parsePkgHelperResponse(scanner.Bytes())
if err != nil {
return false, "helper_error", "", fmt.Sprintf("pkg-helper: invalid response: %v", err)
}
return resp.OK, resp.Code, resp.Data, resp.Error
}
type pkgHelperResponse struct {
OK bool `json:"ok"`
Error string `json:"error"`
Code string `json:"code"`
Data string `json:"data"`
}
func parsePkgHelperResponse(out []byte) (pkgHelperResponse, error) {
var resp pkgHelperResponse
if err := json.Unmarshal(out, &resp); err != nil {
return pkgHelperResponse{}, err
}
// Default missing code to system_error for v1-era helpers that omit the field.
if resp.Code == "" && !resp.OK {
resp.Code = "system_error"
}
return resp, nil
}
func apkHelperCallFallback(ctx context.Context, helperPath, action, pkg string) (ok bool, code, data, errMsg string) {
cmd := exec.CommandContext(ctx, helperPath, action)
if pkg != "" {
cmd.Args = append(cmd.Args, pkg)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, execErr := cmd.Output()
resp, parseErr := parsePkgHelperResponse(out)
if parseErr == nil {
if execErr != nil && resp.OK {
return false, "system_error", "", fmt.Sprintf("pkg-helper fallback exited after successful response: %v", execErr)
}
return resp.OK, resp.Code, resp.Data, resp.Error
}
detail := helperFallbackOutputDetail(out, stderr.String())
if execErr != nil {
return false, "system_error", "", fmt.Sprintf("pkg-helper fallback failed: %v%s", execErr, detail)
}
return false, "helper_error", "", fmt.Sprintf("pkg-helper fallback invalid response: %v%s", parseErr, detail)
}
func helperFallbackOutputDetail(stdout []byte, stderr string) string {
var parts []string
if trimmed := strings.TrimSpace(string(stdout)); trimmed != "" {
parts = append(parts, "stdout: "+trimmed)
}
if trimmed := strings.TrimSpace(stderr); trimmed != "" {
parts = append(parts, "stderr: "+trimmed)
}
if len(parts) == 0 {
return ""
}
return ": " + strings.Join(parts, "; ")
}
func findPkgHelperBinary() (string, bool) {
if path, err := exec.LookPath("pkg-helper"); err == nil {
return path, true
}
return firstExecutableFile(pkgHelperFallbackPaths())
}
func pkgHelperFallbackPaths() []string {
paths := []string{pkgHelperBundledPath}
if exe, err := os.Executable(); err == nil {
paths = append([]string{filepath.Join(filepath.Dir(exe), "pkg-helper")}, paths...)
}
return paths
}
func firstExecutableFile(paths []string) (string, bool) {
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
info, err := os.Stat(path)
if err == nil && !info.IsDir() && info.Mode().Perm()&0111 != 0 {
return path, true
}
}
return "", false
}
// apkViaHelper is the legacy 2-return-value wrapper used by InstallSingleDep,
// InstallDeps, and UninstallPackage. Delegates to apkHelperCall; callers
// receive (ok, errMsg) and do not need the code/data fields.
func apkViaHelper(ctx context.Context, action, pkg string) (bool, string) {
ok, _, _, errMsg := apkHelperCall(ctx, action, pkg)
return ok, errMsg
}
// cleanCaches removes pip and npm caches to save disk space.
// Uses pipBinary so test fixtures can redirect pip3 invocations.
func cleanCaches(ctx context.Context) {
exec.CommandContext(ctx, pipBinary, "cache", "purge").Run() //nolint:errcheck
// Remove npm temp dirs using native Go (avoid sh -c shell glob + symlink risk).
// Matches only direct entries in /tmp; skips symlinks to prevent attacker-pointed rm.
matches, _ := filepath.Glob("/tmp/npm-*")
for _, p := range matches {
info, lerr := os.Lstat(p)
if lerr != nil {
continue
}
if info.Mode()&os.ModeSymlink != 0 {
continue // skip symlinks
}
_ = os.RemoveAll(p)
}
}