mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
Create sanitized npm tarballs directly in Go so workspace dependency fallback does not run package lifecycle scripts or create global symlinks to temporary folders.
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package skills
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var npmPackageVersionResolver = npmViewPackageVersion
|
|
|
|
func installNpmPackage(ctx context.Context, target string) ([]byte, error) {
|
|
out, err := runNpmInstall(ctx, target)
|
|
if err == nil || !npmOutputHasWorkspaceProtocolError(string(out)) {
|
|
return out, err
|
|
}
|
|
|
|
slog.Warn("skills: npm package contains workspace protocol deps; retrying with sanitized tarball", "target", target)
|
|
fallbackOut, fallbackErr := installNpmPackageWithWorkspaceRewrite(ctx, target)
|
|
if fallbackErr == nil {
|
|
return fallbackOut, nil
|
|
}
|
|
return appendNpmFallbackOutput(out, fallbackOut), fallbackErr
|
|
}
|
|
|
|
func runNpmInstall(ctx context.Context, target string) ([]byte, error) {
|
|
if err := os.MkdirAll(npmGlobalPrefix(), 0o750); err != nil {
|
|
return nil, fmt.Errorf("npm prefix setup: %w", err)
|
|
}
|
|
ensureNpmGlobalEnv()
|
|
cmd := exec.CommandContext(ctx, npmBinary, "install", "-g", target)
|
|
cmd.Env = npmCommandEnv()
|
|
cmd.WaitDelay = 2 * time.Second
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func npmOutputHasWorkspaceProtocolError(out string) bool {
|
|
return strings.Contains(out, "EUNSUPPORTEDPROTOCOL") &&
|
|
(strings.Contains(out, `Unsupported URL Type "workspace:"`) || strings.Contains(out, "workspace:"))
|
|
}
|
|
|
|
func installNpmPackageWithWorkspaceRewrite(ctx context.Context, target string) ([]byte, error) {
|
|
tmpDir, err := os.MkdirTemp("", "goclaw-npm-workspace-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("npm workspace fallback temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
tarball, packOut, err := npmPackTarball(ctx, target, tmpDir)
|
|
if err != nil {
|
|
return packOut, err
|
|
}
|
|
|
|
extractDir := filepath.Join(tmpDir, "extract")
|
|
if err := os.MkdirAll(extractDir, 0o750); err != nil {
|
|
return packOut, fmt.Errorf("npm workspace fallback extract dir: %w", err)
|
|
}
|
|
if err := extractNpmTarballToDir(tarball, extractDir); err != nil {
|
|
return packOut, err
|
|
}
|
|
|
|
packageDir := filepath.Join(extractDir, "package")
|
|
rewrites, err := rewriteWorkspacePackageJSON(ctx, filepath.Join(packageDir, "package.json"))
|
|
if err != nil {
|
|
return packOut, err
|
|
}
|
|
if rewrites == 0 {
|
|
return packOut, errors.New("npm workspace fallback found no workspace dependencies to rewrite")
|
|
}
|
|
|
|
repackDir := filepath.Join(tmpDir, "repack")
|
|
if err := os.MkdirAll(repackDir, 0o750); err != nil {
|
|
return packOut, fmt.Errorf("npm workspace fallback repack dir: %w", err)
|
|
}
|
|
sanitizedTarball, err := packNpmPackageDir(packageDir, repackDir)
|
|
if err != nil {
|
|
return packOut, err
|
|
}
|
|
|
|
installOut, err := runNpmInstall(ctx, sanitizedTarball)
|
|
return appendNpmFallbackOutput(packOut, installOut), err
|
|
}
|
|
|
|
func npmPackTarball(ctx context.Context, target, destination string) (string, []byte, error) {
|
|
cmd := exec.CommandContext(ctx, npmBinary, "pack", "--json", "--pack-destination", destination, target)
|
|
cmd.Env = npmCommandEnv()
|
|
cmd.WaitDelay = 2 * time.Second
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
out := appendNpmFallbackOutput(stdout.Bytes(), stderr.Bytes())
|
|
return "", out, fmt.Errorf("npm pack fallback failed: %w", err)
|
|
}
|
|
|
|
var entries []struct {
|
|
Filename string `json:"filename"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &entries); err != nil {
|
|
return "", stdout.Bytes(), fmt.Errorf("npm pack fallback parse: %w", err)
|
|
}
|
|
if len(entries) == 0 || strings.TrimSpace(entries[0].Filename) == "" {
|
|
return "", stdout.Bytes(), errors.New("npm pack fallback returned no tarball")
|
|
}
|
|
|
|
tarball := filepath.Join(destination, filepath.Base(entries[0].Filename))
|
|
if _, err := os.Stat(tarball); err != nil {
|
|
return "", stdout.Bytes(), fmt.Errorf("npm pack fallback tarball missing: %w", err)
|
|
}
|
|
return tarball, stdout.Bytes(), nil
|
|
}
|
|
|
|
func rewriteWorkspacePackageJSON(ctx context.Context, path string) (int, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read package.json: %w", err)
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(raw, &pkg); err != nil {
|
|
return 0, fmt.Errorf("parse package.json: %w", err)
|
|
}
|
|
|
|
rewrites := 0
|
|
for _, section := range []string{"dependencies", "optionalDependencies", "peerDependencies"} {
|
|
deps, ok := pkg[section].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for name, value := range deps {
|
|
spec, ok := value.(string)
|
|
if !ok || !strings.HasPrefix(spec, "workspace:") {
|
|
continue
|
|
}
|
|
resolved, err := resolveWorkspaceDependencySpec(ctx, name, spec)
|
|
if err != nil {
|
|
return rewrites, err
|
|
}
|
|
deps[name] = resolved
|
|
rewrites++
|
|
}
|
|
}
|
|
|
|
if rewrites == 0 {
|
|
return 0, nil
|
|
}
|
|
updated, err := json.MarshalIndent(pkg, "", " ")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encode package.json: %w", err)
|
|
}
|
|
updated = append(updated, '\n')
|
|
if err := os.WriteFile(path, updated, 0o600); err != nil {
|
|
return 0, fmt.Errorf("write package.json: %w", err)
|
|
}
|
|
return rewrites, nil
|
|
}
|
|
|
|
func resolveWorkspaceDependencySpec(ctx context.Context, name, spec string) (string, error) {
|
|
suffix := strings.TrimSpace(strings.TrimPrefix(spec, "workspace:"))
|
|
switch suffix {
|
|
case "", "*":
|
|
return npmPackageVersionResolver(ctx, name)
|
|
case "^", "~":
|
|
version, err := npmPackageVersionResolver(ctx, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return suffix + version, nil
|
|
default:
|
|
if strings.HasPrefix(suffix, ".") || strings.HasPrefix(suffix, "/") {
|
|
return "", fmt.Errorf("unsupported workspace dependency path for %s: %s", name, spec)
|
|
}
|
|
return suffix, nil
|
|
}
|
|
}
|
|
|
|
func npmViewPackageVersion(ctx context.Context, name string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, npmBinary, "view", name, "version", "--json")
|
|
cmd.Env = npmCommandEnv()
|
|
cmd.WaitDelay = 2 * time.Second
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("npm view %s version: %w", name, err)
|
|
}
|
|
var version string
|
|
if err := json.Unmarshal(out, &version); err != nil {
|
|
version = strings.Trim(strings.TrimSpace(string(out)), `"`)
|
|
}
|
|
if version == "" {
|
|
return "", fmt.Errorf("npm view %s version returned empty version", name)
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func packNpmPackageDir(packageDir, destination string) (string, error) {
|
|
raw, err := os.ReadFile(filepath.Join(packageDir, "package.json"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("read package.json for repack: %w", err)
|
|
}
|
|
var pkg struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(raw, &pkg); err != nil {
|
|
return "", fmt.Errorf("parse package.json for repack: %w", err)
|
|
}
|
|
filename := npmTarballFilename(pkg.Name, pkg.Version)
|
|
if filename == "" {
|
|
return "", errors.New("npm workspace fallback cannot derive tarball filename")
|
|
}
|
|
|
|
tarball := filepath.Join(destination, filename)
|
|
out, err := os.OpenFile(tarball, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create sanitized npm tarball: %w", err)
|
|
}
|
|
defer out.Close()
|
|
|
|
gz := gzip.NewWriter(out)
|
|
tw := tar.NewWriter(gz)
|
|
walkErr := filepath.WalkDir(packageDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("npm workspace fallback unsupported package entry: %s", path)
|
|
}
|
|
rel, err := filepath.Rel(packageDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr.Name = filepath.ToSlash(filepath.Join("package", rel))
|
|
hdr.Mode = int64(info.Mode().Perm())
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(tw, in)
|
|
closeErr := in.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
return closeErr
|
|
})
|
|
closeTarErr := tw.Close()
|
|
closeGzipErr := gz.Close()
|
|
if walkErr != nil {
|
|
return "", fmt.Errorf("pack sanitized npm tarball: %w", walkErr)
|
|
}
|
|
if closeTarErr != nil {
|
|
return "", fmt.Errorf("close sanitized npm tarball: %w", closeTarErr)
|
|
}
|
|
if closeGzipErr != nil {
|
|
return "", fmt.Errorf("close sanitized npm gzip: %w", closeGzipErr)
|
|
}
|
|
return tarball, nil
|
|
}
|
|
|
|
func npmTarballFilename(name, version string) string {
|
|
name = strings.TrimSpace(name)
|
|
version = strings.TrimSpace(version)
|
|
if name == "" || version == "" {
|
|
return ""
|
|
}
|
|
name = strings.TrimPrefix(name, "@")
|
|
name = strings.ReplaceAll(name, "/", "-")
|
|
name = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r
|
|
case r >= 'A' && r <= 'Z':
|
|
return r
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == '.' || r == '_' || r == '-':
|
|
return r
|
|
default:
|
|
return '-'
|
|
}
|
|
}, name)
|
|
version = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r
|
|
case r >= 'A' && r <= 'Z':
|
|
return r
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == '.' || r == '_' || r == '-':
|
|
return r
|
|
default:
|
|
return '-'
|
|
}
|
|
}, version)
|
|
return name + "-" + version + ".tgz"
|
|
}
|
|
|
|
func extractNpmTarballToDir(tarball, destination string) error {
|
|
files, err := ExtractArchiveAs(tarball, filepath.Base(tarball), 50*1024*1024)
|
|
if err != nil {
|
|
return fmt.Errorf("extract npm tarball: %w", err)
|
|
}
|
|
|
|
cleanDest := filepath.Clean(destination)
|
|
for _, file := range files {
|
|
target := filepath.Join(cleanDest, file.Name)
|
|
if !isPathWithin(target, cleanDest) {
|
|
return fmt.Errorf("npm tarball contains unsafe path: %s", file.Name)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
|
return fmt.Errorf("extract npm tarball parent: %w", err)
|
|
}
|
|
mode := file.Mode.Perm()
|
|
if mode == 0 {
|
|
mode = 0o600
|
|
}
|
|
if err := os.WriteFile(target, file.Content, mode); err != nil {
|
|
return fmt.Errorf("extract npm tarball file: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isPathWithin(path, parent string) bool {
|
|
cleanPath := filepath.Clean(path)
|
|
cleanParent := filepath.Clean(parent)
|
|
if cleanPath == cleanParent {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(cleanPath, cleanParent+string(os.PathSeparator))
|
|
}
|
|
|
|
func appendNpmFallbackOutput(parts ...[]byte) []byte {
|
|
var out []byte
|
|
for _, part := range parts {
|
|
trimmed := bytes.TrimSpace(part)
|
|
if len(trimmed) == 0 {
|
|
continue
|
|
}
|
|
if len(out) > 0 {
|
|
out = append(out, '\n')
|
|
}
|
|
out = append(out, trimmed...)
|
|
}
|
|
return out
|
|
}
|