bcd246bf85
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.
431 lines
12 KiB
Go
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")
|
|
}
|
|
}
|