yao/sandbox/helpers.go
Max 28363b973e Fix sandbox compatibility, claude-proxy streaming, and rename playwright to browser
- 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>
2026-02-06 12:31:07 +08:00

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)
}