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:
parent
f265aee974
commit
cd5cf32a20
30 changed files with 4874 additions and 19 deletions
13
.github/workflows/pr-test.yml
vendored
13
.github/workflows/pr-test.yml
vendored
|
|
@ -541,8 +541,19 @@ jobs:
|
|||
echo "YAO_DB_DRIVER=sqlite3" >> $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)
|
||||
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
|
||||
uses: codecov/codecov-action@v4
|
||||
|
|
|
|||
13
.github/workflows/unit-test.yml
vendored
13
.github/workflows/unit-test.yml
vendored
|
|
@ -435,8 +435,19 @@ jobs:
|
|||
echo "YAO_DB_DRIVER=sqlite3" >> $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)
|
||||
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
|
||||
uses: codecov/codecov-action@v4
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||
)
|
||||
|
||||
// Stream stream the agent
|
||||
|
|
@ -150,6 +151,33 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
}
|
||||
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
|
||||
// ================================================
|
||||
|
|
@ -254,7 +282,14 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
})
|
||||
|
||||
// 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 {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
|
|
|
|||
|
|
@ -714,6 +714,15 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
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.)
|
||||
// Merge hierarchy: global uses < assistant uses
|
||||
if uses, has := data["uses"]; has {
|
||||
|
|
|
|||
210
agent/assistant/sandbox.go
Normal file
210
agent/assistant/sandbox.go
Normal 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
|
||||
}
|
||||
82
agent/assistant/sandbox_debug_test.go
Normal file
82
agent/assistant/sandbox_debug_test.go
Normal 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!")
|
||||
}
|
||||
}
|
||||
329
agent/assistant/sandbox_e2e_test.go
Normal file
329
agent/assistant/sandbox_e2e_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
173
agent/assistant/sandbox_integration_test.go
Normal file
173
agent/assistant/sandbox_integration_test.go
Normal 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")
|
||||
}
|
||||
225
agent/assistant/sandbox_test.go
Normal file
225
agent/assistant/sandbox_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -152,6 +152,15 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
235
agent/context/jsapi_sandbox.go
Normal file
235
agent/context/jsapi_sandbox.go
Normal 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
|
||||
})
|
||||
}
|
||||
482
agent/context/jsapi_sandbox_test.go
Normal file
482
agent/context/jsapi_sandbox_test.go
Normal 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"])
|
||||
}
|
||||
|
|
@ -250,6 +250,7 @@ type Context struct {
|
|||
// Internal
|
||||
trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access
|
||||
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)
|
||||
Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector
|
||||
|
|
|
|||
1146
agent/sandbox/DESIGN.md
Normal file
1146
agent/sandbox/DESIGN.md
Normal file
File diff suppressed because it is too large
Load diff
252
agent/sandbox/PLAN.md
Normal file
252
agent/sandbox/PLAN.md
Normal 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
|
||||
214
agent/sandbox/claude/command.go
Normal file
214
agent/sandbox/claude/command.go
Normal 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, "", " ")
|
||||
}
|
||||
137
agent/sandbox/claude/command_test.go
Normal file
137
agent/sandbox/claude/command_test.go
Normal 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")
|
||||
}
|
||||
330
agent/sandbox/claude/executor.go
Normal file
330
agent/sandbox/claude/executor.go
Normal 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 ""
|
||||
}
|
||||
168
agent/sandbox/claude/executor_test.go
Normal file
168
agent/sandbox/claude/executor_test.go
Normal 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")
|
||||
}
|
||||
37
agent/sandbox/claude/types.go
Normal file
37
agent/sandbox/claude/types.go
Normal 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"`
|
||||
}
|
||||
55
agent/sandbox/cursor/README.md
Normal file
55
agent/sandbox/cursor/README.md
Normal 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
49
agent/sandbox/executor.go
Normal 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)
|
||||
}
|
||||
}
|
||||
133
agent/sandbox/executor_test.go
Normal file
133
agent/sandbox/executor_test.go
Normal 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")
|
||||
}
|
||||
207
agent/sandbox/integration_test.go
Normal file
207
agent/sandbox/integration_test.go
Normal 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
122
agent/sandbox/types.go
Normal 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
|
||||
}
|
||||
87
agent/sandbox/types_test.go
Normal file
87
agent/sandbox/types_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
func ToMySQLTime(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
|
|
|
|||
|
|
@ -362,6 +362,16 @@ type Workflow struct {
|
|||
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
|
||||
type Tool struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
|
|
@ -434,6 +444,7 @@ type AssistantModel struct {
|
|||
DB *Database `json:"db,omitempty"` // Database configuration
|
||||
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers 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
|
||||
Source string `json:"source,omitempty"` // Hook script source code
|
||||
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/yaoapp/yao/sandbox/ipc"
|
||||
)
|
||||
|
||||
|
|
@ -32,6 +33,60 @@ func (e *execReadCloser) Close() error {
|
|||
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
|
||||
type Manager struct {
|
||||
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 &execReadCloser{
|
||||
Reader: attachResp.Reader,
|
||||
closer: attachResp.Conn,
|
||||
}, nil
|
||||
// Return demuxed reader that properly handles Docker multiplexed stream
|
||||
// This removes the 8-byte header from each frame and separates stdout from stderr
|
||||
return newDemuxReadCloser(attachResp.Reader, attachResp.Conn), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
// Buffers for demuxed stdout and stderr
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
|
||||
go func() {
|
||||
output, err := io.ReadAll(attachResp.Reader)
|
||||
if err != nil {
|
||||
// Use stdcopy to properly demux Docker multiplexed stream
|
||||
_, err := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, attachResp.Reader)
|
||||
if err != nil && err != io.EOF {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
outputCh <- output
|
||||
outputCh <- nil
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case err := <-errCh:
|
||||
return nil, fmt.Errorf("failed to read output: %w", err)
|
||||
case output = <-outputCh:
|
||||
case <-outputCh:
|
||||
// Output received
|
||||
}
|
||||
|
||||
|
|
@ -426,14 +482,10 @@ func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *Exe
|
|||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Parse Docker multiplexed stream
|
||||
// TODO: Properly demux stdout/stderr from Docker stream
|
||||
stdout := string(output)
|
||||
|
||||
return &ExecResult{
|
||||
ExitCode: exitCode,
|
||||
Stdout: stdout,
|
||||
Stderr: "",
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@ type FileInfo struct {
|
|||
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
|
||||
const (
|
||||
StatusCreated = "created"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue