Enhance Sandbox Integration and CI Workflow for AI Tests

- Added steps to pull necessary Docker images for sandbox testing in both CI workflows.
- Updated the AI test execution to utilize sandbox configurations, ensuring proper environment setup.
- Introduced sandbox initialization in the Assistant's Stream method, allowing for execution of coding agents like Claude and Cursor.
- Enhanced context management to support sandbox execution, improving flexibility in handling agent operations.
This commit is contained in:
Max 2026-01-30 17:41:06 +08:00
parent f265aee974
commit cd5cf32a20
30 changed files with 4874 additions and 19 deletions

View file

@ -541,8 +541,19 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Sandbox Test Images
run: |
docker pull alpine:latest
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
- name: Run AI Tests (agent, aigc) - name: Run AI Tests (agent, aigc)
run: make unit-test-ai env:
YAO_SANDBOX_WORKSPACE: ${{ runner.temp }}/sandbox/workspace
YAO_SANDBOX_IPC: ${{ runner.temp }}/sandbox/ipc
run: |
export YAO_SANDBOX_CONTAINER_USER="$(id -u):$(id -g)"
make unit-test-ai
- name: Codecov Report - name: Codecov Report
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4

View file

@ -435,8 +435,19 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Sandbox Test Images
run: |
docker pull alpine:latest
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
- name: Run AI Tests (agent, aigc) - name: Run AI Tests (agent, aigc)
run: make unit-test-ai env:
YAO_SANDBOX_WORKSPACE: ${{ runner.temp }}/sandbox/workspace
YAO_SANDBOX_IPC: ${{ runner.temp }}/sandbox/ipc
run: |
export YAO_SANDBOX_CONTAINER_USER="$(id -u):$(id -g)"
make unit-test-ai
- name: Codecov Report - name: Codecov Report
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4

View file

@ -12,6 +12,7 @@ import (
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
) )
// Stream stream the agent // Stream stream the agent
@ -150,6 +151,33 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
} }
ctx.Logger.PhaseComplete("History") ctx.Logger.PhaseComplete("History")
// ================================================
// Initialize Sandbox (if configured)
// ================================================
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
var sandboxExecutor agentsandbox.Executor
var sandboxCleanup func()
if ast.HasSandbox() {
ctx.Logger.Phase("Sandbox")
var err error
sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Set sandbox executor in context so hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor
ctx.SetSandboxExecutor(sandboxExecutor)
ctx.Logger.PhaseComplete("Sandbox")
}
// Ensure sandbox cleanup on exit
defer func() {
if sandboxCleanup != nil {
sandboxCleanup()
}
}()
// ================================================ // ================================================
// Execute Create Hook // Execute Create Hook
// ================================================ // ================================================
@ -254,7 +282,14 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
}) })
// Execute the LLM streaming call // Execute the LLM streaming call
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) // Choose between sandbox execution or direct LLM execution
if ast.HasSandbox() {
// Sandbox execution path (Claude CLI, Cursor CLI, etc.)
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor)
} else {
// Direct LLM execution path
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
}
if err != nil { if err != nil {
finalStatus = context.ResumeStatusFailed finalStatus = context.ResumeStatusFailed
finalError = err finalError = err

View file

@ -714,6 +714,15 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
assistant.Workflow = wf assistant.Workflow = wf
} }
// sandbox (for coding agents like Claude CLI, Cursor CLI)
if sandbox, has := data["sandbox"]; has {
sb, err := store.ToSandbox(sandbox)
if err != nil {
return nil, err
}
assistant.Sandbox = sb
}
// uses (wrapper configurations for vision, audio, etc.) // uses (wrapper configurations for vision, audio, etc.)
// Merge hierarchy: global uses < assistant uses // Merge hierarchy: global uses < assistant uses
if uses, has := data["uses"]; has { if uses, has := data["uses"]; has {

210
agent/assistant/sandbox.go Normal file
View file

@ -0,0 +1,210 @@
package assistant
import (
"fmt"
"path/filepath"
"sync"
"time"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
traceTypes "github.com/yaoapp/yao/trace/types"
)
var (
sandboxManager *infraSandbox.Manager
sandboxManagerOnce sync.Once
sandboxManagerErr error
)
// GetSandboxManager returns the sandbox manager singleton
// Returns nil and error if sandbox is not configured or Docker is unavailable
func GetSandboxManager() (*infraSandbox.Manager, error) {
sandboxManagerOnce.Do(func() {
// Create sandbox config from Yao config
cfg := &infraSandbox.Config{}
// Use YAO_DATA_ROOT for workspace and IPC paths
dataRoot := config.Conf.DataRoot
if dataRoot != "" {
cfg.Init(dataRoot)
}
// Create manager (will fail if Docker is not available)
sandboxManager, sandboxManagerErr = infraSandbox.NewManager(cfg)
})
return sandboxManager, sandboxManagerErr
}
// HasSandbox returns true if the assistant has sandbox configuration
func (ast *Assistant) HasSandbox() bool {
return ast.Sandbox != nil && ast.Sandbox.Command != ""
}
// initSandbox initializes the sandbox executor
// Returns the full Executor (for LLM calls), cleanup function, and any error
// This is called BEFORE hooks so that hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) {
// Get sandbox manager (singleton)
manager, err := GetSandboxManager()
if err != nil {
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
return nil, nil, fmt.Errorf("sandbox manager not available: %w", err)
}
if manager == nil {
return nil, nil, fmt.Errorf("sandbox manager not initialized")
}
// Build executor options from assistant config
execOpts, err := ast.buildSandboxOptions(ctx, opts)
if err != nil {
ctx.Logger.Error("Failed to build sandbox options: %v", err)
return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err)
}
// Log sandbox creation
ctx.Logger.Info("Creating sandbox container for command: %s", ast.Sandbox.Command)
// Add trace for sandbox creation
trace, traceErr := ctx.Trace()
if traceErr == nil && trace != nil {
trace.Info("Creating sandbox container...")
}
// Send loading message to user
loadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "Preparing sandbox environment...",
},
}
loadingMsgID, _ := ctx.SendStream(loadingMsg)
// Create executor (container starts here)
executor, err := agentsandbox.New(manager, execOpts)
if err != nil {
ctx.Logger.Error("Sandbox creation failed: %v", err)
if traceErr == nil && trace != nil {
trace.Error("Sandbox creation failed: %v", err)
}
// End loading message
if loadingMsgID != "" {
ctx.End(loadingMsgID)
}
return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err)
}
// Log sandbox ready
ctx.Logger.Info("Sandbox container ready")
if traceErr == nil && trace != nil {
trace.Info("Sandbox container ready")
}
// End loading message
if loadingMsgID != "" {
ctx.End(loadingMsgID)
}
// Return cleanup function
cleanup := func() {
if err := executor.Close(); err != nil {
ctx.Logger.Error("Failed to close sandbox executor: %v", err)
}
}
return executor, cleanup, nil
}
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
// This is called when ast.Sandbox is configured
// NOTE: The executor is passed directly from initSandbox, no type assertion needed
func (ast *Assistant) executeSandboxStream(
ctx *context.Context,
completionMessages []context.Message,
agentNode traceTypes.Node,
streamHandler message.StreamFunc,
executor agentsandbox.Executor,
) (*context.CompletionResponse, error) {
// Mark the agentNode as used to avoid unused variable error
_ = agentNode
if executor == nil {
return nil, fmt.Errorf("sandbox executor not initialized (call initSandbox first)")
}
// Log sandbox execution
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
// Execute LLM call via sandbox
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
if err != nil {
return nil, fmt.Errorf("sandbox execution failed: %w", err)
}
return resp, nil
}
// buildSandboxOptions builds executor options from assistant config
func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Options) (*agentsandbox.Options, error) {
if ast.Sandbox == nil {
return nil, fmt.Errorf("sandbox configuration is required")
}
execOpts := &agentsandbox.Options{
Command: ast.Sandbox.Command,
Image: ast.Sandbox.Image,
MaxMemory: ast.Sandbox.MaxMemory,
MaxCPU: ast.Sandbox.MaxCPU,
Arguments: ast.Sandbox.Arguments,
}
// Parse timeout string (e.g., "10m") to duration
if ast.Sandbox.Timeout != "" {
timeout, err := time.ParseDuration(ast.Sandbox.Timeout)
if err != nil {
return nil, fmt.Errorf("invalid timeout format: %w", err)
}
execOpts.Timeout = timeout
}
// Set user and chat IDs for workspace isolation
if ctx.Authorized != nil && ctx.Authorized.UserID != "" {
execOpts.UserID = ctx.Authorized.UserID
} else {
execOpts.UserID = "anonymous"
}
execOpts.ChatID = ctx.ChatID
// Set skills directory (auto-resolved from assistant path)
if ast.Path != "" {
execOpts.SkillsDir = filepath.Join(ast.Path, "skills")
}
// Resolve connector settings
conn, _, err := ast.GetConnector(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to get connector: %w", err)
}
setting := conn.Setting()
if host, ok := setting["host"].(string); ok {
execOpts.ConnectorHost = host
}
if key, ok := setting["key"].(string); ok {
execOpts.ConnectorKey = key
}
if model, ok := setting["model"].(string); ok {
execOpts.Model = model
}
// Build MCP config if needed
// TODO: implement MCP config building for sandbox
return execOpts, nil
}

View file

@ -0,0 +1,82 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/testutils"
)
// TestSandboxDebugHasSandbox tests the HasSandbox method directly
func TestSandboxDebugHasSandbox(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
testCases := []struct {
name string
assistantID string
expectTrue bool
}{
{"BasicSandbox", "tests.sandbox.basic", true},
{"HooksSandbox", "tests.sandbox.hooks", true},
{"FullSandbox", "tests.sandbox.full", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ast, err := assistant.Get(tc.assistantID)
require.NoError(t, err, "Failed to get assistant %s", tc.assistantID)
// Check Sandbox struct
t.Logf("Assistant ID: %s", ast.ID)
t.Logf("Sandbox: %+v", ast.Sandbox)
if ast.Sandbox != nil {
t.Logf("Sandbox.Command: %q", ast.Sandbox.Command)
t.Logf("Sandbox.Timeout: %s", ast.Sandbox.Timeout)
t.Logf("Sandbox.Image: %s", ast.Sandbox.Image)
t.Logf("Sandbox.Arguments: %v", ast.Sandbox.Arguments)
}
// Check HasSandbox
hasSandbox := ast.HasSandbox()
t.Logf("HasSandbox() = %v", hasSandbox)
if tc.expectTrue {
assert.True(t, hasSandbox, "Expected HasSandbox() to be true for %s", tc.assistantID)
} else {
assert.False(t, hasSandbox, "Expected HasSandbox() to be false for %s", tc.assistantID)
}
})
}
}
// TestSandboxDebugPrompts tests if Prompts is set (affects execution path)
func TestSandboxDebugPrompts(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("tests.sandbox.basic")
require.NoError(t, err)
t.Logf("Assistant ID: %s", ast.ID)
t.Logf("Prompts: %v", ast.Prompts)
t.Logf("MCP: %v", ast.MCP)
t.Logf("HasSandbox: %v", ast.HasSandbox())
// The condition in agent.go is:
// if ast.Prompts != nil || ast.MCP != nil {
// // ... execute LLM
// if ast.HasSandbox() {
// // sandbox path
// } else {
// // direct LLM path
// }
// }
// So we need Prompts or MCP to be non-nil
if ast.Prompts == nil && ast.MCP == nil {
t.Log("WARNING: Neither Prompts nor MCP is set, LLM phase will be skipped entirely!")
}
}

