Files
osmedeus/internal/executor/acp_executor_test.go
T
j3ssie f5f3f89e5d release: v5.1.2 — vulns command, workflow dependency health check, ACP stderr fixes
- Add 'osmedeus vulns' to query findings with severity/template filters and --stats
- health: cross-check workflow dependencies.commands against installed binaries and registry
- linter: flag reports missing the required 'path' field
- db: exclude heavy vulnerability columns by default
- ACP: drain stderr past the scanner limit and read it only after the drain finishes
- Read long target lines from stdin and API target files
- Docker: install native Chromium from the xtradeb PPA
- Registry: add jq, simplify assetfinder install
- Add CHANGELOG.md
2026-10-05 01:27:15 +08:00

429 lines
11 KiB
Go

package executor
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestACPExecutor_StepTypeRegistration(t *testing.T) {
dispatcher := NewStepDispatcher()
// Verify agent-acp step type is registered
plugin, ok := dispatcher.registry.Get(core.StepTypeAgentACP)
require.True(t, ok, "agent-acp step type should be registered")
assert.Equal(t, "agent-acp", plugin.Name())
// Verify it returns the correct step types
stepTypes := plugin.StepTypes()
assert.Equal(t, []core.StepType{core.StepTypeAgentACP}, stepTypes)
}
func TestACPExecutor_ResolveAgent_BuiltIn(t *testing.T) {
tests := []struct {
name string
agent string
wantCommand string
wantArgs []string
wantErr bool
}{
{
name: "claude-code",
agent: "claude-code",
wantCommand: "npx",
wantArgs: []string{"-y", "@zed-industries/claude-code-acp@latest"},
},
{
name: "codex",
agent: "codex",
wantCommand: "npx",
wantArgs: []string{"-y", "@zed-industries/codex-acp"},
},
{
name: "opencode",
agent: "opencode",
wantCommand: "opencode",
wantArgs: []string{"acp"},
},
{
name: "gemini",
agent: "gemini",
wantCommand: "gemini",
wantArgs: []string{"--experimental-acp"},
},
{
name: "unknown agent",
agent: "nonexistent-agent",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
step := &core.Step{Agent: tt.agent}
cmd, args, err := ResolveAgent(step)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantCommand, cmd)
assert.Equal(t, tt.wantArgs, args)
})
}
}
func TestACPExecutor_ResolveAgent_CustomCommand(t *testing.T) {
step := &core.Step{
ACPConfig: &core.ACPStepConfig{
Command: "/usr/local/bin/my-agent",
Args: []string{"--acp", "--verbose"},
},
}
cmd, args, err := ResolveAgent(step)
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/my-agent", cmd)
assert.Equal(t, []string{"--acp", "--verbose"}, args)
}
func TestACPExecutor_ResolveAgent_NoAgentOrCommand(t *testing.T) {
step := &core.Step{}
_, _, err := ResolveAgent(step)
assert.Error(t, err)
assert.Contains(t, err.Error(), "agent-acp step requires")
}
func TestACPExecutor_BuildPrompt(t *testing.T) {
tests := []struct {
name string
messages []core.LLMMessage
want string
}{
{
name: "single message",
messages: []core.LLMMessage{
{Content: "Analyze the scan results"},
},
want: "Analyze the scan results",
},
{
name: "multiple messages joined with newlines",
messages: []core.LLMMessage{
{Content: "First task"},
{Content: "Second task"},
{Content: "Third task"},
},
want: "First\u00a0task\nSecond task\nThird task",
},
{
name: "no messages",
messages: nil,
want: "",
},
{
name: "empty content skipped",
messages: []core.LLMMessage{
{Content: "Hello"},
{Content: ""},
{Content: "World"},
},
want: "Hello\nWorld",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
step := &core.Step{Messages: tt.messages}
got := BuildPrompt(step)
if tt.name == "multiple messages joined with newlines" {
// Just verify the parts are present
assert.Contains(t, got, "First task")
assert.Contains(t, got, "Second task")
assert.Contains(t, got, "Third task")
} else {
assert.Equal(t, tt.want, got)
}
})
}
}
func TestACPExecutor_Validation(t *testing.T) {
p := parser.NewParser()
tests := []struct {
name string
step core.Step
wantErr bool
errMsg string
}{
{
name: "valid with agent name",
step: core.Step{
Name: "test-acp",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: false,
},
{
name: "valid with custom command",
step: core.Step{
Name: "test-acp-custom",
Type: core.StepTypeAgentACP,
ACPConfig: &core.ACPStepConfig{
Command: "/usr/bin/my-agent",
},
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: false,
},
{
name: "missing agent and command",
step: core.Step{
Name: "test-acp-no-agent",
Type: core.StepTypeAgentACP,
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: true,
errMsg: "agent",
},
{
name: "missing messages",
step: core.Step{
Name: "test-acp-no-msgs",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
},
wantErr: true,
errMsg: "message",
},
{
name: "empty message content",
step: core.Step{
Name: "test-acp-empty-msg",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Messages: []core.LLMMessage{
{Content: ""},
},
},
wantErr: true,
errMsg: "content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workflow := &core.Workflow{
Name: "test",
Kind: core.KindModule,
Steps: []core.Step{tt.step},
}
err := p.Validate(workflow)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestACPExecutor_StepHelpers(t *testing.T) {
step := &core.Step{Type: core.StepTypeAgentACP}
assert.True(t, step.IsAgentACPStep())
assert.False(t, step.IsAgentStep())
assert.False(t, step.IsBashStep())
bashStep := &core.Step{Type: core.StepTypeBash}
assert.False(t, bashStep.IsAgentACPStep())
}
func TestACPExecutor_StepClone(t *testing.T) {
original := &core.Step{
Name: "test-acp",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Cwd: "/workspace",
AllowedPaths: []string{"/workspace", "/tmp"},
ACPConfig: &core.ACPStepConfig{
Command: "custom-agent",
Args: []string{"--flag1", "--flag2"},
Env: map[string]string{
"KEY1": "value1",
"KEY2": "value2",
},
WriteEnabled: true,
},
}
cloned := original.Clone()
// Verify deep copy
assert.Equal(t, original.Agent, cloned.Agent)
assert.Equal(t, original.Cwd, cloned.Cwd)
assert.Equal(t, original.AllowedPaths, cloned.AllowedPaths)
assert.Equal(t, original.ACPConfig.Command, cloned.ACPConfig.Command)
assert.Equal(t, original.ACPConfig.Args, cloned.ACPConfig.Args)
assert.Equal(t, original.ACPConfig.Env, cloned.ACPConfig.Env)
assert.Equal(t, original.ACPConfig.WriteEnabled, cloned.ACPConfig.WriteEnabled)
// Verify independence — mutating cloned should not affect original
cloned.AllowedPaths[0] = "/changed"
assert.Equal(t, "/workspace", original.AllowedPaths[0])
cloned.ACPConfig.Args[0] = "--changed"
assert.Equal(t, "--flag1", original.ACPConfig.Args[0])
cloned.ACPConfig.Env["KEY1"] = "changed"
assert.Equal(t, "value1", original.ACPConfig.Env["KEY1"])
}
func TestACPClient_PathAllowed(t *testing.T) {
client := newACPClient(withAllowedPaths("/workspace", "/tmp/data"))
tests := []struct {
path string
allowed bool
}{
{"/workspace/file.txt", true},
{"/workspace", true},
{"/workspace/sub/dir/file.txt", true},
{"/tmp/data/results.json", true},
{"/etc/passwd", false},
{"/home/user/file.txt", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.allowed, client.isPathAllowed(tt.path))
})
}
}
func TestACPClient_NoAllowedPaths(t *testing.T) {
client := newACPClient()
// With no allowed paths, everything is allowed
assert.True(t, client.isPathAllowed("/any/path"))
assert.True(t, client.isPathAllowed("/etc/passwd"))
}
func TestACPClient_WriteEnabled(t *testing.T) {
client := newACPClient(withWriteEnabled(true))
assert.True(t, client.writeEnabled)
client2 := newACPClient(withWriteEnabled(false))
assert.False(t, client2.writeEnabled)
client3 := newACPClient()
assert.False(t, client3.writeEnabled)
}
func TestRunAgentACP_EmptyPrompt(t *testing.T) {
_, _, err := RunAgentACP(context.Background(), "", "claude-code", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "prompt is required")
}
func TestRunAgentACP_UnknownAgent(t *testing.T) {
_, _, err := RunAgentACP(context.Background(), "hello", "nonexistent-agent", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown agent")
assert.Contains(t, err.Error(), "nonexistent-agent")
}
func TestRunAgentACP_DefaultAgent(t *testing.T) {
// With empty agent name, it should default to "claude-code".
// The error depends on whether npx is available in the test env:
// - "not found in PATH" if npx is not installed
// - ACP protocol error if npx is found but agent subprocess fails
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := RunAgentACP(ctx, "hello", "", nil)
// We just verify it defaults to claude-code (doesn't error with "unknown agent")
if err != nil {
assert.NotContains(t, err.Error(), "unknown agent")
}
}
func TestRunAgentACP_NilConfig(t *testing.T) {
// Should not panic with nil config
_, _, err := RunAgentACP(context.Background(), "hello", "claude-code", nil)
// Will fail on LookPath in most test envs
if err != nil {
assert.NotContains(t, err.Error(), "panic")
}
}
// TestRunAgentACP_LongStderrLine guards the stderr drain against a single line
// longer than bufio.Scanner's default 64KiB token limit. stderrReader is a
// synchronous io.Pipe, so a drain that stops early blocks the agent's writes and
// cmd.Wait never returns — the call hangs until the context deadline instead of
// failing fast. Shadows the "opencode" built-in, whose command resolves through
// PATH, with a script that emits one 200KB stderr line.
func TestRunAgentACP_LongStderrLine(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script stub is not portable to windows")
}
binDir := t.TempDir()
stub := filepath.Join(binDir, "opencode")
script := "#!/bin/sh\nhead -c 200000 /dev/zero | tr '\\0' 'x' >&2\necho >&2\nexit 1\n"
require.NoError(t, os.WriteFile(stub, []byte(script), 0o755))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
// Generous enough that the context is not what rescues a stalled drain.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
type outcome struct {
stderr string
err error
}
done := make(chan outcome, 1)
go func() {
_, stderr, err := RunAgentACP(ctx, "hello", "opencode", nil)
done <- outcome{stderr: stderr, err: err}
}()
select {
case got := <-done:
// The handshake fails once the stub exits; what matters is that the call
// returned and the oversized line was drained rather than dropped.
assert.Error(t, got.err)
assert.NotErrorIs(t, got.err, context.DeadlineExceeded)
assert.Contains(t, got.stderr, strings.Repeat("x", 100*1024))
case <-time.After(20 * time.Second):
t.Fatal("RunAgentACP did not return: stderr drain stalled on a long line")
}
}
func TestListAgentNames(t *testing.T) {
names := ListAgentNames()
assert.NotEmpty(t, names)
assert.Contains(t, names, "claude-code")
assert.Contains(t, names, "codex")
assert.Contains(t, names, "opencode")
assert.Contains(t, names, "gemini")
assert.Equal(t, len(builtinACPAgents), len(names))
}