From cd5cf32a203bf7b44d92ecfea0f8dc233f9c7bae Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 30 Jan 2026 17:41:06 +0800 Subject: [PATCH] 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. --- .github/workflows/pr-test.yml | 13 +- .github/workflows/unit-test.yml | 13 +- agent/assistant/agent.go | 37 +- agent/assistant/load.go | 9 + agent/assistant/sandbox.go | 210 ++++ agent/assistant/sandbox_debug_test.go | 82 ++ agent/assistant/sandbox_e2e_test.go | 329 ++++++ agent/assistant/sandbox_integration_test.go | 173 +++ agent/assistant/sandbox_test.go | 225 ++++ agent/context/jsapi.go | 9 + agent/context/jsapi_sandbox.go | 235 ++++ agent/context/jsapi_sandbox_test.go | 482 ++++++++ agent/context/types.go | 1 + agent/sandbox/DESIGN.md | 1146 +++++++++++++++++++ agent/sandbox/PLAN.md | 252 ++++ agent/sandbox/claude/command.go | 214 ++++ agent/sandbox/claude/command_test.go | 137 +++ agent/sandbox/claude/executor.go | 330 ++++++ agent/sandbox/claude/executor_test.go | 168 +++ agent/sandbox/claude/types.go | 37 + agent/sandbox/cursor/README.md | 55 + agent/sandbox/executor.go | 49 + agent/sandbox/executor_test.go | 133 +++ agent/sandbox/integration_test.go | 207 ++++ agent/sandbox/types.go | 122 ++ agent/sandbox/types_test.go | 87 ++ agent/store/types/convert.go | 28 + agent/store/types/types.go | 11 + sandbox/manager.go | 84 +- sandbox/types.go | 15 + 30 files changed, 4874 insertions(+), 19 deletions(-) create mode 100644 agent/assistant/sandbox.go create mode 100644 agent/assistant/sandbox_debug_test.go create mode 100644 agent/assistant/sandbox_e2e_test.go create mode 100644 agent/assistant/sandbox_integration_test.go create mode 100644 agent/assistant/sandbox_test.go create mode 100644 agent/context/jsapi_sandbox.go create mode 100644 agent/context/jsapi_sandbox_test.go create mode 100644 agent/sandbox/DESIGN.md create mode 100644 agent/sandbox/PLAN.md create mode 100644 agent/sandbox/claude/command.go create mode 100644 agent/sandbox/claude/command_test.go create mode 100644 agent/sandbox/claude/executor.go create mode 100644 agent/sandbox/claude/executor_test.go create mode 100644 agent/sandbox/claude/types.go create mode 100644 agent/sandbox/cursor/README.md create mode 100644 agent/sandbox/executor.go create mode 100644 agent/sandbox/executor_test.go create mode 100644 agent/sandbox/integration_test.go create mode 100644 agent/sandbox/types.go create mode 100644 agent/sandbox/types_test.go diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index df537ab2..11efaf26 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -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 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 79a1f5b7..60f819ed 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index b92c5397..cda66e13 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -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 diff --git a/agent/assistant/load.go b/agent/assistant/load.go index 9d763837..990bd351 100644 --- a/agent/assistant/load.go +++ b/agent/assistant/load.go @@ -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 { diff --git a/agent/assistant/sandbox.go b/agent/assistant/sandbox.go new file mode 100644 index 00000000..3bac065a --- /dev/null +++ b/agent/assistant/sandbox.go @@ -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 +} diff --git a/agent/assistant/sandbox_debug_test.go b/agent/assistant/sandbox_debug_test.go new file mode 100644 index 00000000..680f29e4 --- /dev/null +++ b/agent/assistant/sandbox_debug_test.go @@ -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!") + } +} diff --git a/agent/assistant/sandbox_e2e_test.go b/agent/assistant/sandbox_e2e_test.go new file mode 100644 index 00000000..88673117 --- /dev/null +++ b/agent/assistant/sandbox_e2e_test.go @@ -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) + } + }) + } +} diff --git a/agent/assistant/sandbox_integration_test.go b/agent/assistant/sandbox_integration_test.go new file mode 100644 index 00000000..cbad24f6 --- /dev/null +++ b/agent/assistant/sandbox_integration_test.go @@ -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") +} diff --git a/agent/assistant/sandbox_test.go b/agent/assistant/sandbox_test.go new file mode 100644 index 00000000..934c649f --- /dev/null +++ b/agent/assistant/sandbox_test.go @@ -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") +} diff --git a/agent/context/jsapi.go b/agent/context/jsapi.go index 24ef5692..1081ba7d 100644 --- a/agent/context/jsapi.go +++ b/agent/context/jsapi.go @@ -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 } diff --git a/agent/context/jsapi_sandbox.go b/agent/context/jsapi_sandbox.go new file mode 100644 index 00000000..8439d691 --- /dev/null +++ b/agent/context/jsapi_sandbox.go @@ -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 + }) +} diff --git a/agent/context/jsapi_sandbox_test.go b/agent/context/jsapi_sandbox_test.go new file mode 100644 index 00000000..414347f0 --- /dev/null +++ b/agent/context/jsapi_sandbox_test.go @@ -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"]) +} diff --git a/agent/context/types.go b/agent/context/types.go index 6c2b9010..b873b3b7 100644 --- a/agent/context/types.go +++ b/agent/context/types.go @@ -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 diff --git a/agent/sandbox/DESIGN.md b/agent/sandbox/DESIGN.md new file mode 100644 index 00000000..f88376fa --- /dev/null +++ b/agent/sandbox/DESIGN.md @@ -0,0 +1,1146 @@ +# Agent Sandbox Design + +Inject Coding Agent capabilities (Claude CLI, Cursor CLI) into Yao's LLM request pipeline via Docker-based sandbox execution. + +## 1. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Yao LLM Pipeline │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Before Hooks / Auth / Logging │ │ +│ └─────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ LLM Request Handler │ │ +│ │ │ │ +│ │ sandbox: nil → Direct LLM API call (default) │ │ +│ │ sandbox: config → Sandbox + Claude CLI │ │ ← Inject here +│ │ sandbox: config → Sandbox + Cursor CLI (future) │ │ +│ │ │ │ +│ └─────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ After Hooks / Billing / Audit │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 2. Design Benefits + +| Aspect | Standalone Executor | Pipeline Injection | +| ------------- | ------------------------ | ----------------------- | +| **Hooks** | Need re-implementation | ✅ Fully reused | +| **Auth** | Need re-implementation | ✅ Fully reused | +| **Logging** | Need re-implementation | ✅ Fully reused | +| **Billing** | Need re-implementation | ✅ Fully reused | +| **Errors** | Need re-implementation | ✅ Fully reused | +| **Code size** | Large (full executor) | Small (one branch) | +| **Extend** | One executor per agent | Just add agent type | + +## 3. Configuration + +### 3.1 Assistant Configuration + +Sandbox is configured at the **Assistant level** (not Connector), because: + +- Same Connector can be shared by multiple Assistants +- Some Assistants need Sandbox (Coding), others don't (Q&A) +- Assistant defines "behavior", Connector defines "connection" + +```jsonc +// assistants/coder/package.yao +{ + "name": "Coder Assistant", + "connector": "deepseek.v3", + + // Sandbox configuration + "sandbox": { + "command": "claude", // claude | cursor (future) + "image": "yaoapp/sandbox-claude:latest", // Optional, auto-selected by agent + "max_memory": "4g", // Optional + "max_cpu": 2.0, // Optional + "timeout": "10m", // Optional, execution timeout + "arguments": { + // Command-specific arguments (passed to Claude CLI) + "max_turns": 20, + "permission_mode": "acceptEdits" + // Different commands may have different arguments + } + }, + + // MCP servers + "mcp": { + "servers": [ + { + "server_id": "filesystem", + "tools": ["read_file", "write_file", "list_directory"] + } + ] + } +} +``` + +### 3.2 Directory Structure + +Skills are auto-discovered from `skills/` directory following the [Agent Skills](https://agentskills.io) open standard: + +``` +assistants/coder/ +├── package.yao # Assistant config +├── prompts.yml # Prompts +├── mcps/ # MCP server definitions +└── skills/ # Skills directory (auto-discovered) + ├── code-review/ + │ ├── SKILL.md # Required: instructions + metadata + │ ├── scripts/ # Optional: executable code (Python, Bash, JS) + │ ├── references/ # Optional: additional documentation + │ └── assets/ # Optional: templates, images, data files + └── deploy/ + ├── SKILL.md + └── scripts/ + └── deploy.sh +``` + +### 3.3 SKILL.md Format + +Each skill must have a `SKILL.md` file with YAML frontmatter: + +```yaml +--- +name: code-review # Required: must match parent directory name +description: > # Required: when to use this skill + Review code for bugs, security issues, and best practices. + Use when the user asks to review, audit, or analyze code quality. +license: Apache-2.0 # Optional +compatibility: Requires git # Optional: environment requirements +metadata: # Optional: arbitrary key-value pairs + author: yao-team + version: "1.0" +allowed-tools: Bash(git:*) Read # Optional: pre-approved tools (experimental) +--- + +# Code Review + +## When to use this skill +Use this skill when the user asks to review code... + +## Steps +1. Check for security vulnerabilities +2. Review code style and best practices +3. Identify potential bugs +... +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | 1-64 chars, lowercase + hyphens, must match directory name | +| `description` | Yes | 1-1024 chars, describes what skill does and when to use it | +| `license` | No | License name or reference to bundled LICENSE file | +| `compatibility` | No | Environment requirements (tools, network access, etc.) | +| `metadata` | No | Arbitrary key-value pairs for additional properties | +| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental) | + +### 3.4 Skills Progressive Disclosure + +Skills use progressive disclosure to manage context efficiently: + +1. **Discovery**: At startup, agent loads only `name` and `description` of each skill +2. **Activation**: When task matches a skill's description, agent reads full `SKILL.md` +3. **Execution**: Agent follows instructions, loading `scripts/`, `references/`, `assets/` as needed + +### 3.5 No Sandbox (Default) + +If `sandbox` is not configured, Assistant uses direct LLM API calls: + +```jsonc +// assistants/qa/package.yao +{ + "name": "QA Assistant", + "connector": "deepseek.v3" + // No sandbox config → direct API call +} +``` + +## 4. Implementation + +### 4.1 Package Structure + +``` +yao/ +├── sandbox/ # Low-level sandbox infrastructure +│ ├── manager.go # Container management (✅ Done) +│ ├── config.go # Configuration (✅ Done) +│ ├── ipc/ # IPC communication (✅ Done) +│ │ ├── manager.go +│ │ ├── session.go +│ │ └── types.go +│ ├── bridge/ # yao-bridge binary (✅ Done) +│ │ └── main.go +│ └── docker/ # Docker images (✅ Done) +│ ├── base/ +│ ├── claude/ +│ └── build.sh +│ +└── agent/ + └── sandbox/ # Agent-level sandbox integration (NEW) + ├── DESIGN.md # This document + ├── types.go # Common types and interfaces + ├── executor.go # Factory function and registry + ├── claude/ # Claude CLI agent + │ ├── types.go # Claude-specific types (StreamMessage, ToolCall, etc.) + │ ├── executor.go # Executor implementation + │ ├── command.go # CLI command builder + │ ├── stream.go # Stream output parser + │ ├── environment.go # Container environment setup + │ └── executor_test.go + ├── cursor/ # Cursor CLI agent (future) + │ └── README.md # Placeholder for future implementation + └── sandbox_test.go # Integration tests +``` + +### 4.2 Types and Interfaces + +```go +// agent/sandbox/types.go +package sandbox + +import ( + "time" + + "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" +) + +// Executor executes LLM requests in sandbox +type Executor interface { + // Execute runs the request and returns response + Execute(ctx *context.Context, messages []context.Message, opts *Options) (*context.CompletionResponse, error) + + // Stream runs the request with streaming output + Stream(ctx *context.Context, messages []context.Message, opts *Options, handler message.StreamFunc) (*context.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) ([]os.FileInfo, error) + + // Command execution (for Hooks) + Exec(ctx context.Context, cmd []string) (string, error) + + // Close releases container resources + Close() error +} + +// Options for sandbox execution +type Options struct { + // Command type (claude, cursor) + Command string `json:"command"` + + // Docker image (optional, auto-selected by agent) + 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 + // ======================================== + + // 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:"-"` +} +``` + +### 4.3 Executor Factory + +```go +// agent/sandbox/executor.go +package sandbox + +import ( + "fmt" + + "github.com/yaoapp/yao/agent/sandbox/claude" + "github.com/yaoapp/yao/agent/sandbox/cursor" + "github.com/yaoapp/yao/sandbox" +) + +// New creates an executor based on agent type +func New(manager *sandbox.Manager, opts *Options) (Executor, error) { + if opts == nil { + return nil, fmt.Errorf("options cannot be nil") + } + + // Set default image if not specified + if opts.Image == "" { + opts.Image = DefaultImage(opts.Command) + } + + switch opts.Command { + case "claude": + return claude.New(manager, opts) + case "cursor": + return cursor.New(manager, opts) + default: + return nil, fmt.Errorf("unknown command type: %s", opts.Command) + } +} + +// 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 "" + } +} +``` + +### 4.4 Claude Agent Implementation + +The Claude agent is split into multiple files for maintainability: + +#### 4.4.1 Executor (claude/executor.go) + +```go +// agent/sandbox/claude/executor.go +package claude + +import ( + "fmt" + + "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" + sandbox "github.com/yaoapp/yao/agent/sandbox" + infra "github.com/yaoapp/yao/sandbox" +) + +// Executor executes requests via Claude CLI in sandbox +type Executor struct { + manager *infra.Manager + opts *sandbox.Options +} + +// New creates a new Claude executor +func New(manager *infra.Manager, opts *sandbox.Options) (*Executor, error) { + return &Executor{ + manager: manager, + opts: opts, + }, nil +} + +// Stream executes with streaming output +func (e *Executor) Stream( + ctx *context.Context, + messages []context.Message, + opts *sandbox.Options, + handler message.StreamFunc, +) (*context.CompletionResponse, error) { + + // 1. Get or create container + container, err := e.manager.GetOrCreate(ctx.Context(), ctx.Authorized.UserID, ctx.ChatID) + if err != nil { + return nil, fmt.Errorf("failed to get container: %w", err) + } + + // 2. Prepare environment + workDir := fmt.Sprintf("/workspace/%s/chat-%s", ctx.Authorized.UserID, ctx.ChatID) + if err := e.prepareEnvironment(ctx, container, workDir); err != nil { + return nil, fmt.Errorf("failed to prepare environment: %w", err) + } + + // 3. Build CLI command + cmd, env := BuildCommand(messages, opts, workDir) + + // 4. Execute in container with streaming + reader, err := e.manager.Stream(ctx.Context(), container.Name, cmd, &infra.ExecOptions{ + Env: env, + WorkingDir: workDir, + }) + if err != nil { + return nil, fmt.Errorf("failed to execute: %w", err) + } + defer reader.Close() + + // 5. Parse streaming output + return ParseStream(reader, handler) +} + +// Execute runs without streaming (wraps Stream) +func (e *Executor) Execute(ctx *context.Context, messages []context.Message, opts *sandbox.Options) (*context.CompletionResponse, error) { + var result *context.CompletionResponse + _, err := e.Stream(ctx, messages, opts, func(msg *message.Message) error { + // Collect final result + return nil + }) + return result, err +} + +// Close releases resources +func (e *Executor) Close() error { + return nil +} +``` + +#### 4.4.2 Environment Setup (claude/environment.go) + +```go +// agent/sandbox/claude/environment.go +package claude + +import ( + "path/filepath" + + "github.com/yaoapp/yao/agent/context" + sandbox "github.com/yaoapp/yao/agent/sandbox" + infra "github.com/yaoapp/yao/sandbox" +) + +// prepareEnvironment sets up the container environment +func (e *Executor) prepareEnvironment(ctx *context.Context, container *infra.Container, workDir string) error { + // Create work directory + if err := e.manager.MkDir(ctx.Context(), container.Name, workDir); err != nil { + return err + } + + // Write MCP config + if len(e.opts.MCPConfig) > 0 { + mcpPath := filepath.Join(workDir, ".mcp.json") + if err := e.manager.WriteFile(ctx.Context(), container.Name, mcpPath, e.opts.MCPConfig); err != nil { + return err + } + } + + // Copy skills if configured + if e.opts.SkillsDir != "" { + claudeDir := filepath.Join(workDir, ".claude") + if err := e.manager.MkDir(ctx.Context(), container.Name, claudeDir); err != nil { + return err + } + targetSkillsDir := filepath.Join(claudeDir, "skills") + if err := e.manager.CopyToContainer(ctx.Context(), container.Name, e.opts.SkillsDir, targetSkillsDir); err != nil { + return err + } + } + + return nil +} +``` + +#### 4.4.3 Command Builder (claude/command.go) + +```go +// agent/sandbox/claude/command.go +package claude + +import ( + "fmt" + "strconv" + "strings" + + "github.com/yaoapp/yao/agent/context" + sandbox "github.com/yaoapp/yao/agent/sandbox" +) + +// BuildCommand constructs the Claude CLI command +func BuildCommand(messages []context.Message, opts *sandbox.Options, workDir string) ([]string, map[string]string) { + cmd := []string{ + "claude", + "--print", + "--output-format", "stream-json", + } + + // Model + if opts.Model != "" { + cmd = append(cmd, "--model", opts.Model) + } + + // Agent-specific options from sandbox.options + if opts.Arguments != nil { + if maxTurns, ok := opts.Arguments["max_turns"].(int); ok && maxTurns > 0 { + cmd = append(cmd, "--max-turns", strconv.Itoa(maxTurns)) + } + if permMode, ok := opts.Arguments["permission_mode"].(string); ok && permMode != "" { + cmd = append(cmd, "--permission-mode", permMode) + } + } + + // MCP config + cmd = append(cmd, "--mcp-config", ".mcp.json") + + // System prompt (with history) + systemPrompt := buildSystemPrompt(messages) + if systemPrompt != "" { + cmd = append(cmd, "--system-prompt", systemPrompt) + } + + // User prompt (last user message) + prompt := extractUserPrompt(messages) + cmd = append(cmd, prompt) + + // Environment variables + env := map[string]string{} + if opts.ConnectorHost != "" { + env["ANTHROPIC_BASE_URL"] = opts.ConnectorHost + } + if opts.ConnectorKey != "" { + env["ANTHROPIC_API_KEY"] = opts.ConnectorKey + } + + return cmd, env +} + +// buildSystemPrompt builds system prompt with conversation history +func buildSystemPrompt(messages []context.Message) string { + var systemParts []string + var history []string + + for i, msg := range messages { + if msg.Role == "system" { + if content, ok := msg.Content.(string); ok { + systemParts = append(systemParts, content) + } + continue + } + + // Skip last user message (it becomes the prompt) + if i == len(messages)-1 && msg.Role == "user" { + continue + } + + // Add to history + if content, ok := msg.Content.(string); ok { + history = append(history, fmt.Sprintf("[%s]: %s", msg.Role, content)) + } + } + + if len(history) > 0 { + systemParts = append(systemParts, + "\n## Conversation History:\n"+strings.Join(history, "\n\n")) + } + + return strings.Join(systemParts, "\n") +} + +// extractUserPrompt gets the last user message +func extractUserPrompt(messages []context.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + if content, ok := messages[i].Content.(string); ok { + return content + } + } + } + return "" +} +``` + +#### 4.4.4 Types (claude/types.go) + +```go +// agent/sandbox/claude/types.go +package claude + +// StreamMessage represents a Claude CLI stream-json message +type StreamMessage struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message,omitempty"` + Result string `json:"result,omitempty"` + TotalCostUSD float64 `json:"total_cost_usd,omitempty"` + NumTurns int `json:"num_turns,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage,omitempty"` +} + +// ToolCall represents a tool invocation from Claude CLI +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"` +} +``` + +#### 4.4.5 Stream Parser (claude/stream.go) + +```go +// agent/sandbox/claude/stream.go +package claude + +import ( + "bufio" + "encoding/json" + "io" + "strings" + + "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" +) + +// ParseStream parses Claude CLI stream-json output +func ParseStream(reader io.ReadCloser, handler message.StreamFunc) (*context.CompletionResponse, error) { + resp := &context.CompletionResponse{} + var fullContent strings.Builder + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var msg StreamMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + + switch msg.Type { + case "assistant": + // Extract text content + for _, content := range msg.Message.Content { + if content.Type == "text" && content.Text != "" { + fullContent.WriteString(content.Text) + + // Send to stream handler + if handler != nil { + handler(&message.Message{ + Type: "text", + Data: content.Text, + }) + } + } + } + + case "result": + // Final result + if msg.Result != "" { + resp.Content = msg.Result + } + if msg.Usage != nil { + resp.Usage = &context.Usage{ + PromptTokens: msg.Usage.InputTokens, + CompletionTokens: msg.Usage.OutputTokens, + TotalTokens: msg.Usage.InputTokens + msg.Usage.OutputTokens, + } + } + resp.Extra = map[string]interface{}{ + "total_cost_usd": msg.TotalCostUSD, + "num_turns": msg.NumTurns, + "duration_ms": msg.DurationMs, + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + if resp.Content == "" { + resp.Content = fullContent.String() + } + + return resp, nil +} +``` + +### 4.5 Sandbox Lifecycle + +#### 4.5.1 Design Principles + +The sandbox follows a **stateless container + persistent workspace** model: + +| Component | Lifecycle | Storage | +|-----------|-----------|---------| +| **Container** | Per-request, disposable | None (stateless) | +| **Workspace** | Persistent across requests | `{YAO_DATA_ROOT}/sandbox/workspace/{user}/chat-{chat_id}/` | +| **Message History** | Managed by Yao | Yao's session store | + +This means: +- Container can be recreated anytime without losing state +- All files are preserved in the mounted workspace +- Conversation history is passed in each request (not stored in container) + +#### 4.5.2 Container Lifecycle + +``` +Request Start + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. Create Executor (docker run --rm) │ +│ - Mount workspace directory │ +│ - Set resource limits │ +│ - ~500ms-1s cold start │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. Before Hook (can access Executor) │ +│ - Write config files │ +│ - Check/prepare environment │ +│ - Can reject request (container auto-cleaned) │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. LLM Execute (Claude CLI in container) │ +│ - Run with full message history │ +│ - Stream output to handler │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. After Hook (can access Executor) │ +│ - Read generated files │ +│ - Execute post-commands (git commit, etc.) │ +│ - Cleanup temp files │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 5. Close Executor (defer) │ +│ - Container removed (--rm) │ +│ - Workspace persists │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 4.5.3 Workspace Cleanup + +Workspace cleanup is separate from container lifecycle: + +```go +// Global cleanup configuration +type CleanupConfig struct { + // Workspace retention period (default: 7 days) + WorkspaceRetention time.Duration `json:"workspace_retention"` + + // Run cleanup on schedule (default: daily at 3am) + CleanupSchedule string `json:"cleanup_schedule"` +} + +// Cleanup worker +func (m *Manager) cleanupStaleWorkspaces() { + // Find workspaces older than retention period + // Delete directories not accessed within the period +} +``` + +### 4.6 Hook Integration (via JSAPI) + +Hooks interact with the sandbox via **Context JSAPI**, not Go code. The Executor methods are exposed to JavaScript through `ctx.sandbox`: + +#### 4.6.1 Context JSAPI Extension + +```typescript +// Extension to Context interface (see agent/context/JSAPI.md) +interface Context { + // ... existing properties ... + + // Sandbox operations (only available when sandbox is configured) + sandbox?: { + // Filesystem operations + ReadFile(path: string): string; // Read file content + WriteFile(path: string, content: string): void; // Write file + ListDir(path: string): FileInfo[]; // List directory + + // Command execution + Exec(cmd: string[]): string; // Execute command, return output + + // Workspace info + workdir: string; // Container workspace path + }; +} + +interface FileInfo { + name: string; + size: number; + is_dir: boolean; + mod_time: string; +} +``` + +#### 4.6.2 Create Hook Examples (JavaScript) + +```javascript +// assistants/coder/src/index.ts + +/** + * Create Hook - runs after container created, before LLM execution + */ +function Create(ctx, messages, options) { + // Check if sandbox is available + if (!ctx.sandbox) { + return { messages }; + } + + // Send loading message to user + const loadingId = ctx.SendStream({ + type: "loading", + props: { message: "Preparing sandbox environment..." } + }); + + // Write project configuration + ctx.sandbox.WriteFile(".env", "DEBUG=true\nNODE_ENV=development"); + + // Check environment prerequisites + try { + const nodeVersion = ctx.sandbox.Exec(["node", "--version"]); + log.Info(`Node.js version: ${nodeVersion}`); + } catch (e) { + log.Error("Node.js not available"); + ctx.End(loadingId); + throw new Error("Node.js is required"); + } + + // List existing files + const files = ctx.sandbox.ListDir("."); + log.Debug(`Workspace files: ${files.map(f => f.name).join(", ")}`); + + // Update loading message and end it + ctx.Replace(loadingId, { + type: "text", + props: { content: "Sandbox ready" } + }); + ctx.End(loadingId); + + return { messages }; +} +``` + +#### 4.6.3 Next Hook Examples (JavaScript) + +```javascript +/** + * Next Hook - runs after LLM execution, before container cleanup + */ +function Next(ctx, payload, options) { + const { completion, error } = payload; + + if (error || !ctx.sandbox) { + return null; + } + + // Read generated files + try { + const result = ctx.sandbox.ReadFile("output/result.json"); + log.Info(`Generated output: ${result}`); + ctx.memory.context.Set("generated_result", JSON.parse(result)); + } catch (e) { + log.Debug("No result file generated"); + } + + // List generated source files + const srcFiles = ctx.sandbox.ListDir("src/"); + for (const file of srcFiles) { + log.Debug(`Generated: ${file.name} (${file.size} bytes)`); + } + + // Execute git commit if files changed + try { + ctx.sandbox.Exec(["git", "add", "."]); + ctx.sandbox.Exec(["git", "commit", "-m", "auto-commit by sandbox"]); + log.Info("Changes committed"); + } catch (e) { + log.Debug(`git commit skipped: ${e.message}`); + } + + // Cleanup temporary files + try { + ctx.sandbox.Exec(["rm", "-rf", "tmp/"]); + } catch (e) { + // Ignore cleanup errors + } + + return null; // Use default response +} +``` + +#### 4.6.4 Go Implementation (Internal) + +The Go layer only handles exposing the Executor to JSAPI: + +```go +// In agent/context/sandbox.go - expose Executor to JS runtime + +// SandboxJSAPI wraps Executor for JavaScript access +type SandboxJSAPI struct { + executor sandbox.Executor + workdir string +} + +// Methods are called from JavaScript via v8go bindings +func (s *SandboxJSAPI) ReadFile(path string) (string, error) { + content, err := s.executor.ReadFile(context.Background(), path) + return string(content), err +} + +func (s *SandboxJSAPI) WriteFile(path string, content string) error { + return s.executor.WriteFile(context.Background(), path, []byte(content)) +} + +func (s *SandboxJSAPI) ListDir(path string) ([]FileInfo, error) { + return s.executor.ListDir(context.Background(), path) +} + +func (s *SandboxJSAPI) Exec(cmd []string) (string, error) { + return s.executor.Exec(context.Background(), cmd) +} +``` + +### 4.7 Integration with Assistant + +The sandbox integration is separated into two files: + +- `agent/assistant/sandbox.go` - Sandbox handler (new file, contains all sandbox logic) +- `agent/assistant/llm.go` - Add sandbox detection (minimal change) + +#### 4.7.1 Sandbox Detection (`agent/assistant/llm.go`) + +```go +// In agent/assistant/llm.go - add sandbox detection + +func (ast *Assistant) executeLLMStream(...) (*context.CompletionResponse, error) { + // Check if sandbox is configured + if ast.Sandbox != nil && ast.Sandbox.Command != "" { + return ast.executeSandboxStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) + } + + // Default: direct LLM API call + // ... existing code ... +} +``` + +#### 4.7.2 Sandbox Handler (`agent/assistant/sandbox.go`) + +```go +// In agent/assistant/sandbox.go - sandbox execution logic + +func (ast *Assistant) executeSandboxStream(...) (*context.CompletionResponse, error) { + // Get sandbox manager (singleton) + manager := sandbox.GetManager() + if manager == nil { + return nil, fmt.Errorf("sandbox manager not initialized") + } + + // Build executor options + execOpts := &agentsandbox.Options{ + Command: ast.Sandbox.Command, + Image: ast.Sandbox.Image, + MaxMemory: ast.Sandbox.MaxMemory, + MaxCPU: ast.Sandbox.MaxCPU, + Timeout: ast.Sandbox.Timeout, + Arguments: ast.Sandbox.Arguments, + SkillsDir: filepath.Join(ast.Path, "skills"), + } + + // Get connector settings (resolved from connector config) + conn, _, err := ast.GetConnector(ctx, opts) + if err != nil { + return nil, 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 + execOpts.MCPConfig = ast.buildMCPConfig(ctx) + + // 1. Create executor (container starts here) + ctx.Trace().Info("Creating sandbox container...") + executor, err := agentsandbox.New(manager, execOpts) + if err != nil { + ctx.Trace().Error(fmt.Sprintf("Sandbox creation failed: %v", err)) + return nil, err + } + ctx.Trace().Info("Sandbox container ready") + defer executor.Close() // 5. Container cleanup on exit + + // 2. Before Hook - can access executor + if err := ast.runBeforeHook(ctx, executor); err != nil { + return nil, err // Container cleaned by defer + } + + // 3. LLM Execute + resp, err := executor.Stream(ctx, completionMessages, execOpts, streamHandler) + if err != nil { + return nil, err + } + + // 4. After Hook - can access executor + if err := ast.runAfterHook(ctx, executor, resp); err != nil { + log.Printf("after hook error: %v", err) // Log but don't fail + } + + return resp, nil +} +``` + +## 5. IPC Communication (Yao ↔ Sandbox) + +The sandbox can call Yao processes via MCP: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Sandbox Container │ +│ ┌─────────────────┐ ┌──────────────────┐ │ +│ │ Claude CLI │───▶│ yao-bridge │ │ +│ │ │ │ │ │ +│ │ MCP: tools/call│ │ Unix Socket │ │ +│ │ "yao.process" │ │ /tmp/yao.sock │ │ +│ └─────────────────┘ └────────┬─────────┘ │ +└──────────────────────────────────┼─────────────────────────────┘ + │ JSON-RPC + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Yao Host │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ IPC Manager │ │ +│ │ │ │ +│ │ Session: user123-chat456 │ │ +│ │ Socket: /data/sandbox/ipc/user123-chat456.sock │ │ +│ │ │ │ +│ │ Handlers: │ │ +│ │ tools/list → Return authorized tool list │ │ +│ │ tools/call → Execute Yao Process │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## 6. Skills Support + +Skills follow [Agent Skills](https://agentskills.io) standard: + +```markdown +--- +name: code-review +description: "Review code for security, performance, and best practices" +allowed-tools: + - Read + - Grep + - Glob +--- + +# Code Review + +When reviewing code: + +## Security +- Check for SQL injection, XSS, CSRF +- Verify input validation + +## Performance +- Look for N+1 queries +- Check for unnecessary re-renders +``` + +Skills are auto-copied to container at `{workDir}/.claude/skills/`. + +## 7. Docker Images + +### Available Images + +| Image | Contents | Size | +|-------|----------|------| +| `yaoapp/sandbox-base` | Ubuntu 24.04, git, vim, network tools, yao-bridge | ~200MB | +| `yaoapp/sandbox-claude` | base + Node.js 22, Python 3.12, Claude CLI, CCR | ~900MB | +| `yaoapp/sandbox-claude-full` | claude + Go 1.23 | ~1.2GB | + +### Included Tools + +- **Editors**: vim, less, tree +- **Network**: ping, netstat, nslookup, nc, telnet +- **Compression**: zip, unzip, tar, gzip +- **System**: htop, ps, sed, awk, grep +- **Development**: git, Node.js, Python, Claude CLI, CCR + +## 8. Implementation Status + +See [PLAN.md](./PLAN.md) for detailed implementation tasks and testing requirements. + +### Completed (sandbox package) + +- [x] Container management (`sandbox/manager.go`) +- [x] Configuration (`sandbox/config.go`) +- [x] IPC communication (`sandbox/ipc/`) +- [x] yao-bridge binary (`sandbox/bridge/`) +- [x] Docker images (`sandbox/docker/`) + +### To Implement (agent/sandbox package) + +- [ ] Types and interfaces +- [ ] Executor factory +- [ ] Claude executor (claude/) +- [ ] Context JSAPI bindings +- [ ] Assistant integration +- [ ] Workspace cleanup + +## 9. Testing + +See [PLAN.md](./PLAN.md) for complete testing strategy. + +**Quick test:** + +```bash +# Test Docker image +docker run --rm yaoapp/sandbox-claude:latest bash -c " + node --version && python3 --version && claude --version +" + +# Run integration tests +source /Users/max/Yao/yao/env.local.sh +go test -v ./agent/sandbox/... +``` + +## 10. Usage Example + +```bash +# Test script at yao-dev-app/agents/claude/run.sh +./run.sh "Hello, what is 1+1?" # Default: deepseek/v3 +./run.sh -c deepseek/r1 "Hello" # Use R1 model +./run.sh -c claude/sonnet-4_0 "Hello" # Use Claude +``` diff --git a/agent/sandbox/PLAN.md b/agent/sandbox/PLAN.md new file mode 100644 index 00000000..7209f230 --- /dev/null +++ b/agent/sandbox/PLAN.md @@ -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 diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go new file mode 100644 index 00000000..2a30cb3a --- /dev/null +++ b/agent/sandbox/claude/command.go @@ -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, "", " ") +} diff --git a/agent/sandbox/claude/command_test.go b/agent/sandbox/claude/command_test.go new file mode 100644 index 00000000..180f4ca2 --- /dev/null +++ b/agent/sandbox/claude/command_test.go @@ -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") +} diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go new file mode 100644 index 00000000..ae2008a0 --- /dev/null +++ b/agent/sandbox/claude/executor.go @@ -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 "" +} diff --git a/agent/sandbox/claude/executor_test.go b/agent/sandbox/claude/executor_test.go new file mode 100644 index 00000000..d06deadb --- /dev/null +++ b/agent/sandbox/claude/executor_test.go @@ -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") +} diff --git a/agent/sandbox/claude/types.go b/agent/sandbox/claude/types.go new file mode 100644 index 00000000..67748e2a --- /dev/null +++ b/agent/sandbox/claude/types.go @@ -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"` +} diff --git a/agent/sandbox/cursor/README.md b/agent/sandbox/cursor/README.md new file mode 100644 index 00000000..81835f05 --- /dev/null +++ b/agent/sandbox/cursor/README.md @@ -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) diff --git a/agent/sandbox/executor.go b/agent/sandbox/executor.go new file mode 100644 index 00000000..30f59856 --- /dev/null +++ b/agent/sandbox/executor.go @@ -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) + } +} diff --git a/agent/sandbox/executor_test.go b/agent/sandbox/executor_test.go new file mode 100644 index 00000000..1332a278 --- /dev/null +++ b/agent/sandbox/executor_test.go @@ -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") +} diff --git a/agent/sandbox/integration_test.go b/agent/sandbox/integration_test.go new file mode 100644 index 00000000..16d99ded --- /dev/null +++ b/agent/sandbox/integration_test.go @@ -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") +} diff --git a/agent/sandbox/types.go b/agent/sandbox/types.go new file mode 100644 index 00000000..f0d5b8f3 --- /dev/null +++ b/agent/sandbox/types.go @@ -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 +} diff --git a/agent/sandbox/types_test.go b/agent/sandbox/types_test.go new file mode 100644 index 00000000..bcd75c95 --- /dev/null +++ b/agent/sandbox/types_test.go @@ -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) +} diff --git a/agent/store/types/convert.go b/agent/store/types/convert.go index 604dcda1..b45e93df 100644 --- a/agent/store/types/convert.go +++ b/agent/store/types/convert.go @@ -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) { diff --git a/agent/store/types/types.go b/agent/store/types/types.go index d433a61b..657c8e8e 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -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 diff --git a/sandbox/manager.go b/sandbox/manager.go index d47651c6..f19b9a98 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -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 } diff --git a/sandbox/types.go b/sandbox/types.go index d12d4ca9..7db7e63c 100644 --- a/sandbox/types.go +++ b/sandbox/types.go @@ -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"