View file

@ -0,0 +1,329 @@
package assistant_test
import (
stdContext "context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSandboxE2EContext creates a Context for sandbox E2E testing
// Uses unique chatID to avoid container name conflicts
func newSandboxE2EContext(chatIDPrefix, assistantID string) *context.Context {
// Generate unique chatID using timestamp to avoid container conflicts
chatID := fmt.Sprintf("%s-%d", chatIDPrefix, time.Now().UnixNano())
authorized := &types.AuthorizedInfo{
Subject: "sandbox-e2e-test-user",
ClientID: "sandbox-e2e-test-client",
Scope: "openid profile",
SessionID: "sandbox-e2e-test-session",
UserID: "sandbox-user-123",
TeamID: "sandbox-team-456",
TenantID: "sandbox-tenant-789",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "SandboxE2ETest/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestSandboxBasicE2E tests the basic sandbox assistant end-to-end
// This test verifies that:
// 1. Sandbox is correctly initialized
// 2. Claude CLI command is built correctly
// 3. Docker container is created and managed
func TestSandboxBasicE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the basic sandbox assistant
ast, err := assistant.Get("tests.sandbox.basic")
if err != nil {
t.Skipf("Skipping test: sandbox assistant not available: %v", err)
}
// Verify sandbox is configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
t.Logf("✓ Sandbox configured with command: %s", ast.Sandbox.Command)
// Create context
ctx := newSandboxE2EContext("sandbox-basic-e2e", "tests.sandbox.basic")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "echo hello sandbox"},
}
// Execute stream
// Note: This will fail if Docker/Claude image is not available, which is expected in CI
response, err := ast.Stream(ctx, messages)
if err != nil {
// Check if it's a Docker/sandbox availability issue
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
// Verify response
require.NotNil(t, response, "Response should not be nil")
// Verify response completion (Claude CLI should return some response)
if response.Completion != nil && response.Completion.Content != nil {
if contentStr, ok := response.Completion.Content.(string); ok && contentStr != "" {
t.Logf("✓ Response content: %s", truncateString(contentStr, 200))
} else {
t.Logf("⚠ Response content type: %T", response.Completion.Content)
}
} else {
t.Log("⚠ Response content is empty (might be expected for some commands)")
}
t.Log("✓ Basic sandbox E2E test passed")
}
// truncateString truncates a string to maxLen and adds "..." if truncated
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// TestSandboxHooksE2E tests the sandbox assistant with hooks
func TestSandboxHooksE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the hooks sandbox assistant
ast, err := assistant.Get("tests.sandbox.hooks")
if err != nil {
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
}
// Verify sandbox and hooks are configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
t.Logf("✓ Sandbox and hooks configured")
// Create context
ctx := newSandboxE2EContext("sandbox-hooks-e2e", "tests.sandbox.hooks")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "test hooks integration"},
}
// Execute stream
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
t.Log("✓ Sandbox hooks E2E test passed")
}
// TestSandboxFullE2E tests the full sandbox assistant with MCPs and Skills
func TestSandboxFullE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the full sandbox assistant
ast, err := assistant.Get("tests.sandbox.full")
if err != nil {
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
}
// Verify all components are configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
require.NotNil(t, ast.MCP, "MCP should be configured")
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
t.Logf("✓ Full sandbox configured: command=%s, MCP servers=%d",
ast.Sandbox.Command, len(ast.MCP.Servers))
// Verify MCP configuration
assert.Len(t, ast.MCP.Servers, 1)
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
t.Logf("✓ MCP server: %s with tools %v", ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
// Create context
ctx := newSandboxE2EContext("sandbox-full-e2e", "tests.sandbox.full")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "test full sandbox with MCP and skills"},
}
// Execute stream
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
t.Log("✓ Full sandbox E2E test passed")
}
// TestSandboxContextAccess tests that sandbox is accessible in hooks via ctx.sandbox
func TestSandboxContextAccess(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox context access test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the hooks sandbox assistant
ast, err := assistant.Get("tests.sandbox.hooks")
if err != nil {
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
}
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
// Create context
ctx := newSandboxE2EContext("sandbox-ctx-access", "tests.sandbox.hooks")
// Test Create Hook - it should have access to ctx.sandbox
messages := []context.Message{
{Role: context.RoleUser, Content: "test sandbox context access"},
}
// Execute Create hook directly
// This tests that the hook runs without error (sandbox operations tested within)
opts := &context.Options{}
response, _, err := ast.HookScript.Create(ctx, messages, opts)
// The hook might fail if sandbox isn't initialized yet (that's done in Stream)
// But we can at least verify the hook exists and can be called
if err != nil {
// If the error is about sandbox not being available, that's expected
// because we haven't initialized the sandbox yet
if strings.Contains(err.Error(), "sandbox") {
t.Logf("Expected error: sandbox not available in direct hook call: %v", err)
} else {
t.Fatalf("Unexpected error: %v", err)
}
}
// Response might be nil, that's okay
t.Logf("Create hook response: %v", response)
t.Log("✓ Sandbox context access test passed")
}
// TestSandboxLoadConfiguration verifies that sandbox assistants load correctly
func TestSandboxLoadConfiguration(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
testCases := []struct {
name string
assistantID string
expectSandbox bool
expectMCP bool
expectHooks bool
}{
{
name: "BasicSandbox",
assistantID: "tests.sandbox.basic",
expectSandbox: true,
expectMCP: false,
expectHooks: false,
},
{
name: "HooksSandbox",
assistantID: "tests.sandbox.hooks",
expectSandbox: true,
expectMCP: false,
expectHooks: true,
},
{
name: "FullSandbox",
assistantID: "tests.sandbox.full",
expectSandbox: true,
expectMCP: true,
expectHooks: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ast, err := assistant.Get(tc.assistantID)
if err != nil {
t.Skipf("Skipping: assistant %s not available: %v", tc.assistantID, err)
}
// Check sandbox
if tc.expectSandbox {
require.NotNil(t, ast.Sandbox, "Expected sandbox to be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
t.Logf("✓ %s: Sandbox configured with command=%s", tc.name, ast.Sandbox.Command)
}
// Check MCP
if tc.expectMCP {
require.NotNil(t, ast.MCP, "Expected MCP to be configured")
assert.True(t, len(ast.MCP.Servers) > 0, "Expected at least one MCP server")
t.Logf("✓ %s: MCP configured with %d servers", tc.name, len(ast.MCP.Servers))
}
// Check hooks
if tc.expectHooks {
require.NotNil(t, ast.HookScript, "Expected hooks to be loaded")
t.Logf("✓ %s: Hooks loaded", tc.name)
}
})
}
}

View file

@ -0,0 +1,173 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/assistant"
agentContext "github.com/yaoapp/yao/agent/context"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/agent/sandbox/claude"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestSandboxOptionsBuilding tests that sandbox options are correctly built from assistant config
func TestSandboxOptionsBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure connectors are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
// Load the full test assistant
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify sandbox is configured
require.NotNil(t, ast.Sandbox)
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify arguments are set
require.NotNil(t, ast.Sandbox.Arguments)
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
// Verify MCP configuration
require.NotNil(t, ast.MCP)
assert.Len(t, ast.MCP.Servers, 1)
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
t.Logf("Sandbox config: command=%s, timeout=%s", ast.Sandbox.Command, ast.Sandbox.Timeout)
t.Logf("Sandbox arguments: %v", ast.Sandbox.Arguments)
t.Logf("MCP servers: %v", ast.MCP.Servers)
}
// TestClaudeCommandBuilding tests that Claude CLI commands are correctly built
func TestClaudeCommandBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Create test messages
messages := []agentContext.Message{
{Role: "system", Content: "You are a helpful coding assistant."},
{Role: "user", Content: "Hello, how are you?"},
}
// Create options similar to what buildSandboxOptions would produce
opts := &claude.Options{
Command: "claude",
UserID: "test-user",
ChatID: "test-chat",
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
ConnectorKey: "test-api-key",
Model: "ep-xxxxx",
Arguments: map[string]interface{}{
"max_turns": 10,
"permission_mode": "acceptEdits",
},
}
// Build the command
cmd, env, err := claude.BuildCommand(messages, opts)
require.NoError(t, err)
// Verify command structure
assert.NotEmpty(t, cmd)
assert.Equal(t, "ccr-run", cmd[0], "Command should start with ccr-run")
t.Logf("Built command: %v", cmd)
// Verify environment variables
assert.NotEmpty(t, env)
assert.Equal(t, "https://ark.cn-beijing.volces.com/api/v3", env["CCR_API_BASE"])
assert.Equal(t, "test-api-key", env["CCR_API_KEY"])
assert.Equal(t, "ep-xxxxx", env["CCR_MODEL"])
assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"])
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant")
t.Logf("Built environment: %v", env)
}
// TestClaudeCCRConfigBuilding tests that CCR config is correctly built
func TestClaudeCCRConfigBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
opts := &claude.Options{
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
ConnectorKey: "test-api-key",
Model: "ep-xxxxx",
}
configJSON, err := claude.BuildCCRConfig(opts)
require.NoError(t, err)
require.NotEmpty(t, configJSON)
t.Logf("CCR config: %s", string(configJSON))
// Verify the JSON contains expected fields
assert.Contains(t, string(configJSON), "baseUrl")
assert.Contains(t, string(configJSON), "apiKey")
assert.Contains(t, string(configJSON), "model")
}
// TestDefaultImageSelection tests that default images are correctly selected
func TestDefaultImageSelection(t *testing.T) {
tests := []struct {
command string
expectedImage string
}{
{"claude", "yaoapp/sandbox-claude:latest"},
{"cursor", "yaoapp/sandbox-cursor:latest"},
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
image := agentsandbox.DefaultImage(tt.command)
assert.Equal(t, tt.expectedImage, image)
})
}
}
// TestSandboxCommandValidation tests that command validation works correctly
func TestSandboxCommandValidation(t *testing.T) {
tests := []struct {
command string
valid bool
}{
{"claude", true},
{"cursor", true},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := agentsandbox.IsValidCommand(tt.command)
assert.Equal(t, tt.valid, result)
})
}
}
// TestHasSandboxMethod tests the HasSandbox method on Assistant
func TestHasSandboxMethod(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Test assistant with sandbox
astWithSandbox, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
require.NoError(t, err)
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
// Test assistant without sandbox (fullfields doesn't have sandbox)
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
}

View file

