Enhance Sandbox Integration and CI Workflow for AI Tests
- Added steps to pull necessary Docker images for sandbox testing in both CI workflows. - Updated the AI test execution to utilize sandbox configurations, ensuring proper environment setup. - Introduced sandbox initialization in the Assistant's Stream method, allowing for execution of coding agents like Claude and Cursor. - Enhanced context management to support sandbox execution, improving flexibility in handling agent operations.
This commit is contained in:
parent
f265aee974
commit
cd5cf32a20
30 changed files with 4874 additions and 19 deletions
13
.github/workflows/pr-test.yml
vendored
13
.github/workflows/pr-test.yml
vendored
|
|
@ -541,8 +541,19 @@ jobs:
|
||||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Pull Sandbox Test Images
|
||||||
|
run: |
|
||||||
|
docker pull alpine:latest
|
||||||
|
docker pull yaoapp/sandbox-base:latest || true
|
||||||
|
docker pull yaoapp/sandbox-claude:latest || true
|
||||||
|
|
||||||
- name: Run AI Tests (agent, aigc)
|
- name: Run AI Tests (agent, aigc)
|
||||||
run: make unit-test-ai
|
env:
|
||||||
|
YAO_SANDBOX_WORKSPACE: ${{ runner.temp }}/sandbox/workspace
|
||||||
|
YAO_SANDBOX_IPC: ${{ runner.temp }}/sandbox/ipc
|
||||||
|
run: |
|
||||||
|
export YAO_SANDBOX_CONTAINER_USER="$(id -u):$(id -g)"
|
||||||
|
make unit-test-ai
|
||||||
|
|
||||||
- name: Codecov Report
|
- name: Codecov Report
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
|
|
|
||||||
13
.github/workflows/unit-test.yml
vendored
13
.github/workflows/unit-test.yml
vendored
|
|
@ -435,8 +435,19 @@ jobs:
|
||||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Pull Sandbox Test Images
|
||||||
|
run: |
|
||||||
|
docker pull alpine:latest
|
||||||
|
docker pull yaoapp/sandbox-base:latest || true
|
||||||
|
docker pull yaoapp/sandbox-claude:latest || true
|
||||||
|
|
||||||
- name: Run AI Tests (agent, aigc)
|
- name: Run AI Tests (agent, aigc)
|
||||||
run: make unit-test-ai
|
env:
|
||||||
|
YAO_SANDBOX_WORKSPACE: ${{ runner.temp }}/sandbox/workspace
|
||||||
|
YAO_SANDBOX_IPC: ${{ runner.temp }}/sandbox/ipc
|
||||||
|
run: |
|
||||||
|
export YAO_SANDBOX_CONTAINER_USER="$(id -u):$(id -g)"
|
||||||
|
make unit-test-ai
|
||||||
|
|
||||||
- name: Codecov Report
|
- name: Codecov Report
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
"github.com/yaoapp/yao/agent/llm"
|
"github.com/yaoapp/yao/agent/llm"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stream stream the agent
|
// Stream stream the agent
|
||||||
|
|
@ -150,6 +151,33 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
}
|
}
|
||||||
ctx.Logger.PhaseComplete("History")
|
ctx.Logger.PhaseComplete("History")
|
||||||
|
|
||||||
|
// ================================================
|
||||||
|
// Initialize Sandbox (if configured)
|
||||||
|
// ================================================
|
||||||
|
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
|
||||||
|
var sandboxExecutor agentsandbox.Executor
|
||||||
|
var sandboxCleanup func()
|
||||||
|
if ast.HasSandbox() {
|
||||||
|
ctx.Logger.Phase("Sandbox")
|
||||||
|
var err error
|
||||||
|
sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
ast.traceAgentFail(agentNode, err)
|
||||||
|
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Set sandbox executor in context so hooks can access ctx.sandbox
|
||||||
|
// The executor implements both agentsandbox.Executor and context.SandboxExecutor
|
||||||
|
ctx.SetSandboxExecutor(sandboxExecutor)
|
||||||
|
ctx.Logger.PhaseComplete("Sandbox")
|
||||||
|
}
|
||||||
|
// Ensure sandbox cleanup on exit
|
||||||
|
defer func() {
|
||||||
|
if sandboxCleanup != nil {
|
||||||
|
sandboxCleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// Execute Create Hook
|
// Execute Create Hook
|
||||||
// ================================================
|
// ================================================
|
||||||
|
|
@ -254,7 +282,14 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
})
|
})
|
||||||
|
|
||||||
// Execute the LLM streaming call
|
// Execute the LLM streaming call
|
||||||
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
// Choose between sandbox execution or direct LLM execution
|
||||||
|
if ast.HasSandbox() {
|
||||||
|
// Sandbox execution path (Claude CLI, Cursor CLI, etc.)
|
||||||
|
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor)
|
||||||
|
} else {
|
||||||
|
// Direct LLM execution path
|
||||||
|
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
finalStatus = context.ResumeStatusFailed
|
finalStatus = context.ResumeStatusFailed
|
||||||
finalError = err
|
finalError = err
|
||||||
|
|
|
||||||
|
|
@ -714,6 +714,15 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
||||||
assistant.Workflow = wf
|
assistant.Workflow = wf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sandbox (for coding agents like Claude CLI, Cursor CLI)
|
||||||
|
if sandbox, has := data["sandbox"]; has {
|
||||||
|
sb, err := store.ToSandbox(sandbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assistant.Sandbox = sb
|
||||||
|
}
|
||||||
|
|
||||||
// uses (wrapper configurations for vision, audio, etc.)
|
// uses (wrapper configurations for vision, audio, etc.)
|
||||||
// Merge hierarchy: global uses < assistant uses
|
// Merge hierarchy: global uses < assistant uses
|
||||||
if uses, has := data["uses"]; has {
|
if uses, has := data["uses"]; has {
|
||||||
|
|
|
||||||
210
agent/assistant/sandbox.go
Normal file
210
agent/assistant/sandbox.go
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
traceTypes "github.com/yaoapp/yao/trace/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sandboxManager *infraSandbox.Manager
|
||||||
|
sandboxManagerOnce sync.Once
|
||||||
|
sandboxManagerErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSandboxManager returns the sandbox manager singleton
|
||||||
|
// Returns nil and error if sandbox is not configured or Docker is unavailable
|
||||||
|
func GetSandboxManager() (*infraSandbox.Manager, error) {
|
||||||
|
sandboxManagerOnce.Do(func() {
|
||||||
|
// Create sandbox config from Yao config
|
||||||
|
cfg := &infraSandbox.Config{}
|
||||||
|
|
||||||
|
// Use YAO_DATA_ROOT for workspace and IPC paths
|
||||||
|
dataRoot := config.Conf.DataRoot
|
||||||
|
if dataRoot != "" {
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manager (will fail if Docker is not available)
|
||||||
|
sandboxManager, sandboxManagerErr = infraSandbox.NewManager(cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
return sandboxManager, sandboxManagerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSandbox returns true if the assistant has sandbox configuration
|
||||||
|
func (ast *Assistant) HasSandbox() bool {
|
||||||
|
return ast.Sandbox != nil && ast.Sandbox.Command != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSandbox initializes the sandbox executor
|
||||||
|
// Returns the full Executor (for LLM calls), cleanup function, and any error
|
||||||
|
// This is called BEFORE hooks so that hooks can access ctx.sandbox
|
||||||
|
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
|
||||||
|
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) {
|
||||||
|
// Get sandbox manager (singleton)
|
||||||
|
manager, err := GetSandboxManager()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
|
||||||
|
return nil, nil, fmt.Errorf("sandbox manager not available: %w", err)
|
||||||
|
}
|
||||||
|
if manager == nil {
|
||||||
|
return nil, nil, fmt.Errorf("sandbox manager not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build executor options from assistant config
|
||||||
|
execOpts, err := ast.buildSandboxOptions(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Error("Failed to build sandbox options: %v", err)
|
||||||
|
return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log sandbox creation
|
||||||
|
ctx.Logger.Info("Creating sandbox container for command: %s", ast.Sandbox.Command)
|
||||||
|
|
||||||
|
// Add trace for sandbox creation
|
||||||
|
trace, traceErr := ctx.Trace()
|
||||||
|
if traceErr == nil && trace != nil {
|
||||||
|
trace.Info("Creating sandbox container...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send loading message to user
|
||||||
|
loadingMsg := &message.Message{
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": "Preparing sandbox environment...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
loadingMsgID, _ := ctx.SendStream(loadingMsg)
|
||||||
|
|
||||||
|
// Create executor (container starts here)
|
||||||
|
executor, err := agentsandbox.New(manager, execOpts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Error("Sandbox creation failed: %v", err)
|
||||||
|
if traceErr == nil && trace != nil {
|
||||||
|
trace.Error("Sandbox creation failed: %v", err)
|
||||||
|
}
|
||||||
|
// End loading message
|
||||||
|
if loadingMsgID != "" {
|
||||||
|
ctx.End(loadingMsgID)
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log sandbox ready
|
||||||
|
ctx.Logger.Info("Sandbox container ready")
|
||||||
|
if traceErr == nil && trace != nil {
|
||||||
|
trace.Info("Sandbox container ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
// End loading message
|
||||||
|
if loadingMsgID != "" {
|
||||||
|
ctx.End(loadingMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
cleanup := func() {
|
||||||
|
if err := executor.Close(); err != nil {
|
||||||
|
ctx.Logger.Error("Failed to close sandbox executor: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executor, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
|
||||||
|
// This is called when ast.Sandbox is configured
|
||||||
|
// NOTE: The executor is passed directly from initSandbox, no type assertion needed
|
||||||
|
func (ast *Assistant) executeSandboxStream(
|
||||||
|
ctx *context.Context,
|
||||||
|
completionMessages []context.Message,
|
||||||
|
agentNode traceTypes.Node,
|
||||||
|
streamHandler message.StreamFunc,
|
||||||
|
executor agentsandbox.Executor,
|
||||||
|
) (*context.CompletionResponse, error) {
|
||||||
|
|
||||||
|
// Mark the agentNode as used to avoid unused variable error
|
||||||
|
_ = agentNode
|
||||||
|
|
||||||
|
if executor == nil {
|
||||||
|
return nil, fmt.Errorf("sandbox executor not initialized (call initSandbox first)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log sandbox execution
|
||||||
|
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
|
||||||
|
|
||||||
|
// Execute LLM call via sandbox
|
||||||
|
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox execution failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSandboxOptions builds executor options from assistant config
|
||||||
|
func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Options) (*agentsandbox.Options, error) {
|
||||||
|
if ast.Sandbox == nil {
|
||||||
|
return nil, fmt.Errorf("sandbox configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
execOpts := &agentsandbox.Options{
|
||||||
|
Command: ast.Sandbox.Command,
|
||||||
|
Image: ast.Sandbox.Image,
|
||||||
|
MaxMemory: ast.Sandbox.MaxMemory,
|
||||||
|
MaxCPU: ast.Sandbox.MaxCPU,
|
||||||
|
Arguments: ast.Sandbox.Arguments,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeout string (e.g., "10m") to duration
|
||||||
|
if ast.Sandbox.Timeout != "" {
|
||||||
|
timeout, err := time.ParseDuration(ast.Sandbox.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid timeout format: %w", err)
|
||||||
|
}
|
||||||
|
execOpts.Timeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user and chat IDs for workspace isolation
|
||||||
|
if ctx.Authorized != nil && ctx.Authorized.UserID != "" {
|
||||||
|
execOpts.UserID = ctx.Authorized.UserID
|
||||||
|
} else {
|
||||||
|
execOpts.UserID = "anonymous"
|
||||||
|
}
|
||||||
|
execOpts.ChatID = ctx.ChatID
|
||||||
|
|
||||||
|
// Set skills directory (auto-resolved from assistant path)
|
||||||
|
if ast.Path != "" {
|
||||||
|
execOpts.SkillsDir = filepath.Join(ast.Path, "skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve connector settings
|
||||||
|
conn, _, err := ast.GetConnector(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get connector: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setting := conn.Setting()
|
||||||
|
if host, ok := setting["host"].(string); ok {
|
||||||
|
execOpts.ConnectorHost = host
|
||||||
|
}
|
||||||
|
if key, ok := setting["key"].(string); ok {
|
||||||
|
execOpts.ConnectorKey = key
|
||||||
|
}
|
||||||
|
if model, ok := setting["model"].(string); ok {
|
||||||
|
execOpts.Model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build MCP config if needed
|
||||||
|
// TODO: implement MCP config building for sandbox
|
||||||
|
|
||||||
|
return execOpts, nil
|
||||||
|
}
|
||||||
82
agent/assistant/sandbox_debug_test.go
Normal file
82
agent/assistant/sandbox_debug_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package assistant_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSandboxDebugHasSandbox tests the HasSandbox method directly
|
||||||
|
func TestSandboxDebugHasSandbox(t *testing.T) {
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
assistantID string
|
||||||
|
expectTrue bool
|
||||||
|
}{
|
||||||
|
{"BasicSandbox", "tests.sandbox.basic", true},
|
||||||
|
{"HooksSandbox", "tests.sandbox.hooks", true},
|
||||||
|
{"FullSandbox", "tests.sandbox.full", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ast, err := assistant.Get(tc.assistantID)
|
||||||
|
require.NoError(t, err, "Failed to get assistant %s", tc.assistantID)
|
||||||
|
|
||||||
|
// Check Sandbox struct
|
||||||
|
t.Logf("Assistant ID: %s", ast.ID)
|
||||||
|
t.Logf("Sandbox: %+v", ast.Sandbox)
|
||||||
|
|
||||||
|
if ast.Sandbox != nil {
|
||||||
|
t.Logf("Sandbox.Command: %q", ast.Sandbox.Command)
|
||||||
|
t.Logf("Sandbox.Timeout: %s", ast.Sandbox.Timeout)
|
||||||
|
t.Logf("Sandbox.Image: %s", ast.Sandbox.Image)
|
||||||
|
t.Logf("Sandbox.Arguments: %v", ast.Sandbox.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check HasSandbox
|
||||||
|
hasSandbox := ast.HasSandbox()
|
||||||
|
t.Logf("HasSandbox() = %v", hasSandbox)
|
||||||
|
|
||||||
|
if tc.expectTrue {
|
||||||
|
assert.True(t, hasSandbox, "Expected HasSandbox() to be true for %s", tc.assistantID)
|
||||||
|
} else {
|
||||||
|
assert.False(t, hasSandbox, "Expected HasSandbox() to be false for %s", tc.assistantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxDebugPrompts tests if Prompts is set (affects execution path)
|
||||||
|
func TestSandboxDebugPrompts(t *testing.T) {
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
ast, err := assistant.Get("tests.sandbox.basic")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Assistant ID: %s", ast.ID)
|
||||||
|
t.Logf("Prompts: %v", ast.Prompts)
|
||||||
|
t.Logf("MCP: %v", ast.MCP)
|
||||||
|
t.Logf("HasSandbox: %v", ast.HasSandbox())
|
||||||
|
|
||||||
|
// The condition in agent.go is:
|
||||||
|
// if ast.Prompts != nil || ast.MCP != nil {
|
||||||
|
// // ... execute LLM
|
||||||
|
// if ast.HasSandbox() {
|
||||||
|
// // sandbox path
|
||||||
|
// } else {
|
||||||
|
// // direct LLM path
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// So we need Prompts or MCP to be non-nil
|
||||||
|
if ast.Prompts == nil && ast.MCP == nil {
|
||||||
|
t.Log("WARNING: Neither Prompts nor MCP is set, LLM phase will be skipped entirely!")
|
||||||
|
}
|
||||||
|
}
|
||||||
329
agent/assistant/sandbox_e2e_test.go
Normal file
329
agent/assistant/sandbox_e2e_test.go
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
package assistant_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSandboxE2EContext creates a Context for sandbox E2E testing
|
||||||
|
// Uses unique chatID to avoid container name conflicts
|
||||||
|
func newSandboxE2EContext(chatIDPrefix, assistantID string) *context.Context {
|
||||||
|
// Generate unique chatID using timestamp to avoid container conflicts
|
||||||
|
chatID := fmt.Sprintf("%s-%d", chatIDPrefix, time.Now().UnixNano())
|
||||||
|
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: "sandbox-e2e-test-user",
|
||||||
|
ClientID: "sandbox-e2e-test-client",
|
||||||
|
Scope: "openid profile",
|
||||||
|
SessionID: "sandbox-e2e-test-session",
|
||||||
|
UserID: "sandbox-user-123",
|
||||||
|
TeamID: "sandbox-team-456",
|
||||||
|
TenantID: "sandbox-tenant-789",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||||
|
ctx.AssistantID = assistantID
|
||||||
|
ctx.Locale = "en-us"
|
||||||
|
ctx.Theme = "light"
|
||||||
|
ctx.Client = context.Client{
|
||||||
|
Type: "web",
|
||||||
|
UserAgent: "SandboxE2ETest/1.0",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
}
|
||||||
|
ctx.Referer = context.RefererAPI
|
||||||
|
ctx.Accept = context.AcceptWebCUI
|
||||||
|
ctx.Route = ""
|
||||||
|
ctx.Metadata = make(map[string]interface{})
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxBasicE2E tests the basic sandbox assistant end-to-end
|
||||||
|
// This test verifies that:
|
||||||
|
// 1. Sandbox is correctly initialized
|
||||||
|
// 2. Claude CLI command is built correctly
|
||||||
|
// 3. Docker container is created and managed
|
||||||
|
func TestSandboxBasicE2E(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the basic sandbox assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.basic")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: sandbox assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sandbox is configured
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
t.Logf("✓ Sandbox configured with command: %s", ast.Sandbox.Command)
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
ctx := newSandboxE2EContext("sandbox-basic-e2e", "tests.sandbox.basic")
|
||||||
|
|
||||||
|
// Test messages
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "echo hello sandbox"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute stream
|
||||||
|
// Note: This will fail if Docker/Claude image is not available, which is expected in CI
|
||||||
|
response, err := ast.Stream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a Docker/sandbox availability issue
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "Docker") ||
|
||||||
|
strings.Contains(errStr, "sandbox") ||
|
||||||
|
strings.Contains(errStr, "container") ||
|
||||||
|
strings.Contains(errStr, "image") {
|
||||||
|
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("Stream failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify response
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
|
||||||
|
// Verify response completion (Claude CLI should return some response)
|
||||||
|
if response.Completion != nil && response.Completion.Content != nil {
|
||||||
|
if contentStr, ok := response.Completion.Content.(string); ok && contentStr != "" {
|
||||||
|
t.Logf("✓ Response content: %s", truncateString(contentStr, 200))
|
||||||
|
} else {
|
||||||
|
t.Logf("⚠ Response content type: %T", response.Completion.Content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ Response content is empty (might be expected for some commands)")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Basic sandbox E2E test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateString truncates a string to maxLen and adds "..." if truncated
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxHooksE2E tests the sandbox assistant with hooks
|
||||||
|
func TestSandboxHooksE2E(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the hooks sandbox assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.hooks")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sandbox and hooks are configured
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||||
|
t.Logf("✓ Sandbox and hooks configured")
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
ctx := newSandboxE2EContext("sandbox-hooks-e2e", "tests.sandbox.hooks")
|
||||||
|
|
||||||
|
// Test messages
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "test hooks integration"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute stream
|
||||||
|
response, err := ast.Stream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "Docker") ||
|
||||||
|
strings.Contains(errStr, "sandbox") ||
|
||||||
|
strings.Contains(errStr, "container") ||
|
||||||
|
strings.Contains(errStr, "image") {
|
||||||
|
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("Stream failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
t.Log("✓ Sandbox hooks E2E test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxFullE2E tests the full sandbox assistant with MCPs and Skills
|
||||||
|
func TestSandboxFullE2E(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the full sandbox assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.full")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all components are configured
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||||
|
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||||
|
t.Logf("✓ Full sandbox configured: command=%s, MCP servers=%d",
|
||||||
|
ast.Sandbox.Command, len(ast.MCP.Servers))
|
||||||
|
|
||||||
|
// Verify MCP configuration
|
||||||
|
assert.Len(t, ast.MCP.Servers, 1)
|
||||||
|
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
|
||||||
|
t.Logf("✓ MCP server: %s with tools %v", ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
ctx := newSandboxE2EContext("sandbox-full-e2e", "tests.sandbox.full")
|
||||||
|
|
||||||
|
// Test messages
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "test full sandbox with MCP and skills"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute stream
|
||||||
|
response, err := ast.Stream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "Docker") ||
|
||||||
|
strings.Contains(errStr, "sandbox") ||
|
||||||
|
strings.Contains(errStr, "container") ||
|
||||||
|
strings.Contains(errStr, "image") {
|
||||||
|
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("Stream failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
t.Log("✓ Full sandbox E2E test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxContextAccess tests that sandbox is accessible in hooks via ctx.sandbox
|
||||||
|
func TestSandboxContextAccess(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox context access test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the hooks sandbox assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.hooks")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
ctx := newSandboxE2EContext("sandbox-ctx-access", "tests.sandbox.hooks")
|
||||||
|
|
||||||
|
// Test Create Hook - it should have access to ctx.sandbox
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "test sandbox context access"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute Create hook directly
|
||||||
|
// This tests that the hook runs without error (sandbox operations tested within)
|
||||||
|
opts := &context.Options{}
|
||||||
|
response, _, err := ast.HookScript.Create(ctx, messages, opts)
|
||||||
|
|
||||||
|
// The hook might fail if sandbox isn't initialized yet (that's done in Stream)
|
||||||
|
// But we can at least verify the hook exists and can be called
|
||||||
|
if err != nil {
|
||||||
|
// If the error is about sandbox not being available, that's expected
|
||||||
|
// because we haven't initialized the sandbox yet
|
||||||
|
if strings.Contains(err.Error(), "sandbox") {
|
||||||
|
t.Logf("Expected error: sandbox not available in direct hook call: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response might be nil, that's okay
|
||||||
|
t.Logf("Create hook response: %v", response)
|
||||||
|
t.Log("✓ Sandbox context access test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxLoadConfiguration verifies that sandbox assistants load correctly
|
||||||
|
func TestSandboxLoadConfiguration(t *testing.T) {
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
assistantID string
|
||||||
|
expectSandbox bool
|
||||||
|
expectMCP bool
|
||||||
|
expectHooks bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "BasicSandbox",
|
||||||
|
assistantID: "tests.sandbox.basic",
|
||||||
|
expectSandbox: true,
|
||||||
|
expectMCP: false,
|
||||||
|
expectHooks: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HooksSandbox",
|
||||||
|
assistantID: "tests.sandbox.hooks",
|
||||||
|
expectSandbox: true,
|
||||||
|
expectMCP: false,
|
||||||
|
expectHooks: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FullSandbox",
|
||||||
|
assistantID: "tests.sandbox.full",
|
||||||
|
expectSandbox: true,
|
||||||
|
expectMCP: true,
|
||||||
|
expectHooks: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ast, err := assistant.Get(tc.assistantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping: assistant %s not available: %v", tc.assistantID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sandbox
|
||||||
|
if tc.expectSandbox {
|
||||||
|
require.NotNil(t, ast.Sandbox, "Expected sandbox to be configured")
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
t.Logf("✓ %s: Sandbox configured with command=%s", tc.name, ast.Sandbox.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check MCP
|
||||||
|
if tc.expectMCP {
|
||||||
|
require.NotNil(t, ast.MCP, "Expected MCP to be configured")
|
||||||
|
assert.True(t, len(ast.MCP.Servers) > 0, "Expected at least one MCP server")
|
||||||
|
t.Logf("✓ %s: MCP configured with %d servers", tc.name, len(ast.MCP.Servers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hooks
|
||||||
|
if tc.expectHooks {
|
||||||
|
require.NotNil(t, ast.HookScript, "Expected hooks to be loaded")
|
||||||
|
t.Logf("✓ %s: Hooks loaded", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
173
agent/assistant/sandbox_integration_test.go
Normal file
173
agent/assistant/sandbox_integration_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
package assistant_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||||
|
"github.com/yaoapp/yao/agent/sandbox/claude"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSandboxOptionsBuilding tests that sandbox options are correctly built from assistant config
|
||||||
|
func TestSandboxOptionsBuilding(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Load agent to ensure connectors are available
|
||||||
|
err := agent.Load(config.Conf)
|
||||||
|
require.NoError(t, err, "agent.Load should succeed")
|
||||||
|
|
||||||
|
// Load the full test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Verify sandbox is configured
|
||||||
|
require.NotNil(t, ast.Sandbox)
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||||
|
|
||||||
|
// Verify arguments are set
|
||||||
|
require.NotNil(t, ast.Sandbox.Arguments)
|
||||||
|
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
|
||||||
|
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
|
||||||
|
|
||||||
|
// Verify MCP configuration
|
||||||
|
require.NotNil(t, ast.MCP)
|
||||||
|
assert.Len(t, ast.MCP.Servers, 1)
|
||||||
|
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
|
||||||
|
|
||||||
|
t.Logf("Sandbox config: command=%s, timeout=%s", ast.Sandbox.Command, ast.Sandbox.Timeout)
|
||||||
|
t.Logf("Sandbox arguments: %v", ast.Sandbox.Arguments)
|
||||||
|
t.Logf("MCP servers: %v", ast.MCP.Servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClaudeCommandBuilding tests that Claude CLI commands are correctly built
|
||||||
|
func TestClaudeCommandBuilding(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create test messages
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are a helpful coding assistant."},
|
||||||
|
{Role: "user", Content: "Hello, how are you?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create options similar to what buildSandboxOptions would produce
|
||||||
|
opts := &claude.Options{
|
||||||
|
Command: "claude",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat",
|
||||||
|
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
ConnectorKey: "test-api-key",
|
||||||
|
Model: "ep-xxxxx",
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"max_turns": 10,
|
||||||
|
"permission_mode": "acceptEdits",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the command
|
||||||
|
cmd, env, err := claude.BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify command structure
|
||||||
|
assert.NotEmpty(t, cmd)
|
||||||
|
assert.Equal(t, "ccr-run", cmd[0], "Command should start with ccr-run")
|
||||||
|
t.Logf("Built command: %v", cmd)
|
||||||
|
|
||||||
|
// Verify environment variables
|
||||||
|
assert.NotEmpty(t, env)
|
||||||
|
assert.Equal(t, "https://ark.cn-beijing.volces.com/api/v3", env["CCR_API_BASE"])
|
||||||
|
assert.Equal(t, "test-api-key", env["CCR_API_KEY"])
|
||||||
|
assert.Equal(t, "ep-xxxxx", env["CCR_MODEL"])
|
||||||
|
assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"])
|
||||||
|
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
|
||||||
|
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
|
||||||
|
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant")
|
||||||
|
t.Logf("Built environment: %v", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClaudeCCRConfigBuilding tests that CCR config is correctly built
|
||||||
|
func TestClaudeCCRConfigBuilding(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
opts := &claude.Options{
|
||||||
|
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
ConnectorKey: "test-api-key",
|
||||||
|
Model: "ep-xxxxx",
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := claude.BuildCCRConfig(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, configJSON)
|
||||||
|
|
||||||
|
t.Logf("CCR config: %s", string(configJSON))
|
||||||
|
|
||||||
|
// Verify the JSON contains expected fields
|
||||||
|
assert.Contains(t, string(configJSON), "baseUrl")
|
||||||
|
assert.Contains(t, string(configJSON), "apiKey")
|
||||||
|
assert.Contains(t, string(configJSON), "model")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultImageSelection tests that default images are correctly selected
|
||||||
|
func TestDefaultImageSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
expectedImage string
|
||||||
|
}{
|
||||||
|
{"claude", "yaoapp/sandbox-claude:latest"},
|
||||||
|
{"cursor", "yaoapp/sandbox-cursor:latest"},
|
||||||
|
{"unknown", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
image := agentsandbox.DefaultImage(tt.command)
|
||||||
|
assert.Equal(t, tt.expectedImage, image)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxCommandValidation tests that command validation works correctly
|
||||||
|
func TestSandboxCommandValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"claude", true},
|
||||||
|
{"cursor", true},
|
||||||
|
{"invalid", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
result := agentsandbox.IsValidCommand(tt.command)
|
||||||
|
assert.Equal(t, tt.valid, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasSandboxMethod tests the HasSandbox method on Assistant
|
||||||
|
func TestHasSandboxMethod(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Test assistant with sandbox
|
||||||
|
astWithSandbox, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
|
||||||
|
|
||||||
|
// Test assistant without sandbox (fullfields doesn't have sandbox)
|
||||||
|
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
|
||||||
|
}
|
||||||
225
agent/assistant/sandbox_test.go
Normal file
225
agent/assistant/sandbox_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
package assistant_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoadSandboxBasicAssistant tests loading the basic sandbox test assistant
|
||||||
|
func TestLoadSandboxBasicAssistant(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Verify basic fields
|
||||||
|
assert.Equal(t, "tests.sandbox.basic", ast.ID)
|
||||||
|
assert.Equal(t, "Sandbox Basic Test", ast.Name)
|
||||||
|
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||||
|
|
||||||
|
// Verify sandbox configuration
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||||
|
|
||||||
|
// Verify HasSandbox returns true
|
||||||
|
assert.True(t, ast.HasSandbox(), "HasSandbox should return true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadSandboxHooksAssistant tests loading the hooks sandbox test assistant
|
||||||
|
func TestLoadSandboxHooksAssistant(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/hooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Verify basic fields
|
||||||
|
assert.Equal(t, "tests.sandbox.hooks", ast.ID)
|
||||||
|
assert.Equal(t, "Sandbox Hooks Test", ast.Name)
|
||||||
|
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||||
|
|
||||||
|
// Verify sandbox configuration
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
|
||||||
|
// Verify hooks are loaded
|
||||||
|
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadSandboxFullAssistant tests loading the full sandbox test assistant with MCPs and Skills
|
||||||
|
func TestLoadSandboxFullAssistant(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Load agent to ensure MCPs are available
|
||||||
|
err := agent.Load(config.Conf)
|
||||||
|
require.NoError(t, err, "agent.Load should succeed")
|
||||||
|
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Verify basic fields
|
||||||
|
assert.Equal(t, "tests.sandbox.full", ast.ID)
|
||||||
|
assert.Equal(t, "Sandbox Full Test", ast.Name)
|
||||||
|
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||||
|
|
||||||
|
// Verify sandbox configuration
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||||
|
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||||
|
|
||||||
|
// Verify sandbox arguments (command-specific options)
|
||||||
|
require.NotNil(t, ast.Sandbox.Arguments, "Sandbox arguments should be configured")
|
||||||
|
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
|
||||||
|
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
|
||||||
|
|
||||||
|
// Verify MCP configuration
|
||||||
|
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||||
|
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should be configured")
|
||||||
|
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
|
||||||
|
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID, "MCP server ID should be 'echo'")
|
||||||
|
assert.Contains(t, ast.MCP.Servers[0].Tools, "ping", "MCP tools should contain 'ping'")
|
||||||
|
assert.Contains(t, ast.MCP.Servers[0].Tools, "echo", "MCP tools should contain 'echo'")
|
||||||
|
|
||||||
|
// Verify hooks are loaded
|
||||||
|
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxConfigValidation tests sandbox configuration validation
|
||||||
|
func TestSandboxConfigValidation(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
hasError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Basic sandbox config",
|
||||||
|
path: "/assistants/tests/sandbox/basic",
|
||||||
|
hasError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hooks sandbox config",
|
||||||
|
path: "/assistants/tests/sandbox/hooks",
|
||||||
|
hasError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full sandbox config with MCPs",
|
||||||
|
path: "/assistants/tests/sandbox/full",
|
||||||
|
hasError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ast, err := assistant.LoadPath(tt.path)
|
||||||
|
if tt.hasError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
require.NotNil(t, ast.Sandbox)
|
||||||
|
assert.NotEmpty(t, ast.Sandbox.Command)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillsDirectoryResolution tests that skills directory exists and has correct structure
|
||||||
|
// Note: Skills are auto-discovered from skills/ directory, not stored in AssistantModel
|
||||||
|
func TestSkillsDirectoryResolution(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Get app root from environment
|
||||||
|
appRoot := os.Getenv("YAO_ROOT")
|
||||||
|
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
|
||||||
|
|
||||||
|
// Verify assistant path is set
|
||||||
|
assert.NotEmpty(t, ast.Path, "Assistant path should be set")
|
||||||
|
|
||||||
|
// Build expected skills directory path
|
||||||
|
// ast.Path is like "/assistants/tests/sandbox/full"
|
||||||
|
expectedSkillsDir := filepath.Join(appRoot, ast.Path, "skills")
|
||||||
|
|
||||||
|
// Verify skills directory exists
|
||||||
|
info, err := os.Stat(expectedSkillsDir)
|
||||||
|
require.NoError(t, err, "Skills directory should exist: %s", expectedSkillsDir)
|
||||||
|
assert.True(t, info.IsDir(), "Skills path should be a directory")
|
||||||
|
|
||||||
|
// Verify skills directory structure
|
||||||
|
entries, err := os.ReadDir(expectedSkillsDir)
|
||||||
|
require.NoError(t, err, "Should be able to read skills directory")
|
||||||
|
|
||||||
|
// Find echo-test skill
|
||||||
|
var foundEchoTest bool
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() && entry.Name() == "echo-test" {
|
||||||
|
foundEchoTest = true
|
||||||
|
|
||||||
|
// Verify SKILL.md exists (required)
|
||||||
|
skillMdPath := filepath.Join(expectedSkillsDir, "echo-test", "SKILL.md")
|
||||||
|
_, err := os.Stat(skillMdPath)
|
||||||
|
assert.NoError(t, err, "SKILL.md should exist")
|
||||||
|
|
||||||
|
// Verify scripts directory exists (optional but we created it)
|
||||||
|
scriptsDir := filepath.Join(expectedSkillsDir, "echo-test", "scripts")
|
||||||
|
_, err = os.Stat(scriptsDir)
|
||||||
|
assert.NoError(t, err, "scripts directory should exist")
|
||||||
|
|
||||||
|
// Verify echo.sh exists
|
||||||
|
echoShPath := filepath.Join(scriptsDir, "echo.sh")
|
||||||
|
_, err = os.Stat(echoShPath)
|
||||||
|
assert.NoError(t, err, "echo.sh should exist")
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, foundEchoTest, "echo-test skill should exist in skills directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPConfiguration tests that MCP is correctly loaded for sandbox assistant
|
||||||
|
func TestMCPConfiguration(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Load agent to ensure MCPs are available
|
||||||
|
err := agent.Load(config.Conf)
|
||||||
|
require.NoError(t, err, "agent.Load should succeed")
|
||||||
|
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
|
||||||
|
// Verify MCP configuration structure
|
||||||
|
require.NotNil(t, ast.MCP, "MCP should not be nil")
|
||||||
|
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should not be nil")
|
||||||
|
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
|
||||||
|
|
||||||
|
// Verify echo server configuration
|
||||||
|
echoServer := ast.MCP.Servers[0]
|
||||||
|
assert.Equal(t, "echo", echoServer.ServerID, "Server ID should be 'echo'")
|
||||||
|
assert.Len(t, echoServer.Tools, 3, "Should have 3 tools configured")
|
||||||
|
assert.Contains(t, echoServer.Tools, "ping")
|
||||||
|
assert.Contains(t, echoServer.Tools, "echo")
|
||||||
|
assert.Contains(t, echoServer.Tools, "status")
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,15 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
||||||
memoryObj.Release()
|
memoryObj.Release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sandbox object - only set if sandbox executor is available
|
||||||
|
if ctx.sandboxExecutor != nil {
|
||||||
|
sandboxObj := ctx.createSandboxInstance(v8ctx)
|
||||||
|
if sandboxObj != nil {
|
||||||
|
obj.Set("sandbox", sandboxObj)
|
||||||
|
sandboxObj.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return instance.Value, nil
|
return instance.Value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
235
agent/context/jsapi_sandbox.go
Normal file
235
agent/context/jsapi_sandbox.go
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"rogchap.com/v8go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SandboxExecutor defines the interface for sandbox operations
|
||||||
|
// This interface is implemented by agent/sandbox.Executor
|
||||||
|
// It's defined here to avoid import cycles
|
||||||
|
type SandboxExecutor interface {
|
||||||
|
// Filesystem operations
|
||||||
|
ReadFile(ctx context.Context, path string) ([]byte, error)
|
||||||
|
WriteFile(ctx context.Context, path string, content []byte) error
|
||||||
|
ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error)
|
||||||
|
|
||||||
|
// Command execution
|
||||||
|
Exec(ctx context.Context, cmd []string) (string, error)
|
||||||
|
|
||||||
|
// Workspace info
|
||||||
|
GetWorkDir() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSandboxExecutor sets the sandbox executor for this context
|
||||||
|
// This should be called before hooks are executed
|
||||||
|
func (ctx *Context) SetSandboxExecutor(executor SandboxExecutor) {
|
||||||
|
ctx.sandboxExecutor = executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSandboxExecutor returns the sandbox executor if available
|
||||||
|
func (ctx *Context) GetSandboxExecutor() SandboxExecutor {
|
||||||
|
return ctx.sandboxExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSandbox returns true if sandbox executor is available
|
||||||
|
func (ctx *Context) HasSandbox() bool {
|
||||||
|
return ctx.sandboxExecutor != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSandboxObject creates the ctx.sandbox JavaScript object
|
||||||
|
// Returns nil if sandbox executor is not available
|
||||||
|
func (ctx *Context) newSandboxObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sandboxObj := v8go.NewObjectTemplate(iso)
|
||||||
|
|
||||||
|
// Set methods
|
||||||
|
sandboxObj.Set("ReadFile", ctx.sandboxReadFileMethod(iso))
|
||||||
|
sandboxObj.Set("WriteFile", ctx.sandboxWriteFileMethod(iso))
|
||||||
|
sandboxObj.Set("ListDir", ctx.sandboxListDirMethod(iso))
|
||||||
|
sandboxObj.Set("Exec", ctx.sandboxExecMethod(iso))
|
||||||
|
|
||||||
|
return sandboxObj
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSandboxInstance creates the sandbox object instance with workdir property
|
||||||
|
func (ctx *Context) createSandboxInstance(v8ctx *v8go.Context) *v8go.Value {
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sandboxTemplate := ctx.newSandboxObject(v8ctx.Isolate())
|
||||||
|
if sandboxTemplate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set workdir as a property
|
||||||
|
sandboxTemplate.Set("workdir", ctx.sandboxExecutor.GetWorkDir())
|
||||||
|
|
||||||
|
instance, err := sandboxTemplate.NewInstance(v8ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandboxReadFileMethod implements ctx.sandbox.ReadFile(path)
|
||||||
|
func (ctx *Context) sandboxReadFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
|
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
v8ctx := info.Context()
|
||||||
|
args := info.Args()
|
||||||
|
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return bridge.JsException(v8ctx, "ReadFile requires path parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := args[0].String()
|
||||||
|
|
||||||
|
content, err := ctx.sandboxExecutor.ReadFile(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as string
|
||||||
|
jsVal, err := v8go.NewValue(iso, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsVal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandboxWriteFileMethod implements ctx.sandbox.WriteFile(path, content)
|
||||||
|
func (ctx *Context) sandboxWriteFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
|
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
v8ctx := info.Context()
|
||||||
|
args := info.Args()
|
||||||
|
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return bridge.JsException(v8ctx, "WriteFile requires path and content parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := args[0].String()
|
||||||
|
content := args[1].String()
|
||||||
|
|
||||||
|
err := ctx.sandboxExecutor.WriteFile(context.Background(), path, []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return undefined on success
|
||||||
|
return v8go.Undefined(iso)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandboxListDirMethod implements ctx.sandbox.ListDir(path)
|
||||||
|
func (ctx *Context) sandboxListDirMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
|
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
v8ctx := info.Context()
|
||||||
|
args := info.Args()
|
||||||
|
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return bridge.JsException(v8ctx, "ListDir requires path parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := args[0].String()
|
||||||
|
|
||||||
|
files, err := ctx.sandboxExecutor.ListDir(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to JavaScript array of objects
|
||||||
|
result := make([]map[string]interface{}, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
result[i] = map[string]interface{}{
|
||||||
|
"name": f.Name,
|
||||||
|
"size": f.Size,
|
||||||
|
"is_dir": f.IsDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsVal, err := bridge.JsValue(v8ctx, result)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsVal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandboxExecMethod implements ctx.sandbox.Exec(cmd)
|
||||||
|
func (ctx *Context) sandboxExecMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
|
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
v8ctx := info.Context()
|
||||||
|
args := info.Args()
|
||||||
|
|
||||||
|
if ctx.sandboxExecutor == nil {
|
||||||
|
return bridge.JsException(v8ctx, "sandbox executor not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return bridge.JsException(v8ctx, "Exec requires cmd parameter (array of strings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command array
|
||||||
|
cmdArg := args[0]
|
||||||
|
if !cmdArg.IsArray() {
|
||||||
|
return bridge.JsException(v8ctx, "Exec requires cmd to be an array of strings")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdObj, err := cmdArg.AsObject()
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, "failed to parse cmd array: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get array length
|
||||||
|
lengthVal, err := cmdObj.Get("length")
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, "failed to get cmd array length: "+err.Error())
|
||||||
|
}
|
||||||
|
length := int(lengthVal.Integer())
|
||||||
|
|
||||||
|
// Build command slice
|
||||||
|
cmd := make([]string, length)
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
itemVal, err := cmdObj.GetIdx(uint32(i))
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, "failed to get cmd array element: "+err.Error())
|
||||||
|
}
|
||||||
|
cmd[i] = itemVal.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := ctx.sandboxExecutor.Exec(context.Background(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
jsVal, err := v8go.NewValue(v8ctx.Isolate(), output)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(v8ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsVal
|
||||||
|
})
|
||||||
|
}
|
||||||
482
agent/context/jsapi_sandbox_test.go
Normal file
482
agent/context/jsapi_sandbox_test.go
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
package context_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestSandboxManager creates a real sandbox manager for testing
|
||||||
|
func createTestSandboxManager(t *testing.T) *infraSandbox.Manager {
|
||||||
|
// Get data root from environment or use temp directory
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
dataRoot = t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with proper paths
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestContainer creates a container and returns a cleanup function
|
||||||
|
func createTestContainer(t *testing.T, manager *infraSandbox.Manager, userID, chatID string) (*infraSandbox.Container, func()) {
|
||||||
|
container, err := manager.GetOrCreate(stdContext.Background(), userID, chatID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, container)
|
||||||
|
|
||||||
|
// Return cleanup function that removes the container
|
||||||
|
cleanup := func() {
|
||||||
|
err := manager.Remove(stdContext.Background(), container.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: failed to cleanup container %s: %v", container.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return container, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// realSandboxExecutor wraps infraSandbox.Manager to implement context.SandboxExecutor
|
||||||
|
type realSandboxExecutor struct {
|
||||||
|
manager *infraSandbox.Manager
|
||||||
|
containerName string
|
||||||
|
workDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *realSandboxExecutor) ReadFile(ctx stdContext.Context, path string) ([]byte, error) {
|
||||||
|
fullPath := e.workDir + "/" + path
|
||||||
|
return e.manager.ReadFile(ctx, e.containerName, fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *realSandboxExecutor) WriteFile(ctx stdContext.Context, path string, content []byte) error {
|
||||||
|
fullPath := e.workDir + "/" + path
|
||||||
|
return e.manager.WriteFile(ctx, e.containerName, fullPath, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *realSandboxExecutor) ListDir(ctx stdContext.Context, path string) ([]infraSandbox.FileInfo, error) {
|
||||||
|
fullPath := e.workDir + "/" + path
|
||||||
|
return e.manager.ListDir(ctx, e.containerName, fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *realSandboxExecutor) Exec(ctx stdContext.Context, cmd []string) (string, error) {
|
||||||
|
result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: e.workDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Stdout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *realSandboxExecutor) GetWorkDir() string {
|
||||||
|
return e.workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxNotAvailable tests ctx.sandbox when not configured
|
||||||
|
func TestJsSandboxNotAvailable(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-no-sandbox")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
|
||||||
|
// Test that ctx.sandbox is undefined when not configured
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (ctx.sandbox === undefined || ctx.sandbox === null) {
|
||||||
|
return { success: true, hasSandbox: false };
|
||||||
|
}
|
||||||
|
return { success: true, hasSandbox: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"])
|
||||||
|
assert.Equal(t, false, result["hasSandbox"], "ctx.sandbox should not be available when not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxWriteFile tests ctx.sandbox.WriteFile via JavaScript
|
||||||
|
func TestJsSandboxWriteFile(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-writefile")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-writefile")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test WriteFile via JavaScript
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a file
|
||||||
|
ctx.sandbox.WriteFile("js-test.txt", "Hello from JavaScript!");
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "WriteFile should succeed: %v", result["error"])
|
||||||
|
|
||||||
|
// Verify file was written by reading it back directly
|
||||||
|
content, err := executor.ReadFile(stdContext.Background(), "js-test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Hello from JavaScript!", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxReadFile tests ctx.sandbox.ReadFile via JavaScript
|
||||||
|
func TestJsSandboxReadFile(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-readfile")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a file first
|
||||||
|
err := executor.WriteFile(stdContext.Background(), "read-test.txt", []byte("Content to read"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-readfile")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test ReadFile via JavaScript
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file
|
||||||
|
const content = ctx.sandbox.ReadFile("read-test.txt");
|
||||||
|
|
||||||
|
return { success: true, content: content };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "ReadFile should succeed: %v", result["error"])
|
||||||
|
assert.Equal(t, "Content to read", result["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxListDir tests ctx.sandbox.ListDir via JavaScript
|
||||||
|
func TestJsSandboxListDir(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-listdir")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write some files first
|
||||||
|
err := executor.WriteFile(stdContext.Background(), "file1.txt", []byte("content1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = executor.WriteFile(stdContext.Background(), "file2.txt", []byte("content2"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-listdir")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test ListDir via JavaScript
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// List directory
|
||||||
|
const files = ctx.sandbox.ListDir(".");
|
||||||
|
|
||||||
|
// Find our test files
|
||||||
|
const fileNames = files.map(f => f.name);
|
||||||
|
const hasFile1 = fileNames.includes("file1.txt");
|
||||||
|
const hasFile2 = fileNames.includes("file2.txt");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
fileCount: files.length,
|
||||||
|
hasFile1: hasFile1,
|
||||||
|
hasFile2: hasFile2,
|
||||||
|
files: fileNames
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "ListDir should succeed: %v", result["error"])
|
||||||
|
assert.Equal(t, true, result["hasFile1"], "Should find file1.txt")
|
||||||
|
assert.Equal(t, true, result["hasFile2"], "Should find file2.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxExec tests ctx.sandbox.Exec via JavaScript
|
||||||
|
func TestJsSandboxExec(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-exec")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-exec")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test Exec via JavaScript
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute echo command
|
||||||
|
const output = ctx.sandbox.Exec(["echo", "hello-from-js"]);
|
||||||
|
|
||||||
|
return { success: true, output: output.trim() };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "Exec should succeed: %v", result["error"])
|
||||||
|
// Output may contain Docker stream header bytes, so use Contains
|
||||||
|
output, _ := result["output"].(string)
|
||||||
|
assert.Contains(t, output, "hello-from-js", "Exec output should contain expected text")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxWorkdir tests ctx.sandbox.workdir property via JavaScript
|
||||||
|
func TestJsSandboxWorkdir(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-workdir")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-workdir")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test workdir property via JavaScript
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workdir property
|
||||||
|
const workdir = ctx.sandbox.workdir;
|
||||||
|
|
||||||
|
return { success: true, workdir: workdir };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "workdir access should succeed: %v", result["error"])
|
||||||
|
assert.Equal(t, "/workspace", result["workdir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJsSandboxCompleteWorkflow tests a complete workflow via JavaScript
|
||||||
|
func TestJsSandboxCompleteWorkflow(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestSandboxManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create container with auto-cleanup
|
||||||
|
container, cleanup := createTestContainer(t, manager, "test-user", "test-js-workflow")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
executor := &realSandboxExecutor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
workDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with sandbox
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-workflow")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.SetSandboxExecutor(executor)
|
||||||
|
|
||||||
|
// Test complete workflow: write file, exec cat, verify content
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
if (!ctx.sandbox) {
|
||||||
|
return { success: false, error: "sandbox not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check workdir
|
||||||
|
const workdir = ctx.sandbox.workdir;
|
||||||
|
if (workdir !== "/workspace") {
|
||||||
|
return { success: false, error: "unexpected workdir: " + workdir };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Write a file
|
||||||
|
const testContent = "Test workflow content: " + Date.now();
|
||||||
|
ctx.sandbox.WriteFile("workflow-test.txt", testContent);
|
||||||
|
|
||||||
|
// 3. Read it back
|
||||||
|
const readContent = ctx.sandbox.ReadFile("workflow-test.txt");
|
||||||
|
if (readContent !== testContent) {
|
||||||
|
return { success: false, error: "content mismatch after read" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. List directory and verify file exists
|
||||||
|
const files = ctx.sandbox.ListDir(".");
|
||||||
|
const fileNames = files.map(f => f.name);
|
||||||
|
if (!fileNames.includes("workflow-test.txt")) {
|
||||||
|
return { success: false, error: "file not found in listing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Execute cat command
|
||||||
|
const catOutput = ctx.sandbox.Exec(["cat", workdir + "/workflow-test.txt"]);
|
||||||
|
if (!catOutput.includes("Test workflow content")) {
|
||||||
|
return { success: false, error: "cat output mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Execute pwd command
|
||||||
|
const pwdOutput = ctx.sandbox.Exec(["pwd"]);
|
||||||
|
if (!pwdOutput.includes("/workspace")) {
|
||||||
|
return { success: false, error: "pwd output mismatch: " + pwdOutput };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workdir: workdir,
|
||||||
|
content: readContent,
|
||||||
|
fileCount: files.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message, stack: error.stack };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, ok := res.(map[string]interface{})
|
||||||
|
require.True(t, ok, "Expected map result")
|
||||||
|
assert.Equal(t, true, result["success"], "Complete workflow should succeed: %v", result["error"])
|
||||||
|
assert.Equal(t, "/workspace", result["workdir"])
|
||||||
|
}
|
||||||
|
|
@ -250,6 +250,7 @@ type Context struct {
|
||||||
// Internal
|
// Internal
|
||||||
trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access
|
trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access
|
||||||
messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations
|
messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations
|
||||||
|
sandboxExecutor SandboxExecutor `json:"-"` // Sandbox executor for hooks (set by assistant when sandbox is configured)
|
||||||
|
|
||||||
// Model capabilities (set by assistant, used by output adapters)
|
// Model capabilities (set by assistant, used by output adapters)
|
||||||
Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector
|
Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector
|
||||||
|
|
|
||||||
1146
agent/sandbox/DESIGN.md
Normal file
1146
agent/sandbox/DESIGN.md
Normal file
File diff suppressed because it is too large
Load diff
252
agent/sandbox/PLAN.md
Normal file
252
agent/sandbox/PLAN.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Agent Sandbox Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan covers the implementation of the agent sandbox integration layer (`agent/sandbox/`), which enables coding agents (Claude CLI, Cursor CLI) to run in isolated Docker containers with Yao's LLM pipeline.
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
Tests should run with the local development environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source environment variables
|
||||||
|
source /Users/max/Yao/yao/env.local.sh
|
||||||
|
|
||||||
|
# Key variables used:
|
||||||
|
# YAO_TEST_APPLICATION=/Users/max/Yao/yao-dev-app
|
||||||
|
# YAO_ROOT=$YAO_TEST_APPLICATION
|
||||||
|
# DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, DEEPSEEK_MODELS_V3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Application
|
||||||
|
|
||||||
|
Test assistants at `yao-dev-app/assistants/tests/sandbox/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
yao-dev-app/assistants/tests/
|
||||||
|
└── sandbox/
|
||||||
|
├── basic/ # Basic sandbox execution test
|
||||||
|
│ ├── package.yao # uses.search: disabled
|
||||||
|
│ └── prompts.yml
|
||||||
|
├── hooks/ # Hook integration test
|
||||||
|
│ ├── package.yao # uses.search: disabled
|
||||||
|
│ ├── prompts.yml
|
||||||
|
│ └── src/index.ts
|
||||||
|
└── full/ # Full test with MCPs, Skills, Hooks
|
||||||
|
├── package.yao # uses.search: disabled, mcp: {servers: [...]}
|
||||||
|
├── prompts.yml
|
||||||
|
├── src/index.ts
|
||||||
|
└── skills/echo-test/ # Agent Skills standard
|
||||||
|
├── SKILL.md
|
||||||
|
└── scripts/echo.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connector Configuration
|
||||||
|
|
||||||
|
Use `deepseek.v3` as the default connector (via Volcengine API).
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
### Phase 1: Core Types and Interfaces ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Define `Executor` interface with all methods
|
||||||
|
- [x] Define `Options` struct with JSON tags
|
||||||
|
- [x] Define `FileInfo` alias to infrastructure sandbox
|
||||||
|
- [x] Add `DefaultImage()` and `IsValidCommand()` helpers
|
||||||
|
|
||||||
|
### Phase 2: Claude Executor Implementation ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Implement `Executor` struct
|
||||||
|
- [x] Implement `NewExecutor()` constructor with container reuse
|
||||||
|
- [x] Implement `Stream()` method with CCR config writing
|
||||||
|
- [x] Implement `Execute()` method (wrapper)
|
||||||
|
- [x] Implement `Close()` method (removes container)
|
||||||
|
- [x] Implement filesystem methods: `ReadFile`, `WriteFile`, `ListDir`
|
||||||
|
- [x] Implement `Exec()` method
|
||||||
|
- [x] Implement `GetWorkDir()` method
|
||||||
|
|
||||||
|
### Phase 3: CCR Configuration ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Implement `BuildCCRConfig()` with correct CCR format
|
||||||
|
- [x] Auto-detect provider type (volcengine, deepseek, openai, claude)
|
||||||
|
- [x] Add transformer for DeepSeek/Volcengine (maxtoken)
|
||||||
|
- [x] Generate Router configuration
|
||||||
|
- [x] Write config to container before execution
|
||||||
|
|
||||||
|
### Phase 4: Assistant Integration ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Implement `GetSandboxManager()` singleton
|
||||||
|
- [x] Implement `HasSandbox()` method
|
||||||
|
- [x] Implement `initSandbox()` with cleanup function
|
||||||
|
- [x] Implement `executeSandboxStream()` method
|
||||||
|
- [x] Build executor options from assistant config
|
||||||
|
- [x] Resolve connector settings (host, key, model)
|
||||||
|
- [x] Add trace logging for sandbox creation
|
||||||
|
- [x] Send loading message during sandbox init
|
||||||
|
- [x] Expose executor to hooks via `ctx.SetSandboxExecutor()`
|
||||||
|
- [x] Handle sandbox lifecycle (create → hooks → execute → cleanup)
|
||||||
|
|
||||||
|
### Phase 5: JSAPI Integration ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Define `SandboxExecutor` interface
|
||||||
|
- [x] Implement JS bindings for `ReadFile`, `WriteFile`, `ListDir`, `Exec`
|
||||||
|
- [x] Expose `workdir` property
|
||||||
|
- [x] Register in context's `NewObject` method
|
||||||
|
|
||||||
|
### Phase 6: Concurrency & Resource Management ✅ COMPLETED
|
||||||
|
|
||||||
|
- [x] Container creation uses Double-Check Locking (in `manager.GetOrCreate`)
|
||||||
|
- [x] Same chatID reuses container (by design)
|
||||||
|
- [x] Container cleanup on request completion (`defer sandboxCleanup()`)
|
||||||
|
- [x] Unique chatID in tests to avoid conflicts
|
||||||
|
|
||||||
|
### Phase 7: Workspace Management ⏳ PENDING
|
||||||
|
|
||||||
|
- [ ] Implement workspace cleanup configuration
|
||||||
|
- [ ] Implement stale workspace detection
|
||||||
|
- [ ] Implement cleanup scheduler
|
||||||
|
|
||||||
|
### Phase 8: Cursor Placeholder ⏳ PENDING
|
||||||
|
|
||||||
|
- [ ] Create `cursor/README.md` placeholder
|
||||||
|
|
||||||
|
## Testing Status
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
| Package | Test File | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `agent/sandbox` | `types_test.go` | ✅ PASS |
|
||||||
|
| `agent/sandbox` | `executor_test.go` | ✅ PASS |
|
||||||
|
| `agent/sandbox/claude` | `command_test.go` | ✅ PASS |
|
||||||
|
| `agent/sandbox/claude` | `executor_test.go` | ✅ PASS |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
| Package | Test File | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `agent/sandbox` | `integration_test.go` | ✅ PASS |
|
||||||
|
|
||||||
|
### JSAPI Tests
|
||||||
|
|
||||||
|
| Package | Test File | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `agent/context` | `jsapi_sandbox_test.go` | ✅ PASS |
|
||||||
|
|
||||||
|
### Assistant Loading Tests
|
||||||
|
|
||||||
|
| Package | Test File | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `agent/assistant` | `sandbox_test.go` | ✅ PASS |
|
||||||
|
| `agent/assistant` | `sandbox_integration_test.go` | ✅ PASS |
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
| Package | Test Case | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `agent/assistant` | `TestSandboxBasicE2E` | ✅ PASS |
|
||||||
|
| `agent/assistant` | `TestSandboxHooksE2E` | ✅ PASS |
|
||||||
|
| `agent/assistant` | `TestSandboxFullE2E` | ✅ PASS |
|
||||||
|
| `agent/assistant` | `TestSandboxContextAccess` | ✅ PASS |
|
||||||
|
| `agent/assistant` | `TestSandboxLoadConfiguration` | ✅ PASS |
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source environment
|
||||||
|
source /Users/max/Yao/yao/env.local.sh
|
||||||
|
|
||||||
|
# Run all sandbox tests
|
||||||
|
go test -v ./agent/sandbox/...
|
||||||
|
|
||||||
|
# Run assistant sandbox tests
|
||||||
|
go test -v ./agent/assistant -run "Sandbox"
|
||||||
|
|
||||||
|
# Run E2E tests (requires Docker)
|
||||||
|
go test -v ./agent/assistant -run "TestSandbox.*E2E" -timeout 300s
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
yao/agent/sandbox/ # Executor layer
|
||||||
|
├── DESIGN.md # ✅ Design document
|
||||||
|
├── PLAN.md # ✅ This file
|
||||||
|
├── types.go # ✅ Common types and interfaces
|
||||||
|
├── types_test.go # ✅ Types tests
|
||||||
|
├── executor.go # ✅ Factory function
|
||||||
|
├── executor_test.go # ✅ Factory tests
|
||||||
|
├── integration_test.go # ✅ Integration tests
|
||||||
|
├── claude/
|
||||||
|
│ ├── types.go # ✅ Claude-specific types
|
||||||
|
│ ├── executor.go # ✅ Executor implementation
|
||||||
|
│ ├── executor_test.go # ✅ Executor tests
|
||||||
|
│ ├── command.go # ✅ Command builder + CCR config
|
||||||
|
│ └── command_test.go # ✅ Command tests
|
||||||
|
└── cursor/
|
||||||
|
└── README.md # ⏳ Placeholder (pending)
|
||||||
|
|
||||||
|
yao/agent/assistant/ # Integration layer
|
||||||
|
├── sandbox.go # ✅ Sandbox handler
|
||||||
|
├── sandbox_test.go # ✅ Loading tests
|
||||||
|
├── sandbox_integration_test.go # ✅ Integration tests
|
||||||
|
├── sandbox_e2e_test.go # ✅ E2E tests
|
||||||
|
├── sandbox_debug_test.go # ✅ Debug tests
|
||||||
|
└── agent.go # ✅ Modified: sandbox detection in Stream()
|
||||||
|
|
||||||
|
yao/agent/context/ # Context layer
|
||||||
|
├── jsapi_sandbox.go # ✅ Sandbox JSAPI bindings
|
||||||
|
└── jsapi_sandbox_test.go # ✅ Sandbox JSAPI tests
|
||||||
|
|
||||||
|
yao-dev-app/assistants/tests/sandbox/ # Test assistants
|
||||||
|
├── basic/ # ✅ Basic sandbox test
|
||||||
|
├── hooks/ # ✅ Hooks test
|
||||||
|
└── full/ # ✅ Full test with MCPs and Skills
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### 1. Container Reuse
|
||||||
|
|
||||||
|
Same `userID + chatID` reuses the same container:
|
||||||
|
- Workspace directory persists across requests
|
||||||
|
- CCR config is written on each request (same content, safe to overwrite)
|
||||||
|
- Container is removed when request completes
|
||||||
|
|
||||||
|
### 2. Concurrency
|
||||||
|
|
||||||
|
- Container creation: Protected by mutex + double-check locking
|
||||||
|
- Container execution: Multiple requests can run concurrently in same container
|
||||||
|
- Claude CLI: Supports concurrent execution
|
||||||
|
|
||||||
|
### 3. CCR Configuration
|
||||||
|
|
||||||
|
CCR requires specific JSON format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Providers": [{"name": "volcengine", "api_base_url": "...", ...}],
|
||||||
|
"Router": {"default": "volcengine,model", ...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-detection of provider type based on host URL.
|
||||||
|
|
||||||
|
### 4. Resource Cleanup
|
||||||
|
|
||||||
|
- `executor.Close()` removes the container
|
||||||
|
- `defer sandboxCleanup()` in `agent.go` ensures cleanup
|
||||||
|
- Tests use unique chatID (timestamp) to avoid conflicts
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
1. **MCP config building**: TODO in `buildSandboxOptions` - MCP configuration not yet passed to sandbox
|
||||||
|
2. **Skills mounting**: Skills directory path is set but not mounted into container
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All tests validate return values (use `require`/`assert`)
|
||||||
|
- Docker must be available for integration and E2E tests
|
||||||
|
- Tests automatically clean up containers after completion
|
||||||
|
- Use `uses.search: disabled` in test assistants to avoid auto-search LLM calls
|
||||||
214
agent/sandbox/claude/command.go
Normal file
214
agent/sandbox/claude/command.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildCommand builds the Claude CLI command and environment variables
|
||||||
|
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
||||||
|
// Build system prompt from conversation history
|
||||||
|
systemPrompt, userPrompt := buildPrompts(messages)
|
||||||
|
|
||||||
|
// Start with ccr-run if available, otherwise fall back to claude directly
|
||||||
|
cmd := []string{"ccr-run"}
|
||||||
|
|
||||||
|
// Add the prompt
|
||||||
|
if userPrompt != "" {
|
||||||
|
cmd = append(cmd, userPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build environment variables
|
||||||
|
env := buildEnvironment(opts, systemPrompt)
|
||||||
|
|
||||||
|
return cmd, env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPrompts extracts system prompt and user prompt from messages
|
||||||
|
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
||||||
|
var systemParts []string
|
||||||
|
var conversationParts []string
|
||||||
|
var lastUserMessage string
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
switch msg.Role {
|
||||||
|
case "system":
|
||||||
|
systemParts = append(systemParts, getMessageContent(msg))
|
||||||
|
case "user":
|
||||||
|
lastUserMessage = getMessageContent(msg)
|
||||||
|
conversationParts = append(conversationParts, fmt.Sprintf("User: %s", lastUserMessage))
|
||||||
|
case "assistant":
|
||||||
|
conversationParts = append(conversationParts, fmt.Sprintf("Assistant: %s", getMessageContent(msg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt with conversation history
|
||||||
|
systemPrompt = strings.Join(systemParts, "\n\n")
|
||||||
|
|
||||||
|
// If there's conversation history, include it in the system prompt
|
||||||
|
if len(conversationParts) > 1 {
|
||||||
|
historySection := "\n\n## Conversation History\n\n" + strings.Join(conversationParts[:len(conversationParts)-1], "\n\n")
|
||||||
|
systemPrompt += historySection
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user prompt is the last user message
|
||||||
|
userPrompt = lastUserMessage
|
||||||
|
|
||||||
|
return systemPrompt, userPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessageContent extracts text content from a message
|
||||||
|
func getMessageContent(msg agentContext.Message) string {
|
||||||
|
if msg.Content == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string content
|
||||||
|
if str, ok := msg.Content.(string); ok {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle content array (multimodal messages)
|
||||||
|
if arr, ok := msg.Content.([]interface{}); ok {
|
||||||
|
var parts []string
|
||||||
|
for _, item := range arr {
|
||||||
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
|
if m["type"] == "text" {
|
||||||
|
if text, ok := m["text"].(string); ok {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvironment builds environment variables for Claude CLI
|
||||||
|
func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
|
env := make(map[string]string)
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCR configuration via environment
|
||||||
|
// CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format
|
||||||
|
if opts.ConnectorHost != "" {
|
||||||
|
// CCR expects ANTHROPIC_BASE_URL but will proxy through its own router
|
||||||
|
env["CCR_API_BASE"] = opts.ConnectorHost
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ConnectorKey != "" {
|
||||||
|
env["CCR_API_KEY"] = opts.ConnectorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Model != "" {
|
||||||
|
env["CCR_MODEL"] = opts.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set system prompt via environment (Claude CLI supports this)
|
||||||
|
if systemPrompt != "" {
|
||||||
|
env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional Claude CLI options from Arguments
|
||||||
|
if opts.Arguments != nil {
|
||||||
|
// max_turns
|
||||||
|
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
|
||||||
|
env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// permission_mode
|
||||||
|
if permMode, ok := opts.Arguments["permission_mode"].(string); ok {
|
||||||
|
env["CLAUDE_PERMISSION_MODE"] = permMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// output_format (default to stream-json for streaming)
|
||||||
|
if outputFormat, ok := opts.Arguments["output_format"].(string); ok {
|
||||||
|
env["CLAUDE_OUTPUT_FORMAT"] = outputFormat
|
||||||
|
} else {
|
||||||
|
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON
|
||||||
|
// CCR requires a specific format with Providers array and Router configuration
|
||||||
|
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
||||||
|
if opts == nil {
|
||||||
|
return nil, fmt.Errorf("options is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine provider name based on host
|
||||||
|
providerName := "custom"
|
||||||
|
apiBaseURL := opts.ConnectorHost
|
||||||
|
needsTransformer := false
|
||||||
|
|
||||||
|
if strings.Contains(opts.ConnectorHost, "volces.com") || strings.Contains(opts.ConnectorHost, "volcengine") {
|
||||||
|
providerName = "volcengine"
|
||||||
|
needsTransformer = true
|
||||||
|
// Ensure URL ends with chat/completions
|
||||||
|
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
||||||
|
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
|
||||||
|
}
|
||||||
|
} else if strings.Contains(opts.ConnectorHost, "deepseek") {
|
||||||
|
providerName = "deepseek"
|
||||||
|
needsTransformer = true
|
||||||
|
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
||||||
|
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
|
||||||
|
}
|
||||||
|
} else if strings.Contains(opts.ConnectorHost, "openai.com") {
|
||||||
|
providerName = "openai"
|
||||||
|
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
||||||
|
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/v1/chat/completions"
|
||||||
|
}
|
||||||
|
} else if strings.Contains(opts.ConnectorHost, "anthropic.com") {
|
||||||
|
providerName = "claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build provider configuration
|
||||||
|
provider := map[string]interface{}{
|
||||||
|
"name": providerName,
|
||||||
|
"api_base_url": apiBaseURL,
|
||||||
|
"api_key": opts.ConnectorKey,
|
||||||
|
"models": []string{opts.Model},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add transformer for providers that need it (DeepSeek, Volcengine)
|
||||||
|
if needsTransformer {
|
||||||
|
provider["transformer"] = map[string]interface{}{
|
||||||
|
"use": []interface{}{
|
||||||
|
[]interface{}{"maxtoken", map[string]interface{}{"max_tokens": 16384}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build router configuration
|
||||||
|
routerKey := fmt.Sprintf("%s,%s", providerName, opts.Model)
|
||||||
|
router := map[string]interface{}{
|
||||||
|
"default": routerKey,
|
||||||
|
"background": routerKey,
|
||||||
|
"think": routerKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full config
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"LOG": true,
|
||||||
|
"API_TIMEOUT_MS": 600000,
|
||||||
|
"NON_INTERACTIVE_MODE": true,
|
||||||
|
"Providers": []interface{}{provider},
|
||||||
|
"Router": router,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.MarshalIndent(config, "", " ")
|
||||||
|
}
|
||||||
137
agent/sandbox/claude/command_test.go
Normal file
137
agent/sandbox/claude/command_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildCommand(t *testing.T) {
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant"},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "key123",
|
||||||
|
Model: "test-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, env, err := BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify command structure
|
||||||
|
assert.Equal(t, "ccr-run", cmd[0])
|
||||||
|
assert.Contains(t, cmd, "Hello") // User prompt should be in command
|
||||||
|
|
||||||
|
// Verify environment variables
|
||||||
|
assert.Equal(t, "https://api.example.com", env["CCR_API_BASE"])
|
||||||
|
assert.Equal(t, "key123", env["CCR_API_KEY"])
|
||||||
|
assert.Equal(t, "test-model", env["CCR_MODEL"])
|
||||||
|
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommandWithSystemPrompt(t *testing.T) {
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are a code reviewer"},
|
||||||
|
{Role: "user", Content: "Review this code"},
|
||||||
|
{Role: "assistant", Content: "Sure, I'll review it"},
|
||||||
|
{Role: "user", Content: "Here is the code"},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &Options{}
|
||||||
|
|
||||||
|
_, env, err := BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// System prompt should include conversation history
|
||||||
|
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer")
|
||||||
|
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommandWithArguments(t *testing.T) {
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"max_turns": 20,
|
||||||
|
"permission_mode": "acceptEdits",
|
||||||
|
"output_format": "json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, env, err := BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"])
|
||||||
|
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
|
||||||
|
assert.Equal(t, "json", env["CLAUDE_OUTPUT_FORMAT"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCCRConfig(t *testing.T) {
|
||||||
|
opts := &Options{
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "key123",
|
||||||
|
Model: "test-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := BuildCCRConfig(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configStr := string(configJSON)
|
||||||
|
// CCR config uses snake_case for fields
|
||||||
|
assert.Contains(t, configStr, "api_base_url")
|
||||||
|
assert.Contains(t, configStr, "https://api.example.com")
|
||||||
|
assert.Contains(t, configStr, "api_key")
|
||||||
|
assert.Contains(t, configStr, "key123")
|
||||||
|
assert.Contains(t, configStr, "models")
|
||||||
|
assert.Contains(t, configStr, "test-model")
|
||||||
|
// Verify new CCR format fields
|
||||||
|
assert.Contains(t, configStr, "Providers")
|
||||||
|
assert.Contains(t, configStr, "Router")
|
||||||
|
assert.Contains(t, configStr, "NON_INTERACTIVE_MODE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCCRConfigVolcengine(t *testing.T) {
|
||||||
|
opts := &Options{
|
||||||
|
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||||
|
ConnectorKey: "test-key",
|
||||||
|
Model: "ep-xxx",
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := BuildCCRConfig(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configStr := string(configJSON)
|
||||||
|
// Verify volcengine-specific configuration
|
||||||
|
assert.Contains(t, configStr, "volcengine")
|
||||||
|
assert.Contains(t, configStr, "transformer")
|
||||||
|
assert.Contains(t, configStr, "maxtoken")
|
||||||
|
// URL should end with /chat/completions
|
||||||
|
assert.Contains(t, configStr, "/chat/completions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMessageContent(t *testing.T) {
|
||||||
|
// String content
|
||||||
|
msg1 := agentContext.Message{Content: "Hello World"}
|
||||||
|
assert.Equal(t, "Hello World", getMessageContent(msg1))
|
||||||
|
|
||||||
|
// Nil content
|
||||||
|
msg2 := agentContext.Message{Content: nil}
|
||||||
|
assert.Equal(t, "", getMessageContent(msg2))
|
||||||
|
|
||||||
|
// Array content (multimodal)
|
||||||
|
msg3 := agentContext.Message{
|
||||||
|
Content: []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "Part 1"},
|
||||||
|
map[string]interface{}{"type": "text", "text": "Part 2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Contains(t, getMessageContent(msg3), "Part 1")
|
||||||
|
assert.Contains(t, getMessageContent(msg3), "Part 2")
|
||||||
|
}
|
||||||
330
agent/sandbox/claude/executor.go
Normal file
330
agent/sandbox/claude/executor.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options for Claude executor (copied from parent package to avoid import cycle)
|
||||||
|
type Options struct {
|
||||||
|
Command string
|
||||||
|
Image string
|
||||||
|
MaxMemory string
|
||||||
|
MaxCPU float64
|
||||||
|
Timeout time.Duration
|
||||||
|
Arguments map[string]interface{}
|
||||||
|
UserID string
|
||||||
|
ChatID string
|
||||||
|
MCPConfig []byte
|
||||||
|
SkillsDir string
|
||||||
|
ConnectorHost string
|
||||||
|
ConnectorKey string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor implements the sandbox.Executor interface for Claude CLI
|
||||||
|
type Executor struct {
|
||||||
|
manager *infraSandbox.Manager
|
||||||
|
containerName string
|
||||||
|
opts *Options
|
||||||
|
workDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new Claude executor
|
||||||
|
func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, error) {
|
||||||
|
if manager == nil {
|
||||||
|
return nil, fmt.Errorf("manager is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assertion to get options
|
||||||
|
var execOpts *Options
|
||||||
|
switch o := opts.(type) {
|
||||||
|
case *Options:
|
||||||
|
execOpts = o
|
||||||
|
default:
|
||||||
|
// Try to convert from map or other struct
|
||||||
|
return nil, fmt.Errorf("invalid options type: %T", opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if execOpts == nil {
|
||||||
|
return nil, fmt.Errorf("options is required")
|
||||||
|
}
|
||||||
|
if execOpts.UserID == "" {
|
||||||
|
return nil, fmt.Errorf("UserID is required")
|
||||||
|
}
|
||||||
|
if execOpts.ChatID == "" {
|
||||||
|
return nil, fmt.Errorf("ChatID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or get container
|
||||||
|
ctx := context.Background()
|
||||||
|
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workspace directory from config
|
||||||
|
config := manager.GetConfig()
|
||||||
|
workDir := config.ContainerWorkDir
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = "/workspace"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Executor{
|
||||||
|
manager: manager,
|
||||||
|
containerName: container.Name,
|
||||||
|
opts: execOpts,
|
||||||
|
workDir: workDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream runs the Claude CLI with streaming output
|
||||||
|
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
||||||
|
stdCtx := context.Background()
|
||||||
|
if ctx != nil && ctx.Context != nil {
|
||||||
|
stdCtx = ctx.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write CCR config file to container before executing
|
||||||
|
if err := e.writeCCRConfig(stdCtx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write CCR config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Claude CLI command using stored options
|
||||||
|
cmd, env, err := BuildCommand(messages, e.opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare execution options
|
||||||
|
execOpts := &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: e.workDir,
|
||||||
|
Env: env,
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.opts != nil && e.opts.Timeout > 0 {
|
||||||
|
execOpts.Timeout = e.opts.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := e.manager.Stream(stdCtx, e.containerName, cmd, execOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to execute command: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Parse streaming output
|
||||||
|
return e.parseStream(reader, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCCRConfig writes the CCR configuration file to the container
|
||||||
|
func (e *Executor) writeCCRConfig(ctx context.Context) error {
|
||||||
|
// Build CCR config
|
||||||
|
configJSON, err := BuildCCRConfig(e.opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build CCR config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config to container's CCR directory
|
||||||
|
configPath := "/home/sandbox/.claude-code-router/config.json"
|
||||||
|
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config to %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the Claude CLI and returns the response
|
||||||
|
func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) {
|
||||||
|
return e.Stream(ctx, messages, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStream parses Claude CLI streaming output
|
||||||
|
func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
// Increase buffer size for potentially large outputs
|
||||||
|
buf := make([]byte, 0, 64*1024)
|
||||||
|
scanner.Buffer(buf, 1024*1024)
|
||||||
|
|
||||||
|
var textContent strings.Builder
|
||||||
|
var toolCalls []agentContext.ToolCall
|
||||||
|
var model string
|
||||||
|
var usage *message.UsageInfo
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Docker stream demuxing is handled by sandbox.Manager.Stream()
|
||||||
|
// which uses stdcopy.StdCopy to properly separate stdout/stderr
|
||||||
|
|
||||||
|
// Try to parse as JSON (Claude CLI --output-format stream-json)
|
||||||
|
var msg StreamMessage
|
||||||
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||||
|
// Not JSON, might be plain text output
|
||||||
|
textContent.WriteString(line)
|
||||||
|
textContent.WriteString("\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process different message types
|
||||||
|
switch msg.Type {
|
||||||
|
case "content_block_delta":
|
||||||
|
// Streaming text content
|
||||||
|
if delta, ok := msg.Content.(map[string]interface{}); ok {
|
||||||
|
if text, ok := delta["text"].(string); ok {
|
||||||
|
textContent.WriteString(text)
|
||||||
|
// Send to stream handler if available
|
||||||
|
if handler != nil {
|
||||||
|
handler(message.ChunkText, []byte(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_delta":
|
||||||
|
// Message completion with usage
|
||||||
|
if content, ok := msg.Content.(map[string]interface{}); ok {
|
||||||
|
if usageData, ok := content["usage"].(map[string]interface{}); ok {
|
||||||
|
usage = &message.UsageInfo{}
|
||||||
|
if v, ok := usageData["input_tokens"].(float64); ok {
|
||||||
|
usage.PromptTokens = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := usageData["output_tokens"].(float64); ok {
|
||||||
|
usage.CompletionTokens = int(v)
|
||||||
|
}
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_start":
|
||||||
|
// Extract model from message_start
|
||||||
|
if content, ok := msg.Content.(map[string]interface{}); ok {
|
||||||
|
if m, ok := content["model"].(string); ok {
|
||||||
|
model = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_start":
|
||||||
|
// Might contain tool use blocks
|
||||||
|
if block, ok := msg.Content.(map[string]interface{}); ok {
|
||||||
|
if block["type"] == "tool_use" {
|
||||||
|
toolCall := agentContext.ToolCall{
|
||||||
|
ID: getString(block, "id"),
|
||||||
|
Type: agentContext.ToolTypeFunction,
|
||||||
|
Function: agentContext.Function{
|
||||||
|
Name: getString(block, "name"),
|
||||||
|
Arguments: "{}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return nil, fmt.Errorf("Claude CLI error: %s", msg.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
response := &agentContext.CompletionResponse{
|
||||||
|
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||||
|
Model: model,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Role: "assistant",
|
||||||
|
Content: textContent.String(),
|
||||||
|
FinishReason: agentContext.FinishReasonStop,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool calls if any
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
response.ToolCalls = toolCalls
|
||||||
|
response.FinishReason = agentContext.FinishReasonToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add usage if available
|
||||||
|
if usage != nil {
|
||||||
|
response.Usage = usage
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile reads a file from the container
|
||||||
|
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
// Make path absolute if not
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = e.workDir + "/" + path
|
||||||
|
}
|
||||||
|
return e.manager.ReadFile(ctx, e.containerName, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes content to a file in the container
|
||||||
|
func (e *Executor) WriteFile(ctx context.Context, path string, content []byte) error {
|
||||||
|
// Make path absolute if not
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = e.workDir + "/" + path
|
||||||
|
}
|
||||||
|
return e.manager.WriteFile(ctx, e.containerName, path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir lists directory contents in the container
|
||||||
|
func (e *Executor) ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error) {
|
||||||
|
// Make path absolute if not
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = e.workDir + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.manager.ListDir(ctx, e.containerName, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec executes a command in the container
|
||||||
|
func (e *Executor) Exec(ctx context.Context, cmd []string) (string, error) {
|
||||||
|
result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: e.workDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return result.Stdout, fmt.Errorf("command exited with code %d: %s", result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Stdout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkDir returns the container workspace directory
|
||||||
|
func (e *Executor) GetWorkDir() string {
|
||||||
|
return e.workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the executor resources and removes the container
|
||||||
|
func (e *Executor) Close() error {
|
||||||
|
if e.manager != nil && e.containerName != "" {
|
||||||
|
ctx := context.Background()
|
||||||
|
return e.manager.Remove(ctx, e.containerName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get string from map
|
||||||
|
func getString(m map[string]interface{}, key string) string {
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
168
agent/sandbox/claude/executor_test.go
Normal file
168
agent/sandbox/claude/executor_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestManager creates a sandbox manager for testing with proper configuration
|
||||||
|
func createTestManager(t *testing.T) *infraSandbox.Manager {
|
||||||
|
// Get data root from environment or use temp directory
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
dataRoot = t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with proper paths
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClaudeExecutor(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat-claude-1",
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "key123",
|
||||||
|
Model: "test-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, exec)
|
||||||
|
|
||||||
|
// Verify executor was created
|
||||||
|
assert.Equal(t, "/workspace", exec.GetWorkDir())
|
||||||
|
assert.NoError(t, exec.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeExecutorMissingRequiredFields(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Missing UserID
|
||||||
|
_, err := NewExecutor(manager, &Options{
|
||||||
|
Command: "claude",
|
||||||
|
ChatID: "test-chat",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "UserID is required")
|
||||||
|
|
||||||
|
// Missing ChatID
|
||||||
|
_, err = NewExecutor(manager, &Options{
|
||||||
|
Command: "claude",
|
||||||
|
UserID: "test-user",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ChatID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeExecutorFileOperations(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest", // Use alpine for simpler testing
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat-file-ops",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test WriteFile
|
||||||
|
content := []byte("Hello, World!")
|
||||||
|
err = exec.WriteFile(ctx, "test-file.txt", content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test ReadFile
|
||||||
|
readContent, err := exec.ReadFile(ctx, "test-file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, content, readContent)
|
||||||
|
|
||||||
|
// Test ListDir
|
||||||
|
files, err := exec.ListDir(ctx, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, len(files) > 0, "Expected at least one file in directory")
|
||||||
|
|
||||||
|
// Find our test file
|
||||||
|
var found bool
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Name == "test-file.txt" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected to find test-file.txt in directory listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeExecutorExec(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest", // Use alpine for simpler testing
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat-exec",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test simple echo command
|
||||||
|
output, err := exec.Exec(ctx, []string{"echo", "hello-world"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "hello-world")
|
||||||
|
}
|
||||||
37
agent/sandbox/claude/types.go
Normal file
37
agent/sandbox/claude/types.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
// StreamMessage represents a parsed stream message from Claude CLI
|
||||||
|
type StreamMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Subtype string `json:"subtype,omitempty"`
|
||||||
|
Content interface{} `json:"content,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a tool invocation from the agent
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments map[string]interface{} `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolResult represents a tool execution result
|
||||||
|
type ToolResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIResponse represents the parsed response from Claude CLI
|
||||||
|
type CLIResponse struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage represents token usage statistics
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens,omitempty"`
|
||||||
|
OutputTokens int `json:"output_tokens,omitempty"`
|
||||||
|
}
|
||||||
55
agent/sandbox/cursor/README.md
Normal file
55
agent/sandbox/cursor/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Cursor Executor
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Not Implemented** - This is a placeholder for future Cursor CLI integration.
|
||||||
|
|
||||||
|
## Planned Features
|
||||||
|
|
||||||
|
The Cursor executor will provide similar functionality to the Claude executor:
|
||||||
|
|
||||||
|
- Execute Cursor CLI in a Docker sandbox container
|
||||||
|
- Stream output in real-time
|
||||||
|
- File system operations (ReadFile, WriteFile, ListDir)
|
||||||
|
- Command execution (Exec)
|
||||||
|
- Integration with Yao's MCP servers
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
When implemented, the Cursor executor will be configured in assistant `package.yao`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "Coder Assistant",
|
||||||
|
"connector": "deepseek.v3",
|
||||||
|
"sandbox": {
|
||||||
|
"command": "cursor", // Use Cursor CLI
|
||||||
|
"image": "yaoapp/sandbox-cursor:latest",
|
||||||
|
"timeout": "10m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The implementation should follow the same pattern as `claude/executor.go`:
|
||||||
|
|
||||||
|
1. Create `cursor/executor.go` implementing the `sandbox.Executor` interface
|
||||||
|
2. Create `cursor/command.go` for building Cursor CLI commands
|
||||||
|
3. Create `cursor/types.go` for Cursor-specific types
|
||||||
|
4. Add appropriate tests
|
||||||
|
|
||||||
|
## Docker Image
|
||||||
|
|
||||||
|
A `yaoapp/sandbox-cursor` Docker image will need to be created with:
|
||||||
|
|
||||||
|
- Ubuntu 24.04 LTS base
|
||||||
|
- Node.js 22 LTS
|
||||||
|
- Python 3.12
|
||||||
|
- Cursor CLI installed and configured
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Cursor CLI Documentation](https://cursor.sh/docs)
|
||||||
|
- [Claude Executor Implementation](../claude/executor.go)
|
||||||
|
- [Sandbox Design Document](../DESIGN.md)
|
||||||
49
agent/sandbox/executor.go
Normal file
49
agent/sandbox/executor.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/sandbox/claude"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a new Executor based on the command type
|
||||||
|
func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
|
||||||
|
if opts == nil {
|
||||||
|
return nil, fmt.Errorf("options is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsValidCommand(opts.Command) {
|
||||||
|
return nil, fmt.Errorf("unsupported command type: %s, supported: %v", opts.Command, CommandTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default image if not specified
|
||||||
|
if opts.Image == "" {
|
||||||
|
opts.Image = DefaultImage(opts.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opts.Command {
|
||||||
|
case "claude":
|
||||||
|
// Convert to claude.Options
|
||||||
|
claudeOpts := &claude.Options{
|
||||||
|
Command: opts.Command,
|
||||||
|
Image: opts.Image,
|
||||||
|
MaxMemory: opts.MaxMemory,
|
||||||
|
MaxCPU: opts.MaxCPU,
|
||||||
|
Timeout: opts.Timeout,
|
||||||
|
Arguments: opts.Arguments,
|
||||||
|
UserID: opts.UserID,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
MCPConfig: opts.MCPConfig,
|
||||||
|
SkillsDir: opts.SkillsDir,
|
||||||
|
ConnectorHost: opts.ConnectorHost,
|
||||||
|
ConnectorKey: opts.ConnectorKey,
|
||||||
|
Model: opts.Model,
|
||||||
|
}
|
||||||
|
return claude.NewExecutor(manager, claudeOpts)
|
||||||
|
case "cursor":
|
||||||
|
return nil, fmt.Errorf("cursor executor not implemented yet")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported command type: %s", opts.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
agent/sandbox/executor_test.go
Normal file
133
agent/sandbox/executor_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestManager creates a sandbox manager for testing with proper configuration
|
||||||
|
func createTestManager(t *testing.T) *infraSandbox.Manager {
|
||||||
|
// Get data root from environment or use temp directory
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
dataRoot = t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with proper paths
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewExecutorWithInvalidOptions(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Test with nil options
|
||||||
|
_, err := New(manager, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "options is required")
|
||||||
|
|
||||||
|
// Test with invalid command
|
||||||
|
_, err = New(manager, &Options{
|
||||||
|
Command: "invalid",
|
||||||
|
UserID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported command type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewExecutorWithValidOptions(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Test with valid claude options
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat",
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "key123",
|
||||||
|
Model: "test-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := New(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, exec)
|
||||||
|
|
||||||
|
// Verify executor was created
|
||||||
|
assert.NotEmpty(t, exec.GetWorkDir())
|
||||||
|
assert.NoError(t, exec.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultImageIsSetWhenEmpty(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "", // Empty, should be set to default
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := New(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec.Close() // Ensure cleanup
|
||||||
|
|
||||||
|
// The image should have been set to default
|
||||||
|
assert.Equal(t, "yaoapp/sandbox-claude:latest", opts.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorExecutorNotImplemented(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "cursor",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-chat-3",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := New(manager, opts)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not implemented")
|
||||||
|
}
|
||||||
207
agent/sandbox/integration_test.go
Normal file
207
agent/sandbox/integration_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createIntegrationTestManager creates a sandbox manager for integration testing
|
||||||
|
func createIntegrationTestManager(t *testing.T) *infraSandbox.Manager {
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
dataRoot = t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutorInterfaceCompatibility verifies that the executor implements both interfaces correctly
|
||||||
|
func TestExecutorInterfaceCompatibility(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createIntegrationTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create executor via factory function
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-compat",
|
||||||
|
}
|
||||||
|
|
||||||
|
executor, err := New(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, executor)
|
||||||
|
defer executor.Close()
|
||||||
|
|
||||||
|
// Verify executor implements agent/sandbox.Executor interface
|
||||||
|
var _ Executor = executor
|
||||||
|
|
||||||
|
// Verify executor can be cast to context.SandboxExecutor
|
||||||
|
ctxExecutor, ok := executor.(agentContext.SandboxExecutor)
|
||||||
|
require.True(t, ok, "executor should implement context.SandboxExecutor")
|
||||||
|
require.NotNil(t, ctxExecutor)
|
||||||
|
|
||||||
|
// Test SandboxExecutor methods work
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// WriteFile
|
||||||
|
err = ctxExecutor.WriteFile(ctx, "compat-test.txt", []byte("compatibility test"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// ReadFile
|
||||||
|
content, err := ctxExecutor.ReadFile(ctx, "compat-test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "compatibility test", string(content))
|
||||||
|
|
||||||
|
// ListDir
|
||||||
|
files, err := ctxExecutor.ListDir(ctx, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, len(files) > 0)
|
||||||
|
|
||||||
|
// Exec
|
||||||
|
output, err := ctxExecutor.Exec(ctx, []string{"echo", "compat"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "compat")
|
||||||
|
|
||||||
|
// GetWorkDir
|
||||||
|
workDir := ctxExecutor.GetWorkDir()
|
||||||
|
assert.NotEmpty(t, workDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutorRoundTrip tests the full round-trip of creating executor and performing operations
|
||||||
|
func TestExecutorRoundTrip(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createIntegrationTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-roundtrip",
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "test-key",
|
||||||
|
Model: "test-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create executor
|
||||||
|
executor, err := New(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, executor)
|
||||||
|
defer executor.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Write a file
|
||||||
|
testContent := "Hello, integration test!"
|
||||||
|
err = executor.WriteFile(ctx, "integration.txt", []byte(testContent))
|
||||||
|
require.NoError(t, err, "WriteFile should succeed")
|
||||||
|
|
||||||
|
// 2. Read the file back
|
||||||
|
readContent, err := executor.ReadFile(ctx, "integration.txt")
|
||||||
|
require.NoError(t, err, "ReadFile should succeed")
|
||||||
|
assert.Equal(t, testContent, string(readContent), "Content should match")
|
||||||
|
|
||||||
|
// 3. List directory
|
||||||
|
files, err := executor.ListDir(ctx, ".")
|
||||||
|
require.NoError(t, err, "ListDir should succeed")
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Name == "integration.txt" {
|
||||||
|
found = true
|
||||||
|
assert.False(t, f.IsDir, "Should not be a directory")
|
||||||
|
assert.Equal(t, int64(len(testContent)), f.Size, "Size should match")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Should find integration.txt in listing")
|
||||||
|
|
||||||
|
// 4. Execute command
|
||||||
|
output, err := executor.Exec(ctx, []string{"cat", "/workspace/integration.txt"})
|
||||||
|
require.NoError(t, err, "Exec should succeed")
|
||||||
|
assert.Contains(t, output, testContent, "cat output should contain file content")
|
||||||
|
|
||||||
|
// 5. Verify workdir
|
||||||
|
assert.Equal(t, "/workspace", executor.GetWorkDir(), "WorkDir should be /workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultipleExecutorsIsolation verifies that multiple executors have isolated workspaces
|
||||||
|
func TestMultipleExecutorsIsolation(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createIntegrationTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create two executors with different chat IDs
|
||||||
|
opts1 := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-isolation-1",
|
||||||
|
}
|
||||||
|
opts2 := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: "test-isolation-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
exec1, err := New(manager, opts1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec1.Close()
|
||||||
|
|
||||||
|
exec2, err := New(manager, opts2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec2.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Write different content to each executor
|
||||||
|
err = exec1.WriteFile(ctx, "test.txt", []byte("executor 1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = exec2.WriteFile(ctx, "test.txt", []byte("executor 2"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Read back and verify isolation
|
||||||
|
content1, err := exec1.ReadFile(ctx, "test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "executor 1", string(content1), "Executor 1 should have its own content")
|
||||||
|
|
||||||
|
content2, err := exec2.ReadFile(ctx, "test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "executor 2", string(content2), "Executor 2 should have its own content")
|
||||||
|
}
|
||||||
122
agent/sandbox/types.go
Normal file
122
agent/sandbox/types.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor executes LLM requests in sandbox
|
||||||
|
type Executor interface {
|
||||||
|
// Execute runs the request and returns response (uses options set at creation time)
|
||||||
|
Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error)
|
||||||
|
|
||||||
|
// Stream runs the request with streaming output (uses options set at creation time)
|
||||||
|
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
|
||||||
|
|
||||||
|
// Filesystem operations (for Hooks)
|
||||||
|
ReadFile(ctx context.Context, path string) ([]byte, error)
|
||||||
|
WriteFile(ctx context.Context, path string, content []byte) error
|
||||||
|
ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error)
|
||||||
|
|
||||||
|
// Command execution (for Hooks)
|
||||||
|
Exec(ctx context.Context, cmd []string) (string, error)
|
||||||
|
|
||||||
|
// GetWorkDir returns the container workspace directory
|
||||||
|
GetWorkDir() string
|
||||||
|
|
||||||
|
// Close releases container resources
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfo is an alias to infrastructure sandbox FileInfo for convenience
|
||||||
|
type FileInfo = infraSandbox.FileInfo
|
||||||
|
|
||||||
|
// Options for sandbox execution
|
||||||
|
type Options struct {
|
||||||
|
// Command type (claude, cursor)
|
||||||
|
Command string `json:"command"`
|
||||||
|
|
||||||
|
// Docker image (optional, auto-selected by command)
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
MaxMemory string `json:"max_memory,omitempty"`
|
||||||
|
MaxCPU float64 `json:"max_cpu,omitempty"`
|
||||||
|
|
||||||
|
// Execution timeout
|
||||||
|
Timeout time.Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Command-specific arguments (passed to CLI)
|
||||||
|
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Internal fields (auto-resolved by Yao)
|
||||||
|
// Do NOT set these in package.yao config
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// UserID for workspace isolation
|
||||||
|
UserID string `json:"-"`
|
||||||
|
|
||||||
|
// ChatID for session isolation
|
||||||
|
ChatID string `json:"-"`
|
||||||
|
|
||||||
|
// MCP configuration - auto-loaded from assistants/{name}/mcps/
|
||||||
|
MCPConfig []byte `json:"-"`
|
||||||
|
|
||||||
|
// Skills directory - auto-resolved to assistants/{name}/skills/
|
||||||
|
SkillsDir string `json:"-"`
|
||||||
|
|
||||||
|
// Connector settings - auto-resolved from connector config file
|
||||||
|
// e.g., connectors/deepseek/v3.conn.yao → host, key, model
|
||||||
|
ConnectorHost string `json:"-"`
|
||||||
|
ConnectorKey string `json:"-"`
|
||||||
|
Model string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxConfig represents the sandbox configuration in assistant package.yao
|
||||||
|
type SandboxConfig struct {
|
||||||
|
// Command type (claude, cursor)
|
||||||
|
Command string `json:"command" yaml:"command"`
|
||||||
|
|
||||||
|
// Docker image (optional, auto-selected by command)
|
||||||
|
Image string `json:"image,omitempty" yaml:"image,omitempty"`
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
MaxMemory string `json:"max_memory,omitempty" yaml:"max_memory,omitempty"`
|
||||||
|
MaxCPU float64 `json:"max_cpu,omitempty" yaml:"max_cpu,omitempty"`
|
||||||
|
|
||||||
|
// Execution timeout
|
||||||
|
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Command-specific arguments (passed to CLI)
|
||||||
|
Arguments map[string]interface{} `json:"arguments,omitempty" yaml:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultImage returns the default Docker image for a command type
|
||||||
|
func DefaultImage(command string) string {
|
||||||
|
switch command {
|
||||||
|
case "claude":
|
||||||
|
return "yaoapp/sandbox-claude:latest"
|
||||||
|
case "cursor":
|
||||||
|
return "yaoapp/sandbox-cursor:latest"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandTypes is the list of supported command types
|
||||||
|
var CommandTypes = []string{"claude", "cursor"}
|
||||||
|
|
||||||
|
// IsValidCommand checks if a command type is valid
|
||||||
|
func IsValidCommand(command string) bool {
|
||||||
|
for _, c := range CommandTypes {
|
||||||
|
if c == command {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
87
agent/sandbox/types_test.go
Normal file
87
agent/sandbox/types_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultImage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"claude", "yaoapp/sandbox-claude:latest"},
|
||||||
|
{"cursor", "yaoapp/sandbox-cursor:latest"},
|
||||||
|
{"unknown", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
result := DefaultImage(tt.command)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidCommand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"claude", true},
|
||||||
|
{"cursor", true},
|
||||||
|
{"unknown", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
result := IsValidCommand(tt.command)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionsValidation(t *testing.T) {
|
||||||
|
// Test that Options struct can be created with all fields
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude:latest",
|
||||||
|
MaxMemory: "4g",
|
||||||
|
MaxCPU: 2.0,
|
||||||
|
UserID: "user123",
|
||||||
|
ChatID: "chat456",
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "key123",
|
||||||
|
Model: "deepseek-v3",
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"max_turns": 20,
|
||||||
|
"permission_mode": "acceptEdits",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "claude", opts.Command)
|
||||||
|
assert.Equal(t, "user123", opts.UserID)
|
||||||
|
assert.Equal(t, "chat456", opts.ChatID)
|
||||||
|
assert.Equal(t, 20, opts.Arguments["max_turns"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSandboxConfigParsing(t *testing.T) {
|
||||||
|
// Test that SandboxConfig can be used for parsing assistant config
|
||||||
|
config := &SandboxConfig{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "custom-image:v1",
|
||||||
|
MaxMemory: "8g",
|
||||||
|
MaxCPU: 4.0,
|
||||||
|
Timeout: "10m",
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"permission_mode": "bypassPermissions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "claude", config.Command)
|
||||||
|
assert.Equal(t, "custom-image:v1", config.Image)
|
||||||
|
assert.Equal(t, "8g", config.MaxMemory)
|
||||||
|
assert.Equal(t, "10m", config.Timeout)
|
||||||
|
}
|
||||||
|
|
@ -158,6 +158,34 @@ func ToWorkflow(v interface{}) (*Workflow, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToSandbox converts various types to Sandbox
|
||||||
|
func ToSandbox(v interface{}) (*Sandbox, error) {
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sandbox := v.(type) {
|
||||||
|
case *Sandbox:
|
||||||
|
return sandbox, nil
|
||||||
|
|
||||||
|
case Sandbox:
|
||||||
|
return &sandbox, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
raw, err := jsoniter.Marshal(sandbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox format error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb Sandbox
|
||||||
|
err = jsoniter.Unmarshal(raw, &sb)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox format error: %s", err.Error())
|
||||||
|
}
|
||||||
|
return &sb, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ToMySQLTime converts various types to MySQL datetime format
|
// ToMySQLTime converts various types to MySQL datetime format
|
||||||
func ToMySQLTime(v interface{}) string {
|
func ToMySQLTime(v interface{}) string {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,16 @@ type Workflow struct {
|
||||||
Options map[string]interface{} `json:"options,omitempty"` // Additional workflow options
|
Options map[string]interface{} `json:"options,omitempty"` // Additional workflow options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sandbox the sandbox configuration for coding agents (Claude CLI, Cursor CLI)
|
||||||
|
type Sandbox struct {
|
||||||
|
Command string `json:"command"` // Command type: "claude" or "cursor"
|
||||||
|
Image string `json:"image,omitempty"` // Docker image (optional, auto-selected by command)
|
||||||
|
MaxMemory string `json:"max_memory,omitempty"` // Memory limit (e.g., "4g")
|
||||||
|
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
|
||||||
|
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
|
||||||
|
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
|
||||||
|
}
|
||||||
|
|
||||||
// Tool represents a tool configuration for storage
|
// Tool represents a tool configuration for storage
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
|
|
@ -434,6 +444,7 @@ type AssistantModel struct {
|
||||||
DB *Database `json:"db,omitempty"` // Database configuration
|
DB *Database `json:"db,omitempty"` // Database configuration
|
||||||
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration
|
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration
|
||||||
Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration
|
Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration
|
||||||
|
Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents
|
||||||
Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder
|
Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder
|
||||||
Source string `json:"source,omitempty"` // Hook script source code
|
Source string `json:"source,omitempty"` // Hook script source code
|
||||||
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
|
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"github.com/yaoapp/yao/sandbox/ipc"
|
"github.com/yaoapp/yao/sandbox/ipc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,6 +33,60 @@ func (e *execReadCloser) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// demuxReadCloser wraps Docker multiplexed stream and demuxes it to stdout only
|
||||||
|
// It uses a pipe to feed demuxed stdout to the reader
|
||||||
|
type demuxReadCloser struct {
|
||||||
|
reader io.Reader
|
||||||
|
pipeReader *io.PipeReader
|
||||||
|
pipeWriter *io.PipeWriter
|
||||||
|
closer io.Closer
|
||||||
|
done chan struct{}
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
|
||||||
|
func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
d := &demuxReadCloser{
|
||||||
|
reader: src,
|
||||||
|
pipeReader: pr,
|
||||||
|
pipeWriter: pw,
|
||||||
|
closer: closer,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start demux goroutine
|
||||||
|
go func() {
|
||||||
|
defer close(d.done)
|
||||||
|
defer pw.Close()
|
||||||
|
|
||||||
|
// Use stdcopy to demux stdout and stderr
|
||||||
|
// We only care about stdout here, stderr goes to a discard writer
|
||||||
|
_, err := stdcopy.StdCopy(pw, io.Discard, src)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
d.err = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *demuxReadCloser) Read(p []byte) (int, error) {
|
||||||
|
return d.pipeReader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *demuxReadCloser) Close() error {
|
||||||
|
// Close the source to stop the demux goroutine
|
||||||
|
if d.closer != nil {
|
||||||
|
d.closer.Close()
|
||||||
|
}
|
||||||
|
// Close the pipe reader to unblock any pending reads
|
||||||
|
d.pipeReader.Close()
|
||||||
|
// Wait for demux goroutine to finish
|
||||||
|
<-d.done
|
||||||
|
return d.err
|
||||||
|
}
|
||||||
|
|
||||||
// Manager manages sandbox containers
|
// Manager manages sandbox containers
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.Mutex // Protects creation
|
mu sync.Mutex // Protects creation
|
||||||
|
|
@ -319,11 +374,9 @@ func (m *Manager) Stream(ctx context.Context, name string, cmd []string, opts *E
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap in a ReadCloser
|
// Return demuxed reader that properly handles Docker multiplexed stream
|
||||||
return &execReadCloser{
|
// This removes the 8-byte header from each frame and separates stdout from stderr
|
||||||
Reader: attachResp.Reader,
|
return newDemuxReadCloser(attachResp.Reader, attachResp.Conn), nil
|
||||||
closer: attachResp.Conn,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exec executes command and waits for completion
|
// Exec executes command and waits for completion
|
||||||
|
|
@ -393,22 +446,25 @@ func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *Exe
|
||||||
outputCh := make(chan []byte, 1)
|
outputCh := make(chan []byte, 1)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// Buffers for demuxed stdout and stderr
|
||||||
|
var stdoutBuf, stderrBuf bytes.Buffer
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
output, err := io.ReadAll(attachResp.Reader)
|
// Use stdcopy to properly demux Docker multiplexed stream
|
||||||
if err != nil {
|
_, err := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, attachResp.Reader)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
outputCh <- output
|
outputCh <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var output []byte
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
return nil, fmt.Errorf("failed to read output: %w", err)
|
return nil, fmt.Errorf("failed to read output: %w", err)
|
||||||
case output = <-outputCh:
|
case <-outputCh:
|
||||||
// Output received
|
// Output received
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,14 +482,10 @@ func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *Exe
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Docker multiplexed stream
|
|
||||||
// TODO: Properly demux stdout/stderr from Docker stream
|
|
||||||
stdout := string(output)
|
|
||||||
|
|
||||||
return &ExecResult{
|
return &ExecResult{
|
||||||
ExitCode: exitCode,
|
ExitCode: exitCode,
|
||||||
Stdout: stdout,
|
Stdout: stdoutBuf.String(),
|
||||||
Stderr: "",
|
Stderr: stderrBuf.String(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,21 @@ type FileInfo struct {
|
||||||
IsDir bool // Is directory
|
IsDir bool // Is directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetName returns the file name (implements context.SandboxFileInfo)
|
||||||
|
func (f FileInfo) GetName() string {
|
||||||
|
return f.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSize returns the file size (implements context.SandboxFileInfo)
|
||||||
|
func (f FileInfo) GetSize() int64 {
|
||||||
|
return f.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIsDir returns whether this is a directory (implements context.SandboxFileInfo)
|
||||||
|
func (f FileInfo) GetIsDir() bool {
|
||||||
|
return f.IsDir
|
||||||
|
}
|
||||||
|
|
||||||
// ContainerStatus constants
|
// ContainerStatus constants
|
||||||
const (
|
const (
|
||||||
StatusCreated = "created"
|
StatusCreated = "created"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue