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..1a02197d 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 @@ -282,8 +317,9 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa // ================================================ // Execute tool calls with retry // ================================================ + // Note: Skip MCP tool calls execution for sandbox mode - Claude CLI handles them internally var toolCallResponses []context.ToolCallResponse = nil - if completionResponse != nil && completionResponse.ToolCalls != nil { + if completionResponse != nil && completionResponse.ToolCalls != nil && !ast.HasSandbox() { maxToolRetries := 3 currentMessages := completionMessages 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..66e5733b --- /dev/null +++ b/agent/assistant/sandbox.go @@ -0,0 +1,356 @@ +package assistant + +import ( + stdContext "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + gouMCP "github.com/yaoapp/gou/mcp" + mcpProcess "github.com/yaoapp/gou/mcp/process" + "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" + "github.com/yaoapp/yao/sandbox/ipc" + 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) + // Only set if the directory actually exists + if ast.Path != "" { + appRoot := config.Conf.AppSource + skillsDir := filepath.Join(appRoot, ast.Path, "skills") + if info, err := os.Stat(skillsDir); err == nil && info.IsDir() { + execOpts.SkillsDir = skillsDir + ctx.Logger.Debug("Skills directory found: %s", skillsDir) + } + } + + // 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 and load tools if the assistant has MCP servers configured + if ast.MCP != nil && len(ast.MCP.Servers) > 0 { + // Build MCP config for Claude CLI + mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx) + if err != nil { + ctx.Logger.Warn("Failed to build MCP config for sandbox: %v", err) + // Non-fatal: sandbox can work without MCP + } else { + execOpts.MCPConfig = mcpConfig + ctx.Logger.Debug("MCP config built for sandbox (%d bytes)", len(mcpConfig)) + } + + // Load MCP tools for IPC session + mcpTools, err := ast.loadMCPToolsForIPC(ctx) + if err != nil { + ctx.Logger.Warn("Failed to load MCP tools for IPC: %v", err) + // Non-fatal: IPC will have no tools + } else if len(mcpTools) > 0 { + execOpts.MCPTools = mcpTools + ctx.Logger.Debug("Loaded %d MCP tools for IPC", len(mcpTools)) + } + } + + return execOpts, nil +} + +// loadMCPToolsForIPC loads MCP tools from configured servers and converts them to IPC format +func (ast *Assistant) loadMCPToolsForIPC(ctx *context.Context) (map[string]*ipc.MCPTool, error) { + if ast.MCP == nil || len(ast.MCP.Servers) == 0 { + return nil, nil + } + + tools := make(map[string]*ipc.MCPTool) + stdCtx := ctx.Context + if stdCtx == nil { + stdCtx = stdContext.Background() + } + + for _, serverConfig := range ast.MCP.Servers { + if serverConfig.ServerID == "" { + continue + } + + // Get MCP client + client, err := gouMCP.Select(serverConfig.ServerID) + if err != nil { + ctx.Logger.Warn("MCP server '%s' not found: %v", serverConfig.ServerID, err) + continue + } + + // List tools from the MCP client + toolsResp, err := client.ListTools(stdCtx, "") + if err != nil { + ctx.Logger.Warn("Failed to list tools from MCP server '%s': %v", serverConfig.ServerID, err) + continue + } + + // Get tool mapping for process names + mapping, ok := mcpProcess.GetMapping(serverConfig.ServerID) + if !ok { + ctx.Logger.Warn("No mapping found for MCP server '%s'", serverConfig.ServerID) + continue + } + + // Filter tools if specified in config + toolFilter := make(map[string]bool) + if len(serverConfig.Tools) > 0 { + for _, t := range serverConfig.Tools { + toolFilter[t] = true + } + } + + // Convert tools to IPC format + // Tool names are prefixed with server ID to avoid conflicts + // e.g., "echo" server's "ping" tool becomes "echo__ping" + for _, tool := range toolsResp.Tools { + // Apply tool filter if specified + if len(toolFilter) > 0 && !toolFilter[tool.Name] { + continue + } + + // Find the process name from mapping + processName := "" + if toolSchema, ok := mapping.Tools[tool.Name]; ok { + processName = toolSchema.Process + } + if processName == "" { + ctx.Logger.Warn("No process mapping for tool '%s' in server '%s'", tool.Name, serverConfig.ServerID) + continue + } + + // Prefixed tool name: serverID__toolName + // This matches Claude's MCP naming: mcp__yao__serverID__toolName + prefixedName := serverConfig.ServerID + "__" + tool.Name + + // Create IPC tool entry with prefixed name + ipcTool := &ipc.MCPTool{ + Name: prefixedName, + Description: tool.Description, + Process: processName, + InputSchema: tool.InputSchema, + } + + tools[prefixedName] = ipcTool + } + } + + return tools, nil +} + +// BuildMCPConfigForSandbox builds the MCP configuration JSON for sandbox +// This creates a .mcp.json format that Claude CLI can understand +// Exported for testing +func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, error) { + if ast.MCP == nil || len(ast.MCP.Servers) == 0 { + return nil, nil + } + + // Build MCP config in Claude CLI format + // Claude CLI expects: { "mcpServers": { "server_id": { "command": "...", "args": [...] } } } + // + // For Yao's MCP servers, we use yao-bridge to connect to the IPC socket. + // yao-bridge bridges stdio to Unix socket, allowing Claude CLI to communicate + // with Yao's IPC server running on the host. + // + // Architecture: + // Claude CLI → yao-bridge → Unix Socket → IPC Session → Yao Process + config := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + // Single "yao" server that handles all MCP tools via IPC + "yao": map[string]interface{}{ + "command": "yao-bridge", + "args": []string{"/tmp/yao.sock"}, // ContainerIPCSocket from sandbox config + }, + }, + } + + return json.Marshal(config) +} 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..099b8799 --- /dev/null +++ b/agent/assistant/sandbox_e2e_test.go @@ -0,0 +1,481 @@ +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") +} + +// TestSandboxMCPToolCall tests that Claude actually calls MCP tools via IPC +// This test specifically asks Claude to use the echo tool and verifies the result +func TestSandboxMCPToolCall(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox MCP tool call test in short mode") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Load the full sandbox assistant (has MCP echo tool) + ast, err := assistant.Get("tests.sandbox.full") + if err != nil { + t.Skipf("Skipping test: full sandbox assistant not available: %v", err) + } + + // Verify MCP is configured with echo tools + require.NotNil(t, ast.MCP, "MCP should be configured") + require.NotEmpty(t, ast.MCP.Servers, "MCP servers should be configured") + t.Logf("✓ MCP configured with server: %s, tools: %v", + ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools) + + // Create context + ctx := newSandboxE2EContext("sandbox-mcp-tool", "tests.sandbox.full") + + // Explicit prompt to use echo tool + // This tells Claude to use the MCP tool specifically + messages := []context.Message{ + { + Role: context.RoleUser, + Content: `Please use the 'ping' MCP tool to send a ping with message "MCP_TEST_SUCCESS". +Just call the tool and show me the result. Do not explain, just use the tool.`, + }, + } + + // Collect all response content + var responseContent strings.Builder + + // 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") + + // Get the response content + fullResponse := "" + if response.Completion != nil && response.Completion.Content != nil { + if contentStr, ok := response.Completion.Content.(string); ok { + fullResponse = contentStr + responseContent.WriteString(contentStr) + } + } + + t.Logf("Claude response: %s", fullResponse) + + // Check if Claude acknowledged using the tool or returned tool results + // The response should contain either: + // 1. Evidence of tool call (tool_use block in response) + // 2. The ping result "pong" or "MCP_TEST_SUCCESS" + // 3. Some indication that it attempted to use the MCP tool + + hasToolEvidence := strings.Contains(fullResponse, "pong") || + strings.Contains(fullResponse, "MCP_TEST_SUCCESS") || + strings.Contains(fullResponse, "ping") || + strings.Contains(fullResponse, "tool") + + if hasToolEvidence { + t.Log("✓ Claude appears to have used the MCP tool") + } else { + t.Logf("⚠ Claude response does not clearly show MCP tool usage") + t.Logf("Response: %s", fullResponse) + } + + // At minimum, verify we got a response + if fullResponse == "" { + t.Log("⚠ Response content is empty") + } + t.Log("✓ Sandbox MCP tool call test completed") +} + +// TestSandboxMCPEchoTool tests the echo MCP tool specifically +// This test uses a more explicit prompt to force tool usage +func TestSandboxMCPEchoTool(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox MCP echo 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) + } + + // Create context + ctx := newSandboxE2EContext("sandbox-mcp-echo", "tests.sandbox.full") + + // Very explicit prompt for echo tool + messages := []context.Message{ + { + Role: context.RoleUser, + Content: `Call the 'echo' MCP tool with message "ECHO_VERIFICATION_12345" and uppercase=true. +Show me the exact response from the tool.`, + }, + } + + 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) + + fullResponse := "" + if response.Completion != nil && response.Completion.Content != nil { + if contentStr, ok := response.Completion.Content.(string); ok { + fullResponse = contentStr + } + } + t.Logf("Claude response for echo tool: %s", fullResponse) + + // The echo tool with uppercase=true should return "ECHO_VERIFICATION_12345" + // Check if this appears in the response + if strings.Contains(fullResponse, "ECHO_VERIFICATION_12345") { + t.Log("✓ MCP echo tool executed successfully - found verification string in response") + } else if strings.Contains(fullResponse, "echo") || strings.Contains(fullResponse, "ECHO") { + t.Log("✓ MCP echo tool appears to have been used (found 'echo' in response)") + } else { + t.Logf("⚠ Could not verify echo tool execution. Response: %s", fullResponse) + } + + t.Log("✓ Sandbox MCP echo tool test completed") +} + +// 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..4852f9dd --- /dev/null +++ b/agent/assistant/sandbox_integration_test.go @@ -0,0 +1,181 @@ +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 + // Command is now: ["bash", "-c", "nohup ccr start ... && ccr code ..."] + assert.NotEmpty(t, cmd) + assert.Equal(t, "bash", cmd[0], "Command should start with bash") + assert.Equal(t, "-c", cmd[1], "Second arg should be -c") + assert.Contains(t, cmd[2], "ccr code", "Bash command should contain ccr code") + assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode") + 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 (CCR uses snake_case) + assert.Contains(t, string(configJSON), "api_base_url") + assert.Contains(t, string(configJSON), "api_key") + assert.Contains(t, string(configJSON), "models") + // Verify CCR format fields + assert.Contains(t, string(configJSON), "Providers") + assert.Contains(t, string(configJSON), "Router") + assert.Contains(t, string(configJSON), "volcengine") +} + +// 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..a03dc495 --- /dev/null +++ b/agent/assistant/sandbox_test.go @@ -0,0 +1,319 @@ +package assistant_test + +import ( + "context" + "encoding/json" + "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" + agentContext "github.com/yaoapp/yao/agent/context" + "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") +} + +// TestBuildMCPConfigForSandbox tests that MCP configuration is correctly built for sandbox +func TestBuildMCPConfigForSandbox(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) + require.NotNil(t, ast.MCP, "MCP configuration should exist") + + // Create a mock context for the test + ctx := agentContext.New(context.Background(), nil, "test-mcp-config-build") + + // Call BuildMCPConfigForSandbox and verify the result + mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx) + require.NoError(t, err, "BuildMCPConfigForSandbox should not error") + require.NotEmpty(t, mcpConfig, "MCP config should not be empty") + + t.Logf("MCP config JSON: %s", string(mcpConfig)) + + // Parse and verify the JSON structure + var config map[string]interface{} + err = json.Unmarshal(mcpConfig, &config) + require.NoError(t, err, "MCP config should be valid JSON") + + // Verify mcpServers key exists + mcpServers, ok := config["mcpServers"].(map[string]interface{}) + require.True(t, ok, "mcpServers should be a map") + require.NotEmpty(t, mcpServers, "mcpServers should not be empty") + + // Verify "yao" server exists (single server using yao-bridge for IPC) + yaoServer, ok := mcpServers["yao"].(map[string]interface{}) + require.True(t, ok, "yao server should exist in mcpServers") + + // Verify server structure - uses yao-bridge to connect to IPC socket + assert.Equal(t, "yao-bridge", yaoServer["command"], "command should be yao-bridge") + + args, ok := yaoServer["args"].([]interface{}) + require.True(t, ok, "args should be an array") + require.Len(t, args, 1, "args should have 1 element") + assert.Equal(t, "/tmp/yao.sock", args[0], "first arg should be IPC socket path") + + t.Logf("✓ MCP config verified: uses yao-bridge with IPC socket /tmp/yao.sock") +} + +// TestSandboxMCPAndSkillsOptions tests that sandbox options include MCP and Skills +func TestSandboxMCPAndSkillsOptions(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 sandbox configuration is present + require.NotNil(t, ast.Sandbox, "Sandbox should be configured") + assert.Equal(t, "claude", ast.Sandbox.Command) + + // Verify MCP is configured (will be passed to sandbox) + require.NotNil(t, ast.MCP, "MCP should be configured") + assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server") + + // Verify skills directory exists + appRoot := os.Getenv("YAO_ROOT") + require.NotEmpty(t, appRoot, "YAO_ROOT should be set") + + skillsDir := filepath.Join(appRoot, ast.Path, "skills") + info, err := os.Stat(skillsDir) + require.NoError(t, err, "Skills directory should exist") + assert.True(t, info.IsDir(), "Skills should be a directory") + + // Verify echo-test skill exists + echoTestDir := filepath.Join(skillsDir, "echo-test") + info, err = os.Stat(echoTestDir) + require.NoError(t, err, "echo-test skill should exist") + assert.True(t, info.IsDir(), "echo-test should be a directory") + + // Verify SKILL.md exists + skillMd := filepath.Join(echoTestDir, "SKILL.md") + _, err = os.Stat(skillMd) + require.NoError(t, err, "SKILL.md should exist") +} diff --git a/agent/context/JSAPI.md b/agent/context/JSAPI.md index 4103b244..2963e132 100644 --- a/agent/context/JSAPI.md +++ b/agent/context/JSAPI.md @@ -40,6 +40,7 @@ interface Context { mcp: MCP; // MCP object for external tool/resource access agent: Agent; // Agent-to-Agent calls (A2A) llm: LLM; // Direct LLM connector calls + sandbox?: Sandbox; // Sandbox operations (only when sandbox configured) } ``` @@ -2211,6 +2212,251 @@ function Next(ctx, payload) { } ``` +## Sandbox API + +The `ctx.sandbox` object provides access to sandbox operations when the assistant is configured with a sandbox executor (e.g., Claude CLI, Cursor CLI). The sandbox allows hooks to interact with an isolated Docker container environment for file operations and command execution. + +> **Note:** `ctx.sandbox` is only available when the assistant has `sandbox` configuration in `package.yao`. If no sandbox is configured, `ctx.sandbox` will be `null`. + +### Properties + +- `ctx.sandbox.workdir`: String - The workspace directory path inside the container (e.g., `/workspace`) + +### Methods Summary + +| Method | Description | +| ----------------------------- | ---------------------------------------- | +| `ReadFile(path)` | Read a file from the container | +| `WriteFile(path, content)` | Write content to a file in the container | +| `ListDir(path)` | List directory contents | +| `Exec(command)` | Execute a command in the container | + +### File Operations + +#### `ctx.sandbox.ReadFile(path): string` + +Reads a file from the sandbox container. + +**Parameters:** + +- `path`: String - File path (relative to workdir or absolute) + +**Returns:** + +- `string`: File contents as string + +**Example:** + +```javascript +// Read a file from workspace +const content = ctx.sandbox.ReadFile("config.json"); +console.log(content); + +// Read with absolute path +const readme = ctx.sandbox.ReadFile("/workspace/README.md"); +``` + +#### `ctx.sandbox.WriteFile(path, content): void` + +Writes content to a file in the sandbox container. + +**Parameters:** + +- `path`: String - File path (relative to workdir or absolute) +- `content`: String - Content to write + +**Example:** + +```javascript +// Write a configuration file +ctx.sandbox.WriteFile("config.json", JSON.stringify({ debug: true })); + +// Write a script +ctx.sandbox.WriteFile("script.sh", "#!/bin/bash\necho 'Hello'"); +``` + +#### `ctx.sandbox.ListDir(path): FileInfo[]` + +Lists the contents of a directory in the sandbox container. + +**Parameters:** + +- `path`: String - Directory path (relative to workdir or absolute) + +**Returns:** + +- `FileInfo[]`: Array of file information objects + +**FileInfo Structure:** + +```typescript +interface FileInfo { + name: string; // File or directory name + size: number; // Size in bytes + is_dir: boolean; // True if directory +} +``` + +**Example:** + +```javascript +// List workspace contents +const files = ctx.sandbox.ListDir("."); +files.forEach(f => { + console.log(`${f.is_dir ? "DIR" : "FILE"} ${f.name} (${f.size} bytes)`); +}); + +// List specific directory +const srcFiles = ctx.sandbox.ListDir("src"); +``` + +### Command Execution + +#### `ctx.sandbox.Exec(command): string` + +Executes a command in the sandbox container and returns the output. + +**Parameters:** + +- `command`: String[] - Command and arguments as an array + +**Returns:** + +- `string`: Command stdout output + +**Throws:** + +- Error if command exits with non-zero code (includes stderr in error message) + +**Example:** + +```javascript +// Run a simple command +const output = ctx.sandbox.Exec(["echo", "Hello, World!"]); +console.log(output); // "Hello, World!\n" + +// Run git commands +const status = ctx.sandbox.Exec(["git", "status"]); +console.log(status); + +// Run npm install +try { + const result = ctx.sandbox.Exec(["npm", "install"]); + console.log("Install complete:", result); +} catch (e) { + console.error("Install failed:", e.message); +} + +// Run shell script +ctx.sandbox.WriteFile("test.sh", "#!/bin/bash\necho 'Running script'\nls -la"); +ctx.sandbox.Exec(["chmod", "+x", "test.sh"]); +const scriptOutput = ctx.sandbox.Exec(["./test.sh"]); +``` + +### Use Cases + +```javascript +// Use case 1: Prepare workspace before Claude CLI execution +function Create(ctx, messages) { + if (ctx.sandbox) { + // Create project structure + ctx.sandbox.WriteFile("package.json", JSON.stringify({ + name: "project", + version: "1.0.0" + }, null, 2)); + + // Write initial code + ctx.sandbox.WriteFile("src/index.ts", "console.log('Hello');"); + + ctx.trace.Info("Workspace prepared"); + } + return { messages }; +} + +// Use case 2: Post-process sandbox results +function Next(ctx, payload) { + if (ctx.sandbox && !payload.error) { + // Read generated files + try { + const files = ctx.sandbox.ListDir("output"); + const results = files.map(f => ({ + name: f.name, + content: ctx.sandbox.ReadFile(`output/${f.name}`) + })); + + return { + data: { + status: "success", + generated_files: results + } + }; + } catch (e) { + ctx.trace.Warn("No output directory found"); + } + } + return null; +} + +// Use case 3: Run tests after code generation +function Next(ctx, payload) { + if (ctx.sandbox && payload.completion) { + try { + // Run tests + const testOutput = ctx.sandbox.Exec(["npm", "test"]); + ctx.trace.Info("Tests passed"); + + return { + data: { + status: "success", + test_output: testOutput + } + }; + } catch (e) { + ctx.trace.Error("Tests failed: " + e.message); + return { + data: { + status: "test_failed", + error: e.message + } + }; + } + } + return null; +} +``` + +### Sandbox Configuration + +The sandbox is configured in the assistant's `package.yao`: + +```jsonc +{ + "name": "Coder Assistant", + "connector": "deepseek.v3", + "sandbox": { + "command": "claude", // claude | cursor (future) + "image": "yaoapp/sandbox-claude:latest", // Optional, auto-selected by command + "max_memory": "4g", // Memory limit (optional) + "max_cpu": 2.0, // CPU limit (optional) + "timeout": "10m", // Execution timeout + "arguments": { // Command-specific arguments + "max_turns": 20, + "permission_mode": "acceptEdits" + } + } +} +``` + +### Notes + +- Sandbox operations are **synchronous** - they block until complete +- File paths can be relative (to workdir) or absolute +- Relative paths are resolved against the `workdir` directory +- The sandbox container is created at the start of the request and removed when the request completes +- Commands are executed with the sandbox user's permissions +- Errors throw JavaScript exceptions - use try/catch for error handling +- Large file operations may timeout - use appropriate timeout settings + ## LLM API The `ctx.llm` object provides direct access to LLM connectors for streaming completions. This allows calling LLM models directly without going through the full agent pipeline, useful for quick completions, model comparisons, or building custom workflows. 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/docs/context-api.md b/agent/docs/context-api.md index b3a9a49e..380fac42 100644 --- a/agent/docs/context-api.md +++ b/agent/docs/context-api.md @@ -21,6 +21,7 @@ interface Context { search: Search; // Search API agent: Agent; // Agent-to-Agent calls (A2A) llm: LLM; // Direct LLM calls + sandbox?: Sandbox; // Sandbox operations (optional) } ``` @@ -446,6 +447,75 @@ interface Message { } ``` +## Sandbox API + +The `ctx.sandbox` object provides access to sandbox operations when the assistant is configured with a sandbox executor (e.g., Claude CLI). Only available when `sandbox` is configured in `package.yao`. + +### Properties + +```typescript +ctx.sandbox.workdir // Workspace directory path (e.g., "/workspace") +``` + +### File Operations + +```typescript +// Read file +const content = ctx.sandbox.ReadFile("config.json"); + +// Write file +ctx.sandbox.WriteFile("output.txt", "Hello World"); + +// List directory +const files = ctx.sandbox.ListDir("src"); +files.forEach(f => console.log(f.name, f.is_dir, f.size)); +``` + +### Command Execution + +```typescript +// Execute command (returns stdout) +const output = ctx.sandbox.Exec(["npm", "test"]); + +// Handle errors +try { + ctx.sandbox.Exec(["git", "commit", "-m", "fix"]); +} catch (e) { + console.error("Command failed:", e.message); +} +``` + +### FileInfo Structure + +```typescript +interface FileInfo { + name: string; // File/directory name + size: number; // Size in bytes + is_dir: boolean; // True if directory +} +``` + +### Use Cases + +```typescript +// Prepare workspace before execution +function Create(ctx, messages) { + if (ctx.sandbox) { + ctx.sandbox.WriteFile("config.json", JSON.stringify({ debug: true })); + } + return { messages }; +} + +// Post-process results +function Next(ctx, payload) { + if (ctx.sandbox && !payload.error) { + const files = ctx.sandbox.ListDir("output"); + return { data: { generated: files.map(f => f.name) } }; + } + return null; +} +``` + ## LLM API The `ctx.llm` object provides direct access to LLM connectors for streaming completions. 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..2fafb1cf --- /dev/null +++ b/agent/sandbox/PLAN.md @@ -0,0 +1,297 @@ +# 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: MCP & Skills Integration ✅ COMPLETED + +- [x] Build MCP config from assistant's `mcp.servers` configuration +- [x] Write MCP config to container workspace (`.mcp.json`) +- [x] Resolve skills directory from `assistants/{name}/skills/` +- [x] Copy skills to container (`/workspace/.claude/skills/`) +- [x] Skip MCP tool execution in `agent.go` for sandbox mode (Claude CLI handles internally) +- [x] Add unit tests for MCP config building (`TestBuildMCPConfigForSandbox`) +- [x] Add unit tests for skills directory resolution (`TestSandboxMCPAndSkillsOptions`) + +### Phase 8: MCP IPC Bridge ✅ COMPLETED + +- [x] Modify `BuildMCPConfigForSandbox` to use `yao-bridge` command for IPC +- [x] Create IPC session in `sandbox/manager.createContainer()` (socket created before container) +- [x] Bind mount IPC socket to container at `/tmp/yao.sock` +- [x] Add `SetMCPTools()` method to `ipc.Session` for runtime tool configuration +- [x] Set MCP tools dynamically in `claude.Executor.Stream()` before execution +- [x] IPC session lifecycle managed by `sandbox.Manager` (create on container create, close on remove) +- [x] Load MCP tool definitions from gou/mcp and pass to IPC session +- [x] Add `TestClaudeExecutorIPCSocketMount` to verify socket bind mount +- [x] Verify E2E test shows "Loaded X MCP tools for IPC" + +### Phase 9: Workspace Management ⏳ PENDING + +- [ ] Implement workspace cleanup configuration +- [ ] Implement stale workspace detection +- [ ] Implement cleanup scheduler + +### Phase 9: 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 | +| `agent/assistant` | `TestSandboxMCPToolCall` | ✅ PASS | +| `agent/assistant` | `TestSandboxMCPEchoTool` | ✅ 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 and closes IPC session +- `defer sandboxCleanup()` in `agent.go` ensures cleanup +- Tests use unique chatID (timestamp) to avoid conflicts + +### 5. MCP IPC Architecture + +``` +Host (Yao) Container (Claude CLI) +┌────────────────────────┐ ┌────────────────────────┐ +│ IPC Manager │ │ yao-bridge │ +│ └─ Session │◄─────────────│ (stdio ↔ socket) │ +│ └─ MCPTools │ Unix Socket │ │ +│ └─ Process │ (/tmp/ │ Claude CLI reads │ +│ executor │ yao.sock) │ .mcp.json and calls │ +└────────────────────────┘ │ yao-bridge for tools │ + └────────────────────────┘ +``` + +- `.mcp.json` points to single "yao" server using `yao-bridge /tmp/yao.sock` +- IPC session created with authorized MCP tools from assistant config +- Tools executed via `process.New()` in IPC session handler + +## Known Issues + +### macOS Docker Desktop Socket Permissions + +On macOS with Docker Desktop (gRPC-FUSE), Unix socket permissions are not properly preserved when bind mounting from the host. The IPC socket created on the host with `0666` permissions appears as `0660` inside the container. + +**Solution**: After container start, we execute `chmod 666 /tmp/yao.sock` as root inside the container to fix permissions. This is handled automatically by `sandbox.Manager.fixIPCSocketPermissions()`. + +## 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..5409807f --- /dev/null +++ b/agent/sandbox/claude/command.go @@ -0,0 +1,240 @@ +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) + + // Build the ccr code command with all arguments + // We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling + var ccrArgs []string + + // Add permission mode (required for MCP tools to work) + permMode := "acceptEdits" // default + if opts != nil && opts.Arguments != nil { + if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" { + permMode = mode + } + } + ccrArgs = append(ccrArgs, "--permission-mode", permMode) + + // Add MCP config if available + if opts != nil && len(opts.MCPConfig) > 0 { + ccrArgs = append(ccrArgs, "--mcp-config", "/workspace/.mcp.json") + // Allow all tools from the "yao" MCP server + ccrArgs = append(ccrArgs, "--allowedTools", "mcp__yao__*") + } + + // Build the full bash command + // Start CCR daemon, wait, then run ccr code with arguments + bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code" + for _, arg := range ccrArgs { + // Quote arguments that might contain special characters + bashCmd += fmt.Sprintf(" %q", arg) + } + bashCmd += " -p" + if userPrompt != "" { + bashCmd += fmt.Sprintf(" %q", userPrompt) + } + + cmd := []string{"bash", "-c", bashCmd} + + // 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..eeeb6615 --- /dev/null +++ b/agent/sandbox/claude/executor.go @@ -0,0 +1,413 @@ +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" + "github.com/yaoapp/yao/sandbox/ipc" +) + +// 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 + MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC + 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 + // Note: IPC session is created by manager.createContainer, socket is already bind mounted + ctx := context.Background() + container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID) + 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 + } + + // Set MCP tools for this request (dynamic, runtime configuration) + if len(e.opts.MCPTools) > 0 { + ipcManager := e.manager.GetIPCManager() + if ipcManager != nil { + if session, ok := ipcManager.Get(e.opts.ChatID); ok { + session.SetMCPTools(e.opts.MCPTools) + } + } + } + + // Prepare environment: write configs and copy skills + if err := e.prepareEnvironment(stdCtx); err != nil { + return nil, fmt.Errorf("failed to prepare environment: %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) +} + +// prepareEnvironment prepares the container environment before execution +// This includes: CCR config, MCP config, and Skills directory +func (e *Executor) prepareEnvironment(ctx context.Context) error { + // 1. Write CCR config (Claude Code Router configuration) + if err := e.writeCCRConfig(ctx); err != nil { + return fmt.Errorf("failed to write CCR config: %w", err) + } + + // 2. Write MCP config if provided + if len(e.opts.MCPConfig) > 0 { + if err := e.writeMCPConfig(ctx); err != nil { + return fmt.Errorf("failed to write MCP config: %w", err) + } + } + + // 3. Copy Skills directory if provided + if e.opts.SkillsDir != "" { + if err := e.copySkillsDirectory(ctx); err != nil { + // Non-fatal: log warning but continue + // Skills might not exist or be optional + _ = err // Ignore error, skills are optional + } + } + + return nil +} + +// 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 +} + +// writeMCPConfig writes the MCP configuration file to the container workspace +func (e *Executor) writeMCPConfig(ctx context.Context) error { + if len(e.opts.MCPConfig) == 0 { + return nil + } + + // Write MCP config to workspace (.mcp.json) + mcpPath := e.workDir + "/.mcp.json" + if err := e.manager.WriteFile(ctx, e.containerName, mcpPath, e.opts.MCPConfig); err != nil { + return fmt.Errorf("failed to write MCP config to %s: %w", mcpPath, err) + } + + return nil +} + +// copySkillsDirectory copies the skills directory to the container +func (e *Executor) copySkillsDirectory(ctx context.Context) error { + if e.opts.SkillsDir == "" { + return nil + } + + // Target path in container: /workspace/.claude/skills/ + // This follows Claude CLI's expected skills location + claudeDir := e.workDir + "/.claude" + + // Create .claude directory first + if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", claudeDir}, nil); err != nil { + return fmt.Errorf("failed to create .claude directory: %w", err) + } + + // Copy skills from host to container + // CopyToContainer extracts tar to containerPath, and createTarFromPath uses + // filepath.Dir(hostPath) as base, so if hostPath is /path/to/skills, + // tar entries are like "skills/skill-name/SKILL.md" + // Extracting to /workspace/.claude/ gives us /workspace/.claude/skills/skill-name/SKILL.md + if err := e.manager.CopyToContainer(ctx, e.containerName, e.opts.SkillsDir, claudeDir); err != nil { + return fmt.Errorf("failed to copy skills to container: %w", 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 +// Note: IPC session is managed by sandbox.Manager.Remove() +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..212d41fe --- /dev/null +++ b/agent/sandbox/claude/executor_test.go @@ -0,0 +1,395 @@ +package claude + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "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: fmt.Sprintf("test-chat-claude-%d", time.Now().UnixNano()), + 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: fmt.Sprintf("test-chat-file-ops-%d", time.Now().UnixNano()), + } + + 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: fmt.Sprintf("test-chat-exec-%d", time.Now().UnixNano()), + } + + 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") +} + +// TestClaudeExecutorMCPConfigWrite tests that MCP config is correctly written to container +func TestClaudeExecutorMCPConfigWrite(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + // Create MCP config JSON + mcpConfig := []byte(`{"mcpServers":{"echo":{"command":"yao-mcp-proxy","args":["echo"],"tools":["ping","echo"]}}}`) + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()), + MCPConfig: mcpConfig, + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Call prepareEnvironment to write configs + err = exec.prepareEnvironment(ctx) + require.NoError(t, err, "prepareEnvironment should succeed") + + // Verify MCP config was written by reading it back + readContent, err := exec.ReadFile(ctx, ".mcp.json") + require.NoError(t, err, "Should be able to read .mcp.json") + require.NotEmpty(t, readContent, "MCP config should not be empty") + + t.Logf("MCP config in container: %s", string(readContent)) + + // Verify content matches + assert.JSONEq(t, string(mcpConfig), string(readContent), "MCP config content should match") + + t.Log("✓ MCP config verified in container") +} + +// TestClaudeExecutorSkillsCopy tests that skills directory is correctly copied to container +// Uses real test application skills directory +func TestClaudeExecutorSkillsCopy(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + // Use real skills directory from test application + appRoot := os.Getenv("YAO_ROOT") + require.NotEmpty(t, appRoot, "YAO_ROOT should be set") + + skillsDir := appRoot + "/assistants/tests/sandbox/full/skills" + + // Verify skills directory exists on host + info, err := os.Stat(skillsDir) + require.NoError(t, err, "Skills directory should exist: %s", skillsDir) + require.True(t, info.IsDir(), "Skills path should be a directory") + t.Logf("Using real skills directory: %s", skillsDir) + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()), + SkillsDir: skillsDir, + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Call prepareEnvironment to copy skills + err = exec.prepareEnvironment(ctx) + require.NoError(t, err, "prepareEnvironment should succeed") + + // Verify .claude directory was created + output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude"}) + require.NoError(t, err, ".claude directory should exist") + t.Logf(".claude directory contents:\n%s", output) + + // Verify skills directory exists in container + output, err = exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"}) + require.NoError(t, err, "skills directory should exist in container") + t.Logf("skills directory contents:\n%s", output) + assert.Contains(t, output, "echo-test", "echo-test skill should exist") + + // Verify echo-test skill was copied correctly + output, err = exec.Exec(ctx, []string{"ls", "-la", ".claude/skills/echo-test"}) + require.NoError(t, err, "echo-test skill directory should exist") + assert.Contains(t, output, "SKILL.md", "SKILL.md should exist in echo-test") + assert.Contains(t, output, "scripts", "scripts directory should exist in echo-test") + t.Logf("echo-test skill contents:\n%s", output) + + // Read SKILL.md content to verify + readContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md") + require.NoError(t, err, "Should be able to read SKILL.md from container") + require.NotEmpty(t, readContent, "SKILL.md content should not be empty") + // Verify content contains expected strings from the real SKILL.md + assert.Contains(t, string(readContent), "name: echo-test", "SKILL.md should contain skill name") + assert.Contains(t, string(readContent), "# Echo Test", "SKILL.md should contain the title") + t.Logf("✓ SKILL.md content verified (%d bytes)", len(readContent)) + + t.Log("✓ Skills directory verified in container with real test data") +} + +// TestClaudeExecutorPrepareEnvironmentIntegration tests full environment preparation +// Uses real test application data +func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + // Use real skills directory from test application + appRoot := os.Getenv("YAO_ROOT") + require.NotEmpty(t, appRoot, "YAO_ROOT should be set") + skillsDir := appRoot + "/assistants/tests/sandbox/full/skills" + + // Verify skills directory exists + _, err := os.Stat(skillsDir) + require.NoError(t, err, "Skills directory should exist") + + // Create MCP config (simulating what buildMCPConfigForSandbox produces) + mcpConfig := []byte(`{"mcpServers":{"echo":{"command":"yao-mcp-proxy","args":["echo"],"tools":["ping","echo","status"]}}}`) + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()), + ConnectorHost: "https://api.test.com", + ConnectorKey: "test-key", + Model: "test-model", + MCPConfig: mcpConfig, + SkillsDir: skillsDir, + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Call prepareEnvironment + err = exec.prepareEnvironment(ctx) + require.NoError(t, err, "prepareEnvironment should succeed") + + // Verify all files exist + // 1. Check CCR config + ccrContent, err := exec.Exec(ctx, []string{"cat", "/home/sandbox/.claude-code-router/config.json"}) + require.NoError(t, err, "CCR config should exist") + assert.Contains(t, ccrContent, "api_base_url", "CCR config should contain api_base_url") + t.Logf("✓ CCR config verified: %d bytes", len(ccrContent)) + + // 2. Check MCP config + mcpContent, err := exec.ReadFile(ctx, ".mcp.json") + require.NoError(t, err, "MCP config should exist in container") + assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match") + t.Logf("✓ MCP config verified: %s", string(mcpContent)) + + // 3. Check Skills directory structure + output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"}) + require.NoError(t, err, "Skills directory should exist in container") + assert.Contains(t, output, "echo-test", "echo-test skill should exist") + t.Logf("✓ Skills directory contents:\n%s", output) + + // 4. Check skill content + skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md") + require.NoError(t, err, "SKILL.md should exist in container") + require.NotEmpty(t, skillContent, "SKILL.md should not be empty") + assert.Contains(t, string(skillContent), "name: echo-test", "SKILL.md should contain skill name") + assert.Contains(t, string(skillContent), "# Echo Test", "SKILL.md should contain the title") + t.Logf("✓ SKILL.md verified: %d bytes", len(skillContent)) + + t.Log("✓ Full environment preparation verified with real test data") +} + +// TestClaudeExecutorIPCSocketMount verifies that IPC socket is bind mounted to container +func TestClaudeExecutorIPCSocketMount(t *testing.T) { + manager := createTestManager(t) + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: "test-ipc-socket-" + fmt.Sprintf("%d", time.Now().UnixNano()), + ConnectorHost: "https://api.test.com", + ConnectorKey: "test-key", + Model: "test-model", + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Check if IPC socket exists in container + output, err := exec.Exec(ctx, []string{"ls", "-la", "/tmp/yao.sock"}) + require.NoError(t, err, "IPC socket should exist in container") + assert.Contains(t, output, "yao.sock", "Should find yao.sock file") + t.Logf("✓ IPC socket mounted: %s", strings.TrimSpace(output)) + + // Verify it's a socket file (starts with 's' in ls output) + assert.Contains(t, output, "srw", "Should be a socket file (starts with 's')") + t.Log("✓ IPC socket is correctly bind mounted to container") +} 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..550cf24c --- /dev/null +++ b/agent/sandbox/executor.go @@ -0,0 +1,50 @@ +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, + MCPTools: opts.MCPTools, // MCP tools to expose via IPC + 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..cd413ba7 --- /dev/null +++ b/agent/sandbox/types.go @@ -0,0 +1,126 @@ +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" + "github.com/yaoapp/yao/sandbox/ipc" +) + +// 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:"-"` + + // MCPTools - MCP tools to expose via IPC (tool name → tool definition) + MCPTools map[string]*ipc.MCPTool `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/ipc/manager.go b/sandbox/ipc/manager.go index 57151901..cbd617c6 100644 --- a/sandbox/ipc/manager.go +++ b/sandbox/ipc/manager.go @@ -2,6 +2,8 @@ package ipc import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "net" "os" @@ -27,8 +29,9 @@ func (m *Manager) Create(ctx context.Context, sessionID string, agentCtx *AgentC // Close existing session if any m.Close(sessionID) - // Create socket path - socketPath := filepath.Join(m.sockDir, sessionID+".sock") + // Create socket path using hash to avoid path length issues + // Unix socket paths are limited to ~104-108 bytes + socketPath := m.socketPath(sessionID) // Ensure directory exists if err := os.MkdirAll(m.sockDir, 0755); err != nil { @@ -44,8 +47,9 @@ func (m *Manager) Create(ctx context.Context, sessionID string, agentCtx *AgentC return nil, fmt.Errorf("failed to create Unix socket: %w", err) } - // Set socket permissions (readable/writable by owner and group) - if err := os.Chmod(socketPath, 0660); err != nil { + // Set socket permissions (readable/writable by all users) + // This allows container processes running as non-root to connect + if err := os.Chmod(socketPath, 0666); err != nil { listener.Close() os.Remove(socketPath) return nil, fmt.Errorf("failed to set socket permissions: %w", err) @@ -98,3 +102,16 @@ func (m *Manager) CloseAll() { return true }) } + +// socketPath generates a short socket path using hash +// Unix socket paths are limited to ~104-108 bytes on most systems +func (m *Manager) socketPath(sessionID string) string { + hash := sha256.Sum256([]byte(sessionID)) + shortHash := hex.EncodeToString(hash[:8]) // 16 chars + return filepath.Join(m.sockDir, shortHash+".sock") +} + +// GetSocketPath returns the socket path for a session ID (for external use) +func (m *Manager) GetSocketPath(sessionID string) string { + return m.socketPath(sessionID) +} diff --git a/sandbox/ipc/manager_test.go b/sandbox/ipc/manager_test.go index e02bc2eb..bae3ba2c 100644 --- a/sandbox/ipc/manager_test.go +++ b/sandbox/ipc/manager_test.go @@ -6,7 +6,7 @@ import ( "fmt" "net" "os" - "path/filepath" + "strings" "sync" "testing" "time" @@ -14,7 +14,7 @@ import ( // TestNewManager tests IPC manager creation func TestNewManager(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-manager-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-manager-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -32,7 +32,8 @@ func TestNewManager(t *testing.T) { // TestCreateSession tests creating an IPC session func TestCreateSession(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-session-test-*") + // Use /tmp directly to avoid long paths (Unix socket path limit ~104 bytes) + tmpDir, err := os.MkdirTemp("/tmp", "ipc-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -68,9 +69,12 @@ func TestCreateSession(t *testing.T) { t.Errorf("Expected session ID %s, got %s", sessionID, session.ID) } - expectedSocketPath := filepath.Join(tmpDir, sessionID+".sock") - if session.SocketPath != expectedSocketPath { - t.Errorf("Expected socket path %s, got %s", expectedSocketPath, session.SocketPath) + // Socket path uses hash now, just verify it's in the right directory and ends with .sock + if !strings.HasPrefix(session.SocketPath, tmpDir) { + t.Errorf("Socket path should be in %s, got %s", tmpDir, session.SocketPath) + } + if !strings.HasSuffix(session.SocketPath, ".sock") { + t.Errorf("Socket path should end with .sock, got %s", session.SocketPath) } if session.Context.UserID != "user1" { @@ -89,7 +93,7 @@ func TestCreateSession(t *testing.T) { // TestGetSession tests retrieving a session func TestGetSession(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-get-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-get-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -127,7 +131,7 @@ func TestGetSession(t *testing.T) { // TestCloseSession tests closing a session func TestCloseSession(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-close-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-close-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -167,7 +171,7 @@ func TestCloseSession(t *testing.T) { // TestCloseNonExistentSession tests closing a non-existent session func TestCloseNonExistentSession(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-close-nonexist-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-close-nonexist-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -184,7 +188,7 @@ func TestCloseNonExistentSession(t *testing.T) { // TestCloseAllSessions tests closing all sessions func TestCloseAllSessions(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-closeall-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-closeall-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -223,7 +227,7 @@ func TestCloseAllSessions(t *testing.T) { // TestSessionReplace tests that creating a session with existing ID replaces it func TestSessionReplace(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-replace-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-replace-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -321,7 +325,7 @@ func TestConcurrentSessionAccess(t *testing.T) { // TestSessionConnection tests connecting to a session socket func TestSessionConnection(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-connect-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-connect-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -396,7 +400,7 @@ func TestSessionConnection(t *testing.T) { // TestToolsList tests the tools/list method func TestToolsList(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-tools-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-tools-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -489,7 +493,7 @@ func TestToolsList(t *testing.T) { // TestMethodNotFound tests handling of unknown methods func TestMethodNotFound(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-notfound-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-notfound-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -545,7 +549,7 @@ func TestMethodNotFound(t *testing.T) { // TestParseError tests handling of invalid JSON func TestParseError(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-parse-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-parse-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -595,7 +599,7 @@ func TestParseError(t *testing.T) { // TestInitializedNotification tests that initialized notification doesn't return response func TestInitializedNotification(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "ipc-initialized-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "ipc-initialized-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } diff --git a/sandbox/ipc/session.go b/sandbox/ipc/session.go index eb3ae490..3a6811b8 100644 --- a/sandbox/ipc/session.go +++ b/sandbox/ipc/session.go @@ -11,6 +11,17 @@ import ( "github.com/yaoapp/gou/process" ) +// SetMCPTools dynamically updates the MCP tools for this session +// Called at runtime before executing requests +func (s *Session) SetMCPTools(tools map[string]*MCPTool) { + s.MCPTools = tools +} + +// SetContext dynamically updates the agent context +func (s *Session) SetContext(ctx *AgentContext) { + s.Context = ctx +} + // Close closes the session and cleans up resources func (s *Session) Close() error { if s.cancel != nil { diff --git a/sandbox/ipc/session_test.go b/sandbox/ipc/session_test.go index a216ac19..6bc2425d 100644 --- a/sandbox/ipc/session_test.go +++ b/sandbox/ipc/session_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net" "os" + "strings" "testing" "time" @@ -14,7 +15,7 @@ import ( // TestSessionHandleInitialize tests the initialize handler func TestSessionHandleInitialize(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "session-init-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-init-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -93,7 +94,7 @@ func TestSessionHandleInitialize(t *testing.T) { // TestSessionHandleResourcesList tests the resources/list handler func TestSessionHandleResourcesList(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "session-resources-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-resources-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -161,7 +162,7 @@ func TestSessionHandleResourcesList(t *testing.T) { // TestSessionHandleResourcesRead tests the resources/read handler func TestSessionHandleResourcesRead(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "session-read-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-read-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -360,7 +361,7 @@ func TestSessionToolsCallWithYaoApp(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - tmpDir, err := os.MkdirTemp("", "session-yao-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-yao-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -444,6 +445,247 @@ func TestSessionToolsCallWithYaoApp(t *testing.T) { t.Logf("Tool result: %v", toolResult.Content) } +// TestSessionToolsCallEcho tests the echo MCP tool specifically +// This verifies the full MCP → IPC → Yao Process chain works +func TestSessionToolsCallEcho(t *testing.T) { + // Prepare Yao test environment + test.Prepare(t, config.Conf) + defer test.Clean() + + tmpDir, err := os.MkdirTemp("/tmp", "ipc-echo-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + m := NewManager(tmpDir) + ctx := context.Background() + + // Create session with echo tool (matches mcps/echo.mcp.yao) + mcpTools := map[string]*MCPTool{ + "echo": { + Name: "echo", + Description: "Echo back a message", + Process: "scripts.tests.mcp.Echo", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "message": {"type": "string", "description": "Message to echo"}, + "uppercase": {"type": "boolean", "description": "Convert to uppercase"} + }, + "required": ["message"] + }`), + }, + "ping": { + Name: "ping", + Description: "Simple ping tool", + Process: "scripts.tests.mcp.Ping", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "count": {"type": "number"}, + "message": {"type": "string"} + } + }`), + }, + } + + session, err := m.Create(ctx, "echo-test", &AgentContext{ + UserID: "test-user", + ChatID: "test-chat", + Locale: "en-US", + }, mcpTools) + if err != nil { + t.Fatalf("Create session failed: %v", err) + } + defer m.Close("echo-test") + + time.Sleep(50 * time.Millisecond) + + conn, err := net.Dial("unix", session.SocketPath) + if err != nil { + t.Fatalf("Failed to connect to IPC socket: %v", err) + } + defer conn.Close() + + // Test 1: tools/list should return our registered tools + t.Run("tools/list", func(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/list", + } + data, _ := json.Marshal(req) + conn.Write(append(data, '\n')) + + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(buf[:n], &resp); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if resp.Error != nil { + t.Fatalf("tools/list returned error: %v", resp.Error) + } + + resultBytes, _ := json.Marshal(resp.Result) + var listResult ToolsListResult + json.Unmarshal(resultBytes, &listResult) + + if len(listResult.Tools) != 2 { + t.Errorf("Expected 2 tools, got %d", len(listResult.Tools)) + } + + // Check tool names + toolNames := make(map[string]bool) + for _, tool := range listResult.Tools { + toolNames[tool.Name] = true + t.Logf("✓ Tool available: %s", tool.Name) + } + if !toolNames["echo"] { + t.Error("echo tool not found in tools/list") + } + if !toolNames["ping"] { + t.Error("ping tool not found in tools/list") + } + }) + + // Test 2: Call ping tool + t.Run("tools/call ping", func(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 2, + Method: "tools/call", + Params: json.RawMessage(`{"name": "ping", "arguments": {"count": 3, "message": "hello"}}`), + } + data, _ := json.Marshal(req) + conn.Write(append(data, '\n')) + + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(buf[:n], &resp); err != nil { + t.Fatalf("Unmarshal failed: %v (raw: %s)", err, string(buf[:n])) + } + + if resp.Error != nil { + t.Fatalf("ping tool call failed: code=%d, message=%s", resp.Error.Code, resp.Error.Message) + } + + resultBytes, _ := json.Marshal(resp.Result) + var toolResult ToolResult + json.Unmarshal(resultBytes, &toolResult) + + if toolResult.IsError { + t.Errorf("ping returned error: %v", toolResult.Content) + } + + // Parse the content + if len(toolResult.Content) > 0 { + text := toolResult.Content[0].Text + t.Logf("✓ ping response: %s", text) + + // Verify response contains expected fields + if !strings.Contains(text, "hello") { + t.Error("ping response should contain the message 'hello'") + } + if !strings.Contains(text, "count") { + t.Error("ping response should contain 'count'") + } + } + }) + + // Test 3: Call echo tool + t.Run("tools/call echo", func(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 3, + Method: "tools/call", + Params: json.RawMessage(`{"name": "echo", "arguments": {"message": "Hello from IPC test!", "uppercase": true}}`), + } + data, _ := json.Marshal(req) + conn.Write(append(data, '\n')) + + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(buf[:n], &resp); err != nil { + t.Fatalf("Unmarshal failed: %v (raw: %s)", err, string(buf[:n])) + } + + if resp.Error != nil { + t.Fatalf("echo tool call failed: code=%d, message=%s", resp.Error.Code, resp.Error.Message) + } + + resultBytes, _ := json.Marshal(resp.Result) + var toolResult ToolResult + json.Unmarshal(resultBytes, &toolResult) + + if toolResult.IsError { + t.Errorf("echo returned error: %v", toolResult.Content) + } + + // Parse and verify the content + if len(toolResult.Content) > 0 { + text := toolResult.Content[0].Text + t.Logf("✓ echo response: %s", text) + + // The echo should be uppercase + if !strings.Contains(text, "HELLO FROM IPC TEST!") { + t.Errorf("echo response should contain uppercase message, got: %s", text) + } + } else { + t.Error("echo response has no content") + } + }) + + // Test 4: Call unauthorized tool should fail + t.Run("tools/call unauthorized", func(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 4, + Method: "tools/call", + Params: json.RawMessage(`{"name": "not_registered_tool", "arguments": {}}`), + } + data, _ := json.Marshal(req) + conn.Write(append(data, '\n')) + + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + var resp JSONRPCResponse + json.Unmarshal(buf[:n], &resp) + + if resp.Error == nil { + t.Error("Expected error for unauthorized tool") + } else { + t.Logf("✓ Unauthorized tool correctly rejected: %s", resp.Error.Message) + } + }) + + t.Log("✓ All echo MCP tool tests passed - IPC → Yao Process chain verified") +} + // TestSessionMultipleRequests tests multiple requests over single connection func TestSessionMultipleRequests(t *testing.T) { // Use /tmp for shorter socket path @@ -522,7 +764,7 @@ func TestSessionMultipleRequests(t *testing.T) { // TestSessionClose tests session close behavior func TestSessionClose(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "session-close-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-close-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } @@ -574,7 +816,7 @@ func TestSessionClose(t *testing.T) { // TestSessionEmptyLines tests handling of empty lines func TestSessionEmptyLines(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "session-empty-test-*") + tmpDir, err := os.MkdirTemp("/tmp", "session-empty-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } diff --git a/sandbox/manager.go b/sandbox/manager.go index d47651c6..161f0e72 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 @@ -106,6 +161,8 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont if c, ok := m.containers.Load(name); ok { cont := c.(*Container) cont.LastUsedAt = time.Now() + // Ensure IPC session exists (may have been closed) + m.ensureIPCSession(ctx, userID, chatID) return cont, nil } @@ -117,6 +174,8 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont if c, ok := m.containers.Load(name); ok { cont := c.(*Container) cont.LastUsedAt = time.Now() + // Ensure IPC session exists (may have been closed) + m.ensureIPCSession(ctx, userID, chatID) return cont, nil } @@ -153,9 +212,16 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (* return nil, fmt.Errorf("failed to create workspace: %w", err) } - // IPC socket path + // Create IPC session BEFORE container creation + // This creates the socket file so it can be bind mounted sessionID := chatID - ipcSocketHost := filepath.Join(m.config.IPCDir, sessionID+".sock") + agentCtx := &ipc.AgentContext{UserID: userID, ChatID: chatID} + if _, err := m.ipcManager.Create(ctx, sessionID, agentCtx, nil); err != nil { + return nil, fmt.Errorf("failed to create IPC session: %w", err) + } + + // Get socket path (uses hash to avoid path length issues) + ipcSocketHost := m.ipcManager.GetSocketPath(sessionID) // Container configuration containerConfig := &container.Config{ @@ -168,13 +234,10 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (* }, } - // Host configuration - only mount IPC socket if it exists + // Host configuration - mount IPC socket (now exists after ipcManager.Create) binds := []string{ workspaceHost + ":" + m.config.ContainerWorkDir, - } - // Only mount IPC socket if the file exists (it's created by IPC manager) - if _, err := os.Stat(ipcSocketHost); err == nil { - binds = append(binds, ipcSocketHost+":"+m.config.ContainerIPCSocket) + ipcSocketHost + ":" + m.config.ContainerIPCSocket, } hostConfig := &container.HostConfig{ @@ -257,6 +320,11 @@ func (m *Manager) ensureRunning(ctx context.Context, name string) error { time.Sleep(100 * time.Millisecond) } + // Fix IPC socket permissions inside container + // This is needed because macOS Docker Desktop doesn't properly preserve + // Unix socket permissions when bind mounting from host + m.fixIPCSocketPermissions(ctx, cont.ID) + m.mu.Lock() cont.Status = StatusRunning cont.LastUsedAt = time.Now() @@ -319,11 +387,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 +459,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 +495,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 } @@ -698,3 +763,46 @@ func (m *Manager) GetIPCManager() *ipc.Manager { func (m *Manager) GetConfig() *Config { return m.config } + +// ensureIPCSession ensures IPC session exists for the given chatID +// This is called when reusing an existing container to handle cases where +// the IPC session was closed but the container still exists +func (m *Manager) ensureIPCSession(ctx context.Context, userID, chatID string) { + sessionID := chatID + // Check if session already exists + if _, ok := m.ipcManager.Get(sessionID); ok { + return + } + // Create new session (ignore error - container can work without IPC) + agentCtx := &ipc.AgentContext{UserID: userID, ChatID: chatID} + m.ipcManager.Create(ctx, sessionID, agentCtx, nil) +} + +// fixIPCSocketPermissions fixes IPC socket permissions inside the container +// This is needed because macOS Docker Desktop with gRPC-FUSE doesn't properly +// preserve Unix socket permissions when bind mounting from host. +// We run chmod as root (using container exec with User override) to make the +// socket accessible to the sandbox user. +func (m *Manager) fixIPCSocketPermissions(ctx context.Context, containerID string) { + // Execute chmod as root to fix socket permissions + execConfig := container.ExecOptions{ + Cmd: []string{"chmod", "666", m.config.ContainerIPCSocket}, + User: "root", // Run as root to be able to change permissions + } + + execResp, err := m.dockerClient.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + // Log but don't fail - container can work without proper IPC + return + } + + // Start the exec and wait for completion + err = m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}) + if err != nil { + // Log but don't fail + return + } + + // Wait briefly for the chmod to complete + time.Sleep(50 * time.Millisecond) +} diff --git a/sandbox/manager_test.go b/sandbox/manager_test.go index a57a1229..d6a88bbd 100644 --- a/sandbox/manager_test.go +++ b/sandbox/manager_test.go @@ -25,12 +25,13 @@ func getTestDirs(prefix string) (string, string, string, error) { if workspaceRoot == "" || ipcDir == "" { // Create temporary directories for test - tmpDir, err = os.MkdirTemp("", prefix) + // Use /tmp directly to avoid long paths (Unix socket path limit ~104 bytes) + tmpDir, err = os.MkdirTemp("/tmp", prefix) if err != nil { return "", "", "", err } if workspaceRoot == "" { - workspaceRoot = filepath.Join(tmpDir, "workspace") + workspaceRoot = filepath.Join(tmpDir, "ws") } if ipcDir == "" { ipcDir = filepath.Join(tmpDir, "ipc") 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"