@ -0,0 +1,225 @@
package assistant_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestLoadSandboxBasicAssistant tests loading the basic sandbox test assistant
func TestLoadSandboxBasicAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.basic", ast.ID)
assert.Equal(t, "Sandbox Basic Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify HasSandbox returns true
assert.True(t, ast.HasSandbox(), "HasSandbox should return true")
}
// TestLoadSandboxHooksAssistant tests loading the hooks sandbox test assistant
func TestLoadSandboxHooksAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/hooks")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.hooks", ast.ID)
assert.Equal(t, "Sandbox Hooks Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
// Verify hooks are loaded
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
}
// TestLoadSandboxFullAssistant tests loading the full sandbox test assistant with MCPs and Skills
func TestLoadSandboxFullAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.full", ast.ID)
assert.Equal(t, "Sandbox Full Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify sandbox arguments (command-specific options)
require.NotNil(t, ast.Sandbox.Arguments, "Sandbox arguments should be configured")
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
// Verify MCP configuration
require.NotNil(t, ast.MCP, "MCP should be configured")
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should be configured")
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID, "MCP server ID should be 'echo'")
assert.Contains(t, ast.MCP.Servers[0].Tools, "ping", "MCP tools should contain 'ping'")
assert.Contains(t, ast.MCP.Servers[0].Tools, "echo", "MCP tools should contain 'echo'")
// Verify hooks are loaded
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
}
// TestSandboxConfigValidation tests sandbox configuration validation
func TestSandboxConfigValidation(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
tests := []struct {
name string
path string
hasError bool
}{
{
name: "Basic sandbox config",
path: "/assistants/tests/sandbox/basic",
hasError: false,
},
{
name: "Hooks sandbox config",
path: "/assistants/tests/sandbox/hooks",
hasError: false,
},
{
name: "Full sandbox config with MCPs",
path: "/assistants/tests/sandbox/full",
hasError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ast, err := assistant.LoadPath(tt.path)
if tt.hasError {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.Sandbox)
assert.NotEmpty(t, ast.Sandbox.Command)
})
}
}
// TestSkillsDirectoryResolution tests that skills directory exists and has correct structure
// Note: Skills are auto-discovered from skills/ directory, not stored in AssistantModel
func TestSkillsDirectoryResolution(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Get app root from environment
appRoot := os.Getenv("YAO_ROOT")
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
// Verify assistant path is set
assert.NotEmpty(t, ast.Path, "Assistant path should be set")
// Build expected skills directory path
// ast.Path is like "/assistants/tests/sandbox/full"
expectedSkillsDir := filepath.Join(appRoot, ast.Path, "skills")
// Verify skills directory exists
info, err := os.Stat(expectedSkillsDir)
require.NoError(t, err, "Skills directory should exist: %s", expectedSkillsDir)
assert.True(t, info.IsDir(), "Skills path should be a directory")
// Verify skills directory structure
entries, err := os.ReadDir(expectedSkillsDir)
require.NoError(t, err, "Should be able to read skills directory")
// Find echo-test skill
var foundEchoTest bool
for _, entry := range entries {
if entry.IsDir() && entry.Name() == "echo-test" {
foundEchoTest = true
// Verify SKILL.md exists (required)
skillMdPath := filepath.Join(expectedSkillsDir, "echo-test", "SKILL.md")
_, err := os.Stat(skillMdPath)
assert.NoError(t, err, "SKILL.md should exist")
// Verify scripts directory exists (optional but we created it)
scriptsDir := filepath.Join(expectedSkillsDir, "echo-test", "scripts")
_, err = os.Stat(scriptsDir)
assert.NoError(t, err, "scripts directory should exist")
// Verify echo.sh exists
echoShPath := filepath.Join(scriptsDir, "echo.sh")
_, err = os.Stat(echoShPath)
assert.NoError(t, err, "echo.sh should exist")
break
}
}
assert.True(t, foundEchoTest, "echo-test skill should exist in skills directory")
}
// TestMCPConfiguration tests that MCP is correctly loaded for sandbox assistant
func TestMCPConfiguration(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify MCP configuration structure
require.NotNil(t, ast.MCP, "MCP should not be nil")
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should not be nil")
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
// Verify echo server configuration
echoServer := ast.MCP.Servers[0]
assert.Equal(t, "echo", echoServer.ServerID, "Server ID should be 'echo'")
assert.Len(t, echoServer.Tools, 3, "Should have 3 tools configured")
assert.Contains(t, echoServer.Tools, "ping")
assert.Contains(t, echoServer.Tools, "echo")
assert.Contains(t, echoServer.Tools, "status")
}

View file

@ -152,6 +152,15 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
memoryObj.Release() memoryObj.Release()
} }
// Sandbox object - only set if sandbox executor is available
if ctx.sandboxExecutor != nil {
sandboxObj := ctx.createSandboxInstance(v8ctx)
if sandboxObj != nil {
obj.Set("sandbox", sandboxObj)
sandboxObj.Release()
}
}
return instance.Value, nil return instance.Value, nil
} }

View file

@ -0,0 +1,235 @@
package context
import (
"context"
"github.com/yaoapp/gou/runtime/v8/bridge"
infraSandbox "github.com/yaoapp/yao/sandbox"
"rogchap.com/v8go"
)
// SandboxExecutor defines the interface for sandbox operations
// This interface is implemented by agent/sandbox.Executor
// It's defined here to avoid import cycles
type SandboxExecutor interface {
// Filesystem operations
ReadFile(ctx context.Context, path string) ([]byte, error)
WriteFile(ctx context.Context, path string, content []byte) error
ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error)
// Command execution
Exec(ctx context.Context, cmd []string) (string, error)
// Workspace info
GetWorkDir() string
}
// SetSandboxExecutor sets the sandbox executor for this context
// This should be called before hooks are executed
func (ctx *Context) SetSandboxExecutor(executor SandboxExecutor) {
ctx.sandboxExecutor = executor
}
// GetSandboxExecutor returns the sandbox executor if available
func (ctx *Context) GetSandboxExecutor() SandboxExecutor {
return ctx.sandboxExecutor
}
// HasSandbox returns true if sandbox executor is available
func (ctx *Context) HasSandbox() bool {
return ctx.sandboxExecutor != nil
}
// newSandboxObject creates the ctx.sandbox JavaScript object
// Returns nil if sandbox executor is not available
func (ctx *Context) newSandboxObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
if ctx.sandboxExecutor == nil {
return nil
}
sandboxObj := v8go.NewObjectTemplate(iso)
// Set methods
sandboxObj.Set("ReadFile", ctx.sandboxReadFileMethod(iso))
sandboxObj.Set("WriteFile", ctx.sandboxWriteFileMethod(iso))
sandboxObj.Set("ListDir", ctx.sandboxListDirMethod(iso))
sandboxObj.Set("Exec", ctx.sandboxExecMethod(iso))
return sandboxObj
}
// createSandboxInstance creates the sandbox object instance with workdir property
func (ctx *Context) createSandboxInstance(v8ctx *v8go.Context) *v8go.Value {
if ctx.sandboxExecutor == nil {
return nil
}
sandboxTemplate := ctx.newSandboxObject(v8ctx.Isolate())
if sandboxTemplate == nil {
return nil
}
// Set workdir as a property
sandboxTemplate.Set("workdir", ctx.sandboxExecutor.GetWorkDir())
instance, err := sandboxTemplate.NewInstance(v8ctx)
if err != nil {
return nil
}
return instance.Value
}
// sandboxReadFileMethod implements ctx.sandbox.ReadFile(path)
func (ctx *Context) sandboxReadFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
if len(args) < 1 {
return bridge.JsException(v8ctx, "ReadFile requires path parameter")
}
path := args[0].String()
content, err := ctx.sandboxExecutor.ReadFile(context.Background(), path)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
// Return as string
jsVal, err := v8go.NewValue(iso, string(content))
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
return jsVal
})
}
// sandboxWriteFileMethod implements ctx.sandbox.WriteFile(path, content)
func (ctx *Context) sandboxWriteFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
if len(args) < 2 {
return bridge.JsException(v8ctx, "WriteFile requires path and content parameters")
}
path := args[0].String()
content := args[1].String()
err := ctx.sandboxExecutor.WriteFile(context.Background(), path, []byte(content))
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
// Return undefined on success
return v8go.Undefined(iso)
})
}
// sandboxListDirMethod implements ctx.sandbox.ListDir(path)
func (ctx *Context) sandboxListDirMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
if len(args) < 1 {
return bridge.JsException(v8ctx, "ListDir requires path parameter")
}
path := args[0].String()
files, err := ctx.sandboxExecutor.ListDir(context.Background(), path)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
// Convert to JavaScript array of objects
result := make([]map[string]interface{}, len(files))
for i, f := range files {
result[i] = map[string]interface{}{
"name": f.Name,
"size": f.Size,
"is_dir": f.IsDir,
}
}
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
return jsVal
})
}
// sandboxExecMethod implements ctx.sandbox.Exec(cmd)
func (ctx *Context) sandboxExecMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
if len(args) < 1 {
return bridge.JsException(v8ctx, "Exec requires cmd parameter (array of strings)")
}
// Parse command array
cmdArg := args[0]
if !cmdArg.IsArray() {
return bridge.JsException(v8ctx, "Exec requires cmd to be an array of strings")
}
cmdObj, err := cmdArg.AsObject()
if err != nil {
return bridge.JsException(v8ctx, "failed to parse cmd array: "+err.Error())
}
// Get array length
lengthVal, err := cmdObj.Get("length")
if err != nil {
return bridge.JsException(v8ctx, "failed to get cmd array length: "+err.Error())
}
length := int(lengthVal.Integer())
// Build command slice
cmd := make([]string, length)
for i := 0; i < length; i++ {
itemVal, err := cmdObj.GetIdx(uint32(i))
if err != nil {
return bridge.JsException(v8ctx, "failed to get cmd array element: "+err.Error())
}
cmd[i] = itemVal.String()
}
output, err := ctx.sandboxExecutor.Exec(context.Background(), cmd)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
jsVal, err := v8go.NewValue(v8ctx.Isolate(), output)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
return jsVal
})
}

View file

