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>
This commit is contained in:
Max 2026-02-06 12:31:07 +08:00
parent 0a965c383d
commit 28363b973e
13 changed files with 190 additions and 104 deletions

View file

@ -561,11 +561,21 @@ func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, hand
switch eventType { switch eventType {
case "content_block_start": case "content_block_start":
// Check if this is a tool_use block starting // Handle new content blocks
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}} // Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"|"text",...}}}
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok { if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
blockType, _ := contentBlock["type"].(string) 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) toolName, _ := contentBlock["name"].(string)
blockIndex := 0 blockIndex := 0
if idx, ok := event["index"].(float64); ok { if idx, ok := event["index"].(float64); ok {

View file

@ -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) │ │ │ │ (No VNC) │ │ (Browser + VNC) │ │ desktop (Full) │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ • Claude CLI │ │ • Claude CLI │ │ • Claude CLI │ │ │ │ • 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 | | Image | VNC | Use Case | Size | Memory |
|-------|-----|----------|------|--------| |-------|-----|----------|------|--------|
| `sandbox-claude` | ❌ | Code execution, scripts, CLI tasks | ~700MB | 2GB | | `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 | | `sandbox-claude-desktop` | ✅ | Full visibility, any GUI app | ~2.5GB | 4GB |
### User Selection Flow ### User Selection Flow
@ -105,7 +105,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users
│ │ ○ Standard (sandbox-claude) ││ │ │ ○ Standard (sandbox-claude) ││
│ │ Code execution, no GUI. Lightweight and fast. ││ │ │ Code execution, no GUI. Lightweight and fast. ││
│ │ ││ │ │ ││
│ │ ○ Browser (sandbox-claude-playwright) ⭐ ││ │ │ ○ Browser (sandbox-claude-browser) ⭐ ││
│ │ Playwright browser automation with VNC preview. ││ │ │ Playwright browser automation with VNC preview. ││
│ │ See browser operations in real-time. ││ │ │ See browser operations in real-time. ││
│ │ ││ │ │ ││
@ -130,11 +130,11 @@ Three image variants sharing the same VNC infrastructure:
ubuntu:24.04 ubuntu:24.04
└── sandbox-base:latest (~200MB) └── sandbox-base:latest (~200MB)
└── sandbox-claude:latest (~700MB) # No VNC └── 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 └── 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. For browser automation tasks with real-time visibility.
@ -219,7 +219,7 @@ type ImageType string
const ( const (
ImageTypeClaude ImageType = "claude" // No VNC ImageTypeClaude ImageType = "claude" // No VNC
ImageTypePlaywright ImageType = "playwright" // Browser + VNC ImageTypeBrowser ImageType = "browser" // Browser + VNC
ImageTypeDesktop ImageType = "desktop" // Full desktop + VNC ImageTypeDesktop ImageType = "desktop" // Full desktop + VNC
) )
@ -231,7 +231,7 @@ var ImageConfigs = map[ImageType]struct {
CPU float64 CPU float64
}{ }{
ImageTypeClaude: {"yaoapp/sandbox-claude:latest", false, "2g", 1.0}, 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}, ImageTypeDesktop: {"yaoapp/sandbox-claude-desktop:latest", true, "4g", 2.0},
} }
``` ```
@ -353,7 +353,7 @@ sandbox:
**Available Images**: **Available Images**:
- `yaoapp/sandbox-claude:latest` - No VNC, lightweight - `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 - `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. **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 ## Implementation Details
### Dockerfile.playwright ### Dockerfile.browser (browser/Dockerfile)
```dockerfile ```dockerfile
ARG REGISTRY=yaoapp ARG REGISTRY=yaoapp
@ -1309,7 +1309,7 @@ Docker Desktop runs containers inside a LinuxKit VM, so container IPs (`172.17.0
```bash ```bash
# Enable VNC port mapping for local development # Enable VNC port mapping for local development
export YAO_SANDBOX_VNC_PORT_MAPPING=true 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`: 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 ✅ 完成 ### 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/desktop/Dockerfile` - Full desktop + VNC image
- [x] `sandbox/docker/vnc/start-vnc.sh` - Shared VNC startup script - [x] `sandbox/docker/vnc/start-vnc.sh` - Shared VNC startup script
- [x] `sandbox/docker/vnc/entrypoint-vnc.sh` - VNC entrypoint - [x] `sandbox/docker/vnc/entrypoint-vnc.sh` - VNC entrypoint
@ -1355,7 +1355,7 @@ yao/sandbox/
│ ├── claude/ │ ├── claude/
│ │ ├── Dockerfile │ │ ├── Dockerfile
│ │ └── Dockerfile.full │ │ └── Dockerfile.full
│ ├── playwright/ # Playwright + VNC image │ ├── browser/ # Browser + VNC image
│ │ └── Dockerfile │ │ └── Dockerfile
│ ├── desktop/ # XFCE Desktop + VNC image │ ├── desktop/ # XFCE Desktop + VNC image
│ │ └── Dockerfile │ │ └── Dockerfile
@ -1408,12 +1408,12 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and
| Image | DISPLAY | VNC Visible | Agent Gets Output | | Image | DISPLAY | VNC Visible | Agent Gets Output |
|-------|---------|-------------|-------------------| |-------|---------|-------------|-------------------|
| sandbox-claude | ❌ | N/A | ✅ | | sandbox-claude | ❌ | N/A | ✅ |
| sandbox-claude-playwright | ✅ :99 | Browser window | ✅ | | sandbox-claude-browser | ✅ :99 | Browser window | ✅ |
| sandbox-claude-desktop | ✅ :99 | Browser + Desktop apps | ✅ | | sandbox-claude-desktop | ✅ :99 | Browser + Desktop apps | ✅ |
### What Users See in VNC ### What Users See in VNC
| Operation | sandbox-claude-playwright | sandbox-claude-desktop | | Operation | sandbox-claude-browser | sandbox-claude-desktop |
|-----------|--------------------------|------------------------| |-----------|--------------------------|------------------------|
| Browser automation | ✅ Visible | ✅ Visible | | Browser automation | ✅ Visible | ✅ Visible |
| File operations | ❌ | ✅ (open Thunar) | | File operations | ❌ | ✅ (open Thunar) |
@ -1435,7 +1435,7 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and
### A. Image Comparison ### A. Image Comparison
| Feature | sandbox-claude | sandbox-claude-playwright | sandbox-claude-desktop | | Feature | sandbox-claude | sandbox-claude-browser | sandbox-claude-desktop |
|---------|---------------|--------------------------|------------------------| |---------|---------------|--------------------------|------------------------|
| Claude CLI | ✅ | ✅ | ✅ | | Claude CLI | ✅ | ✅ | ✅ |
| Node.js | ✅ | ✅ | ✅ | | 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: 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 navigation | ✅ See | ✅ See |
| Browser clicks/typing | ✅ See | ✅ See | | Browser clicks/typing | ✅ See | ✅ See |
@ -1481,7 +1481,7 @@ What users can see and do in VNC:
| Component | Location | Changes | | 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 Proxy | `sandbox/vncproxy/` | NEW |
| VNC API | Yao router | NEW endpoints | | VNC API | Yao router | NEW endpoints |
| CUI | `cui/` | **No changes** (navigate action + iframe) | | CUI | `cui/` | **No changes** (navigate action + iframe) |

View file

@ -60,7 +60,7 @@ cd sandbox/docker
./build.sh claude ./build.sh claude
# Build VNC-enabled images # Build VNC-enabled images
./build.sh playwright # Playwright + Fluxbox + VNC ./build.sh browser # Browser (Playwright) + Fluxbox + VNC
./build.sh desktop # XFCE Desktop + VNC ./build.sh desktop # XFCE Desktop + VNC
# Build all images # Build all images
@ -120,7 +120,7 @@ Docker Desktop runs containers in a LinuxKit VM, so container IPs are not direct
```bash ```bash
export YAO_SANDBOX_VNC_PORT_MAPPING=true 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`. 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-base:latest` | ❌ | Base image with git, curl, yao-bridge |
| `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 | | `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 |
| `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 | | `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) | | `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
## IPC Communication ## IPC Communication
@ -172,7 +172,7 @@ sandbox/
├── docker/ # Dockerfiles and build script ├── docker/ # Dockerfiles and build script
│ ├── base/ │ ├── base/
│ ├── claude/ │ ├── claude/
│ ├── playwright/ # Playwright + VNC image │ ├── browser/ # Browser (Playwright) + VNC image
│ ├── desktop/ # XFCE Desktop + VNC image │ ├── desktop/ # XFCE Desktop + VNC image
│ ├── vnc/ # Shared VNC scripts │ ├── vnc/ # Shared VNC scripts
│ └── build.sh │ └── build.sh

View file

@ -1,8 +1,9 @@
# Claude sandbox with Playwright browser automation + VNC preview # Claude sandbox with browser automation + VNC preview
# Image: sandbox-claude-playwright # Image: sandbox-claude-browser
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI) # 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 # Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp 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 # 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/start-vnc.sh /usr/local/bin/start-vnc.sh
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
COPY playwright/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh COPY browser/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/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 RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh /usr/local/bin/setup-fluxbox.sh
# Environment variables for VNC # Environment variables for VNC

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -107,14 +107,14 @@ case $TOOL in
;; ;;
claude-vnc) claude-vnc)
echo "" echo ""
echo "=== Building Claude VNC images (Playwright + Desktop) ===" echo "=== Building Claude VNC images (Browser + Desktop) ==="
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
;; ;;
playwright) browser)
echo "" echo ""
echo "=== Building Claude Playwright image ===" echo "=== Building Claude Browser image ==="
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH" build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
;; ;;
desktop) desktop)
echo "" echo ""
@ -133,18 +133,18 @@ case $TOOL in
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH" build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH" build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
# Claude VNC variants # 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" build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
# Cursor (uncomment when ready) # Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH" # build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;; ;;
*) *)
echo "Unknown tool: $TOOL" 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 # Build Claude images locally"
echo " $0 claude true # Build and push Claude images" echo " $0 claude true # Build and push Claude images"
echo " $0 claude-vnc # Build Claude VNC images (Playwright + Desktop)" echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
echo " $0 playwright # Build Claude Playwright image only" echo " $0 browser # Build Claude Browser image only"
echo " $0 desktop # Build Claude Desktop image only" echo " $0 desktop # Build Claude Desktop image only"
echo " $0 all true # Build and push all images" echo " $0 all true # Build and push all images"
exit 1 exit 1
@ -165,11 +165,11 @@ if [ "$PUSH" = "true" ]; then
echo " - ${REGISTRY}/sandbox-claude-full:latest" echo " - ${REGISTRY}/sandbox-claude-full:latest"
;; ;;
claude-vnc) claude-vnc)
echo " - ${REGISTRY}/sandbox-claude-playwright:latest" echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest" echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;; ;;
playwright) browser)
echo " - ${REGISTRY}/sandbox-claude-playwright:latest" echo " - ${REGISTRY}/sandbox-claude-browser:latest"
;; ;;
desktop) desktop)
echo " - ${REGISTRY}/sandbox-claude-desktop:latest" echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
@ -177,7 +177,7 @@ if [ "$PUSH" = "true" ]; then
all) all)
echo " - ${REGISTRY}/sandbox-claude:latest" echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full: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" echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;; ;;
esac esac

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# VNC services startup script # 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) # Starts: Xvfb (virtual display) + Window Manager + x11vnc + websockify (noVNC)
set -e set -e

View file

@ -58,8 +58,10 @@ func parseMemory(s string) int64 {
} }
} }
// parseLS parses ls -la --time-style=+%s output to []FileInfo // parseLS parses ls -la output to []FileInfo
func parseLS(output string) []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") lines := strings.Split(strings.TrimSpace(output), "\n")
var result []FileInfo var result []FileInfo
@ -69,9 +71,19 @@ func parseLS(output string) []FileInfo {
continue 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) 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 continue
} }
@ -85,12 +97,21 @@ func parseLS(output string) []FileInfo {
// Parse size // Parse size
size, _ := strconv.ParseInt(fields[4], 10, 64) size, _ := strconv.ParseInt(fields[4], 10, 64)
// Parse timestamp (Unix epoch) // Parse timestamp and get filename
timestamp, _ := strconv.ParseInt(fields[5], 10, 64) var modTime time.Time
modTime := time.Unix(timestamp, 0) var name string
// Get filename (may contain spaces) if hasTimeStyle {
name := strings.Join(fields[6:], " ") // 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 .. // Skip . and ..
if name == "." || name == ".." { if name == "." || name == ".." {

View file

@ -65,41 +65,83 @@ func TestMapToSlice(t *testing.T) {
} }
func TestParseLS(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 drwxr-xr-x 2 sandbox sandbox 4096 1700000000 dir1
-rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt -rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt
` `
result := parseLS(output) result := parseLS(output, true)
if len(result) != 3 { if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result)) t.Fatalf("expected 3 items, got %d", len(result))
} }
// Check dir1 // Check dir1
if result[0].Name != "dir1" { if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name) t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
} }
if !result[0].IsDir { if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory") t.Errorf("expected dir1 to be a directory")
} }
// Check file1.txt // Check file1.txt
if result[1].Name != "file1.txt" { if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name) t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
} }
if result[1].Size != 100 { if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size) t.Errorf("expected size 100, got %d", result[1].Size)
} }
if result[1].IsDir { if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory") t.Errorf("expected file1.txt to be a file, not directory")
} }
// Check link1 // Check link1
if result[2].Name != "link1 -> file1.txt" { if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name) 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) { func TestParseStat(t *testing.T) {

View file

@ -811,12 +811,22 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro
// ListDir lists directory contents in container // ListDir lists directory contents in container
func (m *Manager) ListDir(ctx context.Context, name, path string) ([]FileInfo, error) { 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) 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 { if err != nil {
return nil, err 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 // Stat returns file info

View file

@ -313,12 +313,14 @@ func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
result.StopReason = &stopReason result.StopReason = &stopReason
} }
// Convert usage // Convert usage (always include - Claude CLI expects usage to be present)
if resp.Usage != nil { if resp.Usage != nil {
result.Usage = &Usage{ result.Usage = &Usage{
InputTokens: resp.Usage.PromptTokens, InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens, OutputTokens: resp.Usage.CompletionTokens,
} }
} else {
result.Usage = &Usage{InputTokens: 0, OutputTokens: 0}
} }
return result return result

View file

@ -310,6 +310,7 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
var toolCalls []*ToolCallAccumulator var toolCalls []*ToolCallAccumulator
var contentIndex int var contentIndex int
var finishReason string var finishReason string
var lastUsage *Usage // Track the latest usage data from backend
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@ -332,19 +333,13 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
} }
if len(chunk.Choices) == 0 { 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 { if chunk.Usage != nil {
usageEvent := AnthropicStreamEvent{ lastUsage = &Usage{
Type: "message_delta", InputTokens: chunk.Usage.PromptTokens,
Delta: &DeltaContent{ OutputTokens: chunk.Usage.CompletionTokens,
StopReason: &finishReason,
},
Usage: &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
},
} }
s.writeSSE(w, flusher, usageEvent)
} }
continue continue
} }
@ -452,15 +447,20 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
s.writeSSE(w, flusher, stopEvent) 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 == "" { if finishReason == "" {
finishReason = "end_turn" finishReason = "end_turn"
} }
if lastUsage == nil {
lastUsage = &Usage{InputTokens: 0, OutputTokens: 0}
}
deltaEvent := AnthropicStreamEvent{ deltaEvent := AnthropicStreamEvent{
Type: "message_delta", Type: "message_delta",
Delta: &DeltaContent{ Delta: &DeltaContent{
StopReason: &finishReason, StopReason: &finishReason,
}, },
Usage: lastUsage,
} }
s.writeSSE(w, flusher, deltaEvent) s.writeSSE(w, flusher, deltaEvent)

View file

@ -380,11 +380,11 @@ func (p *Proxy) checkVNCReady(ctx context.Context, containerName string) bool {
// serveNoVNCPage serves an inline HTML page with noVNC client // serveNoVNCPage serves an inline HTML page with noVNC client
func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) { func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) {
viewOnlyStr := "false" viewOnlyStr := "false"
modeIndicator := "可交互" modeIndicator := "Interactive"
modeColor := "#4CAF50" modeColor := "#4CAF50"
if viewOnly { if viewOnly {
viewOnlyStr = "true" viewOnlyStr = "true"
modeIndicator = "只读模式" modeIndicator = "View Only"
modeColor = "#FF9800" modeColor = "#FF9800"
} }
@ -423,7 +423,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<body> <body>
<div id="loading"> <div id="loading">
<div class="spinner"></div> <div class="spinner"></div>
<div id="status">正在连接 Sandbox...</div> <div id="status">Connecting to Sandbox...</div>
<div id="retry-count"></div> <div id="retry-count"></div>
<div id="error"></div> <div id="error"></div>
</div> </div>
@ -454,25 +454,25 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
const data = await res.json(); const data = await res.json();
if (data.status === 'ready') { if (data.status === 'ready') {
status.textContent = '正在初始化显示...'; status.textContent = 'Initializing display...';
connectVNC(); connectVNC();
return; return;
} }
if (data.status === 'starting') { if (data.status === 'starting') {
status.textContent = 'Sandbox 启动中...'; status.textContent = 'Sandbox starting...';
} else if (data.status === 'not_supported') { } else if (data.status === 'not_supported') {
showError('Sandbox 不支持可视化'); showError('This Sandbox does not support visualization');
return; return;
} else { } else {
status.textContent = '等待容器就绪...'; status.textContent = 'Waiting for container to be ready...';
} }
retryCount++; retryCount++;
retryCountEl.textContent = '重试 ' + retryCount + '/' + maxRetries; retryCountEl.textContent = 'Retry ' + retryCount + '/' + maxRetries;
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
showError('连接超时请稍后重试'); showError('Connection timeout, please try again later');
return; return;
} }
@ -480,7 +480,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
} catch (err) { } catch (err) {
retryCount++; retryCount++;
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
showError('无法连接到服务器'); showError('Unable to connect to server');
return; return;
} }
setTimeout(checkStatus, 1000); setTimeout(checkStatus, 1000);
@ -518,9 +518,9 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
screen.style.display = 'none'; screen.style.display = 'none';
modeIndicator.style.display = 'none'; modeIndicator.style.display = 'none';
if (e.detail.clean) { if (e.detail.clean) {
status.textContent = '连接已关闭'; status.textContent = 'Connection closed';
} else { } else {
showError('连接已断开'); showError('Connection lost');
} }
}); });
} }