yao/sandbox/ipc/manager_test.go
Max c1e92b726d Implement MCP Configuration and Tool Integration for Sandbox
- Added functionality to build and manage MCP configuration for sandbox environments, allowing for dynamic tool execution.
- Enhanced the Assistant's Stream method to skip MCP tool calls in sandbox mode, with internal handling by Claude CLI.
- Introduced unit tests for MCP configuration building and skills directory resolution, ensuring robust integration.
- Updated sandbox manager to create IPC sessions and manage tool exposure dynamically, improving interaction with external agents.
- Enhanced documentation to reflect new features and integration points for MCP and skills within the sandbox.
2026-01-30 19:57:31 +08:00

642 lines
16 KiB
Go

package ipc
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"strings"
"sync"
"testing"
"time"
)
// TestNewManager tests IPC manager creation
func TestNewManager(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-manager-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
if m == nil {
t.Fatal("NewManager returned nil")
}
if m.sockDir != tmpDir {
t.Errorf("Expected sockDir %s, got %s", tmpDir, m.sockDir)
}
}
// TestCreateSession tests creating an IPC session
func TestCreateSession(t *testing.T) {
// 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)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-session-1"
agentCtx := &AgentContext{
UserID: "user1",
ChatID: "chat1",
Locale: "en-US",
}
mcpTools := map[string]*MCPTool{
"test_tool": {
Name: "test_tool",
Description: "A test tool",
Process: "scripts.test.hello",
InputSchema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`),
},
}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Verify session properties
if session.ID != sessionID {
t.Errorf("Expected session ID %s, got %s", sessionID, session.ID)
}
// 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" {
t.Errorf("Expected UserID user1, got %s", session.Context.UserID)
}
if len(session.MCPTools) != 1 {
t.Errorf("Expected 1 MCP tool, got %d", len(session.MCPTools))
}
// Verify socket file exists
if _, err := os.Stat(session.SocketPath); os.IsNotExist(err) {
t.Error("Socket file should exist")
}
}
// TestGetSession tests retrieving a session
func TestGetSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-get-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-get-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
// Get non-existent session
_, ok := m.Get(sessionID)
if ok {
t.Error("Get should return false for non-existent session")
}
// Create session
_, err = m.Create(ctx, sessionID, agentCtx, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Get existing session
session, ok := m.Get(sessionID)
if !ok {
t.Error("Get should return true for existing session")
}
if session.ID != sessionID {
t.Errorf("Expected session ID %s, got %s", sessionID, session.ID)
}
}
// TestCloseSession tests closing a session
func TestCloseSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-close-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-close-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
session, err := m.Create(ctx, sessionID, agentCtx, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
socketPath := session.SocketPath
// Close session
err = m.Close(sessionID)
if err != nil {
t.Fatalf("Close session failed: %v", err)
}
// Verify session is removed
_, ok := m.Get(sessionID)
if ok {
t.Error("Session should be removed after close")
}
// Verify socket file is removed (give it a moment)
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Error("Socket file should be removed after close")
}
}
// TestCloseNonExistentSession tests closing a non-existent session
func TestCloseNonExistentSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-close-nonexist-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
// Should not error
err = m.Close("nonexistent-session")
if err != nil {
t.Errorf("Close non-existent session should not error: %v", err)
}
}
// TestCloseAllSessions tests closing all sessions
func TestCloseAllSessions(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-closeall-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
// Create multiple sessions
sessionIDs := []string{"session-1", "session-2", "session-3"}
for _, id := range sessionIDs {
_, err := m.Create(ctx, id, &AgentContext{UserID: "user", ChatID: id}, nil)
if err != nil {
t.Fatalf("Create session %s failed: %v", id, err)
}
}
// Verify sessions exist
for _, id := range sessionIDs {
if _, ok := m.Get(id); !ok {
t.Errorf("Session %s should exist", id)
}
}
// Close all
m.CloseAll()
// Verify all sessions are removed
time.Sleep(100 * time.Millisecond)
for _, id := range sessionIDs {
if _, ok := m.Get(id); ok {
t.Errorf("Session %s should be removed after CloseAll", id)
}
}
}
// TestSessionReplace tests that creating a session with existing ID replaces it
func TestSessionReplace(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-replace-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-replace-session"
// Create first session
session1, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user1", ChatID: "chat1"}, nil)
if err != nil {
t.Fatalf("Create first session failed: %v", err)
}
socketPath1 := session1.SocketPath
// Create second session with same ID
session2, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user2", ChatID: "chat2"}, nil)
if err != nil {
t.Fatalf("Create second session failed: %v", err)
}
defer m.Close(sessionID)
// Verify second session replaced first
if session2.Context.UserID != "user2" {
t.Errorf("Expected UserID user2, got %s", session2.Context.UserID)
}
// Get session should return second
session, ok := m.Get(sessionID)
if !ok {
t.Error("Get should return session")
}
if session.Context.UserID != "user2" {
t.Errorf("Expected UserID user2 from Get, got %s", session.Context.UserID)
}
// Same socket path should be reused
if session2.SocketPath != socketPath1 {
t.Errorf("Expected same socket path, got %s vs %s", socketPath1, session2.SocketPath)
}
}
// TestConcurrentSessionAccess tests concurrent access to sessions
func TestConcurrentSessionAccess(t *testing.T) {
// Use /tmp for shorter socket path (macOS has 104 char limit for Unix sockets)
tmpDir, err := os.MkdirTemp("/tmp", "ipc-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
var wg sync.WaitGroup
var mu sync.Mutex
errors := make([]error, 0)
numGoroutines := 5 // Reduced for stability
// Concurrent creates
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := fmt.Sprintf("s%d", idx) // Short session ID
_, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user", ChatID: sessionID}, nil)
if err != nil {
mu.Lock()
errors = append(errors, fmt.Errorf("session %s: %v", sessionID, err))
mu.Unlock()
}
}(i)
}
wg.Wait()
// Check errors
for _, err := range errors {
t.Errorf("Concurrent create error: %v", err)
}
// Verify all sessions exist
for i := 0; i < numGoroutines; i++ {
sessionID := fmt.Sprintf("s%d", i)
if _, ok := m.Get(sessionID); !ok {
t.Errorf("Session %s should exist", sessionID)
}
}
// Cleanup
m.CloseAll()
}
// TestSessionConnection tests connecting to a session socket
func TestSessionConnection(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-connect-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-connect-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
mcpTools := map[string]*MCPTool{}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Give the listener time to start
time.Sleep(50 * time.Millisecond)
// Try to connect to the socket
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send initialize request
initReq := JSONRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
Params: json.RawMessage(`{"protocolVersion":"2024-11-05"}`),
}
data, _ := json.Marshal(initReq)
// Write with newline (NDJSON)
_, err = conn.Write(append(data, '\n'))
if err != nil {
t.Fatalf("Failed to write to socket: %v", err)
}
// Set read deadline
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
// Read response
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
// Parse response
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v (raw: %s)", err, string(buf[:n]))
}
if resp.JSONRPC != "2.0" {
t.Errorf("Expected JSONRPC 2.0, got %s", resp.JSONRPC)
}
if resp.Error != nil {
t.Errorf("Unexpected error: %v", resp.Error)
}
if resp.Result == nil {
t.Error("Expected result, got nil")
}
}
// TestToolsList tests the tools/list method
func TestToolsList(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-tools-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-tools-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
mcpTools := map[string]*MCPTool{
"tool1": {
Name: "tool1",
Description: "First test tool",
Process: "scripts.test.tool1",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
"tool2": {
Name: "tool2",
Description: "Second test tool",
Process: "scripts.test.tool2",
InputSchema: json.RawMessage(`{"type":"object","properties":{"arg":{"type":"string"}}}`),
},
}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send tools/list request
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 2,
Method: "tools/list",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
// Parse result as ToolsListResult
resultBytes, _ := json.Marshal(resp.Result)
var toolsResult ToolsListResult
if err := json.Unmarshal(resultBytes, &toolsResult); err != nil {
t.Fatalf("Failed to parse tools result: %v", err)
}
if len(toolsResult.Tools) != 2 {
t.Errorf("Expected 2 tools, got %d", len(toolsResult.Tools))
}
// Verify tool names
toolNames := make(map[string]bool)
for _, tool := range toolsResult.Tools {
toolNames[tool.Name] = true
}
if !toolNames["tool1"] {
t.Error("Expected tool1 in tools list")
}
if !toolNames["tool2"] {
t.Error("Expected tool2 in tools list")
}
}
// TestMethodNotFound tests handling of unknown methods
func TestMethodNotFound(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-notfound-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-notfound", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-notfound")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send unknown method
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 3,
Method: "unknown/method",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for unknown method")
}
if resp.Error != nil && resp.Error.Code != ErrCodeMethodNotFound {
t.Errorf("Expected error code %d, got %d", ErrCodeMethodNotFound, resp.Error.Code)
}
}
// TestParseError tests handling of invalid JSON
func TestParseError(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-parse-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-parse", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-parse")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send invalid JSON
conn.Write([]byte("not valid json\n"))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for invalid JSON")
}
if resp.Error != nil && resp.Error.Code != ErrCodeParse {
t.Errorf("Expected error code %d, got %d", ErrCodeParse, resp.Error.Code)
}
}
// TestInitializedNotification tests that initialized notification doesn't return response
func TestInitializedNotification(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "ipc-initialized-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-initialized", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-initialized")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send initialized notification (no ID = notification)
req := JSONRPCRequest{
JSONRPC: "2.0",
Method: "initialized",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Set short read deadline - we expect timeout since no response
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
buf := make([]byte, 4096)
_, err = conn.Read(buf)
// Expect timeout (no response for notifications)
if err == nil {
t.Error("Expected no response for notification")
}
}