@ -0,0 +1,482 @@
package context_test
import (
stdContext "context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// createTestSandboxManager creates a real sandbox manager for testing
func createTestSandboxManager(t *testing.T) *infraSandbox.Manager {
// Get data root from environment or use temp directory
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
dataRoot = t.TempDir()
}
// Create config with proper paths
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
return nil
}
return manager
}
// createTestContainer creates a container and returns a cleanup function
func createTestContainer(t *testing.T, manager *infraSandbox.Manager, userID, chatID string) (*infraSandbox.Container, func()) {
container, err := manager.GetOrCreate(stdContext.Background(), userID, chatID)
require.NoError(t, err)
require.NotNil(t, container)
// Return cleanup function that removes the container
cleanup := func() {
err := manager.Remove(stdContext.Background(), container.Name)
if err != nil {
t.Logf("Warning: failed to cleanup container %s: %v", container.Name, err)
}
}
return container, cleanup
}
// realSandboxExecutor wraps infraSandbox.Manager to implement context.SandboxExecutor
type realSandboxExecutor struct {
manager *infraSandbox.Manager
containerName string
workDir string
}
func (e *realSandboxExecutor) ReadFile(ctx stdContext.Context, path string) ([]byte, error) {
fullPath := e.workDir + "/" + path
return e.manager.ReadFile(ctx, e.containerName, fullPath)
}
func (e *realSandboxExecutor) WriteFile(ctx stdContext.Context, path string, content []byte) error {
fullPath := e.workDir + "/" + path
return e.manager.WriteFile(ctx, e.containerName, fullPath, content)
}
func (e *realSandboxExecutor) ListDir(ctx stdContext.Context, path string) ([]infraSandbox.FileInfo, error) {
fullPath := e.workDir + "/" + path
return e.manager.ListDir(ctx, e.containerName, fullPath)
}
func (e *realSandboxExecutor) Exec(ctx stdContext.Context, cmd []string) (string, error) {
result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{
WorkDir: e.workDir,
})
if err != nil {
return "", err
}
return result.Stdout, nil
}
func (e *realSandboxExecutor) GetWorkDir() string {
return e.workDir
}
// TestJsSandboxNotAvailable tests ctx.sandbox when not configured
func TestJsSandboxNotAvailable(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ctx := context.New(stdContext.Background(), nil, "test-chat-no-sandbox")
ctx.AssistantID = "test-assistant"
// Test that ctx.sandbox is undefined when not configured
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (ctx.sandbox === undefined || ctx.sandbox === null) {
return { success: true, hasSandbox: false };
}
return { success: true, hasSandbox: true };
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"])
assert.Equal(t, false, result["hasSandbox"], "ctx.sandbox should not be available when not configured")
}
// TestJsSandboxWriteFile tests ctx.sandbox.WriteFile via JavaScript
func TestJsSandboxWriteFile(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-writefile")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-writefile")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test WriteFile via JavaScript
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// Write a file
ctx.sandbox.WriteFile("js-test.txt", "Hello from JavaScript!");
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "WriteFile should succeed: %v", result["error"])
// Verify file was written by reading it back directly
content, err := executor.ReadFile(stdContext.Background(), "js-test.txt")
require.NoError(t, err)
assert.Equal(t, "Hello from JavaScript!", string(content))
}
// TestJsSandboxReadFile tests ctx.sandbox.ReadFile via JavaScript
func TestJsSandboxReadFile(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-readfile")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Write a file first
err := executor.WriteFile(stdContext.Background(), "read-test.txt", []byte("Content to read"))
require.NoError(t, err)
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-readfile")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test ReadFile via JavaScript
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// Read the file
const content = ctx.sandbox.ReadFile("read-test.txt");
return { success: true, content: content };
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "ReadFile should succeed: %v", result["error"])
assert.Equal(t, "Content to read", result["content"])
}
// TestJsSandboxListDir tests ctx.sandbox.ListDir via JavaScript
func TestJsSandboxListDir(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-listdir")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Write some files first
err := executor.WriteFile(stdContext.Background(), "file1.txt", []byte("content1"))
require.NoError(t, err)
err = executor.WriteFile(stdContext.Background(), "file2.txt", []byte("content2"))
require.NoError(t, err)
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-listdir")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test ListDir via JavaScript
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// List directory
const files = ctx.sandbox.ListDir(".");
// Find our test files
const fileNames = files.map(f => f.name);
const hasFile1 = fileNames.includes("file1.txt");
const hasFile2 = fileNames.includes("file2.txt");
return {
success: true,
fileCount: files.length,
hasFile1: hasFile1,
hasFile2: hasFile2,
files: fileNames
};
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "ListDir should succeed: %v", result["error"])
assert.Equal(t, true, result["hasFile1"], "Should find file1.txt")
assert.Equal(t, true, result["hasFile2"], "Should find file2.txt")
}
// TestJsSandboxExec tests ctx.sandbox.Exec via JavaScript
func TestJsSandboxExec(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-exec")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-exec")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test Exec via JavaScript
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// Execute echo command
const output = ctx.sandbox.Exec(["echo", "hello-from-js"]);
return { success: true, output: output.trim() };
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "Exec should succeed: %v", result["error"])
// Output may contain Docker stream header bytes, so use Contains
output, _ := result["output"].(string)
assert.Contains(t, output, "hello-from-js", "Exec output should contain expected text")
}
// TestJsSandboxWorkdir tests ctx.sandbox.workdir property via JavaScript
func TestJsSandboxWorkdir(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-workdir")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-workdir")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test workdir property via JavaScript
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// Get workdir property
const workdir = ctx.sandbox.workdir;
return { success: true, workdir: workdir };
} catch (error) {
return { success: false, error: error.message };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "workdir access should succeed: %v", result["error"])
assert.Equal(t, "/workspace", result["workdir"])
}
// TestJsSandboxCompleteWorkflow tests a complete workflow via JavaScript
func TestJsSandboxCompleteWorkflow(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestSandboxManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create container with auto-cleanup
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-workflow")
defer cleanup()
executor := &realSandboxExecutor{
manager: manager,
containerName: container.Name,
workDir: "/workspace",
}
// Create context with sandbox
ctx := context.New(stdContext.Background(), nil, "test-chat-workflow")
ctx.AssistantID = "test-assistant"
ctx.SetSandboxExecutor(executor)
// Test complete workflow: write file, exec cat, verify content
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
if (!ctx.sandbox) {
return { success: false, error: "sandbox not available" };
}
// 1. Check workdir
const workdir = ctx.sandbox.workdir;
if (workdir !== "/workspace") {
return { success: false, error: "unexpected workdir: " + workdir };
}
// 2. Write a file
const testContent = "Test workflow content: " + Date.now();
ctx.sandbox.WriteFile("workflow-test.txt", testContent);
// 3. Read it back
const readContent = ctx.sandbox.ReadFile("workflow-test.txt");
if (readContent !== testContent) {
return { success: false, error: "content mismatch after read" };
}
// 4. List directory and verify file exists
const files = ctx.sandbox.ListDir(".");
const fileNames = files.map(f => f.name);
if (!fileNames.includes("workflow-test.txt")) {
return { success: false, error: "file not found in listing" };
}
// 5. Execute cat command
const catOutput = ctx.sandbox.Exec(["cat", workdir + "/workflow-test.txt"]);
if (!catOutput.includes("Test workflow content")) {
return { success: false, error: "cat output mismatch" };
}
// 6. Execute pwd command
const pwdOutput = ctx.sandbox.Exec(["pwd"]);
if (!pwdOutput.includes("/workspace")) {
return { success: false, error: "pwd output mismatch: " + pwdOutput };
}
return {
success: true,
workdir: workdir,
content: readContent,
fileCount: files.length
};
} catch (error) {
return { success: false, error: error.message, stack: error.stack };
}
}`, ctx)
require.NoError(t, err)
result, ok := res.(map[string]interface{})
require.True(t, ok, "Expected map result")
assert.Equal(t, true, result["success"], "Complete workflow should succeed: %v", result["error"])
assert.Equal(t, "/workspace", result["workdir"])
}

View file

@ -250,6 +250,7 @@ type Context struct {
// Internal // Internal
trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access
messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations
sandboxExecutor SandboxExecutor `json:"-"` // Sandbox executor for hooks (set by assistant when sandbox is configured)
// Model capabilities (set by assistant, used by output adapters) // Model capabilities (set by assistant, used by output adapters)
Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector

1146
agent/sandbox/DESIGN.md Normal file

File diff suppressed because it is too large Load diff

252
agent/sandbox/PLAN.md Normal file
View file

@ -0,0 +1,252 @@
# Agent Sandbox Implementation Plan
## Overview
This plan covers the implementation of the agent sandbox integration layer (`agent/sandbox/`), which enables coding agents (Claude CLI, Cursor CLI) to run in isolated Docker containers with Yao's LLM pipeline.
## Test Environment
### Environment Configuration
Tests should run with the local development environment:
```bash
# Source environment variables
source /Users/max/Yao/yao/env.local.sh
# Key variables used:
# YAO_TEST_APPLICATION=/Users/max/Yao/yao-dev-app
# YAO_ROOT=$YAO_TEST_APPLICATION
# DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, DEEPSEEK_MODELS_V3
```
### Test Application
Test assistants at `yao-dev-app/assistants/tests/sandbox/`:
```
yao-dev-app/assistants/tests/
└── sandbox/
├── basic/ # Basic sandbox execution test
│ ├── package.yao # uses.search: disabled
│ └── prompts.yml
├── hooks/ # Hook integration test
│ ├── package.yao # uses.search: disabled
│ ├── prompts.yml
│ └── src/index.ts
└── full/ # Full test with MCPs, Skills, Hooks
├── package.yao # uses.search: disabled, mcp: {servers: [...]}
├── prompts.yml
├── src/index.ts
└── skills/echo-test/ # Agent Skills standard
├── SKILL.md
└── scripts/echo.sh
```
### Connector Configuration
Use `deepseek.v3` as the default connector (via Volcengine API).
## Implementation Status
### Phase 1: Core Types and Interfaces ✅ COMPLETED
- [x] Define `Executor` interface with all methods
- [x] Define `Options` struct with JSON tags
- [x] Define `FileInfo` alias to infrastructure sandbox
- [x] Add `DefaultImage()` and `IsValidCommand()` helpers
### Phase 2: Claude Executor Implementation ✅ COMPLETED
- [x] Implement `Executor` struct
- [x] Implement `NewExecutor()` constructor with container reuse
- [x] Implement `Stream()` method with CCR config writing
- [x] Implement `Execute()` method (wrapper)
- [x] Implement `Close()` method (removes container)
- [x] Implement filesystem methods: `ReadFile`, `WriteFile`, `ListDir`
- [x] Implement `Exec()` method
- [x] Implement `GetWorkDir()` method
### Phase 3: CCR Configuration ✅ COMPLETED
- [x] Implement `BuildCCRConfig()` with correct CCR format
- [x] Auto-detect provider type (volcengine, deepseek, openai, claude)
- [x] Add transformer for DeepSeek/Volcengine (maxtoken)
- [x] Generate Router configuration
- [x] Write config to container before execution
### Phase 4: Assistant Integration ✅ COMPLETED
- [x] Implement `GetSandboxManager()` singleton
- [x] Implement `HasSandbox()` method
- [x] Implement `initSandbox()` with cleanup function
- [x] Implement `executeSandboxStream()` method
- [x] Build executor options from assistant config
- [x] Resolve connector settings (host, key, model)
- [x] Add trace logging for sandbox creation
- [x] Send loading message during sandbox init
- [x] Expose executor to hooks via `ctx.SetSandboxExecutor()`
- [x] Handle sandbox lifecycle (create → hooks → execute → cleanup)
### Phase 5: JSAPI Integration ✅ COMPLETED
- [x] Define `SandboxExecutor` interface
- [x] Implement JS bindings for `ReadFile`, `WriteFile`, `ListDir`, `Exec`
- [x] Expose `workdir` property
- [x] Register in context's `NewObject` method
### Phase 6: Concurrency & Resource Management ✅ COMPLETED
- [x] Container creation uses Double-Check Locking (in `manager.GetOrCreate`)
- [x] Same chatID reuses container (by design)
- [x] Container cleanup on request completion (`defer sandboxCleanup()`)
- [x] Unique chatID in tests to avoid conflicts
### Phase 7: Workspace Management ⏳ PENDING
- [ ] Implement workspace cleanup configuration
- [ ] Implement stale workspace detection
- [ ] Implement cleanup scheduler
### Phase 8: Cursor Placeholder ⏳ PENDING
- [ ] Create `cursor/README.md` placeholder
## Testing Status
### Unit Tests
| Package | Test File | Status |
|---------|-----------|--------|
| `agent/sandbox` | `types_test.go` | ✅ PASS |
| `agent/sandbox` | `executor_test.go` | ✅ PASS |
| `agent/sandbox/claude` | `command_test.go` | ✅ PASS |
| `agent/sandbox/claude` | `executor_test.go` | ✅ PASS |
### Integration Tests
| Package | Test File | Status |
|---------|-----------|--------|
| `agent/sandbox` | `integration_test.go` | ✅ PASS |
### JSAPI Tests
| Package | Test File | Status |
|---------|-----------|--------|
| `agent/context` | `jsapi_sandbox_test.go` | ✅ PASS |
### Assistant Loading Tests
| Package | Test File | Status |
|---------|-----------|--------|
| `agent/assistant` | `sandbox_test.go` | ✅ PASS |
| `agent/assistant` | `sandbox_integration_test.go` | ✅ PASS |
### E2E Tests
| Package | Test Case | Status |
|---------|-----------|--------|
| `agent/assistant` | `TestSandboxBasicE2E` | ✅ PASS |
| `agent/assistant` | `TestSandboxHooksE2E` | ✅ PASS |
| `agent/assistant` | `TestSandboxFullE2E` | ✅ PASS |
| `agent/assistant` | `TestSandboxContextAccess` | ✅ PASS |
| `agent/assistant` | `TestSandboxLoadConfiguration` | ✅ PASS |
### Running Tests
```bash
# Source environment
source /Users/max/Yao/yao/env.local.sh
# Run all sandbox tests
go test -v ./agent/sandbox/...
# Run assistant sandbox tests
go test -v ./agent/assistant -run "Sandbox"
# Run E2E tests (requires Docker)
go test -v ./agent/assistant -run "TestSandbox.*E2E" -timeout 300s
```
## File Structure
```
yao/agent/sandbox/ # Executor layer
├── DESIGN.md # ✅ Design document
├── PLAN.md # ✅ This file
├── types.go # ✅ Common types and interfaces
├── types_test.go # ✅ Types tests
├── executor.go # ✅ Factory function
├── executor_test.go # ✅ Factory tests
├── integration_test.go # ✅ Integration tests
├── claude/
│ ├── types.go # ✅ Claude-specific types
│ ├── executor.go # ✅ Executor implementation
│ ├── executor_test.go # ✅ Executor tests
│ ├── command.go # ✅ Command builder + CCR config
│ └── command_test.go # ✅ Command tests
└── cursor/
└── README.md # ⏳ Placeholder (pending)
yao/agent/assistant/ # Integration layer
├── sandbox.go # ✅ Sandbox handler
├── sandbox_test.go # ✅ Loading tests
├── sandbox_integration_test.go # ✅ Integration tests
├── sandbox_e2e_test.go # ✅ E2E tests
├── sandbox_debug_test.go # ✅ Debug tests
└── agent.go # ✅ Modified: sandbox detection in Stream()
yao/agent/context/ # Context layer
├── jsapi_sandbox.go # ✅ Sandbox JSAPI bindings
└── jsapi_sandbox_test.go # ✅ Sandbox JSAPI tests
yao-dev-app/assistants/tests/sandbox/ # Test assistants
├── basic/ # ✅ Basic sandbox test
├── hooks/ # ✅ Hooks test
└── full/ # ✅ Full test with MCPs and Skills
```
## Key Design Decisions
### 1. Container Reuse
Same `userID + chatID` reuses the same container:
- Workspace directory persists across requests
- CCR config is written on each request (same content, safe to overwrite)
- Container is removed when request completes
### 2. Concurrency
- Container creation: Protected by mutex + double-check locking
- Container execution: Multiple requests can run concurrently in same container
- Claude CLI: Supports concurrent execution
### 3. CCR Configuration
CCR requires specific JSON format:
```json
{
"Providers": [{"name": "volcengine", "api_base_url": "...", ...}],
"Router": {"default": "volcengine,model", ...}
}
```
Auto-detection of provider type based on host URL.
### 4. Resource Cleanup
- `executor.Close()` removes the container
- `defer sandboxCleanup()` in `agent.go` ensures cleanup
- Tests use unique chatID (timestamp) to avoid conflicts
## Known Issues
1. **MCP config building**: TODO in `buildSandboxOptions` - MCP configuration not yet passed to sandbox
2. **Skills mounting**: Skills directory path is set but not mounted into container
## Notes
- All tests validate return values (use `require`/`assert`)
- Docker must be available for integration and E2E tests
- Tests automatically clean up containers after completion
- Use `uses.search: disabled` in test assistants to avoid auto-search LLM calls

View file

@ -0,0 +1,214 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
agentContext "github.com/yaoapp/yao/agent/context"
)
// BuildCommand builds the Claude CLI command and environment variables
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
// Build system prompt from conversation history
systemPrompt, userPrompt := buildPrompts(messages)
// Start with ccr-run if available, otherwise fall back to claude directly
cmd := []string{"ccr-run"}
// Add the prompt
if userPrompt != "" {
cmd = append(cmd, userPrompt)
}
// Build environment variables
env := buildEnvironment(opts, systemPrompt)
return cmd, env, nil
}
// buildPrompts extracts system prompt and user prompt from messages
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
var systemParts []string
var conversationParts []string
var lastUserMessage string
for _, msg := range messages {
switch msg.Role {
case "system":
systemParts = append(systemParts, getMessageContent(msg))
case "user":
lastUserMessage = getMessageContent(msg)
conversationParts = append(conversationParts, fmt.Sprintf("User: %s", lastUserMessage))
case "assistant":
conversationParts = append(conversationParts, fmt.Sprintf("Assistant: %s", getMessageContent(msg)))
}
}
// Build system prompt with conversation history
systemPrompt = strings.Join(systemParts, "\n\n")
// If there's conversation history, include it in the system prompt
if len(conversationParts) > 1 {
historySection := "\n\n## Conversation History\n\n" + strings.Join(conversationParts[:len(conversationParts)-1], "\n\n")
systemPrompt += historySection
}
// The user prompt is the last user message
userPrompt = lastUserMessage
return systemPrompt, userPrompt
}
// getMessageContent extracts text content from a message
func getMessageContent(msg agentContext.Message) string {
if msg.Content == nil {
return ""
}
// Handle string content
if str, ok := msg.Content.(string); ok {
return str
}
// Handle content array (multimodal messages)
if arr, ok := msg.Content.([]interface{}); ok {
var parts []string
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
if m["type"] == "text" {
if text, ok := m["text"].(string); ok {
parts = append(parts, text)
}
}
}
}
return strings.Join(parts, "\n")
}
return ""
}
// buildEnvironment builds environment variables for Claude CLI
func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
env := make(map[string]string)
if opts == nil {
return env
}
// CCR configuration via environment
// CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format
if opts.ConnectorHost != "" {
// CCR expects ANTHROPIC_BASE_URL but will proxy through its own router
env["CCR_API_BASE"] = opts.ConnectorHost
}
if opts.ConnectorKey != "" {
env["CCR_API_KEY"] = opts.ConnectorKey
}
if opts.Model != "" {
env["CCR_MODEL"] = opts.Model
}
// Set system prompt via environment (Claude CLI supports this)
if systemPrompt != "" {
env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt
}
// Additional Claude CLI options from Arguments
if opts.Arguments != nil {
// max_turns
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns)
}
// permission_mode
if permMode, ok := opts.Arguments["permission_mode"].(string); ok {
env["CLAUDE_PERMISSION_MODE"] = permMode
}
// output_format (default to stream-json for streaming)
if outputFormat, ok := opts.Arguments["output_format"].(string); ok {
env["CLAUDE_OUTPUT_FORMAT"] = outputFormat
} else {
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
}
} else {
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
}
return env
}
// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON
// CCR requires a specific format with Providers array and Router configuration
func BuildCCRConfig(opts *Options) ([]byte, error) {
if opts == nil {
return nil, fmt.Errorf("options is required")
}
// Determine provider name based on host
providerName := "custom"
apiBaseURL := opts.ConnectorHost
needsTransformer := false
if strings.Contains(opts.ConnectorHost, "volces.com") || strings.Contains(opts.ConnectorHost, "volcengine") {
providerName = "volcengine"
needsTransformer = true
// Ensure URL ends with chat/completions
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
}
} else if strings.Contains(opts.ConnectorHost, "deepseek") {
providerName = "deepseek"
needsTransformer = true
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
}
} else if strings.Contains(opts.ConnectorHost, "openai.com") {
providerName = "openai"
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/v1/chat/completions"
}
} else if strings.Contains(opts.ConnectorHost, "anthropic.com") {
providerName = "claude"
}
// Build provider configuration
provider := map[string]interface{}{
"name": providerName,
"api_base_url": apiBaseURL,
"api_key": opts.ConnectorKey,
"models": []string{opts.Model},
}
// Add transformer for providers that need it (DeepSeek, Volcengine)
if needsTransformer {
provider["transformer"] = map[string]interface{}{
"use": []interface{}{
[]interface{}{"maxtoken", map[string]interface{}{"max_tokens": 16384}},
},
}
}
// Build router configuration
routerKey := fmt.Sprintf("%s,%s", providerName, opts.Model)
router := map[string]interface{}{
"default": routerKey,
"background": routerKey,
"think": routerKey,
}
// Build full config
config := map[string]interface{}{
"LOG": true,
"API_TIMEOUT_MS": 600000,
"NON_INTERACTIVE_MODE": true,
"Providers": []interface{}{provider},
"Router": router,
}
return json.MarshalIndent(config, "", " ")
}

View file

@ -0,0 +1,137 @@
package claude
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentContext "github.com/yaoapp/yao/agent/context"
)
func TestBuildCommand(t *testing.T) {
messages := []agentContext.Message{
{Role: "system", Content: "You are a helpful assistant"},
{Role: "user", Content: "Hello"},
}
opts := &Options{
ConnectorHost: "https://api.example.com",
ConnectorKey: "key123",
Model: "test-model",
}
cmd, env, err := BuildCommand(messages, opts)
require.NoError(t, err)
// Verify command structure
assert.Equal(t, "ccr-run", cmd[0])
assert.Contains(t, cmd, "Hello") // User prompt should be in command
// Verify environment variables
assert.Equal(t, "https://api.example.com", env["CCR_API_BASE"])
assert.Equal(t, "key123", env["CCR_API_KEY"])
assert.Equal(t, "test-model", env["CCR_MODEL"])
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
}
func TestBuildCommandWithSystemPrompt(t *testing.T) {
messages := []agentContext.Message{
{Role: "system", Content: "You are a code reviewer"},
{Role: "user", Content: "Review this code"},
{Role: "assistant", Content: "Sure, I'll review it"},
{Role: "user", Content: "Here is the code"},
}
opts := &Options{}
_, env, err := BuildCommand(messages, opts)
require.NoError(t, err)
// System prompt should include conversation history
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer")
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History")
}
func TestBuildCommandWithArguments(t *testing.T) {
messages := []agentContext.Message{
{Role: "user", Content: "Hello"},
}
opts := &Options{
Arguments: map[string]interface{}{
"max_turns": 20,
"permission_mode": "acceptEdits",
"output_format": "json",
},
}
_, env, err := BuildCommand(messages, opts)
require.NoError(t, err)
assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"])
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
assert.Equal(t, "json", env["CLAUDE_OUTPUT_FORMAT"])
}
func TestBuildCCRConfig(t *testing.T) {
opts := &Options{
ConnectorHost: "https://api.example.com",
ConnectorKey: "key123",
Model: "test-model",
}
configJSON, err := BuildCCRConfig(opts)
require.NoError(t, err)
configStr := string(configJSON)
// CCR config uses snake_case for fields
assert.Contains(t, configStr, "api_base_url")
assert.Contains(t, configStr, "https://api.example.com")
assert.Contains(t, configStr, "api_key")
assert.Contains(t, configStr, "key123")
assert.Contains(t, configStr, "models")
assert.Contains(t, configStr, "test-model")
// Verify new CCR format fields
assert.Contains(t, configStr, "Providers")
assert.Contains(t, configStr, "Router")
assert.Contains(t, configStr, "NON_INTERACTIVE_MODE")
}
func TestBuildCCRConfigVolcengine(t *testing.T) {
opts := &Options{
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/",
ConnectorKey: "test-key",
Model: "ep-xxx",
}
configJSON, err := BuildCCRConfig(opts)
require.NoError(t, err)
configStr := string(configJSON)
// Verify volcengine-specific configuration
assert.Contains(t, configStr, "volcengine")
assert.Contains(t, configStr, "transformer")
assert.Contains(t, configStr, "maxtoken")
// URL should end with /chat/completions
assert.Contains(t, configStr, "/chat/completions")
}
func TestGetMessageContent(t *testing.T) {
// String content
msg1 := agentContext.Message{Content: "Hello World"}
assert.Equal(t, "Hello World", getMessageContent(msg1))
// Nil content
msg2 := agentContext.Message{Content: nil}
assert.Equal(t, "", getMessageContent(msg2))
// Array content (multimodal)
msg3 := agentContext.Message{
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Part 1"},
map[string]interface{}{"type": "text", "text": "Part 2"},
},
}
assert.Contains(t, getMessageContent(msg3), "Part 1")
assert.Contains(t, getMessageContent(msg3), "Part 2")
}

View file

@ -0,0 +1,330 @@
package claude
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
infraSandbox "github.com/yaoapp/yao/sandbox"
)
// Options for Claude executor (copied from parent package to avoid import cycle)
type Options struct {
Command string
Image string
MaxMemory string
MaxCPU float64
Timeout time.Duration
Arguments map[string]interface{}
UserID string
ChatID string
MCPConfig []byte
SkillsDir string
ConnectorHost string
ConnectorKey string
Model string
}
// Executor implements the sandbox.Executor interface for Claude CLI
type Executor struct {
manager *infraSandbox.Manager
containerName string
opts *Options
workDir string
}
// NewExecutor creates a new Claude executor
func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, error) {
if manager == nil {
return nil, fmt.Errorf("manager is required")
}
// Type assertion to get options
var execOpts *Options
switch o := opts.(type) {
case *Options:
execOpts = o
default:
// Try to convert from map or other struct
return nil, fmt.Errorf("invalid options type: %T", opts)
}
if execOpts == nil {
return nil, fmt.Errorf("options is required")
}
if execOpts.UserID == "" {
return nil, fmt.Errorf("UserID is required")
}
if execOpts.ChatID == "" {
return nil, fmt.Errorf("ChatID is required")
}
// Create or get container
ctx := context.Background()
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
// Get workspace directory from config
config := manager.GetConfig()
workDir := config.ContainerWorkDir
if workDir == "" {
workDir = "/workspace"
}
return &Executor{
manager: manager,
containerName: container.Name,
opts: execOpts,
workDir: workDir,
}, nil
}
// Stream runs the Claude CLI with streaming output
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
stdCtx := context.Background()
if ctx != nil && ctx.Context != nil {
stdCtx = ctx.Context
}
// Write CCR config file to container before executing
if err := e.writeCCRConfig(stdCtx); err != nil {
return nil, fmt.Errorf("failed to write CCR config: %w", err)
}
// Build Claude CLI command using stored options
cmd, env, err := BuildCommand(messages, e.opts)
if err != nil {
return nil, fmt.Errorf("failed to build command: %w", err)
}
// Prepare execution options
execOpts := &infraSandbox.ExecOptions{
WorkDir: e.workDir,
Env: env,
}
if e.opts != nil && e.opts.Timeout > 0 {
execOpts.Timeout = e.opts.Timeout
}
reader, err := e.manager.Stream(stdCtx, e.containerName, cmd, execOpts)
if err != nil {
return nil, fmt.Errorf("failed to execute command: %w", err)
}
defer reader.Close()
// Parse streaming output
return e.parseStream(reader, handler)
}
// writeCCRConfig writes the CCR configuration file to the container
func (e *Executor) writeCCRConfig(ctx context.Context) error {
// Build CCR config
configJSON, err := BuildCCRConfig(e.opts)
if err != nil {
return fmt.Errorf("failed to build CCR config: %w", err)
}
// Write config to container's CCR directory
configPath := "/home/sandbox/.claude-code-router/config.json"
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
return fmt.Errorf("failed to write config to %s: %w", configPath, err)
}
return nil
}
// Execute runs the Claude CLI and returns the response
func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) {
return e.Stream(ctx, messages, nil)
}
// parseStream parses Claude CLI streaming output
func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
scanner := bufio.NewScanner(reader)
// Increase buffer size for potentially large outputs
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
var textContent strings.Builder
var toolCalls []agentContext.ToolCall
var model string
var usage *message.UsageInfo
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
// Note: Docker stream demuxing is handled by sandbox.Manager.Stream()
// which uses stdcopy.StdCopy to properly separate stdout/stderr
// Try to parse as JSON (Claude CLI --output-format stream-json)
var msg StreamMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
// Not JSON, might be plain text output
textContent.WriteString(line)
textContent.WriteString("\n")
continue
}
// Process different message types
switch msg.Type {
case "content_block_delta":
// Streaming text content
if delta, ok := msg.Content.(map[string]interface{}); ok {
if text, ok := delta["text"].(string); ok {
textContent.WriteString(text)
// Send to stream handler if available
if handler != nil {
handler(message.ChunkText, []byte(text))
}
}
}
case "message_delta":
// Message completion with usage
if content, ok := msg.Content.(map[string]interface{}); ok {
if usageData, ok := content["usage"].(map[string]interface{}); ok {
usage = &message.UsageInfo{}
if v, ok := usageData["input_tokens"].(float64); ok {
usage.PromptTokens = int(v)
}
if v, ok := usageData["output_tokens"].(float64); ok {
usage.CompletionTokens = int(v)
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
}
case "message_start":
// Extract model from message_start
if content, ok := msg.Content.(map[string]interface{}); ok {
if m, ok := content["model"].(string); ok {
model = m
}
}
case "content_block_start":
// Might contain tool use blocks
if block, ok := msg.Content.(map[string]interface{}); ok {
if block["type"] == "tool_use" {
toolCall := agentContext.ToolCall{
ID: getString(block, "id"),
Type: agentContext.ToolTypeFunction,
Function: agentContext.Function{
Name: getString(block, "name"),
Arguments: "{}",
},
}
toolCalls = append(toolCalls, toolCall)
}
}
case "error":
return nil, fmt.Errorf("Claude CLI error: %s", msg.Error)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading stream: %w", err)
}
// Build response
response := &agentContext.CompletionResponse{
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
Model: model,
Created: time.Now().Unix(),
Role: "assistant",
Content: textContent.String(),
FinishReason: agentContext.FinishReasonStop,
}
// Add tool calls if any
if len(toolCalls) > 0 {
response.ToolCalls = toolCalls
response.FinishReason = agentContext.FinishReasonToolCalls
}
// Add usage if available
if usage != nil {
response.Usage = usage
}
return response, nil
}
// ReadFile reads a file from the container
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
// Make path absolute if not
if !strings.HasPrefix(path, "/") {
path = e.workDir + "/" + path
}
return e.manager.ReadFile(ctx, e.containerName, path)
}
// WriteFile writes content to a file in the container
func (e *Executor) WriteFile(ctx context.Context, path string, content []byte) error {
// Make path absolute if not
if !strings.HasPrefix(path, "/") {
path = e.workDir + "/" + path
}
return e.manager.WriteFile(ctx, e.containerName, path, content)
}
// ListDir lists directory contents in the container
func (e *Executor) ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error) {
// Make path absolute if not
if !strings.HasPrefix(path, "/") {
path = e.workDir + "/" + path
}
return e.manager.ListDir(ctx, e.containerName, path)
}
// Exec executes a command in the container
func (e *Executor) Exec(ctx context.Context, cmd []string) (string, error) {
result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{
WorkDir: e.workDir,
})
if err != nil {
return "", err
}
if result.ExitCode != 0 {
return result.Stdout, fmt.Errorf("command exited with code %d: %s", result.ExitCode, result.Stderr)
}
return result.Stdout, nil
}
// GetWorkDir returns the container workspace directory
func (e *Executor) GetWorkDir() string {
return e.workDir
}
// Close releases the executor resources and removes the container
func (e *Executor) Close() error {
if e.manager != nil && e.containerName != "" {
ctx := context.Background()
return e.manager.Remove(ctx, e.containerName)
}
return nil
}
// Helper function to get string from map
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}

View file

@ -0,0 +1,168 @@
package claude
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// createTestManager creates a sandbox manager for testing with proper configuration
func createTestManager(t *testing.T) *infraSandbox.Manager {
// Get data root from environment or use temp directory
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
dataRoot = t.TempDir()
}
// Create config with proper paths
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
return nil
}
return manager
}
func TestNewClaudeExecutor(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "yaoapp/sandbox-claude:latest",
UserID: "test-user",
ChatID: "test-chat-claude-1",
ConnectorHost: "https://api.example.com",
ConnectorKey: "key123",
Model: "test-model",
}
exec, err := NewExecutor(manager, opts)
require.NoError(t, err)
require.NotNil(t, exec)
// Verify executor was created
assert.Equal(t, "/workspace", exec.GetWorkDir())
assert.NoError(t, exec.Close())
}
func TestClaudeExecutorMissingRequiredFields(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Missing UserID
_, err := NewExecutor(manager, &Options{
Command: "claude",
ChatID: "test-chat",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "UserID is required")
// Missing ChatID
_, err = NewExecutor(manager, &Options{
Command: "claude",
UserID: "test-user",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "ChatID is required")
}
func TestClaudeExecutorFileOperations(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "alpine:latest", // Use alpine for simpler testing
UserID: "test-user",
ChatID: "test-chat-file-ops",
}
exec, err := NewExecutor(manager, opts)
require.NoError(t, err)
defer exec.Close()
ctx := context.Background()
// Test WriteFile
content := []byte("Hello, World!")
err = exec.WriteFile(ctx, "test-file.txt", content)
require.NoError(t, err)
// Test ReadFile
readContent, err := exec.ReadFile(ctx, "test-file.txt")
require.NoError(t, err)
assert.Equal(t, content, readContent)
// Test ListDir
files, err := exec.ListDir(ctx, ".")
require.NoError(t, err)
assert.True(t, len(files) > 0, "Expected at least one file in directory")
// Find our test file
var found bool
for _, f := range files {
if f.Name == "test-file.txt" {
found = true
break
}
}
assert.True(t, found, "Expected to find test-file.txt in directory listing")
}
func TestClaudeExecutorExec(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "alpine:latest", // Use alpine for simpler testing
UserID: "test-user",
ChatID: "test-chat-exec",
}
exec, err := NewExecutor(manager, opts)
require.NoError(t, err)
defer exec.Close()
ctx := context.Background()
// Test simple echo command
output, err := exec.Exec(ctx, []string{"echo", "hello-world"})
require.NoError(t, err)
assert.Contains(t, output, "hello-world")
}

View file

@ -0,0 +1,37 @@
package claude
// StreamMessage represents a parsed stream message from Claude CLI
type StreamMessage struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
Content interface{} `json:"content,omitempty"`
Error string `json:"error,omitempty"`
}
// ToolCall represents a tool invocation from the agent
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// ToolResult represents a tool execution result
type ToolResult struct {
ID string `json:"id"`
Content string `json:"content"`
IsError bool `json:"is_error,omitempty"`
}
// CLIResponse represents the parsed response from Claude CLI
type CLIResponse struct {
Text string `json:"text,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Model string `json:"model,omitempty"`
}
// Usage represents token usage statistics
type Usage struct {
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
}

