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:
parent
0a965c383d
commit
28363b973e
13 changed files with 190 additions and 104 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == ".." {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
|||
<body>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div id="status">正在连接 Sandbox...</div>
|
||||
<div id="status">Connecting to Sandbox...</div>
|
||||
<div id="retry-count"></div>
|
||||
<div id="error"></div>
|
||||
</div>
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue