Files
agents_and_robots/pkg/tools/devicemesh/tools_builtin_test.go
T
egutierrez bcd246bf85 feat(0144a): tool registry framework para device-mesh
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry
con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval).
RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag.
Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction.
Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner).

Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
2026-05-24 14:07:13 +02:00

431 lines
12 KiB
Go

package devicemesh
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestRegisterBuiltins_UserExcludesApprovalTools(t *testing.T) {
reg := NewToolRegistry(nil)
names := RegisterBuiltins(reg, ModeUser)
want := map[string]bool{
"exec": true,
"shell.eval": true,
"fs.read": true,
"fs.write": true,
"fs.list": true,
"fs.stat": true,
"git.clone": true,
"git.commit": true,
"git.push": true,
"pkg.search": true,
"proc.list": true,
"docker.list": true,
"docker.exec": true,
"docker.logs": true,
}
got := map[string]bool{}
for _, n := range names {
got[n] = true
}
for w := range want {
if !got[w] {
t.Errorf("user mode missing tool %q", w)
}
}
if got["pkg.install"] {
t.Errorf("user mode should NOT include pkg.install")
}
if got["proc.kill"] {
t.Errorf("user mode should NOT include proc.kill (RequiresApproval)")
}
}
func TestRegisterBuiltins_SudoIncludesOnlyApprovalTools(t *testing.T) {
reg := NewToolRegistry(nil)
names := RegisterBuiltins(reg, ModeSudo)
got := map[string]bool{}
for _, n := range names {
got[n] = true
}
if !got["pkg.install"] {
t.Errorf("sudo mode should include pkg.install")
}
if !got["proc.kill"] {
t.Errorf("sudo mode should include proc.kill")
}
if !got["shell.eval"] {
t.Errorf("sudo mode should include shell.eval (special-cased with RequiresApproval=true)")
}
if got["exec"] {
t.Errorf("sudo mode should NOT include exec (no RequiresApproval)")
}
if got["fs.read"] {
t.Errorf("sudo mode should NOT include fs.read")
}
}
func TestRegisterBuiltins_ModeAll(t *testing.T) {
reg := NewToolRegistry(nil)
names := RegisterBuiltins(reg, ModeAll)
if len(names) < 16 {
t.Errorf("expected all 16 builtins, got %d: %v", len(names), names)
}
got := map[string]bool{}
for _, n := range names {
got[n] = true
}
if !got["exec"] || !got["pkg.install"] {
t.Errorf("ModeAll should include both exec and pkg.install")
}
}
func TestBuiltins_Exec_HappyPath(t *testing.T) {
var received CapabilityRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
_ = json.NewEncoder(w).Encode(CapabilityResponse{
RequestID: received.RequestID,
OK: true,
Result: map[string]any{
"stdout": "hello\n",
"stderr": "",
"exit_code": float64(0), // JSON numbers decode as float64
"duration_ms": float64(12),
},
})
}))
defer srv.Close()
reg := NewToolRegistry(NewClient(srv.URL))
RegisterBuiltins(reg, ModeUser)
out, err := reg.Call(context.Background(), "exec", map[string]any{
"argv": []string{"echo", "hello"},
"cwd": "/tmp",
"timeout_s": 5,
})
if err != nil {
t.Fatalf("exec call: %v", err)
}
// Result should be a normalized map.
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", out)
}
if m["stdout"].(string) != "hello\n" {
t.Errorf("stdout: %v", m["stdout"])
}
if m["exit_code"].(int) != 0 {
t.Errorf("exit_code: %v (%T)", m["exit_code"], m["exit_code"])
}
// Verify the request that was sent.
if received.Capability != "shell.exec" {
t.Errorf("capability: %q", received.Capability)
}
argv, ok := received.Args["argv"].([]any)
if !ok {
t.Fatalf("argv not []any: %T", received.Args["argv"])
}
if len(argv) != 2 || argv[0].(string) != "echo" {
t.Errorf("argv content: %v", argv)
}
if received.Args["cwd"].(string) != "/tmp" {
t.Errorf("cwd: %v", received.Args["cwd"])
}
if int(received.Args["timeout_s"].(float64)) != 5 {
t.Errorf("timeout_s: %v", received.Args["timeout_s"])
}
}
func TestBuiltins_Exec_RejectsEmptyArgv(t *testing.T) {
reg := NewToolRegistry(NewClient("http://nowhere.invalid"))
RegisterBuiltins(reg, ModeUser)
_, err := reg.Call(context.Background(), "exec", map[string]any{
"argv": []string{},
})
if err == nil {
t.Fatalf("expected error for empty argv")
}
}
func TestBuiltins_FSRead_HappyPath(t *testing.T) {
var received CapabilityRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
_ = json.NewEncoder(w).Encode(CapabilityResponse{
RequestID: received.RequestID,
OK: true,
Result: map[string]any{
"content": "file contents here",
"size": float64(18),
},
})
}))
defer srv.Close()
reg := NewToolRegistry(NewClient(srv.URL))
RegisterBuiltins(reg, ModeUser)
out, err := reg.Call(context.Background(), "fs.read", map[string]any{
"path": "/etc/os-release",
"max_bytes": 1024,
})
if err != nil {
t.Fatalf("fs.read: %v", err)
}
m := out.(map[string]any)
if m["content"].(string) != "file contents here" {
t.Errorf("content: %v", m["content"])
}
if received.Capability != "fs.read" {
t.Errorf("capability: %q", received.Capability)
}
if received.Args["path"].(string) != "/etc/os-release" {
t.Errorf("path: %v", received.Args["path"])
}
if int(received.Args["max_bytes"].(float64)) != 1024 {
t.Errorf("max_bytes: %v", received.Args["max_bytes"])
}
}
func TestBuiltins_FSWrite_RequiresContentOrB64(t *testing.T) {
reg := NewToolRegistry(NewClient("http://nowhere.invalid"))
RegisterBuiltins(reg, ModeUser)
_, err := reg.Call(context.Background(), "fs.write", map[string]any{
"path": "/tmp/x",
})
if err == nil {
t.Fatalf("expected error when neither content nor content_b64 provided")
}
}
func TestBuiltins_FSWrite_AcceptsContent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(CapabilityResponse{OK: true, Result: map[string]any{"bytes_written": float64(11)}})
}))
defer srv.Close()
reg := NewToolRegistry(NewClient(srv.URL))
RegisterBuiltins(reg, ModeUser)
_, err := reg.Call(context.Background(), "fs.write", map[string]any{
"path": "/tmp/x",
"content": "hello world",
})
if err != nil {
t.Fatalf("fs.write: %v", err)
}
}
func TestBuiltins_PkgInstall_RegisteredOnlyInSudo(t *testing.T) {
// Build user reg
user := NewToolRegistry(nil)
RegisterBuiltins(user, ModeUser)
if _, ok := user.Get("pkg.install"); ok {
t.Errorf("pkg.install should NOT be in user registry")
}
// Build sudo reg
sudo := NewToolRegistry(nil)
RegisterBuiltins(sudo, ModeSudo)
if _, ok := sudo.Get("pkg.install"); !ok {
t.Errorf("pkg.install should be in sudo registry")
}
}
// ----- shell.eval -----
func TestBuiltins_ShellEval_PresentInUserModeWithoutApproval(t *testing.T) {
reg := NewToolRegistry(nil)
RegisterBuiltins(reg, ModeUser)
spec, ok := reg.Get("shell.eval")
if !ok {
t.Fatalf("shell.eval should be registered in ModeUser")
}
if spec.RequiresApproval {
t.Errorf("shell.eval in ModeUser should have RequiresApproval=false, got true")
}
if spec.Capability != "shell.eval" {
t.Errorf("capability mismatch: %q", spec.Capability)
}
}
func TestBuiltins_ShellEval_PresentInSudoModeWithApproval(t *testing.T) {
reg := NewToolRegistry(nil)
RegisterBuiltins(reg, ModeSudo)
spec, ok := reg.Get("shell.eval")
if !ok {
t.Fatalf("shell.eval should be registered in ModeSudo")
}
if !spec.RequiresApproval {
t.Errorf("shell.eval in ModeSudo should have RequiresApproval=true, got false")
}
// Ensure withApprovalRequired did not mutate the original spec returned
// from builtinSpecs (other registries should still see false).
userReg := NewToolRegistry(nil)
RegisterBuiltins(userReg, ModeUser)
userSpec, _ := userReg.Get("shell.eval")
if userSpec.RequiresApproval {
t.Errorf("ModeUser shell.eval should remain RequiresApproval=false; sudo registration leaked")
}
}
func TestBuiltins_ShellEval_InputSchemaValidation(t *testing.T) {
reg := NewToolRegistry(nil)
RegisterBuiltins(reg, ModeUser)
spec, ok := reg.Get("shell.eval")
if !ok {
t.Fatalf("shell.eval not registered")
}
// Happy: minimal valid input.
if err := ValidateInput(spec, map[string]any{"cmd": "git status"}); err != nil {
t.Errorf("expected valid input to pass, got %v", err)
}
// Happy: with shell enum.
if err := ValidateInput(spec, map[string]any{"cmd": "ls -la", "shell": "bash"}); err != nil {
t.Errorf("shell=bash should be valid, got %v", err)
}
if err := ValidateInput(spec, map[string]any{"cmd": "Get-Process", "shell": "powershell"}); err != nil {
t.Errorf("shell=powershell should be valid, got %v", err)
}
if err := ValidateInput(spec, map[string]any{"cmd": "ls", "shell": "auto"}); err != nil {
t.Errorf("shell=auto should be valid, got %v", err)
}
// Reject: shell not in enum.
if err := ValidateInput(spec, map[string]any{"cmd": "ls", "shell": "zsh"}); err == nil {
t.Errorf("shell=zsh should be rejected by enum")
}
// Reject: missing required cmd.
if err := ValidateInput(spec, map[string]any{}); err == nil {
t.Errorf("empty input should fail (cmd required)")
}
// Reject: unknown property (additionalProperties=false).
if err := ValidateInput(spec, map[string]any{"cmd": "ls", "extra": "x"}); err == nil {
t.Errorf("unknown property should be rejected by additionalProperties=false")
}
// Reject: cmd not a string.
if err := ValidateInput(spec, map[string]any{"cmd": 42}); err == nil {
t.Errorf("cmd as integer should be rejected")
}
}
func TestBuiltins_ShellEval_ArgMapping(t *testing.T) {
spec := shellEvalSpec()
// Pass cmd alone.
out, err := spec.ArgMapping(map[string]any{"cmd": "git status"})
if err != nil {
t.Fatalf("argmap cmd-only: %v", err)
}
if out["cmd"].(string) != "git status" {
t.Errorf("cmd not passed through: %v", out["cmd"])
}
if _, ok := out["shell"]; ok {
t.Errorf("shell should be absent when not provided")
}
if _, ok := out["cwd"]; ok {
t.Errorf("cwd should be absent when not provided")
}
// Pass all fields.
out, err = spec.ArgMapping(map[string]any{
"cmd": "ls -la",
"shell": "bash",
"cwd": "/home/lucas",
})
if err != nil {
t.Fatalf("argmap full: %v", err)
}
if out["shell"].(string) != "bash" {
t.Errorf("shell not propagated: %v", out["shell"])
}
if out["cwd"].(string) != "/home/lucas" {
t.Errorf("cwd not propagated: %v", out["cwd"])
}
// Empty strings for optional fields are filtered out.
out, err = spec.ArgMapping(map[string]any{"cmd": "ls", "shell": "", "cwd": ""})
if err != nil {
t.Fatalf("argmap empty optionals: %v", err)
}
if _, ok := out["shell"]; ok {
t.Errorf("empty shell should be filtered, got %v", out["shell"])
}
if _, ok := out["cwd"]; ok {
t.Errorf("empty cwd should be filtered, got %v", out["cwd"])
}
// Missing cmd is an error.
if _, err := spec.ArgMapping(map[string]any{}); err == nil {
t.Errorf("ArgMapping should error on missing cmd")
}
}
func TestBuiltins_ShellEval_SmokeCall(t *testing.T) {
var received CapabilityRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
_ = json.NewEncoder(w).Encode(CapabilityResponse{
RequestID: received.RequestID,
OK: true,
Result: map[string]any{
"stdout": "hola\n",
"stderr": "",
"exit_code": float64(0),
"approval_status": "auto_approved",
"cmd_executed": "echo hola",
"truncated": false,
"duration_ms": float64(7),
},
})
}))
defer srv.Close()
reg := NewToolRegistry(NewClient(srv.URL))
RegisterBuiltins(reg, ModeUser)
out, err := reg.Call(context.Background(), "shell.eval", map[string]any{
"cmd": "echo hola",
})
if err != nil {
t.Fatalf("shell.eval call: %v", err)
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", out)
}
if m["stdout"].(string) != "hola\n" {
t.Errorf("stdout: %v", m["stdout"])
}
if m["approval_status"].(string) != "auto_approved" {
t.Errorf("approval_status: %v", m["approval_status"])
}
if m["cmd_executed"].(string) != "echo hola" {
t.Errorf("cmd_executed: %v", m["cmd_executed"])
}
// Verify the device-facing request envelope.
if received.Capability != "shell.eval" {
t.Errorf("capability: %q", received.Capability)
}
if received.Args["cmd"].(string) != "echo hola" {
t.Errorf("cmd: %v", received.Args["cmd"])
}
if _, ok := received.Args["shell"]; ok {
t.Errorf("shell should be absent when omitted by caller")
}
}