View file

@ -0,0 +1,55 @@
# Cursor Executor
## Status
**Not Implemented** - This is a placeholder for future Cursor CLI integration.
## Planned Features
The Cursor executor will provide similar functionality to the Claude executor:
- Execute Cursor CLI in a Docker sandbox container
- Stream output in real-time
- File system operations (ReadFile, WriteFile, ListDir)
- Command execution (Exec)
- Integration with Yao's MCP servers
## Configuration
When implemented, the Cursor executor will be configured in assistant `package.yao`:
```jsonc
{
"name": "Coder Assistant",
"connector": "deepseek.v3",
"sandbox": {
"command": "cursor", // Use Cursor CLI
"image": "yaoapp/sandbox-cursor:latest",
"timeout": "10m"
}
}
```
## Implementation Notes
The implementation should follow the same pattern as `claude/executor.go`:
1. Create `cursor/executor.go` implementing the `sandbox.Executor` interface
2. Create `cursor/command.go` for building Cursor CLI commands
3. Create `cursor/types.go` for Cursor-specific types
4. Add appropriate tests
## Docker Image
A `yaoapp/sandbox-cursor` Docker image will need to be created with:
- Ubuntu 24.04 LTS base
- Node.js 22 LTS
- Python 3.12
- Cursor CLI installed and configured
## References
- [Cursor CLI Documentation](https://cursor.sh/docs)
- [Claude Executor Implementation](../claude/executor.go)
- [Sandbox Design Document](../DESIGN.md)

49
agent/sandbox/executor.go Normal file
View file

@ -0,0 +1,49 @@
package sandbox
import (
"fmt"
"github.com/yaoapp/yao/agent/sandbox/claude"
infraSandbox "github.com/yaoapp/yao/sandbox"
)
// New creates a new Executor based on the command type
func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
if opts == nil {
return nil, fmt.Errorf("options is required")
}
if !IsValidCommand(opts.Command) {
return nil, fmt.Errorf("unsupported command type: %s, supported: %v", opts.Command, CommandTypes)
}
// Set default image if not specified
if opts.Image == "" {
opts.Image = DefaultImage(opts.Command)
}
switch opts.Command {
case "claude":
// Convert to claude.Options
claudeOpts := &claude.Options{
Command: opts.Command,
Image: opts.Image,
MaxMemory: opts.MaxMemory,
MaxCPU: opts.MaxCPU,
Timeout: opts.Timeout,
Arguments: opts.Arguments,
UserID: opts.UserID,
ChatID: opts.ChatID,
MCPConfig: opts.MCPConfig,
SkillsDir: opts.SkillsDir,
ConnectorHost: opts.ConnectorHost,
ConnectorKey: opts.ConnectorKey,
Model: opts.Model,
}
return claude.NewExecutor(manager, claudeOpts)
case "cursor":
return nil, fmt.Errorf("cursor executor not implemented yet")
default:
return nil, fmt.Errorf("unsupported command type: %s", opts.Command)
}
}

