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 {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 == ".." {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue