feat(sandbox/v2): enhance connector integration and VNC configuration

- Refactored the sandbox initialization process to resolve the connector before obtaining the Computer, allowing for the injection of OPENAI_PROXY_* environment variables.
- Updated the GetComputer and BuildCreateOptions functions to accept an optional connector parameter for improved environment variable management.
- Standardized the VNC configuration by replacing SANDBOX_VNC_ENABLED with VNC_ENABLED across Dockerfiles and related scripts for consistency.
- Enhanced the VNC service startup script to check the new VNC_ENABLED variable, ensuring proper service initialization.

Made-with: Cursor
This commit is contained in:
Max 2026-03-10 23:28:31 +08:00
parent 6407b8531f
commit 5ec7801689
11 changed files with 94 additions and 82 deletions

View file

@ -39,15 +39,22 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
stdCtx := ctx.Context
// 1. Obtain Computer.
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
// 1. Resolve connector (before Computer so proxy env vars can be injected).
conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
}
// 2. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
}
_ = identifier
// 2. Get Runner.
// 3. Get Runner.
runner, err := sandboxv2.Get(cfg.Runner.Name)
if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
@ -55,14 +62,6 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
}
// 3. Resolve connector.
conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
}
// 4. Resolve skills directory.
skillsDir := ""
if ast.Path != "" {

View file

@ -24,7 +24,6 @@ const (
// ClaudeRunner implements the Runner interface for Claude CLI (mode=cli).
type ClaudeRunner struct {
mode string
proxyReady bool
hasMCP bool
mcpToolPattern string // e.g. "mcp__yao__*,mcp__github__*"
servicePort int
@ -60,30 +59,6 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
})
}
// Runner-specific: write proxy config and start proxy (for non-anthropic connectors).
if req.Connector != nil && !req.Connector.Is(connector.ANTHROPIC) {
setting := req.Connector.Setting()
host, _ := setting["host"].(string)
key, _ := setting["key"].(string)
model, _ := setting["model"].(string)
if host != "" && key != "" {
proxyJSON := buildProxyConfig(host, key, model, setting)
steps = append(steps, types.PrepareStep{
Action: "file",
Path: ".yao/proxy.json",
Content: proxyJSON,
Once: true,
})
steps = append(steps, types.PrepareStep{
Action: "exec",
Cmd: "which start-claude-proxy && start-claude-proxy || true",
Once: true,
IgnoreError: true,
})
r.proxyReady = true
}
}
// Runner-specific: write MCP config.
if len(req.MCPServers) > 0 {
r.hasMCP = true
@ -174,7 +149,6 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
}
// Cleanup kills any remaining claude processes.
// mode=service: don't kill the service daemon (lifecycle manages it), only clean proxy.
// mode=cli: kill all claude CLI processes.
func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) error {
if computer == nil {
@ -185,10 +159,6 @@ func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) err
computer.Exec(ctx, []string{"sh", "-c", "pkill -f 'claude' || true"})
}
if r.proxyReady {
computer.Exec(ctx, []string{"sh", "-c", "pkill -f 'claude-proxy' || true"})
}
return nil
}
@ -320,32 +290,9 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, isContinuation
return []string{"bash", "-c", bash.String()}, env
}
// buildProxyConfig creates the claude-proxy configuration JSON.
func buildProxyConfig(host, key, model string, setting map[string]any) []byte {
backendURL := connector.BuildAPIURL(host, "/chat/completions")
config := map[string]any{
"backend": backendURL,
"api_key": key,
"model": model,
}
opts := make(map[string]any)
for k, v := range setting {
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
opts[k] = v
}
}
if len(opts) > 0 {
config["options"] = opts
}
data, _ := json.MarshalIndent(config, "", " ")
return data
}
// buildMCPConfig creates the .mcp.json for Claude CLI based on declared servers.
// Each server delegates to "tai call" which bridges stdio JSON-RPC to Yao gRPC.
// Each server delegates to "tai mcp" which implements the standard MCP protocol
// over stdio and bridges to Yao gRPC with authentication.
// Connection is configured via env vars (YAO_GRPC_ADDR, YAO_TOKEN, etc.)
// injected by the sandbox infrastructure at container start.
func buildMCPConfig(servers []types.MCPServer) []byte {
@ -357,13 +304,13 @@ func buildMCPConfig(servers []types.MCPServer) []byte {
}
mcpServers[name] = map[string]any{
"command": "tai",
"args": []string{"call"},
"args": []string{"mcp"},
}
}
if len(mcpServers) == 0 {
mcpServers["yao"] = map[string]any{
"command": "tai",
"args": []string{"call"},
"args": []string{"mcp"},
}
}
config := map[string]any{"mcpServers": mcpServers}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"log"
"github.com/yaoapp/gou/connector"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
@ -37,8 +38,9 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
}
// GetComputer obtains or creates a Computer for the current request.
// An optional connector may be passed to inject OPENAI_PROXY_* env vars.
// Returns the Computer, the resolved identifier, and any error.
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (infra.Computer, string, error) {
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
ownerID := resolveOwnerID(ctx)
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, ctx.Metadata)
@ -84,7 +86,11 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
}
// Create new box.
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID)
var c connector.Connector
if len(conn) > 0 {
c = conn[0]
}
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID, c)
if err != nil {
return nil, identifier, fmt.Errorf("build create options: %w", err)
}

View file

