Add sandbox ID and VNC URL methods to sandbox executor
- Implement GetSandboxID method to return a mock sandbox ID for testing. - Add GetVNCUrl method to return an empty string for VNC access in tests. - Update SandboxExecutor interface to include new methods for sandbox identification and VNC URL retrieval. - Enhance context creation to set sandbox ID and VNC URL properties in the sandbox instance. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
55164c5665
commit
0a965c383d
9 changed files with 200 additions and 31 deletions
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
openapiSandbox "github.com/yaoapp/yao/openapi/sandbox"
|
||||
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||
"rogchap.com/v8go"
|
||||
)
|
||||
|
|
@ -22,6 +23,12 @@ type SandboxExecutor interface {
|
|||
|
||||
// Workspace info
|
||||
GetWorkDir() string
|
||||
|
||||
// Sandbox identification
|
||||
GetSandboxID() string
|
||||
|
||||
// VNC access (returns empty string if not available)
|
||||
GetVNCUrl() string
|
||||
}
|
||||
|
||||
// SetSandboxExecutor sets the sandbox executor for this context
|
||||
|
|
@ -54,6 +61,8 @@ func (ctx *Context) newSandboxObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
|||
sandboxObj.Set("WriteFile", ctx.sandboxWriteFileMethod(iso))
|
||||
sandboxObj.Set("ListDir", ctx.sandboxListDirMethod(iso))
|
||||
sandboxObj.Set("Exec", ctx.sandboxExecMethod(iso))
|
||||
sandboxObj.Set("GetVNCUrl", ctx.sandboxGetVNCUrlMethod(iso))
|
||||
sandboxObj.Set("GetSandboxID", ctx.sandboxGetSandboxIDMethod(iso))
|
||||
|
||||
return sandboxObj
|
||||
}
|
||||
|
|
@ -72,6 +81,19 @@ func (ctx *Context) createSandboxInstance(v8ctx *v8go.Context) *v8go.Value {
|
|||
// Set workdir as a property
|
||||
sandboxTemplate.Set("workdir", ctx.sandboxExecutor.GetWorkDir())
|
||||
|
||||
// Set sandbox_id as a property
|
||||
sandboxID := ctx.sandboxExecutor.GetSandboxID()
|
||||
sandboxTemplate.Set("sandbox_id", sandboxID)
|
||||
|
||||
// Set vnc_url as a property (empty string if not available)
|
||||
// GetVNCUrl returns sandbox ID if VNC is supported, empty otherwise
|
||||
vncSandboxID := ctx.sandboxExecutor.GetVNCUrl()
|
||||
if vncSandboxID != "" {
|
||||
sandboxTemplate.Set("vnc_url", openapiSandbox.GetVNCClientURL(vncSandboxID))
|
||||
} else {
|
||||
sandboxTemplate.Set("vnc_url", "")
|
||||
}
|
||||
|
||||
instance, err := sandboxTemplate.NewInstance(v8ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
|
|
@ -233,3 +255,48 @@ func (ctx *Context) sandboxExecMethod(iso *v8go.Isolate) *v8go.FunctionTemplate
|
|||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// sandboxGetVNCUrlMethod implements ctx.sandbox.GetVNCUrl()
|
||||
func (ctx *Context) sandboxGetVNCUrlMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
|
||||
if ctx.sandboxExecutor == nil {
|
||||
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||
}
|
||||
|
||||
// GetVNCUrl returns sandbox ID if VNC is supported, empty otherwise
|
||||
vncSandboxID := ctx.sandboxExecutor.GetVNCUrl()
|
||||
vncUrl := ""
|
||||
if vncSandboxID != "" {
|
||||
vncUrl = openapiSandbox.GetVNCClientURL(vncSandboxID)
|
||||
}
|
||||
|
||||
jsVal, err := v8go.NewValue(iso, vncUrl)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// sandboxGetSandboxIDMethod implements ctx.sandbox.GetSandboxID()
|
||||
func (ctx *Context) sandboxGetSandboxIDMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
|
||||
if ctx.sandboxExecutor == nil {
|
||||
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||
}
|
||||
|
||||
sandboxID := ctx.sandboxExecutor.GetSandboxID()
|
||||
|
||||
jsVal, err := v8go.NewValue(iso, sandboxID)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ func (e *realSandboxExecutor) GetWorkDir() string {
|
|||
return e.workDir
|
||||
}
|
||||
|
||||
func (e *realSandboxExecutor) GetSandboxID() string {
|
||||
// Extract sandbox ID from container name (format: yao-sandbox-{userID}-{chatID})
|
||||
// For tests, just return a mock ID
|
||||
return "test-user-test-chat"
|
||||
}
|
||||
|
||||
func (e *realSandboxExecutor) GetVNCUrl() string {
|
||||
// Tests don't use VNC, return empty
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestJsSandboxNotAvailable tests ctx.sandbox when not configured
|
||||
func TestJsSandboxNotAvailable(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,12 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
|
|||
// Create or get container
|
||||
// Note: IPC session is created by manager.createContainer, socket is already bind mounted
|
||||
ctx := context.Background()
|
||||
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID)
|
||||
createOpts := infraSandbox.CreateOptions{
|
||||
UserID: execOpts.UserID,
|
||||
ChatID: execOpts.ChatID,
|
||||
Image: execOpts.Image,
|
||||
}
|
||||
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID, createOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
|
|
@ -1073,6 +1078,36 @@ func (e *Executor) GetWorkDir() string {
|
|||
return e.workDir
|
||||
}
|
||||
|
||||
// GetSandboxID returns the sandbox ID (userID-chatID)
|
||||
func (e *Executor) GetSandboxID() string {
|
||||
if e.opts == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", e.opts.UserID, e.opts.ChatID)
|
||||
}
|
||||
|
||||
// GetVNCUrl returns the VNC preview URL path
|
||||
// Returns empty string if VNC is not enabled for this sandbox image
|
||||
func (e *Executor) GetVNCUrl() string {
|
||||
if e.opts == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if the image supports VNC (playwright or desktop variants)
|
||||
imageName := e.opts.Image
|
||||
if imageName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// VNC is only available for playwright and desktop images
|
||||
if !strings.Contains(imageName, "playwright") && !strings.Contains(imageName, "desktop") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return only the sandbox ID, the full URL is constructed by openapi/sandbox.GetVNCClientURL()
|
||||
return e.GetSandboxID()
|
||||
}
|
||||
|
||||
// Close releases the executor resources and removes the container
|
||||
// Note: IPC session is managed by sandbox.Manager.Remove()
|
||||
func (e *Executor) Close() error {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ type Executor interface {
|
|||
// GetWorkDir returns the container workspace directory
|
||||
GetWorkDir() string
|
||||
|
||||
// GetSandboxID returns the sandbox ID (userID-chatID)
|
||||
GetSandboxID() string
|
||||
|
||||
// GetVNCUrl returns the VNC preview URL path (e.g., /api/__yao/vnc/{sandboxID}/)
|
||||
// Returns empty string if VNC is not enabled for this sandbox image
|
||||
GetVNCUrl() string
|
||||
|
||||
// Close releases container resources
|
||||
Close() error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
|||
app.Attach(group.Group("/app"), openapi.OAuth)
|
||||
|
||||
// Sandbox handlers (VNC proxy for visual browser automation)
|
||||
sandbox.SetPathPrefix(baseURL)
|
||||
sandbox.Attach(group.Group("/sandbox"), openapi.OAuth)
|
||||
|
||||
// Custom handlers (Defined by developer)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
|
|
@ -19,14 +21,13 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
// Initialize VNC proxy lazily on first request
|
||||
// This avoids startup errors if Docker is not available
|
||||
|
||||
// VNC status endpoint (requires auth)
|
||||
// VNC status endpoint
|
||||
group.GET("/:id/vnc", oauth.Guard, handleVNCStatus)
|
||||
|
||||
// VNC client page (requires auth)
|
||||
// VNC client page
|
||||
group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient)
|
||||
|
||||
// VNC WebSocket proxy (requires auth)
|
||||
// Note: WebSocket upgrade happens after auth middleware
|
||||
// VNC WebSocket proxy
|
||||
group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket)
|
||||
}
|
||||
|
||||
|
|
@ -96,3 +97,23 @@ func Close() error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathPrefix stores the router path prefix for sandbox endpoints
|
||||
var pathPrefix string = "/v1/sandbox"
|
||||
|
||||
// SetPathPrefix sets the path prefix for sandbox URLs
|
||||
// Called during router setup with the actual OpenAPI base URL
|
||||
func SetPathPrefix(prefix string) {
|
||||
pathPrefix = strings.TrimSuffix(prefix, "/") + "/sandbox"
|
||||
}
|
||||
|
||||
// GetVNCClientURL returns the API VNC client page URL
|
||||
// sandboxID is the sandbox identifier (userID-chatID)
|
||||
// Returns the URL path like "/v1/sandbox/{id}/vnc/client"
|
||||
// Note: For CUI navigation, use "$dashboard/sandbox/{id}" directly with sandbox_id
|
||||
func GetVNCClientURL(sandboxID string) string {
|
||||
if sandboxID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/vnc/client", pathPrefix, sandboxID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,9 +185,17 @@ func (m *Manager) Close() error {
|
|||
}
|
||||
|
||||
// GetOrCreate returns existing container or creates new one
|
||||
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) {
|
||||
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string, opts ...CreateOptions) (*Container, error) {
|
||||
name := containerName(userID, chatID)
|
||||
|
||||
// Extract options if provided
|
||||
var createOpts CreateOptions
|
||||
if len(opts) > 0 {
|
||||
createOpts = opts[0]
|
||||
}
|
||||
createOpts.UserID = userID
|
||||
createOpts.ChatID = chatID
|
||||
|
||||
// Check if container already exists (fast path)
|
||||
if c, ok := m.containers.Load(name); ok {
|
||||
cont := c.(*Container)
|
||||
|
|
@ -243,7 +251,7 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
|||
}
|
||||
|
||||
// Create new container
|
||||
cont, err := m.createContainer(ctx, userID, chatID)
|
||||
cont, err := m.createContainer(ctx, createOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -256,11 +264,19 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
|||
}
|
||||
|
||||
// createContainer creates a new Docker container
|
||||
func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*Container, error) {
|
||||
func (m *Manager) createContainer(ctx context.Context, opts CreateOptions) (*Container, error) {
|
||||
userID := opts.UserID
|
||||
chatID := opts.ChatID
|
||||
name := containerName(userID, chatID)
|
||||
|
||||
// Use image from options or fall back to config default
|
||||
image := opts.Image
|
||||
if image == "" {
|
||||
image = m.config.Image
|
||||
}
|
||||
|
||||
// Ensure image exists, pull if not
|
||||
if err := m.ensureImage(ctx, m.config.Image); err != nil {
|
||||
if err := m.ensureImage(ctx, image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +299,7 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
|
|||
|
||||
// Container configuration
|
||||
containerConfig := &container.Config{
|
||||
Image: m.config.Image,
|
||||
Image: image,
|
||||
Cmd: []string{"sleep", "infinity"},
|
||||
WorkingDir: m.config.ContainerWorkDir,
|
||||
User: m.config.ContainerUser, // Empty string uses image default
|
||||
|
|
@ -310,7 +326,7 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
|
|||
|
||||
// VNC port mapping for Docker Desktop (macOS/Windows)
|
||||
// Only enable for VNC-capable images (playwright/desktop) when config is enabled
|
||||
if m.config.VNCPortMapping && isVNCImage(m.config.Image) {
|
||||
if m.config.VNCPortMapping && isVNCImage(image) {
|
||||
// Expose VNC ports in container config
|
||||
containerConfig.ExposedPorts = nat.PortSet{
|
||||
"6080/tcp": struct{}{}, // noVNC websockify
|
||||
|
|
|
|||
|
|
@ -66,3 +66,10 @@ const (
|
|||
StatusRunning = "running"
|
||||
StatusStopped = "stopped"
|
||||
)
|
||||
|
||||
// CreateOptions contains options for creating a container
|
||||
type CreateOptions struct {
|
||||
UserID string // User identifier (required)
|
||||
ChatID string // Chat/session identifier (required)
|
||||
Image string // Docker image to use (optional, falls back to config default)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,15 +172,21 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Upgrade HTTP to WebSocket
|
||||
// Upgrade HTTP to WebSocket (client side)
|
||||
clientConn, err := p.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return // Upgrader already sent error response
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Connect to container's websockify
|
||||
targetConn, err := net.DialTimeout("tcp", targetAddr, 5*time.Second)
|
||||
// Connect to container's websockify via WebSocket (not raw TCP)
|
||||
// websockify expects WebSocket connections at /websockify path with binary subprotocol
|
||||
wsURL := fmt.Sprintf("ws://%s/websockify", targetAddr)
|
||||
dialer := websocket.Dialer{
|
||||
Subprotocols: []string{"binary"},
|
||||
HandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
targetConn, _, err := dialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
clientConn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "VNC connection failed"))
|
||||
|
|
@ -188,8 +194,8 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
defer targetConn.Close()
|
||||
|
||||
// Bidirectional proxy
|
||||
done := make(chan struct{})
|
||||
// Bidirectional WebSocket proxy
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
// Client -> Container
|
||||
go func() {
|
||||
|
|
@ -199,10 +205,8 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
if messageType == websocket.BinaryMessage {
|
||||
if _, err := targetConn.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
if err := targetConn.WriteMessage(messageType, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
@ -210,13 +214,12 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
// Container -> Client
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := targetConn.Read(buf)
|
||||
messageType, data, err := targetConn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := clientConn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||
if err := clientConn.WriteMessage(messageType, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -390,7 +393,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VNC - %s</title>
|
||||
<title>Sandbox - %s</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%%; height: 100%%; overflow: hidden; background: #1e1e1e; }
|
||||
|
|
@ -420,7 +423,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
|
|||
<body>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div id="status">正在连接 VNC...</div>
|
||||
<div id="status">正在连接 Sandbox...</div>
|
||||
<div id="retry-count"></div>
|
||||
<div id="error"></div>
|
||||
</div>
|
||||
|
|
@ -428,7 +431,8 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
|
|||
<div id="screen"></div>
|
||||
|
||||
<script type="module">
|
||||
import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/lib/rfb.js';
|
||||
// noVNC RFB class for VNC connections
|
||||
import RFB from 'https://cdn.skypack.dev/novnc-core';
|
||||
|
||||
const sandboxID = '%s';
|
||||
const wsPath = '%s';
|
||||
|
|
@ -450,15 +454,15 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
|
|||
const data = await res.json();
|
||||
|
||||
if (data.status === 'ready') {
|
||||
status.textContent = '正在初始化 VNC 客户端...';
|
||||
status.textContent = '正在初始化显示...';
|
||||
connectVNC();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'starting') {
|
||||
status.textContent = 'VNC 服务启动中...';
|
||||
status.textContent = 'Sandbox 启动中...';
|
||||
} else if (data.status === 'not_supported') {
|
||||
showError('此容器不支持 VNC');
|
||||
showError('此 Sandbox 不支持可视化');
|
||||
return;
|
||||
} else {
|
||||
status.textContent = '等待容器就绪...';
|
||||
|
|
@ -514,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 = 'VNC 连接已关闭';
|
||||
status.textContent = '连接已关闭';
|
||||
} else {
|
||||
showError('VNC 连接断开');
|
||||
showError('连接已断开');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue