From 28363b973e9267332cc45e98b993736af035f855 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 6 Feb 2026 12:31:07 +0800 Subject: [PATCH] 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 --- agent/sandbox/claude/executor.go | 16 ++- sandbox/DESIGN-PLAYWRIGHT-VNC.md | 34 +++---- sandbox/README.md | 8 +- .../docker/{playwright => browser}/Dockerfile | 11 +- .../config/yao-logo.png | Bin sandbox/docker/build.sh | 26 ++--- sandbox/docker/vnc/start-vnc.sh | 2 +- sandbox/helpers.go | 39 ++++++-- sandbox/helpers_test.go | 94 +++++++++++++----- sandbox/manager.go | 12 ++- sandbox/proxy/convert.go | 4 +- sandbox/proxy/main.go | 24 ++--- sandbox/vncproxy/proxy.go | 24 ++--- 13 files changed, 190 insertions(+), 104 deletions(-) rename sandbox/docker/{playwright => browser}/Dockerfile (89%) rename sandbox/docker/{playwright => browser}/config/yao-logo.png (100%) diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 5cdf51ed..8a2c6087 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -561,11 +561,21 @@ func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, hand switch eventType { case "content_block_start": - // Check if this is a tool_use block starting - // Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}} + // Handle new content blocks + // Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"|"text",...}}} if contentBlock, ok := event["content_block"].(map[string]interface{}); ok { blockType, _ := contentBlock["type"].(string) - if blockType == "tool_use" { + switch blockType { + case "text": + // New text block starting - add paragraph separator if we already have content + // This ensures proper separation between text blocks across tool-use rounds + if textContent.Len() > 0 { + textContent.WriteString("\n\n") + if handler != nil && messageStarted { + handler(message.ChunkText, []byte("\n\n")) + } + } + case "tool_use": toolName, _ := contentBlock["name"].(string) blockIndex := 0 if idx, ok := event["index"].(float64); ok { diff --git a/sandbox/DESIGN-PLAYWRIGHT-VNC.md b/sandbox/DESIGN-PLAYWRIGHT-VNC.md index 51a9e834..46a28dfc 100644 --- a/sandbox/DESIGN-PLAYWRIGHT-VNC.md +++ b/sandbox/DESIGN-PLAYWRIGHT-VNC.md @@ -70,7 +70,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐ │ -│ │ sandbox-claude │ │ sandbox-claude-playwright│ │ sandbox-claude- │ │ +│ │ sandbox-claude │ │ sandbox-claude-browser │ │ sandbox-claude- │ │ │ │ (No VNC) │ │ (Browser + VNC) │ │ desktop (Full) │ │ │ │ │ │ │ │ │ │ │ │ • Claude CLI │ │ • Claude CLI │ │ • Claude CLI │ │ @@ -89,7 +89,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users | Image | VNC | Use Case | Size | Memory | |-------|-----|----------|------|--------| | `sandbox-claude` | ❌ | Code execution, scripts, CLI tasks | ~700MB | 2GB | -| `sandbox-claude-playwright` | ✅ | Browser automation, web scraping | ~1.8GB | 4GB | +| `sandbox-claude-browser` | ✅ | Browser automation, web scraping | ~1.8GB | 4GB | | `sandbox-claude-desktop` | ✅ | Full visibility, any GUI app | ~2.5GB | 4GB | ### User Selection Flow @@ -105,7 +105,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users │ │ ○ Standard (sandbox-claude) ││ │ │ Code execution, no GUI. Lightweight and fast. ││ │ │ ││ -│ │ ○ Browser (sandbox-claude-playwright) ⭐ ││ +│ │ ○ Browser (sandbox-claude-browser) ⭐ ││ │ │ Playwright browser automation with VNC preview. ││ │ │ See browser operations in real-time. ││ │ │ ││ @@ -130,11 +130,11 @@ Three image variants sharing the same VNC infrastructure: ubuntu:24.04 └── sandbox-base:latest (~200MB) └── sandbox-claude:latest (~700MB) # No VNC - ├── sandbox-claude-playwright:latest (~1.8GB) # VNC + Browser + ├── sandbox-claude-browser:latest (~1.8GB) # VNC + Browser └── sandbox-claude-desktop:latest (~2.5GB) # VNC + Full Desktop ``` -#### 1.1 sandbox-claude-playwright (Browser + VNC) +#### 1.1 sandbox-claude-browser (Browser + VNC) For browser automation tasks with real-time visibility. @@ -219,7 +219,7 @@ type ImageType string const ( ImageTypeClaude ImageType = "claude" // No VNC - ImageTypePlaywright ImageType = "playwright" // Browser + VNC + ImageTypeBrowser ImageType = "browser" // Browser + VNC ImageTypeDesktop ImageType = "desktop" // Full desktop + VNC ) @@ -231,7 +231,7 @@ var ImageConfigs = map[ImageType]struct { CPU float64 }{ ImageTypeClaude: {"yaoapp/sandbox-claude:latest", false, "2g", 1.0}, - ImageTypePlaywright: {"yaoapp/sandbox-claude-playwright:latest", true, "4g", 2.0}, + ImageTypeBrowser: {"yaoapp/sandbox-claude-browser:latest", true, "4g", 2.0}, ImageTypeDesktop: {"yaoapp/sandbox-claude-desktop:latest", true, "4g", 2.0}, } ``` @@ -353,7 +353,7 @@ sandbox: **Available Images**: - `yaoapp/sandbox-claude:latest` - No VNC, lightweight -- `yaoapp/sandbox-claude-playwright:latest` - Browser + VNC +- `yaoapp/sandbox-claude-browser:latest` - Browser + VNC - `yaoapp/sandbox-claude-desktop:latest` - Full desktop + VNC **Note**: No changes required to `agent/sandbox/` code. The existing `Image` field in `SandboxConfig` already supports custom images. @@ -563,7 +563,7 @@ When user interaction is needed (e.g., login), Claude can: ## Implementation Details -### Dockerfile.playwright +### Dockerfile.browser (browser/Dockerfile) ```dockerfile ARG REGISTRY=yaoapp @@ -1309,7 +1309,7 @@ Docker Desktop runs containers inside a LinuxKit VM, so container IPs (`172.17.0 ```bash # Enable VNC port mapping for local development export YAO_SANDBOX_VNC_PORT_MAPPING=true -export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-playwright:latest" +export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-browser:latest" ``` When `YAO_SANDBOX_VNC_PORT_MAPPING=true`: @@ -1323,7 +1323,7 @@ On Linux (native Docker), this option is not needed as container IPs are directl ### Yao Backend ✅ 完成 -- [x] `sandbox/docker/playwright/Dockerfile` - Playwright + VNC image +- [x] `sandbox/docker/browser/Dockerfile` - Browser + VNC image - [x] `sandbox/docker/desktop/Dockerfile` - Full desktop + VNC image - [x] `sandbox/docker/vnc/start-vnc.sh` - Shared VNC startup script - [x] `sandbox/docker/vnc/entrypoint-vnc.sh` - VNC entrypoint @@ -1355,7 +1355,7 @@ yao/sandbox/ │ ├── claude/ │ │ ├── Dockerfile │ │ └── Dockerfile.full -│ ├── playwright/ # Playwright + VNC image +│ ├── browser/ # Browser + VNC image │ │ └── Dockerfile │ ├── desktop/ # XFCE Desktop + VNC image │ │ └── Dockerfile @@ -1408,12 +1408,12 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and | Image | DISPLAY | VNC Visible | Agent Gets Output | |-------|---------|-------------|-------------------| | sandbox-claude | ❌ | N/A | ✅ | -| sandbox-claude-playwright | ✅ :99 | Browser window | ✅ | +| sandbox-claude-browser | ✅ :99 | Browser window | ✅ | | sandbox-claude-desktop | ✅ :99 | Browser + Desktop apps | ✅ | ### What Users See in VNC -| Operation | sandbox-claude-playwright | sandbox-claude-desktop | +| Operation | sandbox-claude-browser | sandbox-claude-desktop | |-----------|--------------------------|------------------------| | Browser automation | ✅ Visible | ✅ Visible | | File operations | ❌ | ✅ (open Thunar) | @@ -1435,7 +1435,7 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and ### A. Image Comparison -| Feature | sandbox-claude | sandbox-claude-playwright | sandbox-claude-desktop | +| Feature | sandbox-claude | sandbox-claude-browser | sandbox-claude-desktop | |---------|---------------|--------------------------|------------------------| | Claude CLI | ✅ | ✅ | ✅ | | Node.js | ✅ | ✅ | ✅ | @@ -1453,7 +1453,7 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and What users can see and do in VNC: -| Operation | sandbox-claude-playwright | sandbox-claude-desktop | +| Operation | sandbox-claude-browser | sandbox-claude-desktop | |-----------|--------------------------|------------------------| | Browser navigation | ✅ See | ✅ See | | Browser clicks/typing | ✅ See | ✅ See | @@ -1481,7 +1481,7 @@ What users can see and do in VNC: | Component | Location | Changes | |-----------|----------|---------| -| Docker Images | `sandbox/docker/playwright/`, `sandbox/docker/desktop/` | NEW | +| Docker Images | `sandbox/docker/browser/`, `sandbox/docker/desktop/` | NEW | | VNC Proxy | `sandbox/vncproxy/` | NEW | | VNC API | Yao router | NEW endpoints | | CUI | `cui/` | **No changes** (navigate action + iframe) | diff --git a/sandbox/README.md b/sandbox/README.md index cd51ed35..79c61fca 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -60,7 +60,7 @@ cd sandbox/docker ./build.sh claude # Build VNC-enabled images -./build.sh playwright # Playwright + Fluxbox + VNC +./build.sh browser # Browser (Playwright) + Fluxbox + VNC ./build.sh desktop # XFCE Desktop + VNC # Build all images @@ -120,7 +120,7 @@ Docker Desktop runs containers in a LinuxKit VM, so container IPs are not direct ```bash export YAO_SANDBOX_VNC_PORT_MAPPING=true -export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-playwright:latest" +export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-browser:latest" ``` When enabled, VNC ports (6080, 5900) are automatically mapped to random available host ports on `127.0.0.1`. @@ -132,7 +132,7 @@ When enabled, VNC ports (6080, 5900) are automatically mapped to random availabl | `yaoapp/sandbox-base:latest` | ❌ | Base image with git, curl, yao-bridge | | `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 | | `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 | -| `yaoapp/sandbox-claude-playwright:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) | +| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) | | `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) | ## IPC Communication @@ -172,7 +172,7 @@ sandbox/ ├── docker/ # Dockerfiles and build script │ ├── base/ │ ├── claude/ -│ ├── playwright/ # Playwright + VNC image +│ ├── browser/ # Browser (Playwright) + VNC image │ ├── desktop/ # XFCE Desktop + VNC image │ ├── vnc/ # Shared VNC scripts │ └── build.sh diff --git a/sandbox/docker/playwright/Dockerfile b/sandbox/docker/browser/Dockerfile similarity index 89% rename from sandbox/docker/playwright/Dockerfile rename to sandbox/docker/browser/Dockerfile index e131744f..eace8b69 100644 --- a/sandbox/docker/playwright/Dockerfile +++ b/sandbox/docker/browser/Dockerfile @@ -1,8 +1,9 @@ -# Claude sandbox with Playwright browser automation + VNC preview -# Image: sandbox-claude-playwright +# Claude sandbox with browser automation + VNC preview +# Image: sandbox-claude-browser # Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI) -# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright browsers +# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright/Puppeteer browsers # +# Lightweight browser environment for web automation tasks # Supports both amd64 and arm64 architectures ARG REGISTRY=yaoapp @@ -66,8 +67,8 @@ RUN mkdir -p /usr/local/share/yao # Note: Build context should be sandbox/docker/, so paths are relative to that COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh -COPY playwright/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh -COPY playwright/config/yao-logo.png /usr/local/share/yao/yao-logo.png +COPY browser/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh +COPY browser/config/yao-logo.png /usr/local/share/yao/yao-logo.png RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh /usr/local/bin/setup-fluxbox.sh # Environment variables for VNC diff --git a/sandbox/docker/playwright/config/yao-logo.png b/sandbox/docker/browser/config/yao-logo.png similarity index 100% rename from sandbox/docker/playwright/config/yao-logo.png rename to sandbox/docker/browser/config/yao-logo.png diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh index c1a126de..8b727582 100755 --- a/sandbox/docker/build.sh +++ b/sandbox/docker/build.sh @@ -107,14 +107,14 @@ case $TOOL in ;; claude-vnc) echo "" - echo "=== Building Claude VNC images (Playwright + Desktop) ===" - build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH" + echo "=== Building Claude VNC images (Browser + Desktop) ===" + build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH" ;; - playwright) + browser) echo "" - echo "=== Building Claude Playwright image ===" - build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH" + echo "=== Building Claude Browser image ===" + build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH" ;; desktop) echo "" @@ -133,18 +133,18 @@ case $TOOL in build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH" # Claude VNC variants - build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH" + build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH" # Cursor (uncomment when ready) # build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH" ;; *) echo "Unknown tool: $TOOL" - echo "Usage: $0 [claude|claude-vnc|playwright|desktop|cursor|all] [true|false]" + echo "Usage: $0 [claude|claude-vnc|browser|desktop|cursor|all] [true|false]" echo " $0 claude # Build Claude images locally" echo " $0 claude true # Build and push Claude images" - echo " $0 claude-vnc # Build Claude VNC images (Playwright + Desktop)" - echo " $0 playwright # Build Claude Playwright image only" + echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)" + echo " $0 browser # Build Claude Browser image only" echo " $0 desktop # Build Claude Desktop image only" echo " $0 all true # Build and push all images" exit 1 @@ -165,11 +165,11 @@ if [ "$PUSH" = "true" ]; then echo " - ${REGISTRY}/sandbox-claude-full:latest" ;; claude-vnc) - echo " - ${REGISTRY}/sandbox-claude-playwright:latest" + echo " - ${REGISTRY}/sandbox-claude-browser:latest" echo " - ${REGISTRY}/sandbox-claude-desktop:latest" ;; - playwright) - echo " - ${REGISTRY}/sandbox-claude-playwright:latest" + browser) + echo " - ${REGISTRY}/sandbox-claude-browser:latest" ;; desktop) echo " - ${REGISTRY}/sandbox-claude-desktop:latest" @@ -177,7 +177,7 @@ if [ "$PUSH" = "true" ]; then all) echo " - ${REGISTRY}/sandbox-claude:latest" echo " - ${REGISTRY}/sandbox-claude-full:latest" - echo " - ${REGISTRY}/sandbox-claude-playwright:latest" + echo " - ${REGISTRY}/sandbox-claude-browser:latest" echo " - ${REGISTRY}/sandbox-claude-desktop:latest" ;; esac diff --git a/sandbox/docker/vnc/start-vnc.sh b/sandbox/docker/vnc/start-vnc.sh index 57f0a03c..873c6c8a 100644 --- a/sandbox/docker/vnc/start-vnc.sh +++ b/sandbox/docker/vnc/start-vnc.sh @@ -1,6 +1,6 @@ #!/bin/bash # VNC services startup script -# Shared by sandbox-claude-playwright and sandbox-claude-desktop +# Shared by sandbox-claude-browser and sandbox-claude-desktop # Starts: Xvfb (virtual display) + Window Manager + x11vnc + websockify (noVNC) set -e diff --git a/sandbox/helpers.go b/sandbox/helpers.go index 013d4989..e466ae78 100644 --- a/sandbox/helpers.go +++ b/sandbox/helpers.go @@ -58,8 +58,10 @@ func parseMemory(s string) int64 { } } -// parseLS parses ls -la --time-style=+%s output to []FileInfo -func parseLS(output string) []FileInfo { +// 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 @@ -69,9 +71,19 @@ func parseLS(output string) []FileInfo { continue } - // Parse ls -la output: drwxr-xr-x 2 user group 4096 1234567890 filename + // 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) - if len(fields) < 7 { + + 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 } @@ -85,12 +97,21 @@ func parseLS(output string) []FileInfo { // Parse size size, _ := strconv.ParseInt(fields[4], 10, 64) - // Parse timestamp (Unix epoch) - timestamp, _ := strconv.ParseInt(fields[5], 10, 64) - modTime := time.Unix(timestamp, 0) + // Parse timestamp and get filename + var modTime time.Time + var name string - // Get filename (may contain spaces) - name := strings.Join(fields[6:], " ") + 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 == ".." { diff --git a/sandbox/helpers_test.go b/sandbox/helpers_test.go index 1ae2c41a..878d2207 100644 --- a/sandbox/helpers_test.go +++ b/sandbox/helpers_test.go @@ -65,41 +65,83 @@ func TestMapToSlice(t *testing.T) { } func TestParseLS(t *testing.T) { - output := `total 8 + // Test GNU ls output with --time-style=+%s (Unix epoch timestamp) + t.Run("GNU_ls_with_time_style", func(t *testing.T) { + output := `total 8 drwxr-xr-x 2 sandbox sandbox 4096 1700000000 dir1 -rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt ` - result := parseLS(output) + result := parseLS(output, true) - if len(result) != 3 { - t.Fatalf("expected 3 items, got %d", len(result)) - } + if len(result) != 3 { + t.Fatalf("expected 3 items, got %d", len(result)) + } - // Check dir1 - if result[0].Name != "dir1" { - t.Errorf("expected name 'dir1', got '%s'", result[0].Name) - } - if !result[0].IsDir { - t.Errorf("expected dir1 to be a directory") - } + // Check dir1 + if result[0].Name != "dir1" { + t.Errorf("expected name 'dir1', got '%s'", result[0].Name) + } + if !result[0].IsDir { + t.Errorf("expected dir1 to be a directory") + } - // Check file1.txt - if result[1].Name != "file1.txt" { - t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name) - } - if result[1].Size != 100 { - t.Errorf("expected size 100, got %d", result[1].Size) - } - if result[1].IsDir { - t.Errorf("expected file1.txt to be a file, not directory") - } + // Check file1.txt + if result[1].Name != "file1.txt" { + t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name) + } + if result[1].Size != 100 { + t.Errorf("expected size 100, got %d", result[1].Size) + } + if result[1].IsDir { + t.Errorf("expected file1.txt to be a file, not directory") + } - // Check link1 - if result[2].Name != "link1 -> file1.txt" { - t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name) - } + // Check link1 + if result[2].Name != "link1 -> file1.txt" { + t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name) + } + }) + + // Test BusyBox/basic ls output (Alpine-style) + t.Run("BusyBox_ls_basic", func(t *testing.T) { + output := `total 8 +drwxr-xr-x 2 sandbox sandbox 4096 Jan 1 12:00 dir1 +-rw-r--r-- 1 sandbox sandbox 100 Jan 1 12:01 file1.txt +lrwxrwxrwx 1 sandbox sandbox 10 Jan 1 12:02 link1 -> file1.txt +` + + result := parseLS(output, false) + + if len(result) != 3 { + t.Fatalf("expected 3 items, got %d", len(result)) + } + + // Check dir1 + if result[0].Name != "dir1" { + t.Errorf("expected name 'dir1', got '%s'", result[0].Name) + } + if !result[0].IsDir { + t.Errorf("expected dir1 to be a directory") + } + + // Check file1.txt + if result[1].Name != "file1.txt" { + t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name) + } + if result[1].Size != 100 { + t.Errorf("expected size 100, got %d", result[1].Size) + } + if result[1].IsDir { + t.Errorf("expected file1.txt to be a file, not directory") + } + + // Check link1 (in BusyBox format, symlink target is separate field) + if result[2].Name != "link1 -> file1.txt" { + t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name) + } + }) } func TestParseStat(t *testing.T) { diff --git a/sandbox/manager.go b/sandbox/manager.go index 80c8a545..e9236aa5 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -811,12 +811,22 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro // ListDir lists directory contents in container func (m *Manager) ListDir(ctx context.Context, name, path string) ([]FileInfo, error) { + // Try GNU ls with --time-style first (for GNU coreutils) result, err := m.Exec(ctx, name, []string{"ls", "-la", "--time-style=+%s", path}, nil) + if err == nil && result.ExitCode == 0 { + return parseLS(result.Stdout, true), nil + } + + // Fall back to basic ls (for BusyBox/Alpine) + result, err = m.Exec(ctx, name, []string{"ls", "-la", path}, nil) if err != nil { return nil, err } + if result.ExitCode != 0 { + return nil, fmt.Errorf("ls failed: %s", result.Stderr) + } - return parseLS(result.Stdout), nil + return parseLS(result.Stdout, false), nil } // Stat returns file info diff --git a/sandbox/proxy/convert.go b/sandbox/proxy/convert.go index 02879b43..8cc62a54 100644 --- a/sandbox/proxy/convert.go +++ b/sandbox/proxy/convert.go @@ -313,12 +313,14 @@ func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse { result.StopReason = &stopReason } - // Convert usage + // Convert usage (always include - Claude CLI expects usage to be present) if resp.Usage != nil { result.Usage = &Usage{ InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, } + } else { + result.Usage = &Usage{InputTokens: 0, OutputTokens: 0} } return result diff --git a/sandbox/proxy/main.go b/sandbox/proxy/main.go index 903ca969..e40afb75 100644 --- a/sandbox/proxy/main.go +++ b/sandbox/proxy/main.go @@ -310,6 +310,7 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body var toolCalls []*ToolCallAccumulator var contentIndex int var finishReason string + var lastUsage *Usage // Track the latest usage data from backend for scanner.Scan() { line := scanner.Text() @@ -332,19 +333,13 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body } if len(chunk.Choices) == 0 { - // Usage update at the end + // Usage update at the end - save it but don't send message_delta yet + // It will be included in the final message_delta below if chunk.Usage != nil { - usageEvent := AnthropicStreamEvent{ - Type: "message_delta", - Delta: &DeltaContent{ - StopReason: &finishReason, - }, - Usage: &Usage{ - InputTokens: chunk.Usage.PromptTokens, - OutputTokens: chunk.Usage.CompletionTokens, - }, + lastUsage = &Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, } - s.writeSSE(w, flusher, usageEvent) } continue } @@ -452,15 +447,20 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body s.writeSSE(w, flusher, stopEvent) } - // Send message_delta with stop reason + // Send message_delta with stop reason and usage + // Claude CLI expects usage to always be present in message_delta if finishReason == "" { finishReason = "end_turn" } + if lastUsage == nil { + lastUsage = &Usage{InputTokens: 0, OutputTokens: 0} + } deltaEvent := AnthropicStreamEvent{ Type: "message_delta", Delta: &DeltaContent{ StopReason: &finishReason, }, + Usage: lastUsage, } s.writeSSE(w, flusher, deltaEvent) diff --git a/sandbox/vncproxy/proxy.go b/sandbox/vncproxy/proxy.go index 4a671733..f968f1d9 100644 --- a/sandbox/vncproxy/proxy.go +++ b/sandbox/vncproxy/proxy.go @@ -380,11 +380,11 @@ func (p *Proxy) checkVNCReady(ctx context.Context, containerName string) bool { // serveNoVNCPage serves an inline HTML page with noVNC client func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) { viewOnlyStr := "false" - modeIndicator := "可交互" + modeIndicator := "Interactive" modeColor := "#4CAF50" if viewOnly { viewOnlyStr = "true" - modeIndicator = "只读模式" + modeIndicator = "View Only" modeColor = "#FF9800" } @@ -423,7 +423,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
-
正在连接 Sandbox...
+
Connecting to Sandbox...
@@ -454,25 +454,25 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, const data = await res.json(); if (data.status === 'ready') { - status.textContent = '正在初始化显示...'; + status.textContent = 'Initializing display...'; connectVNC(); return; } if (data.status === 'starting') { - status.textContent = 'Sandbox 启动中...'; + status.textContent = 'Sandbox starting...'; } else if (data.status === 'not_supported') { - showError('此 Sandbox 不支持可视化'); + showError('This Sandbox does not support visualization'); return; } else { - status.textContent = '等待容器就绪...'; + status.textContent = 'Waiting for container to be ready...'; } retryCount++; - retryCountEl.textContent = '重试 ' + retryCount + '/' + maxRetries; + retryCountEl.textContent = 'Retry ' + retryCount + '/' + maxRetries; if (retryCount >= maxRetries) { - showError('连接超时,请稍后重试'); + showError('Connection timeout, please try again later'); return; } @@ -480,7 +480,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, } catch (err) { retryCount++; if (retryCount >= maxRetries) { - showError('无法连接到服务器'); + showError('Unable to connect to server'); return; } setTimeout(checkStatus, 1000); @@ -518,9 +518,9 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, screen.style.display = 'none'; modeIndicator.style.display = 'none'; if (e.detail.clean) { - status.textContent = '连接已关闭'; + status.textContent = 'Connection closed'; } else { - showError('连接已断开'); + showError('Connection lost'); } }); }