- Fix ListDir to support BusyBox/Alpine ls by falling back from GNU --time-style format, resolving CI test failures - Update parseLS to handle both GNU (epoch) and BusyBox (date string) formats - Fix claude-proxy streaming: always include usage in message_delta events to prevent Claude CLI from falling back to non-streaming mode - Fix claude-proxy non-streaming: ensure usage is always present in responses - Add paragraph separators between text blocks in Claude executor stream parser - Translate VNC proxy UI from Chinese to English - Rename sandbox-claude-playwright to sandbox-claude-browser across Dockerfiles, build scripts, and documentation Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
7.1 KiB
Go
335 lines
7.1 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"archive/tar"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// mapToSlice converts map to []string for environment variables
|
|
func mapToSlice(m map[string]string) []string {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(m))
|
|
for k, v := range m {
|
|
result = append(result, k+"="+v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseMemory converts string like "2g" to bytes
|
|
func parseMemory(s string) int64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if len(s) < 2 {
|
|
v, _ := strconv.ParseInt(s, 10, 64)
|
|
return v
|
|
}
|
|
|
|
unit := s[len(s)-1]
|
|
numStr := s[:len(s)-1]
|
|
num, err := strconv.ParseFloat(numStr, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
switch unit {
|
|
case 'k':
|
|
return int64(num * 1024)
|
|
case 'm':
|
|
return int64(num * 1024 * 1024)
|
|
case 'g':
|
|
return int64(num * 1024 * 1024 * 1024)
|
|
case 't':
|
|
return int64(num * 1024 * 1024 * 1024 * 1024)
|
|
default:
|
|
// Assume bytes if no unit
|
|
v, _ := strconv.ParseInt(s, 10, 64)
|
|
return v
|
|
}
|
|
}
|
|
|
|
// parseLS parses ls -la output to []FileInfo
|
|
// If hasTimeStyle is true, expects GNU ls output with --time-style=+%s (Unix epoch)
|
|
// If hasTimeStyle is false, expects BusyBox/basic ls output (date string format)
|
|
func parseLS(output string, hasTimeStyle bool) []FileInfo {
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
var result []FileInfo
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "total") {
|
|
continue
|
|
}
|
|
|
|
// Parse ls -la output
|
|
// GNU with --time-style: drwxr-xr-x 2 user group 4096 1234567890 filename
|
|
// BusyBox/basic: drwxr-xr-x 2 user group 4096 Jan 1 12:00 filename
|
|
fields := strings.Fields(line)
|
|
|
|
var minFields int
|
|
if hasTimeStyle {
|
|
minFields = 7 // mode, links, user, group, size, timestamp, name
|
|
} else {
|
|
minFields = 9 // mode, links, user, group, size, month, day, time/year, name
|
|
}
|
|
|
|
if len(fields) < minFields {
|
|
continue
|
|
}
|
|
|
|
// Parse mode
|
|
modeStr := fields[0]
|
|
if len(modeStr) == 0 {
|
|
continue
|
|
}
|
|
mode := parseLSMode(modeStr)
|
|
|
|
// Parse size
|
|
size, _ := strconv.ParseInt(fields[4], 10, 64)
|
|
|
|
// Parse timestamp and get filename
|
|
var modTime time.Time
|
|
var name string
|
|
|
|
if hasTimeStyle {
|
|
// GNU ls with --time-style=+%s: timestamp is Unix epoch in fields[5]
|
|
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
|
|
modTime = time.Unix(timestamp, 0)
|
|
name = strings.Join(fields[6:], " ")
|
|
} else {
|
|
// BusyBox/basic ls: date is in fields[5:8] (e.g., "Jan 1 12:00" or "Jan 1 2024")
|
|
// Note: time.Now() is used as fallback since BusyBox date parsing is complex
|
|
modTime = time.Now()
|
|
name = strings.Join(fields[8:], " ")
|
|
}
|
|
|
|
// Skip . and ..
|
|
if name == "." || name == ".." {
|
|
continue
|
|
}
|
|
|
|
result = append(result, FileInfo{
|
|
Name: name,
|
|
Size: size,
|
|
Mode: mode,
|
|
ModTime: modTime,
|
|
IsDir: modeStr[0] == 'd',
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// parseLSMode parses ls mode string to os.FileMode
|
|
func parseLSMode(s string) os.FileMode {
|
|
if len(s) < 10 {
|
|
return 0
|
|
}
|
|
|
|
var mode os.FileMode
|
|
|
|
// File type
|
|
switch s[0] {
|
|
case 'd':
|
|
mode |= os.ModeDir
|
|
case 'l':
|
|
mode |= os.ModeSymlink
|
|
case 'c':
|
|
mode |= os.ModeCharDevice
|
|
case 'b':
|
|
mode |= os.ModeDevice
|
|
case 'p':
|
|
mode |= os.ModeNamedPipe
|
|
case 's':
|
|
mode |= os.ModeSocket
|
|
}
|
|
|
|
// Permissions
|
|
perms := s[1:10]
|
|
permBits := []os.FileMode{
|
|
0400, 0200, 0100, // owner
|
|
0040, 0020, 0010, // group
|
|
0004, 0002, 0001, // other
|
|
}
|
|
|
|
for i, b := range perms {
|
|
if b != '-' && i < len(permBits) {
|
|
mode |= permBits[i]
|
|
}
|
|
}
|
|
|
|
return mode
|
|
}
|
|
|
|
// parseStat parses stat --format=%n|%s|%f|%Y|%F output to *FileInfo
|
|
func parseStat(output string) *FileInfo {
|
|
output = strings.TrimSpace(output)
|
|
parts := strings.Split(output, "|")
|
|
if len(parts) < 5 {
|
|
return nil
|
|
}
|
|
|
|
name := parts[0]
|
|
size, _ := strconv.ParseInt(parts[1], 10, 64)
|
|
modeHex, _ := strconv.ParseUint(parts[2], 16, 32)
|
|
timestamp, _ := strconv.ParseInt(parts[3], 10, 64)
|
|
fileType := parts[4]
|
|
|
|
return &FileInfo{
|
|
Name: filepath.Base(name),
|
|
Path: name,
|
|
Size: size,
|
|
Mode: os.FileMode(modeHex),
|
|
ModTime: time.Unix(timestamp, 0),
|
|
IsDir: strings.Contains(fileType, "directory"),
|
|
}
|
|
}
|
|
|
|
// createTarFromPath creates a tar archive from a host path
|
|
func createTarFromPath(hostPath string) (io.ReadCloser, error) {
|
|
// Validate path exists before starting goroutine
|
|
info, err := os.Stat(hostPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat path: %w", err)
|
|
}
|
|
|
|
pr, pw := io.Pipe()
|
|
|
|
go func() {
|
|
tw := tar.NewWriter(pw)
|
|
var finalErr error
|
|
|
|
defer func() {
|
|
tw.Close()
|
|
if finalErr != nil {
|
|
pw.CloseWithError(finalErr)
|
|
} else {
|
|
pw.Close()
|
|
}
|
|
}()
|
|
|
|
baseDir := filepath.Dir(hostPath)
|
|
|
|
walkFn := func(path string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get relative path
|
|
relPath, err := filepath.Rel(baseDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create header
|
|
header, err := tar.FileInfoHeader(fi, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = relPath
|
|
|
|
// Handle symlinks
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
link, err := os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Linkname = link
|
|
}
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write file content
|
|
if fi.Mode().IsRegular() {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(tw, f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if info.IsDir() {
|
|
finalErr = filepath.Walk(hostPath, walkFn)
|
|
} else {
|
|
finalErr = walkFn(hostPath, info, nil)
|
|
}
|
|
}()
|
|
|
|
return pr, nil
|
|
}
|
|
|
|
// extractTarToPath extracts a tar archive to a host path
|
|
func extractTarToPath(reader io.Reader, hostPath string) error {
|
|
tr := tar.NewReader(reader)
|
|
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("tar read error: %w", err)
|
|
}
|
|
|
|
target := filepath.Join(hostPath, header.Name)
|
|
|
|
// Security check: prevent path traversal
|
|
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(hostPath)) {
|
|
return fmt.Errorf("invalid tar path: %s", header.Name)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
// Ensure parent directory exists
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(f, tr); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
f.Close()
|
|
case tar.TypeSymlink:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
os.Remove(target) // Remove existing symlink if any
|
|
if err := os.Symlink(header.Linkname, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// containerName generates a container name from userID and chatID
|
|
func containerName(userID, chatID string) string {
|
|
return fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID)
|
|
}
|