Core features: - Microkernel architecture with Actor model - Session management with JSONL persistence - Tool system (5 built-in tools) - Skill system with SKILL.md parsing - Sandbox security execution - Ollama integration with gemma4:e4b - Prompt-based tool calling (compatible with native function calling) - REPL interface 11 packages, all tests passing
213 lines
5.6 KiB
Go
213 lines
5.6 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewProcessSandbox(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
if ps == nil {
|
|
t.Fatal("NewProcessSandbox() returned nil")
|
|
}
|
|
if ps.WorkingDir != DefaultWorkingDir {
|
|
t.Errorf("expected WorkingDir %q, got %q", DefaultWorkingDir, ps.WorkingDir)
|
|
}
|
|
if ps.OutputLimit != DefaultOutputLimit {
|
|
t.Errorf("expected OutputLimit %d, got %d", DefaultOutputLimit, ps.OutputLimit)
|
|
}
|
|
}
|
|
|
|
func TestExecuteEcho(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx := context.Background()
|
|
|
|
result, err := ps.Execute(ctx, "echo", "hello", "world")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", result.ExitCode)
|
|
}
|
|
if strings.TrimSpace(result.Stdout) != "hello world" {
|
|
t.Errorf("expected stdout 'hello world', got %q", result.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestExecuteWithArgs(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx := context.Background()
|
|
|
|
result, err := ps.Execute(ctx, "sh", "-c", "echo 'arg1 arg2'")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", result.ExitCode)
|
|
}
|
|
if strings.TrimSpace(result.Stdout) != "arg1 arg2" {
|
|
t.Errorf("expected stdout 'arg1 arg2', got %q", result.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestExecuteNonZeroExit(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx := context.Background()
|
|
|
|
result, err := ps.Execute(ctx, "sh", "-c", "exit 42")
|
|
if err != nil {
|
|
t.Fatalf("Execute should not error on non-zero exit: %v", err)
|
|
}
|
|
if result.ExitCode != 42 {
|
|
t.Errorf("expected exit code 42, got %d", result.ExitCode)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCommandNotFound(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx := context.Background()
|
|
|
|
_, err := ps.Execute(ctx, "nonexistent-command-12345")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent command")
|
|
}
|
|
}
|
|
|
|
func TestExecuteTimeout(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := ps.Execute(ctx, "sleep", "10")
|
|
if err == nil {
|
|
t.Fatal("expected timeout error")
|
|
}
|
|
// On macOS the error may be "signal: killed" or "context deadline exceeded".
|
|
// Just verify an error occurred — the exact message varies by platform.
|
|
t.Logf("timeout produced error: %v", err)
|
|
}
|
|
|
|
func TestExecuteWorkingDirectory(t *testing.T) {
|
|
// Use a temp directory for this test
|
|
tmpDir, err := os.MkdirTemp("", "sandbox-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
ps := &ProcessSandbox{
|
|
WorkingDir: tmpDir,
|
|
OutputLimit: DefaultOutputLimit,
|
|
EnvWhitelist: AllowedEnvVars,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result, err := ps.Execute(ctx, "pwd")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", result.ExitCode)
|
|
}
|
|
|
|
// pwd should return the temp directory
|
|
gotDir := strings.TrimSpace(result.Stdout)
|
|
absGot, _ := filepath.EvalSymlinks(gotDir)
|
|
absTmp, _ := filepath.EvalSymlinks(tmpDir)
|
|
if absGot != absTmp {
|
|
t.Errorf("expected working dir %q, got %q", absTmp, absGot)
|
|
}
|
|
}
|
|
|
|
func TestEnvironmentWhitelist(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ps.EnvWhitelist = []string{"HOME"}
|
|
|
|
ctx := context.Background()
|
|
result, err := ps.Execute(ctx, "sh", "-c", "echo $HOME")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", result.ExitCode)
|
|
}
|
|
|
|
home := os.Getenv("HOME")
|
|
if home != "" && strings.TrimSpace(result.Stdout) != home {
|
|
t.Errorf("expected HOME=%q, got %q", home, strings.TrimSpace(result.Stdout))
|
|
}
|
|
}
|
|
|
|
func TestEnvironmentIsolation(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
// Use an empty whitelist to ensure no env vars are passed
|
|
ps.EnvWhitelist = []string{}
|
|
|
|
ctx := context.Background()
|
|
result, err := ps.Execute(ctx, "sh", "-c", "echo $HOME")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
|
|
// HOME should be empty in the child process
|
|
if strings.TrimSpace(result.Stdout) != "" {
|
|
t.Errorf("expected empty HOME in isolated env, got %q", result.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestOutputLimit(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ps.OutputLimit = 10 // Only 10 bytes
|
|
|
|
ctx := context.Background()
|
|
// Generate a long output well beyond the 10-byte limit
|
|
result, err := ps.Execute(ctx, "sh", "-c", "echo 'AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDDEEEEEEEEEEFFFFFFFFFF'")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
|
|
// The output should be truncated to approximately 10 bytes (plus newline)
|
|
if len(result.Stdout) > 15 {
|
|
t.Errorf("expected truncated output (<=15 bytes), got %d bytes: %q", len(result.Stdout), result.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestExecuteStderr(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
ctx := context.Background()
|
|
|
|
result, err := ps.Execute(ctx, "sh", "-c", "echo 'error output' >&2; echo 'normal output'")
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", result.ExitCode)
|
|
}
|
|
if strings.TrimSpace(result.Stderr) != "error output" {
|
|
t.Errorf("expected stderr 'error output', got %q", result.Stderr)
|
|
}
|
|
if strings.TrimSpace(result.Stdout) != "normal output" {
|
|
t.Errorf("expected stdout 'normal output', got %q", result.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestSandboxInterfaceSatisfied(t *testing.T) {
|
|
// Compile-time check
|
|
var ps Sandbox = NewProcessSandbox()
|
|
if ps == nil {
|
|
t.Fatal("ProcessSandbox does not satisfy Sandbox interface")
|
|
}
|
|
}
|
|
|
|
func TestWorkingDirPath(t *testing.T) {
|
|
ps := NewProcessSandbox()
|
|
path := ps.WorkingDirPath()
|
|
if !filepath.IsAbs(path) {
|
|
t.Errorf("expected absolute path, got %q", path)
|
|
}
|
|
}
|