View file

@ -0,0 +1,133 @@
package sandbox
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// createTestManager creates a sandbox manager for testing with proper configuration
func createTestManager(t *testing.T) *infraSandbox.Manager {
// Get data root from environment or use temp directory
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
dataRoot = t.TempDir()
}
// Create config with proper paths
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
return nil
}
return manager
}
func TestNewExecutorWithInvalidOptions(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Test with nil options
_, err := New(manager, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "options is required")
// Test with invalid command
_, err = New(manager, &Options{
Command: "invalid",
UserID: "user1",
ChatID: "chat1",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported command type")
}
func TestNewExecutorWithValidOptions(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Test with valid claude options
opts := &Options{
Command: "claude",
UserID: "test-user",
ChatID: "test-chat",
ConnectorHost: "https://api.example.com",
ConnectorKey: "key123",
Model: "test-model",
}
exec, err := New(manager, opts)
require.NoError(t, err)
require.NotNil(t, exec)
// Verify executor was created
assert.NotEmpty(t, exec.GetWorkDir())
assert.NoError(t, exec.Close())
}
func TestDefaultImageIsSetWhenEmpty(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "", // Empty, should be set to default
UserID: "test-user",
ChatID: "test-chat-2",
}
exec, err := New(manager, opts)
require.NoError(t, err)
defer exec.Close() // Ensure cleanup
// The image should have been set to default
assert.Equal(t, "yaoapp/sandbox-claude:latest", opts.Image)
}
func TestCursorExecutorNotImplemented(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "cursor",
UserID: "test-user",
ChatID: "test-chat-3",
}
_, err := New(manager, opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not implemented")
}

View file

@ -0,0 +1,207 @@
package sandbox
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// createIntegrationTestManager creates a sandbox manager for integration testing
func createIntegrationTestManager(t *testing.T) *infraSandbox.Manager {
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
dataRoot = t.TempDir()
}
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
return nil
}
return manager
}
// TestExecutorInterfaceCompatibility verifies that the executor implements both interfaces correctly
func TestExecutorInterfaceCompatibility(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createIntegrationTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create executor via factory function
opts := &Options{
Command: "claude",
Image: "alpine:latest",
UserID: "test-user",
ChatID: "test-compat",
}
executor, err := New(manager, opts)
require.NoError(t, err)
require.NotNil(t, executor)
defer executor.Close()
// Verify executor implements agent/sandbox.Executor interface
var _ Executor = executor
// Verify executor can be cast to context.SandboxExecutor
ctxExecutor, ok := executor.(agentContext.SandboxExecutor)
require.True(t, ok, "executor should implement context.SandboxExecutor")
require.NotNil(t, ctxExecutor)
// Test SandboxExecutor methods work
ctx := context.Background()
// WriteFile
err = ctxExecutor.WriteFile(ctx, "compat-test.txt", []byte("compatibility test"))
require.NoError(t, err)
// ReadFile
content, err := ctxExecutor.ReadFile(ctx, "compat-test.txt")
require.NoError(t, err)
assert.Equal(t, "compatibility test", string(content))
// ListDir
files, err := ctxExecutor.ListDir(ctx, ".")
require.NoError(t, err)
assert.True(t, len(files) > 0)
// Exec
output, err := ctxExecutor.Exec(ctx, []string{"echo", "compat"})
require.NoError(t, err)
assert.Contains(t, output, "compat")
// GetWorkDir
workDir := ctxExecutor.GetWorkDir()
assert.NotEmpty(t, workDir)
}
// TestExecutorRoundTrip tests the full round-trip of creating executor and performing operations
func TestExecutorRoundTrip(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createIntegrationTestManager(t)
if manager == nil {
return
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "alpine:latest",
UserID: "test-user",
ChatID: "test-roundtrip",
ConnectorHost: "https://api.example.com",
ConnectorKey: "test-key",
Model: "test-model",
}
// Create executor
executor, err := New(manager, opts)
require.NoError(t, err)
require.NotNil(t, executor)
defer executor.Close()
ctx := context.Background()
// 1. Write a file
testContent := "Hello, integration test!"
err = executor.WriteFile(ctx, "integration.txt", []byte(testContent))
require.NoError(t, err, "WriteFile should succeed")
// 2. Read the file back
readContent, err := executor.ReadFile(ctx, "integration.txt")
require.NoError(t, err, "ReadFile should succeed")
assert.Equal(t, testContent, string(readContent), "Content should match")
// 3. List directory
files, err := executor.ListDir(ctx, ".")
require.NoError(t, err, "ListDir should succeed")
var found bool
for _, f := range files {
if f.Name == "integration.txt" {
found = true
assert.False(t, f.IsDir, "Should not be a directory")
assert.Equal(t, int64(len(testContent)), f.Size, "Size should match")
break
}
}
assert.True(t, found, "Should find integration.txt in listing")
// 4. Execute command
output, err := executor.Exec(ctx, []string{"cat", "/workspace/integration.txt"})
require.NoError(t, err, "Exec should succeed")
assert.Contains(t, output, testContent, "cat output should contain file content")
// 5. Verify workdir
assert.Equal(t, "/workspace", executor.GetWorkDir(), "WorkDir should be /workspace")
}
// TestMultipleExecutorsIsolation verifies that multiple executors have isolated workspaces
func TestMultipleExecutorsIsolation(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createIntegrationTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create two executors with different chat IDs
opts1 := &Options{
Command: "claude",
Image: "alpine:latest",
UserID: "test-user",
ChatID: "test-isolation-1",
}
opts2 := &Options{
Command: "claude",
Image: "alpine:latest",
UserID: "test-user",
ChatID: "test-isolation-2",
}
exec1, err := New(manager, opts1)
require.NoError(t, err)
defer exec1.Close()
exec2, err := New(manager, opts2)
require.NoError(t, err)
defer exec2.Close()
ctx := context.Background()
// Write different content to each executor
err = exec1.WriteFile(ctx, "test.txt", []byte("executor 1"))
require.NoError(t, err)
err = exec2.WriteFile(ctx, "test.txt", []byte("executor 2"))
require.NoError(t, err)
// Read back and verify isolation
content1, err := exec1.ReadFile(ctx, "test.txt")
require.NoError(t, err)
assert.Equal(t, "executor 1", string(content1), "Executor 1 should have its own content")
content2, err := exec2.ReadFile(ctx, "test.txt")
require.NoError(t, err)
assert.Equal(t, "executor 2", string(content2), "Executor 2 should have its own content")
}

