2026-03-24 15:13:35 +03:00
|
|
|
package core
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"charm.land/fantasy"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// helper to create a bash tool call with the given command and optional timeout.
|
|
|
|
|
func bashCall(command string, timeout float64) fantasy.ToolCall {
|
|
|
|
|
args := map[string]any{"command": command}
|
|
|
|
|
if timeout > 0 {
|
|
|
|
|
args["timeout"] = timeout
|
|
|
|
|
}
|
|
|
|
|
input, _ := json.Marshal(args)
|
|
|
|
|
return fantasy.ToolCall{
|
|
|
|
|
ID: "test-call",
|
|
|
|
|
Name: "bash",
|
|
|
|
|
Input: string(input),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_SimpleCommand(t *testing.T) {
|
|
|
|
|
resp, err := executeBash(context.Background(), bashCall("echo hello", 0), "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.IsError {
|
|
|
|
|
t.Fatalf("expected success, got error: %s", resp.Content)
|
|
|
|
|
}
|
|
|
|
|
if resp.Content != "hello\n" {
|
|
|
|
|
t.Errorf("expected 'hello\\n', got %q", resp.Content)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_TimeoutKillsProcess(t *testing.T) {
|
|
|
|
|
start := time.Now()
|
|
|
|
|
resp, err := executeBash(context.Background(), bashCall("sleep 60", 2), "")
|
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !resp.IsError {
|
|
|
|
|
t.Fatal("expected error response for timed-out command")
|
|
|
|
|
}
|
|
|
|
|
if elapsed > 10*time.Second {
|
|
|
|
|
t.Errorf("command took %v, expected ~2s timeout", elapsed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_BackgroundProcessDoesNotHang(t *testing.T) {
|
|
|
|
|
// This command spawns a background sleep that would hold pipes open
|
|
|
|
|
// forever if we didn't have process group killing + WaitDelay.
|
|
|
|
|
start := time.Now()
|
|
|
|
|
resp, err := executeBash(context.Background(), bashCall("echo done; sleep 3600 &", 5), "")
|
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// The foreground command (echo) should complete quickly
|
|
|
|
|
if elapsed > 5*time.Second {
|
|
|
|
|
t.Errorf("command took %v, should complete in <5s (background process should not block)", elapsed)
|
|
|
|
|
}
|
|
|
|
|
if resp.IsError {
|
|
|
|
|
t.Fatalf("expected success, got error: %s", resp.Content)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_BackgroundProcessDoesNotHang_Streaming(t *testing.T) {
|
|
|
|
|
// Same test but in streaming mode (with output callback).
|
|
|
|
|
ctx := ContextWithToolOutputCallback(context.Background(), func(_, _, _ string, _ bool) {})
|
|
|
|
|
start := time.Now()
|
|
|
|
|
resp, err := executeBash(ctx, bashCall("echo streaming; sleep 3600 &", 5), "")
|
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if elapsed > 5*time.Second {
|
|
|
|
|
t.Errorf("streaming command took %v, should complete in <5s", elapsed)
|
|
|
|
|
}
|
|
|
|
|
if resp.IsError {
|
|
|
|
|
t.Fatalf("expected success, got error: %s", resp.Content)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_ContextCancellation(t *testing.T) {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
defer close(done)
|
|
|
|
|
_, _ = executeBash(ctx, bashCall("sleep 60", 0), "")
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Cancel after a short delay
|
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
// Should return promptly after cancellation
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
// success
|
|
|
|
|
case <-time.After(5 * time.Second):
|
|
|
|
|
t.Fatal("executeBash did not return after context cancellation")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_BannedCommand(t *testing.T) {
|
|
|
|
|
resp, err := executeBash(context.Background(), bashCall("alias foo=bar", 0), "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !resp.IsError {
|
|
|
|
|
t.Fatal("expected error for banned command")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBash_EmptyCommand(t *testing.T) {
|
|
|
|
|
resp, err := executeBash(context.Background(), bashCall("", 0), "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !resp.IsError {
|
|
|
|
|
t.Fatal("expected error for empty command")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-15 17:33:03 +03:00
|
|
|
|
|
|
|
|
func TestRewriteSudoForStdin(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
input string
|
|
|
|
|
expected string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "simple sudo",
|
|
|
|
|
input: "sudo apt update",
|
|
|
|
|
expected: "sudo -S -p '' apt update",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "sudo with env var",
|
|
|
|
|
input: "DEBIAN_FRONTEND=noninteractive sudo apt update",
|
|
|
|
|
expected: "DEBIAN_FRONTEND=noninteractive sudo -S -p '' apt update",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "sudo in pipeline",
|
|
|
|
|
input: "echo test | sudo tee /etc/test.conf",
|
|
|
|
|
expected: "echo test | sudo -S -p '' tee /etc/test.conf",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "sudo after &&",
|
|
|
|
|
input: "apt update && sudo apt upgrade",
|
|
|
|
|
expected: "apt update && sudo -S -p '' apt upgrade",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "already has -S flag",
|
|
|
|
|
input: "sudo -S apt update",
|
|
|
|
|
expected: "sudo -S apt update",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "no sudo",
|
|
|
|
|
input: "apt update && apt upgrade",
|
|
|
|
|
expected: "apt update && apt upgrade",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "sudo in string (should not match)",
|
|
|
|
|
input: "echo 'use sudo carefully'",
|
|
|
|
|
expected: "echo 'use sudo carefully'",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
result := rewriteSudoForStdin(tt.input)
|
|
|
|
|
if result != tt.expected {
|
|
|
|
|
t.Errorf("rewriteSudoForStdin(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSudoPasswordFromContext(t *testing.T) {
|
|
|
|
|
// Test with password in context
|
2026-06-11 16:13:18 +03:00
|
|
|
ctx := context.WithValue(context.Background(), sudoPasswordKey, "secret123")
|
2026-04-15 17:33:03 +03:00
|
|
|
pw := sudoPasswordFromContext(ctx)
|
|
|
|
|
if pw != "secret123" {
|
|
|
|
|
t.Errorf("expected password 'secret123', got %q", pw)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Test without password
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
pw = sudoPasswordFromContext(ctx)
|
|
|
|
|
if pw != "" {
|
|
|
|
|
t.Errorf("expected empty password, got %q", pw)
|
|
|
|
|
}
|
|
|
|
|
}
|