220 lines
6.0 KiB
Go
220 lines
6.0 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/coder/acp-go-sdk"
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
)
|
|
|
|
func TestResolvePath(t *testing.T) {
|
|
client := NewClient(WithWorkspaceRoot("/workspace/project"))
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
expectErr bool
|
|
}{
|
|
{
|
|
name: "absolute path within workspace",
|
|
input: "/workspace/project/src/main.go",
|
|
expected: "/workspace/project/src/main.go",
|
|
},
|
|
{
|
|
name: "relative path resolves within workspace",
|
|
input: "src/main.go",
|
|
expected: filepath.Join("/workspace/project", "src/main.go"),
|
|
},
|
|
{
|
|
name: "workspace root itself is allowed",
|
|
input: "/workspace/project",
|
|
expected: "/workspace/project",
|
|
},
|
|
{
|
|
name: "dot path resolves to workspace root",
|
|
input: ".",
|
|
expected: "/workspace/project",
|
|
},
|
|
{
|
|
name: "path traversal with relative path is rejected",
|
|
input: "../../etc/passwd",
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "path traversal with dot-dot in middle is rejected",
|
|
input: "src/../../etc/passwd",
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "absolute path outside workspace is rejected",
|
|
input: "/etc/passwd",
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "absolute path with parent traversal is rejected",
|
|
input: "/workspace/project/../../../etc/passwd",
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "nested relative path within workspace",
|
|
input: "src/pkg/handler.go",
|
|
expected: filepath.Join("/workspace/project", "src/pkg/handler.go"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := client.resolvePath(tt.input)
|
|
if tt.expectErr {
|
|
if err == nil {
|
|
t.Errorf("resolvePath(%q) expected error, got path %q", tt.input, got)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("resolvePath(%q) unexpected error: %v", tt.input, err)
|
|
return
|
|
}
|
|
if got != tt.expected {
|
|
t.Errorf("resolvePath(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForwardPermissionRequestTitleDerivation(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
kind := func(k acp.ToolKind) *acp.ToolKind { return &k }
|
|
allowOpt := acp.PermissionOption{OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}
|
|
|
|
tests := []struct {
|
|
name string
|
|
toolCall acp.ToolCallUpdate
|
|
wantTitle string
|
|
wantActionType string
|
|
wantDescInDtl bool
|
|
wantRawInput map[string]any
|
|
}{
|
|
{
|
|
name: "title present, kind other -> human title wins",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-1",
|
|
Title: str("Run bash command 'ls -la'"),
|
|
Kind: kind(acp.ToolKindOther),
|
|
},
|
|
wantTitle: "Run bash command 'ls -la'",
|
|
wantActionType: "other",
|
|
wantDescInDtl: true,
|
|
},
|
|
{
|
|
name: "title present, kind execute -> human title still wins",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-2",
|
|
Title: str("Run bash command 'rm -rf'"),
|
|
Kind: kind(acp.ToolKindExecute),
|
|
},
|
|
wantTitle: "Run bash command 'rm -rf'",
|
|
wantActionType: "execute",
|
|
wantDescInDtl: true,
|
|
},
|
|
{
|
|
name: "only kind -> kind is the title, no description forwarded",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-3",
|
|
Kind: kind(acp.ToolKindEdit),
|
|
},
|
|
wantTitle: "edit",
|
|
wantActionType: "edit",
|
|
wantDescInDtl: false,
|
|
},
|
|
{
|
|
name: "only title, no kind",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-4",
|
|
Title: str("Reading configuration file"),
|
|
},
|
|
wantTitle: "Reading configuration file",
|
|
wantActionType: "",
|
|
wantDescInDtl: true,
|
|
},
|
|
{
|
|
name: "neither title nor kind -> both empty",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-5",
|
|
},
|
|
wantTitle: "",
|
|
wantActionType: "",
|
|
wantDescInDtl: false,
|
|
},
|
|
{
|
|
name: "whitespace-only title falls back to kind",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-ws",
|
|
Title: str(" "),
|
|
Kind: kind(acp.ToolKindExecute),
|
|
},
|
|
wantTitle: "execute",
|
|
wantActionType: "execute",
|
|
wantDescInDtl: false,
|
|
},
|
|
{
|
|
name: "raw_input forwarded into ActionDetails",
|
|
toolCall: acp.ToolCallUpdate{
|
|
ToolCallId: "tc-6",
|
|
Title: str("Run bash"),
|
|
Kind: kind(acp.ToolKindExecute),
|
|
RawInput: map[string]any{"command": "ls -la", "cwd": "/tmp"},
|
|
},
|
|
wantTitle: "Run bash",
|
|
wantActionType: "execute",
|
|
wantDescInDtl: true,
|
|
wantRawInput: map[string]any{"command": "ls -la", "cwd": "/tmp"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := NewClient()
|
|
var captured *types.PermissionRequest
|
|
handler := func(_ context.Context, req *types.PermissionRequest) (*types.PermissionResponse, error) {
|
|
captured = req
|
|
return &types.PermissionResponse{OptionID: "allow"}, nil
|
|
}
|
|
req := acp.RequestPermissionRequest{
|
|
SessionId: "sess",
|
|
ToolCall: tt.toolCall,
|
|
Options: []acp.PermissionOption{allowOpt},
|
|
}
|
|
if _, err := c.forwardPermissionRequest(context.Background(), handler, req); err != nil {
|
|
t.Fatalf("forwardPermissionRequest returned error: %v", err)
|
|
}
|
|
if captured == nil {
|
|
t.Fatal("handler not invoked")
|
|
}
|
|
if captured.Title != tt.wantTitle {
|
|
t.Errorf("Title = %q, want %q", captured.Title, tt.wantTitle)
|
|
}
|
|
if captured.ActionType != tt.wantActionType {
|
|
t.Errorf("ActionType = %q, want %q", captured.ActionType, tt.wantActionType)
|
|
}
|
|
_, hasDesc := captured.ActionDetails["description"]
|
|
if hasDesc != tt.wantDescInDtl {
|
|
t.Errorf("ActionDetails.description present = %v, want %v (details=%v)", hasDesc, tt.wantDescInDtl, captured.ActionDetails)
|
|
}
|
|
if tt.wantRawInput != nil {
|
|
gotRaw, ok := captured.ActionDetails["raw_input"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("ActionDetails.raw_input missing or wrong type: %#v", captured.ActionDetails["raw_input"])
|
|
}
|
|
if !reflect.DeepEqual(gotRaw, tt.wantRawInput) {
|
|
t.Errorf("ActionDetails.raw_input = %v, want %v", gotRaw, tt.wantRawInput)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|