122
agent/sandbox/types.go Normal file
View file

@ -0,0 +1,122 @@
package sandbox
import (
"context"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
infraSandbox "github.com/yaoapp/yao/sandbox"
)
// Executor executes LLM requests in sandbox
type Executor interface {
// Execute runs the request and returns response (uses options set at creation time)
Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error)
// Stream runs the request with streaming output (uses options set at creation time)
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
// Filesystem operations (for Hooks)
ReadFile(ctx context.Context, path string) ([]byte, error)
WriteFile(ctx context.Context, path string, content []byte) error
ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error)
// Command execution (for Hooks)
Exec(ctx context.Context, cmd []string) (string, error)
// GetWorkDir returns the container workspace directory
GetWorkDir() string
// Close releases container resources
Close() error
}
// FileInfo is an alias to infrastructure sandbox FileInfo for convenience
type FileInfo = infraSandbox.FileInfo
// Options for sandbox execution
type Options struct {
// Command type (claude, cursor)
Command string `json:"command"`
// Docker image (optional, auto-selected by command)
Image string `json:"image,omitempty"`
// Resource limits
MaxMemory string `json:"max_memory,omitempty"`
MaxCPU float64 `json:"max_cpu,omitempty"`
// Execution timeout
Timeout time.Duration `json:"timeout,omitempty"`
// Command-specific arguments (passed to CLI)
Arguments map[string]interface{} `json:"arguments,omitempty"`
// ========================================
// Internal fields (auto-resolved by Yao)
// Do NOT set these in package.yao config
// ========================================
// UserID for workspace isolation
UserID string `json:"-"`
// ChatID for session isolation
ChatID string `json:"-"`
// MCP configuration - auto-loaded from assistants/{name}/mcps/
MCPConfig []byte `json:"-"`
// Skills directory - auto-resolved to assistants/{name}/skills/
SkillsDir string `json:"-"`
// Connector settings - auto-resolved from connector config file
// e.g., connectors/deepseek/v3.conn.yao → host, key, model
ConnectorHost string `json:"-"`
ConnectorKey string `json:"-"`
Model string `json:"-"`
}
// SandboxConfig represents the sandbox configuration in assistant package.yao
type SandboxConfig struct {
// Command type (claude, cursor)
Command string `json:"command" yaml:"command"`
// Docker image (optional, auto-selected by command)
Image string `json:"image,omitempty" yaml:"image,omitempty"`
// Resource limits
MaxMemory string `json:"max_memory,omitempty" yaml:"max_memory,omitempty"`
MaxCPU float64 `json:"max_cpu,omitempty" yaml:"max_cpu,omitempty"`
// Execution timeout
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
// Command-specific arguments (passed to CLI)
Arguments map[string]interface{} `json:"arguments,omitempty" yaml:"arguments,omitempty"`
}
// DefaultImage returns the default Docker image for a command type
func DefaultImage(command string) string {
switch command {
case "claude":
return "yaoapp/sandbox-claude:latest"
case "cursor":
return "yaoapp/sandbox-cursor:latest"
default:
return ""
}
}
// CommandTypes is the list of supported command types
var CommandTypes = []string{"claude", "cursor"}
// IsValidCommand checks if a command type is valid
func IsValidCommand(command string) bool {
for _, c := range CommandTypes {
if c == command {
return true
}
}
return false
}