@ -1,11 +1,13 @@
package sandboxv2
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
)
@ -19,8 +21,9 @@ func resolveEnvRef(value string) string {
}
// BuildCreateOptions converts a SandboxConfig into the V2 infrastructure
// CreateOptions. Pure runtime mapping — no file-system or DSL access.
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string) (infra.CreateOptions, error) {
// CreateOptions. An optional connector is used to inject OPENAI_PROXY_*
// environment variables when the connector is OpenAI-compatible (non-Anthropic).
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string, conn ...connector.Connector) (infra.CreateOptions, error) {
opts := infra.CreateOptions{
ID: identifier,
Owner: ownerID,
@ -116,9 +119,69 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
}
}
if opts.Env == nil {
opts.Env = make(map[string]string)
}
// Inject OPENAI_PROXY_* when connector is OpenAI-compatible (non-Anthropic).
// The a2o proxy inside the container translates Anthropic API → OpenAI API.
if len(conn) > 0 && conn[0] != nil && !conn[0].Is(connector.ANTHROPIC) {
injectProxyEnv(opts.Env, conn[0])
}
// Inject VNC_* environment variables from config.
if cfg.Computer.VNC.Enabled {
opts.Env["VNC_ENABLED"] = "true"
if cfg.Computer.VNC.Password != "" {
opts.Env["VNC_PASSWORD"] = resolveEnvRef(cfg.Computer.VNC.Password)
}
if cfg.Computer.VNC.Resolution != "" {
opts.Env["VNC_RESOLUTION"] = cfg.Computer.VNC.Resolution
}
if cfg.Computer.VNC.ViewOnly {
opts.Env["VNC_VIEW_ONLY"] = "true"
}
}
return opts, nil
}
// injectProxyEnv extracts backend URL, model, and API key from an
// OpenAI-compatible connector's settings and writes them as OPENAI_PROXY_*
// environment variables into env.
func injectProxyEnv(env map[string]string, conn connector.Connector) {
settings := conn.Setting()
if settings == nil {
return
}
if host, ok := settings["host"].(string); ok && host != "" {
env["OPENAI_PROXY_BACKEND"] = host
}
if model, ok := settings["model"].(string); ok && model != "" {
env["OPENAI_PROXY_MODEL"] = model
}
if key, ok := settings["key"].(string); ok && key != "" {
env["OPENAI_PROXY_API_KEY"] = key
}
// Forward extra options as JSON.
extra := make(map[string]interface{})
for k, v := range settings {
switch k {
case "host", "model", "key", "proxy", "type":
continue
default:
extra[k] = v
}
}
if len(extra) > 0 {
if data, err := json.Marshal(extra); err == nil {
env["OPENAI_PROXY_OPTIONS"] = string(data)
}
}
}
// parseMemory converts a human-readable memory string to bytes.
// Supported formats: "4GB", "4G", "4g", "512MB", "512M", "512m", "1024KB", "1024K", "1024".
func parseMemory(s string) (int64, error) {

View file

@ -76,7 +76,7 @@ ENV DISPLAY=:99
ENV VNC_PORT=5900
ENV NOVNC_PORT=6080
ENV RESOLUTION=1920x1080x24
ENV SANDBOX_VNC_ENABLED=true
ENV VNC_ENABLED=true
ENV SANDBOX_DESKTOP=fluxbox
# Node.js environment - ensure global modules are accessible

View file

@ -127,7 +127,7 @@ ENV DISPLAY=:99
ENV VNC_PORT=5900
ENV NOVNC_PORT=6080
ENV RESOLUTION=1920x1080x24
ENV SANDBOX_VNC_ENABLED=true
ENV VNC_ENABLED=true
ENV SANDBOX_DESKTOP=fluxbox
# Node.js environment

View file

@ -85,7 +85,7 @@ ENV DISPLAY=:99
ENV VNC_PORT=5900
ENV NOVNC_PORT=6080
ENV RESOLUTION=1920x1080x24
ENV SANDBOX_VNC_ENABLED=true
ENV VNC_ENABLED=true
ENV SANDBOX_DESKTOP=xfce
# Set hostname for XFCE panel display
ENV HOSTNAME="Yao Sandbox"

View file

@ -5,7 +5,7 @@
# ============================================
# VNC Services Startup
# ============================================
if [ "$SANDBOX_VNC_ENABLED" = "true" ]; then
if [ "$VNC_ENABLED" = "true" ]; then
echo "[Entrypoint] Starting VNC services..."
/usr/local/bin/start-vnc.sh &
# Wait for VNC to initialize

View file

@ -350,8 +350,7 @@ func (m *Manager) createContainer(ctx context.Context, opts CreateOptions) (*Con
"6080/tcp": struct{}{}, // noVNC websockify
"5900/tcp": struct{}{}, // VNC
}
// Enable SANDBOX_VNC_ENABLED environment variable
containerConfig.Env = append(containerConfig.Env, "SANDBOX_VNC_ENABLED=true")
containerConfig.Env = append(containerConfig.Env, "VNC_ENABLED=true")
// Map to random available ports on 127.0.0.1
hostConfig.PortBindings = nat.PortMap{

View file

@ -323,10 +323,8 @@ func (p *Proxy) checkVNCEnabled(ctx context.Context, containerName string) bool
return false
}
// Check environment variables for VNC_ENABLED or SANDBOX_VNC_ENABLED
for _, env := range info.Config.Env {
if strings.HasPrefix(env, "SANDBOX_VNC_ENABLED=true") ||
strings.HasPrefix(env, "VNC_ENABLED=true") {
if strings.HasPrefix(env, "VNC_ENABLED=true") {
return true
}
}

View file

@ -59,7 +59,7 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts
shmSize = 256 * 1024 * 1024
}
hostCfg.ShmSize = shmSize
cfg.Env = append(cfg.Env, "SANDBOX_VNC_ENABLED=true")
cfg.Env = append(cfg.Env, "VNC_ENABLED=true")
if addVNCPorts {
for _, p := range []int{6080, 5900} {