View file

@ -0,0 +1,87 @@
package sandbox
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDefaultImage(t *testing.T) {
tests := []struct {
command string
expected string
}{
{"claude", "yaoapp/sandbox-claude:latest"},
{"cursor", "yaoapp/sandbox-cursor:latest"},
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := DefaultImage(tt.command)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsValidCommand(t *testing.T) {
tests := []struct {
command string
expected bool
}{
{"claude", true},
{"cursor", true},
{"unknown", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := IsValidCommand(tt.command)
assert.Equal(t, tt.expected, result)
})
}
}
func TestOptionsValidation(t *testing.T) {
// Test that Options struct can be created with all fields
opts := &Options{
Command: "claude",
Image: "yaoapp/sandbox-claude:latest",
MaxMemory: "4g",
MaxCPU: 2.0,
UserID: "user123",
ChatID: "chat456",
ConnectorHost: "https://api.example.com",
ConnectorKey: "key123",
Model: "deepseek-v3",
Arguments: map[string]interface{}{
"max_turns": 20,
"permission_mode": "acceptEdits",
},
}
assert.Equal(t, "claude", opts.Command)
assert.Equal(t, "user123", opts.UserID)
assert.Equal(t, "chat456", opts.ChatID)
assert.Equal(t, 20, opts.Arguments["max_turns"])
}
func TestSandboxConfigParsing(t *testing.T) {
// Test that SandboxConfig can be used for parsing assistant config
config := &SandboxConfig{
Command: "claude",
Image: "custom-image:v1",
MaxMemory: "8g",
MaxCPU: 4.0,
Timeout: "10m",
Arguments: map[string]interface{}{
"permission_mode": "bypassPermissions",
},
}
assert.Equal(t, "claude", config.Command)
assert.Equal(t, "custom-image:v1", config.Image)
assert.Equal(t, "8g", config.MaxMemory)
assert.Equal(t, "10m", config.Timeout)
}

View file

@ -158,6 +158,34 @@ func ToWorkflow(v interface{}) (*Workflow, error) {
} }
} }
// ToSandbox converts various types to Sandbox
func ToSandbox(v interface{}) (*Sandbox, error) {
if v == nil {
return nil, nil
}
switch sandbox := v.(type) {
case *Sandbox:
return sandbox, nil
case Sandbox:
return &sandbox, nil
default:
raw, err := jsoniter.Marshal(sandbox)
if err != nil {
return nil, fmt.Errorf("sandbox format error: %s", err.Error())
}
var sb Sandbox
err = jsoniter.Unmarshal(raw, &sb)
if err != nil {
return nil, fmt.Errorf("sandbox format error: %s", err.Error())
}
return &sb, nil
}
}
// ToMySQLTime converts various types to MySQL datetime format // ToMySQLTime converts various types to MySQL datetime format
func ToMySQLTime(v interface{}) string { func ToMySQLTime(v interface{}) string {
switch val := v.(type) { switch val := v.(type) {

View file

@ -362,6 +362,16 @@ type Workflow struct {
Options map[string]interface{} `json:"options,omitempty"` // Additional workflow options Options map[string]interface{} `json:"options,omitempty"` // Additional workflow options
} }
// Sandbox the sandbox configuration for coding agents (Claude CLI, Cursor CLI)
type Sandbox struct {
Command string `json:"command"` // Command type: "claude" or "cursor"
Image string `json:"image,omitempty"` // Docker image (optional, auto-selected by command)
MaxMemory string `json:"max_memory,omitempty"` // Memory limit (e.g., "4g")
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
}
// Tool represents a tool configuration for storage // Tool represents a tool configuration for storage
type Tool struct { type Tool struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
@ -434,6 +444,7 @@ type AssistantModel struct {
DB *Database `json:"db,omitempty"` // Database configuration DB *Database `json:"db,omitempty"` // Database configuration
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration
Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration
Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents
Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder
Source string `json:"source,omitempty"` // Hook script source code Source string `json:"source,omitempty"` // Hook script source code
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales

View file

@ -16,6 +16,7 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/yaoapp/yao/sandbox/ipc" "github.com/yaoapp/yao/sandbox/ipc"
) )
@ -32,6 +33,60 @@ func (e *execReadCloser) Close() error {
return nil return nil
} }
// demuxReadCloser wraps Docker multiplexed stream and demuxes it to stdout only
// It uses a pipe to feed demuxed stdout to the reader
type demuxReadCloser struct {
reader io.Reader
pipeReader *io.PipeReader
pipeWriter *io.PipeWriter
closer io.Closer
done chan struct{}
err error
}
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser {
pr, pw := io.Pipe()
d := &demuxReadCloser{
reader: src,
pipeReader: pr,
pipeWriter: pw,
closer: closer,
done: make(chan struct{}),
}
// Start demux goroutine
go func() {
defer close(d.done)
defer pw.Close()
// Use stdcopy to demux stdout and stderr
// We only care about stdout here, stderr goes to a discard writer
_, err := stdcopy.StdCopy(pw, io.Discard, src)
if err != nil && err != io.EOF {
d.err = err
}
}()
return d
}
func (d *demuxReadCloser) Read(p []byte) (int, error) {
return d.pipeReader.Read(p)
}
func (d *demuxReadCloser) Close() error {
// Close the source to stop the demux goroutine
if d.closer != nil {
d.closer.Close()
}
// Close the pipe reader to unblock any pending reads
d.pipeReader.Close()
// Wait for demux goroutine to finish
<-d.done
return d.err
}
// Manager manages sandbox containers // Manager manages sandbox containers
type Manager struct { type Manager struct {
mu sync.Mutex // Protects creation mu sync.Mutex // Protects creation
@ -319,11 +374,9 @@ func (m *Manager) Stream(ctx context.Context, name string, cmd []string, opts *E
}() }()
} }
// Wrap in a ReadCloser // Return demuxed reader that properly handles Docker multiplexed stream
return &execReadCloser{ // This removes the 8-byte header from each frame and separates stdout from stderr
Reader: attachResp.Reader, return newDemuxReadCloser(attachResp.Reader, attachResp.Conn), nil
closer: attachResp.Conn,
}, nil
} }
// Exec executes command and waits for completion // Exec executes command and waits for completion
@ -393,22 +446,25 @@ func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *Exe
outputCh := make(chan []byte, 1) outputCh := make(chan []byte, 1)
errCh := make(chan error, 1) errCh := make(chan error, 1)
// Buffers for demuxed stdout and stderr
var stdoutBuf, stderrBuf bytes.Buffer
go func() { go func() {
output, err := io.ReadAll(attachResp.Reader) // Use stdcopy to properly demux Docker multiplexed stream
if err != nil { _, err := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, attachResp.Reader)
if err != nil && err != io.EOF {
errCh <- err errCh <- err
return return
} }
outputCh <- output outputCh <- nil
}() }()
var output []byte
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case err := <-errCh: case err := <-errCh:
return nil, fmt.Errorf("failed to read output: %w", err) return nil, fmt.Errorf("failed to read output: %w", err)
case output = <-outputCh: case <-outputCh:
// Output received // Output received
} }
@ -426,14 +482,10 @@ func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *Exe
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
// Parse Docker multiplexed stream
// TODO: Properly demux stdout/stderr from Docker stream
stdout := string(output)
return &ExecResult{ return &ExecResult{
ExitCode: exitCode, ExitCode: exitCode,
Stdout: stdout, Stdout: stdoutBuf.String(),
Stderr: "", Stderr: stderrBuf.String(),
}, nil }, nil
} }

View file

@ -45,6 +45,21 @@ type FileInfo struct {
IsDir bool // Is directory IsDir bool // Is directory
} }
// GetName returns the file name (implements context.SandboxFileInfo)
func (f FileInfo) GetName() string {
return f.Name
}
// GetSize returns the file size (implements context.SandboxFileInfo)
func (f FileInfo) GetSize() int64 {
return f.Size
}
// GetIsDir returns whether this is a directory (implements context.SandboxFileInfo)
func (f FileInfo) GetIsDir() bool {
return f.IsDir
}
// ContainerStatus constants // ContainerStatus constants
const ( const (
StatusCreated = "created" StatusCreated = "created"