From bcd246bf85cbaa89b82e02a3ae6c24c4bbb94f03 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 24 May 2026 14:07:13 +0200 Subject: [PATCH] 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. --- pkg/decision/devicemesh.go | 24 + pkg/decision/types.go | 2 + pkg/tools/devicemesh/README.md | 199 ++++++ pkg/tools/devicemesh/adapter.go | 212 ++++++ pkg/tools/devicemesh/adapter_test.go | 219 ++++++ pkg/tools/devicemesh/client.go | 259 +++++++ pkg/tools/devicemesh/client_test.go | 235 +++++++ pkg/tools/devicemesh/registry_test.go | 147 ++++ pkg/tools/devicemesh/schema.go | 244 +++++++ pkg/tools/devicemesh/tools_builtin.go | 775 +++++++++++++++++++++ pkg/tools/devicemesh/tools_builtin_test.go | 430 ++++++++++++ pkg/tools/devicemesh/types.go | 178 +++++ shell/effects/runner.go | 58 +- shell/effects/runner_devicemesh_test.go | 101 +++ 14 files changed, 3080 insertions(+), 3 deletions(-) create mode 100644 pkg/decision/devicemesh.go create mode 100644 pkg/tools/devicemesh/README.md create mode 100644 pkg/tools/devicemesh/adapter.go create mode 100644 pkg/tools/devicemesh/adapter_test.go create mode 100644 pkg/tools/devicemesh/client.go create mode 100644 pkg/tools/devicemesh/client_test.go create mode 100644 pkg/tools/devicemesh/registry_test.go create mode 100644 pkg/tools/devicemesh/schema.go create mode 100644 pkg/tools/devicemesh/tools_builtin.go create mode 100644 pkg/tools/devicemesh/tools_builtin_test.go create mode 100644 pkg/tools/devicemesh/types.go create mode 100644 shell/effects/runner_devicemesh_test.go diff --git a/pkg/decision/devicemesh.go b/pkg/decision/devicemesh.go new file mode 100644 index 0000000..a103e52 --- /dev/null +++ b/pkg/decision/devicemesh.go @@ -0,0 +1,24 @@ +// devicemesh.go: pure data type for "call a device mesh tool" actions. +// +// The runtime decides which agent has which tool registry (user vs sudo). +// The decision layer only describes *what* to call; the runner in +// shell/effects/ resolves the registry and dispatches. +package decision + +// DeviceMeshAction describes an invocation of a registered devicemesh tool. +// It is a pure value — no client, no registry, just the name + input. +// +// Fields: +// +// - Tool: the registered tool name in the agent's devicemesh.ToolRegistry +// (ex "exec", "fs.read", "fs.write"). +// - Input: LLM-supplied arguments. Will be validated by the registry +// before reaching the network. +// - ResultKey: optional. The runtime stores the tool result under this key +// in the conversation state so the LLM can refer to it later. Empty +// string means "do not store, just send back as a tool message". +type DeviceMeshAction struct { + Tool string + Input map[string]any + ResultKey string +} diff --git a/pkg/decision/types.go b/pkg/decision/types.go index 31895e5..fe6db1b 100644 --- a/pkg/decision/types.go +++ b/pkg/decision/types.go @@ -31,6 +31,7 @@ const ( ActionKindMCP ActionKind = "mcp" ActionKindLLM ActionKind = "llm" ActionKindDelegate ActionKind = "delegate" + ActionKindDeviceMesh ActionKind = "device_mesh" ) // Action is a pure description of what the shell should do. @@ -45,6 +46,7 @@ type Action struct { MCP *tools.MCPCallSpec LLM *LLMAction Delegate *DelegateAction + DeviceMesh *DeviceMeshAction } type ReplyAction struct { diff --git a/pkg/tools/devicemesh/README.md b/pkg/tools/devicemesh/README.md new file mode 100644 index 0000000..6faebc4 --- /dev/null +++ b/pkg/tools/devicemesh/README.md @@ -0,0 +1,199 @@ +# pkg/tools/devicemesh + +Tool registry framework that lets an LLM agent in `agents_and_robots` (VPS) call capabilities exposed by a remote `device_agent` over the WireGuard mesh. + +Issue: [0144a](../../../dev/issues/0144-agent-per-machine-llm.md) (POC for the broader 0144 spec). + +## What it does + +``` +LLM (Claude) + │ tool_call exec {argv:["ls","/tmp"]} + ▼ +ToolRegistry.Call("exec", input) + │ 1. ValidateInput against tool's InputSchema + │ 2. ArgMapping(input) → device-facing args + │ 3. Client.Call(CapabilityRequest{capability: "shell.exec", args}) + │ 4. ResultMapping(resp.Result) → LLM-facing output + ▼ +HTTP POST http://10.42.0.10:7474/capability (over mesh WG) + ▼ +device_agent on home-wsl runs the binary, returns audit_hash + result +``` + +The LLM never sees the HTTP layer; it sees a flat list of named tools with JSON-Schema inputs. + +## Pieces + +| File | Purpose | +|---|---| +| `client.go` | HTTP client to `POST /capability` and `GET /health` of the remote `device_agent`. Generates `request_id` (req_<12bytehex>) and `nonce` (16 random bytes base64) when missing. | +| `types.go` | `ToolSpec` + `ToolRegistry`. Thread-safe registry, `Call` is the single dispatch entry point. | +| `schema.go` | Mini JSON-Schema validator (object/array/string/integer/number/boolean + required + additionalProperties + enum). Enough to reject LLM mistakes without pulling a heavy dep. | +| `tools_builtin.go` | The standard catalog: exec, shell.eval, fs.read, fs.write, fs.list, fs.stat, git.clone, git.commit, git.push, pkg.install, pkg.search, proc.list, proc.kill, docker.list, docker.exec, docker.logs. `RegisterBuiltins(reg, ModeUser|ModeSudo|ModeAll)` filters by `RequiresApproval`. `shell.eval` is special-cased to be registered in BOTH modes, with `RequiresApproval=true` forced in `ModeSudo` via `withApprovalRequired`. | + +## How to register a new tool + +```go +import "github.com/enmanuel/agents/pkg/tools/devicemesh" + +reg.Register(devicemesh.ToolSpec{ + Name: "screenshot", + Description: "Capture the display on the remote device. Returns PNG base64.", + Capability: "display.capture", + InputSchema: map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "format": map[string]any{"type": "string", "enum": []any{"png", "jpeg"}}, + }, + }, + ArgMapping: func(in map[string]any) (map[string]any, error) { + // pure transform LLM → device + return in, nil + }, + ResultMapping: func(r map[string]any) (any, error) { + // pure transform device → LLM + return r, nil + }, + RequiresApproval: false, // user-scope +}) +``` + +Then add the tool name to `cfg.DeviceMesh.ToolsAllowed` in the agent's `config.yaml`. + +## Wiring (issue 0144c — done) + +The launcher now constructs the device mesh registry from `cfg.DeviceMesh` and surfaces every spec as a regular `tools.Tool` consumed by the existing LLM tool-use loop. No special LLM path; the LLM does not know (or care) that the tool's `Exec` ends up making an HTTP call over WireGuard. + +``` +config.AgentConfig.DeviceMesh (yaml block) + │ + ▼ buildDeviceMeshRegistry(cfg, logger) ← devagents/registry_build.go + │ 1. resolve URL (env var override wins when present + non-empty) + │ 2. NewClient(url) + apply Timeout + │ 3. RegisterBuiltins(reg, mode) ← user | sudo | all + │ 4. FilterByAllowed(reg, tools_allowed) + │ + ▼ devicemesh.ToolsForLLM(reg) ← pkg/tools/devicemesh/adapter.go + │ 1 tools.Tool per spec; Def.Parameters + │ compressed from JSON-Schema; Exec + │ closure routes through reg.Call + │ + ▼ tools.Registry.Register(...) ← devagents/registry_build.go + │ + ▼ devagents/llm.go runLLM tool-use loop ← unchanged +``` + +The same `*ToolRegistry` is also passed to `effects.NewRunnerWithDeviceMesh` so any rule that emits `decision.ActionKindDeviceMesh` (orchestrator pipelines, `!exec` builtin command, etc.) hits the same dispatcher. Both paths produce the same JSON envelope, so audit chains line up regardless of where the call originated. + +### Config block + +The agent's `config.yaml` opts in via: + +```yaml +device_mesh: + enabled: true + device_id: home-wsl # logged as audit context; aliased as "host" + mode: user # user | sudo | all + device_agent_url: "http://10.42.0.10:7474" + device_agent_url_env: AGENT_HOME_WSL_DEVICE_MESH_URL # optional; wins when set + non-empty + manifest_id: manifest_home-wsl_v1 # metadata only; the device enforces + client_timeout_s: 60 # aliased as "timeout_seconds" + tools_allowed: # whitelist; empty = keep everything mode allowed + - exec + - fs.read + - fs.list +``` + +Names in `tools_allowed` that the catalog does not provide are logged with a `WARN device_mesh tools_allowed lists unknown tool` and dropped. The template ships extras like `project.create`, `memory.recall`, etc. that arrive in 0144d/e — they degrade gracefully today. + +### LLM-side view of a device tool + +The adapter compresses the device-mesh `InputSchema` into the flatter `tools.Def.Parameters` shape (each top-level property becomes one `tools.Param`). The description is enriched with a stable marker so the model can spot remote tools at a glance: + +``` +exec → "Execute a command on the remote device. argv is parsed as exec.Command (NO shell). ... [device_mesh: shell.exec]" +pkg.install → "Install an OS package ... [device_mesh: pkg.install] (approval required)" +``` + +When `RequiresApproval=true`, the marker also reminds the model the call may be queued, which feeds back into the system prompt rules of `agent--sudo`. + +### Approval flow + LLM tool-result mapping + +When the device_agent returns `approval_status="queued"` and the operator does not click 👍 within the timeout (0134 §6.5), the device returns `approval_status="timeout"` or `ok=false, error="approval_required"`. The adapter does NOT silence this — it surfaces the error verbatim: + +``` +ToolRegistry.Call(...) → returns err = "devicemesh: shell.exec: approval_required" +tools.Result{Err: err} +runLLM → appends `role='tool'` message with `error: devicemesh: shell.exec: approval_required` +LLM next iteration → can apologize to operator and ask for retry. +``` + +The actual approval UX (operator clicks 👍 in `#operator-approvals`) is the device_agent's responsibility (issue 0134 §6, validated end-to-end in flow 0009). Nothing new on the agents_and_robots side. + +### What this issue does NOT do + +- **Matrix-side approval rendering** is 0144f — `!preapprove`, `!approve req_id`, pre-approval cache. +- **ed25519 manifest signing** is 0144h — today the wire format is correct but unsigned. +- **`call_monitor` telemetry hook** that emits `function_id = capability___` per call is 0144 §13 (separate plumbing in the audit writer). +- **Cross-room correlation** (`delegate_sudo` posting to `#-sudo` and the bot copying the reply back) is its own issue (0144 main spec §3.3 + 0144c original plan — left intentionally for the room/bus layer once approval is wired). + +## shell.eval — the powerful tool + +`shell.eval` is the **only** built-in tool that lets the LLM execute arbitrary free-form shell text on the device. Every other tool has a tightly-scoped JSON schema (paths, argv lists, container ids); `shell.eval` accepts a single string that the device hands to bash (Linux/WSL) or PowerShell (Windows) unmodified. + +It exists because no structured tool can cover every legal shell idiom: pipes, redirects, here-docs, `$()` expansions, complex globs, environment-aware composition. Without `shell.eval`, the LLM resorts to multi-step `exec` chains that lose fidelity (no shell metacharacters allowed in `exec`'s `argv`). With it, the LLM can ask for "give me the size of every `.log` in `/var/log` sorted desc" in one round-trip. + +### Guardrails (all device-side) + +The flag on `ToolSpec.RequiresApproval` is metadata only. The real protections live in the `device_agent`: + +1. **Hardcoded blocklist** — destructive patterns (`rm -rf /`, `dd if=/dev/...`, `mkfs`, fork-bombs `:(){:|:&};:`, `shutdown`, `reboot`, `:>/dev/sda`, ...) always reject regardless of agent role or operator. There is no override. +2. **Auto-approve whitelist** — read-only / inspection patterns (`^git `, `^ls `, `^cat `, `^grep `, `^ps `, `^uptime`, `^df `, ...) execute directly without operator prompt. The whitelist lives in the device manifest, not here. +3. **Operator approval** — anything that is neither blocked nor auto-approved returns `approval_status="queued"` in the result. The device sends an approval request to `#operator-approvals` in Element and waits up to 60s for the operator to confirm; on timeout the call returns `approval_status="timeout"` and the LLM must reword or `!retry`. + +The fields the LLM gets back from `shell.eval`: `stdout`, `stderr`, `exit_code`, `approval_status`, `cmd_executed` (post-normalization), `truncated` (true if output was capped), `duration_ms`. + +### When the LLM should call shell.eval + +Use it as the **fallback** for cases none of the structured tools cover: + +- Pipes, redirects, sub-shells, here-docs. +- One-liners that combine `find` + `xargs` + `awk`. +- Quick sanity checks (`uptime && df -h`). +- Composing CLI tools the agent isn't going to call enough to warrant a dedicated tool spec. + +Avoid it for things that *do* have a structured tool: `fs.read`, `fs.list`, `git.commit`, `docker.exec`, etc. Those have predictable JSON shapes, narrower attack surface, and richer result mapping. + +### Designing manifests for user vs sudo agents + +`RegisterBuiltins` registers `shell.eval` in **both** `ModeUser` and `ModeSudo` because the device_agent — not the registry — decides what is safe. Recommended manifest defaults: + +| Agent role | `RequiresApproval` (LLM-facing metadata) | Device manifest | +|---|---|---| +| `agent-` (user) | `false` | Auto-approve whitelist + operator approval for anything else. Hardcoded blocklist active. | +| `agent--sudo` (sudo) | `true` (forced via `withApprovalRequired`) | **Every** invocation requires explicit operator approval. No auto-approve whitelist. Hardcoded blocklist active. | + +The `withApprovalRequired` helper clones the spec returned by `shellEvalSpec()` and flips `RequiresApproval=true` without mutating the source, so `ModeUser` registries that re-register after a `ModeSudo` run still get the unmodified spec. See `tools_builtin.go::RegisterBuiltins` for the special-case wiring. + +See also: `apps/device_agent/` (where the blocklist + auto-approve whitelist + approval flow live) and issue 0144 §6.4 for the RBAC design. + +## POC limitations (intentional) + +These are out of scope for 0144a and tracked in sibling issues: + +- **No retry**. A single `Call` failure surfaces immediately. The spec accepts this: tool failures go back to the LLM as a `role='tool'` error message and the LLM decides what to do (issue 0144 §7.1 reglas operativas 2). +- **No pre-approval cache**. `RequiresApproval` is metadata only; the actual gate lives on the device_agent (0144 §3) and the pre-approvals table (0144f). +- **No streaming**. Tools are request/response. Long-running commands (`apt-get install` of a 200MB package) block until done or timeout. Streaming for logs is its own future issue. +- **No exponential backoff**. The Go HTTP client's transport defaults apply (TCP retries on connect, no per-request retry). +- **No output sanitization**. The Runner formats the result as JSON; sanitization against prompt-injection payloads is 0144g. +- **No telemetry to `call_monitor`**. The hook for `function_id = capability___` is part of the agent runtime wiring (0144c) — this package emits no metrics on its own. +- **No manifest signing on the request side**. The Client envelope matches the 0134 §2.1 wire format but does NOT sign; manifest signing arrives in 0144h. + +## Why these specific design choices + +- `Args map[string]any` (object) NOT `[]string` (positional). The current `device_agent` POC uses `[]string` for `shell.exec` (see `apps/device_agent/capability.go`). The 0134 protocol and 0144 spec call for object-shaped args because most capabilities (`fs.read`, `git.clone`, `docker.exec`) are not naturally positional. 0144h migrates the device_agent. +- `ResultMapping` returns `any` instead of `map[string]any`. Some tools (eg the test's `echo` example) collapse their output to a string. The Runner JSON-encodes whatever comes back so the LLM always sees a stable representation. +- `Capability` is a field on `ToolSpec`, not derived from `Name`. The 1:1 mapping is the common case (`fs.read` → `fs.read`), but `docker.list` → `docker.container.list` and `project.create` (future) compose multiple capabilities, so the indirection pays for itself. +- Pure/impure split inside one package. `ToolSpec`, schema, mappings, registry are pure data and pure functions. Only `Client.Call` and `Client.Health` do I/O. The runtime composes them; tests substitute the Client. diff --git a/pkg/tools/devicemesh/adapter.go b/pkg/tools/devicemesh/adapter.go new file mode 100644 index 0000000..a06883e --- /dev/null +++ b/pkg/tools/devicemesh/adapter.go @@ -0,0 +1,212 @@ +// adapter.go: bridges devicemesh.ToolSpec → tools.Tool so device-mesh tools +// can ride the same registry + LLM tool-use loop that already handles +// http/ssh/file/memory tools. +// +// The agents_and_robots tool stack is: +// +// tools.Tool { Def: tools.Def{Name, Description, Parameters}, Exec: ToolFunc } +// → tools.Registry.Register / ToLLMSpecs / ExecuteForRoom +// → devagents/llm.go runLLM tool-use loop +// +// Device-mesh tools speak a richer language (full JSON-Schema in +// InputSchema, capability indirection). The adapter compresses this into the +// flatter tools.Param shape that the LLM-side codec already understands, +// then routes Exec through ToolRegistry.Call so the schema validator, +// ArgMapping, capability dispatch and ResultMapping all still run. +// +// Pure data + one impure closure: the returned tools.Tool's Exec hits the +// network via the embedded Client, but everything outside Exec (Def, Param +// extraction) is a pure transform. +package devicemesh + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/enmanuel/agents/tools" +) + +// ToolsForLLM walks the registry and returns one tools.Tool per registered +// ToolSpec. Names are alpha-sorted for stable prompt-caching on the LLM side. +// +// Order matters: the returned slice is what the launcher feeds to +// tools.Registry.Register, and the LLM sees the tools in registration order +// when ToLLMSpecs() preserves it (it does — registry.Names is sorted). +// +// Returns an empty slice (never nil) when reg has no tools or is nil. +func ToolsForLLM(reg *ToolRegistry) []tools.Tool { + if reg == nil { + return []tools.Tool{} + } + specs := reg.List() + out := make([]tools.Tool, 0, len(specs)) + for _, spec := range specs { + out = append(out, AdaptTool(reg, spec)) + } + return out +} + +// AdaptTool wraps a single ToolSpec as a tools.Tool. Useful when callers +// build a custom subset (ex tests that register one tool and exercise it +// through the LLM loop). For the common "register all" case use ToolsForLLM. +func AdaptTool(reg *ToolRegistry, spec ToolSpec) tools.Tool { + return tools.Tool{ + Def: tools.Def{ + Name: spec.Name, + Description: enrichDescription(spec), + Parameters: paramsFromSchema(spec.InputSchema), + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + if args == nil { + args = map[string]any{} + } + result, err := reg.Call(ctx, spec.Name, args) + if err != nil { + // Surface approval / validation / dispatch errors verbatim so + // the LLM tool-use loop can render them as tool messages and + // give the model a chance to self-correct on the next turn. + return tools.Result{Err: err} + } + return tools.Result{Output: formatToolResult(result)} + }, + } +} + +// enrichDescription appends a one-line marker to the spec description so the +// LLM (and any human reading logs) can see at a glance that this tool is +// remote and which capability it maps to. The format is stable and short to +// avoid bloating the system prompt token budget. +// +// Example: +// +// "Execute a command on the remote device. argv ... [device_mesh: shell.exec]" +// +// When RequiresApproval is true we also append " (approval required)" so the +// model knows the call may be queued / rejected. +func enrichDescription(spec ToolSpec) string { + desc := spec.Description + suffix := fmt.Sprintf(" [device_mesh: %s]", spec.Capability) + if spec.RequiresApproval { + suffix += " (approval required)" + } + return desc + suffix +} + +// paramsFromSchema flattens a top-level JSON-Schema-lite (the shape device +// mesh ToolSpec.InputSchema uses) into the slice of tools.Param the LLM +// codec expects. Only the top-level properties are emitted; nested objects +// get type "object" and the LLM is told to pass them through verbatim. +// +// Required fields from the schema's "required" array are reflected onto each +// Param. Unknown shapes degrade gracefully — we never panic, we just emit +// what we can. Pure function. +func paramsFromSchema(schema map[string]any) []tools.Param { + if schema == nil { + return nil + } + props, _ := schema["properties"].(map[string]any) + if len(props) == 0 { + return nil + } + + requiredSet := make(map[string]bool) + if reqRaw, ok := schema["required"]; ok { + switch req := reqRaw.(type) { + case []string: + for _, n := range req { + requiredSet[n] = true + } + case []any: + for _, n := range req { + if s, ok := n.(string); ok { + requiredSet[s] = true + } + } + } + } + + // Sort property names to make the output deterministic — ToLLMSpecs sorts + // by tool name but does not sort param order; LLMs are sensitive to + // reordering when prompt-caching kicks in. + names := make([]string, 0, len(props)) + for n := range props { + names = append(names, n) + } + sort.Strings(names) + + params := make([]tools.Param, 0, len(names)) + for _, name := range names { + propVal, _ := props[name].(map[string]any) + p := tools.Param{ + Name: name, + Required: requiredSet[name], + } + if propVal != nil { + if t, ok := propVal["type"].(string); ok { + p.Type = t + } + if d, ok := propVal["description"].(string); ok { + p.Description = d + } + } + if p.Type == "" { + p.Type = "string" + } + params = append(params, p) + } + return params +} + +// formatToolResult renders the device_agent's reply as the JSON string that +// gets shoved into the role='tool' message of the LLM transcript. +// +// - nil → "" +// - string → returned as-is (avoids double-encoding) +// - everything else → json.Marshal; on marshal failure fall back to a Go +// printf so we never drop data on the floor. +// +// Note: this mirrors shell/effects/runner.go::formatDeviceMeshResult so +// ActionKindDeviceMesh and the adapter path produce consistent transcripts. +func formatToolResult(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// FilterByAllowed returns a copy of reg containing only tools whose names +// appear in the allowed set. Empty allowed → reg returned unchanged. Names +// in `allowed` that do not match any tool are silently skipped (the +// launcher logs them; this function is pure). +// +// The returned registry shares the same Client as the source, so dispatches +// reach the same device_agent. Re-registering means we keep ArgMapping / +// ResultMapping intact — no schema or spec recompute on the hot path. +func FilterByAllowed(reg *ToolRegistry, allowed []string) *ToolRegistry { + if reg == nil { + return nil + } + if len(allowed) == 0 { + return reg + } + allowSet := make(map[string]bool, len(allowed)) + for _, n := range allowed { + allowSet[n] = true + } + out := NewToolRegistry(reg.Client()) + for _, spec := range reg.List() { + if allowSet[spec.Name] { + out.Register(spec) + } + } + return out +} diff --git a/pkg/tools/devicemesh/adapter_test.go b/pkg/tools/devicemesh/adapter_test.go new file mode 100644 index 0000000..d600b8c --- /dev/null +++ b/pkg/tools/devicemesh/adapter_test.go @@ -0,0 +1,219 @@ +package devicemesh + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestToolsForLLM_EmptyRegistry(t *testing.T) { + if got := ToolsForLLM(nil); len(got) != 0 { + t.Errorf("nil reg → expected 0 tools, got %d", len(got)) + } + reg := NewToolRegistry(nil) + if got := ToolsForLLM(reg); len(got) != 0 { + t.Errorf("empty reg → expected 0 tools, got %d", len(got)) + } +} + +func TestToolsForLLM_PreservesNamesAndDescription(t *testing.T) { + reg := NewToolRegistry(NewClient("http://nowhere.invalid")) + reg.Register(ToolSpec{ + Name: "exec", + Capability: "shell.exec", + Description: "Run a command", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"argv"}, + "properties": map[string]any{ + "argv": map[string]any{"type": "array", "description": "argument vector"}, + }, + }, + }) + reg.Register(ToolSpec{ + Name: "pkg.install", + Capability: "pkg.install", + Description: "Install a package", + RequiresApproval: true, + }) + + got := ToolsForLLM(reg) + if len(got) != 2 { + t.Fatalf("expected 2 tools, got %d", len(got)) + } + + // Alpha-sorted by name + if got[0].Def.Name != "exec" || got[1].Def.Name != "pkg.install" { + t.Errorf("name order: %v", []string{got[0].Def.Name, got[1].Def.Name}) + } + + if !strings.Contains(got[0].Def.Description, "device_mesh: shell.exec") { + t.Errorf("description missing device_mesh marker: %q", got[0].Def.Description) + } + if !strings.Contains(got[1].Def.Description, "(approval required)") { + t.Errorf("approval-required marker missing: %q", got[1].Def.Description) + } + + // Param extraction + if len(got[0].Def.Parameters) != 1 || got[0].Def.Parameters[0].Name != "argv" { + t.Errorf("expected one param 'argv', got %+v", got[0].Def.Parameters) + } + if !got[0].Def.Parameters[0].Required { + t.Errorf("expected argv to be required") + } +} + +func TestAdaptTool_ExecRoutesThroughRegistry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req CapabilityRequest + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + // Echo the args back so we can assert ArgMapping ran. + _ = json.NewEncoder(w).Encode(CapabilityResponse{ + RequestID: req.RequestID, + OK: true, + Result: map[string]any{"got": req.Args}, + }) + })) + defer srv.Close() + + reg := NewToolRegistry(NewClient(srv.URL)) + spec := ToolSpec{ + Name: "echo", + Capability: "x.echo", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"msg"}, + "properties": map[string]any{ + "msg": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(in map[string]any) (map[string]any, error) { + return map[string]any{"msg_upper": strings.ToUpper(in["msg"].(string))}, nil + }, + } + reg.Register(spec) + tool := AdaptTool(reg, spec) + + res := tool.Exec(context.Background(), map[string]any{"msg": "hi"}) + if res.Err != nil { + t.Fatalf("exec err: %v", res.Err) + } + if !strings.Contains(res.Output, "HI") { + t.Errorf("expected HI in output, got %q", res.Output) + } +} + +func TestAdaptTool_PropagatesValidationError(t *testing.T) { + reg := NewToolRegistry(NewClient("http://nowhere.invalid")) + spec := ToolSpec{ + Name: "needs_int", + Capability: "x.y", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"n"}, + "properties": map[string]any{ + "n": map[string]any{"type": "integer"}, + }, + "additionalProperties": false, + }, + } + reg.Register(spec) + tool := AdaptTool(reg, spec) + + res := tool.Exec(context.Background(), map[string]any{"n": "not-an-int"}) + if res.Err == nil { + t.Fatalf("expected validation error") + } + if !strings.Contains(res.Err.Error(), "needs_int") { + t.Errorf("error should mention tool name: %v", res.Err) + } +} + +func TestFormatToolResult(t *testing.T) { + if got := formatToolResult(nil); got != "" { + t.Errorf("nil → expected empty, got %q", got) + } + if got := formatToolResult("plain"); got != "plain" { + t.Errorf("string passthrough: %q", got) + } + if got := formatToolResult(map[string]any{"a": 1}); got != `{"a":1}` { + t.Errorf("map encode: %q", got) + } +} + +func TestFilterByAllowed(t *testing.T) { + reg := NewToolRegistry(NewClient("http://x")) + reg.Register(ToolSpec{Name: "a", Capability: "x.a"}) + reg.Register(ToolSpec{Name: "b", Capability: "x.b"}) + reg.Register(ToolSpec{Name: "c", Capability: "x.c"}) + + // Empty allow-list = passthrough + if got := FilterByAllowed(reg, nil); got.Len() != 3 { + t.Errorf("nil allowed → expected 3, got %d", got.Len()) + } + + // Subset + filtered := FilterByAllowed(reg, []string{"a", "c", "zzz"}) // zzz is silently dropped + if filtered.Len() != 2 { + t.Fatalf("expected 2 filtered, got %d", filtered.Len()) + } + names := filtered.Names() + if names[0] != "a" || names[1] != "c" { + t.Errorf("unexpected names after filter: %v", names) + } + + // Same Client shared + if filtered.Client() != reg.Client() { + t.Errorf("filtered should share Client with source") + } + + // Nil source + if FilterByAllowed(nil, []string{"a"}) != nil { + t.Errorf("nil source → expected nil") + } +} + +func TestParamsFromSchema_EdgeCases(t *testing.T) { + if got := paramsFromSchema(nil); got != nil { + t.Errorf("nil schema → expected nil, got %v", got) + } + // Missing properties + if got := paramsFromSchema(map[string]any{"type": "object"}); got != nil { + t.Errorf("no properties → expected nil, got %v", got) + } + // "required" as []any (json.Unmarshal default) + got := paramsFromSchema(map[string]any{ + "required": []any{"foo"}, + "properties": map[string]any{ + "foo": map[string]any{"type": "string"}, + "bar": map[string]any{"type": "integer"}, + }, + }) + if len(got) != 2 { + t.Fatalf("expected 2 params, got %d", len(got)) + } + // Sorted alpha: bar, foo + if got[0].Name != "bar" || got[1].Name != "foo" { + t.Errorf("expected sorted [bar, foo], got %+v", got) + } + if got[0].Required { + t.Errorf("bar should not be required") + } + if !got[1].Required { + t.Errorf("foo should be required") + } + // Type defaulting + got2 := paramsFromSchema(map[string]any{ + "properties": map[string]any{ + "x": map[string]any{}, + }, + }) + if len(got2) != 1 || got2[0].Type != "string" { + t.Errorf("expected type default 'string', got %+v", got2) + } +} diff --git a/pkg/tools/devicemesh/client.go b/pkg/tools/devicemesh/client.go new file mode 100644 index 0000000..58c62c3 --- /dev/null +++ b/pkg/tools/devicemesh/client.go @@ -0,0 +1,259 @@ +// Package devicemesh provides a Go HTTP client and tool registry for invoking +// capabilities exposed by a remote device_agent over the WireGuard mesh. +// +// Architecture: the LLM agent runs in the VPS (agents_and_robots). It needs to +// execute capabilities on a remote PC (home-wsl, aurgi-pc, ...) reached via +// mesh WG. The remote PC runs device_agent which exposes POST /capability. +// This package is the "right arm" between the LLM (which only sees a tool +// registry) and the device (which only sees capability envelopes). +// +// Pure/impure split: the registry, tool specs, schema validation, and arg +// mappings are pure (no I/O). Client.Call is impure (HTTP). Both live in this +// package to keep the surface area small, but Call is the only function that +// touches the network. +package devicemesh + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// DefaultTimeout is applied when Client.Timeout is zero. +const DefaultTimeout = 30 * time.Second + +// CapabilityRequest is the JSON envelope sent to POST /capability of the +// remote device_agent. Matches the protocol defined in issue 0134 §2.1. +// +// `Args` is map[string]any (NOT []string like the current POC device_agent). +// This matches the spec 0134 which uses object-shaped args. The device_agent +// will migrate to this shape in issue 0144h alongside manifest signing. +type CapabilityRequest struct { + RequestID string `json:"request_id"` + Capability string `json:"capability"` + Args map[string]any `json:"args"` + Nonce string `json:"nonce"` + Timestamp int64 `json:"ts"` +} + +// CapabilityResponse is the JSON envelope returned by the device_agent. +// Result is decoded as `map[string]any` so tool mappings can normalize it. +type CapabilityResponse struct { + RequestID string `json:"request_id"` + OK bool `json:"ok"` + Result map[string]any `json:"result,omitempty"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + AuditHash string `json:"audit_hash,omitempty"` +} + +// Client is an HTTP client to a single device_agent endpoint. +// +// One Client per remote device. The agent runtime constructs it from +// cfg.DeviceMesh.DeviceAgentURL at startup and injects it into the tool +// registry. +type Client struct { + BaseURL string + Timeout time.Duration + HTTPClient *http.Client // optional override, useful for tests +} + +// NewClient builds a Client with sensible defaults. BaseURL is used as-is; +// callers are responsible for including scheme and port (ex +// "http://10.42.0.10:7474"). +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + Timeout: DefaultTimeout, + } +} + +// httpClient returns the effective *http.Client. If the caller injected one +// (HTTPClient != nil), use it as-is (tests rely on this). Otherwise build a +// fresh one with Timeout. Defaults to DefaultTimeout when Timeout is zero. +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + t := c.Timeout + if t == 0 { + t = DefaultTimeout + } + return &http.Client{Timeout: t} +} + +// Call sends a CapabilityRequest envelope to POST {BaseURL}/capability and +// decodes the response. +// +// Side-effects: +// - Generates request_id (if empty) as a 12-byte random hex (24 chars). +// - Generates nonce (if empty) as 16 random bytes base64. +// - Sets ts to time.Now().Unix() if zero. +// - Network call. +// +// Errors: +// - Returns a non-nil error for transport failures, non-2xx HTTP statuses, +// or unparseable JSON. +// - A successful HTTP call with `ok=false` is NOT an error from Call's +// perspective — it returns the response with Error populated and lets the +// caller decide. This mirrors the spec: a failed capability is still a +// valid envelope. +func (c *Client) Call(ctx context.Context, req CapabilityRequest) (*CapabilityResponse, error) { + if c == nil { + return nil, fmt.Errorf("devicemesh.Client: nil receiver") + } + if c.BaseURL == "" { + return nil, fmt.Errorf("devicemesh.Client: BaseURL is empty") + } + if req.Capability == "" { + return nil, fmt.Errorf("devicemesh.Call: capability is required") + } + + if req.RequestID == "" { + id, err := randomRequestID() + if err != nil { + return nil, fmt.Errorf("generate request_id: %w", err) + } + req.RequestID = id + } + if req.Nonce == "" { + nonce, err := randomNonce() + if err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + req.Nonce = nonce + } + if req.Timestamp == 0 { + req.Timestamp = time.Now().Unix() + } + if req.Args == nil { + req.Args = map[string]any{} + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + url := c.BaseURL + "/capability" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build http request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http call: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + // The device_agent returns 500 with a CapabilityResponse body when the + // capability itself failed (see capability.go::capabilityHandler). We try + // to decode the body regardless of status — if it parses as a + // CapabilityResponse, return it (OK=false). Only when decoding fails do + // we surface an HTTP-level error. + var out CapabilityResponse + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode response (status=%d, body=%q): %w", + resp.StatusCode, truncate(string(respBody), 200), err) + } + + // If the body didn't include any recognizable field and status is non-2xx, + // surface the HTTP error. + if resp.StatusCode >= 400 && out.RequestID == "" && out.Error == "" { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, + truncate(string(respBody), 200)) + } + + return &out, nil +} + +// Health pings the device_agent's /health endpoint and returns the device +// identity. Returns empty strings if the endpoint does not provide them. +// +// Expected response shape (loose): +// +// {"device_id":"home-wsl","version":"0.1.0","ok":true} +func (c *Client) Health(ctx context.Context) (deviceID, version string, err error) { + if c == nil { + return "", "", fmt.Errorf("devicemesh.Client: nil receiver") + } + if c.BaseURL == "" { + return "", "", fmt.Errorf("devicemesh.Client: BaseURL is empty") + } + + url := c.BaseURL + "/health" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", "", fmt.Errorf("build http request: %w", err) + } + resp, err := c.httpClient().Do(httpReq) + if err != nil { + return "", "", fmt.Errorf("http call: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode >= 400 { + return "", "", fmt.Errorf("health http %d: %s", resp.StatusCode, + truncate(string(respBody), 200)) + } + var out struct { + DeviceID string `json:"device_id"` + Version string `json:"version"` + } + if err := json.Unmarshal(respBody, &out); err != nil { + return "", "", fmt.Errorf("decode health body: %w", err) + } + return out.DeviceID, out.Version, nil +} + +// randomRequestID returns a 24-char hex string seeded from crypto/rand. +// Format is deliberately compact and URL-safe so it can appear in logs and +// audit chains without escaping. +func randomRequestID() (string, error) { + var buf [12]byte + // Stamp the high 4 bytes with seconds-since-epoch for rough sortability; + // the lower 8 bytes are random. This is not a ULID but plays the same role. + binary.BigEndian.PutUint32(buf[:4], uint32(time.Now().Unix())) + if _, err := rand.Read(buf[4:]); err != nil { + return "", err + } + return "req_" + hex.EncodeToString(buf[:]), nil +} + +// randomNonce returns 16 random bytes base64-encoded (no padding) suitable +// for the device_agent's nonce dedupe table. +func randomNonce() (string, error) { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(buf[:]), nil +} + +// truncate clips a string for error messages so giant payloads don't pollute logs. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/pkg/tools/devicemesh/client_test.go b/pkg/tools/devicemesh/client_test.go new file mode 100644 index 0000000..fa7a232 --- /dev/null +++ b/pkg/tools/devicemesh/client_test.go @@ -0,0 +1,235 @@ +package devicemesh + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestClient_Call_RoundTrip(t *testing.T) { + var received CapabilityRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/capability" { + t.Errorf("expected /capability path, got %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CapabilityResponse{ + RequestID: received.RequestID, + OK: true, + Result: map[string]any{"echo": "ok"}, + DurationMs: 5, + AuditHash: "abc123", + }) + })) + defer srv.Close() + + c := NewClient(srv.URL) + resp, err := c.Call(context.Background(), CapabilityRequest{ + Capability: "shell.exec", + Args: map[string]any{"argv": []string{"ls"}}, + }) + if err != nil { + t.Fatalf("call: %v", err) + } + if !resp.OK { + t.Fatalf("expected ok=true, got %+v", resp) + } + if resp.AuditHash != "abc123" { + t.Errorf("audit hash mismatch: %q", resp.AuditHash) + } + if received.RequestID == "" { + t.Errorf("expected client to populate request_id") + } + if !strings.HasPrefix(received.RequestID, "req_") { + t.Errorf("request_id should have req_ prefix, got %q", received.RequestID) + } + if received.Nonce == "" { + t.Errorf("expected client to populate nonce") + } + if received.Timestamp == 0 { + t.Errorf("expected client to populate ts") + } + if received.Capability != "shell.exec" { + t.Errorf("capability mismatch: %q", received.Capability) + } +} + +func TestClient_Call_PreservesProvidedIDs(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}) + })) + defer srv.Close() + + c := NewClient(srv.URL) + _, err := c.Call(context.Background(), CapabilityRequest{ + RequestID: "req_custom_123", + Capability: "fs.read", + Args: map[string]any{"path": "/tmp/x"}, + Nonce: "fixed_nonce", + Timestamp: 1234567890, + }) + if err != nil { + t.Fatalf("call: %v", err) + } + if received.RequestID != "req_custom_123" { + t.Errorf("request_id overwritten: %q", received.RequestID) + } + if received.Nonce != "fixed_nonce" { + t.Errorf("nonce overwritten: %q", received.Nonce) + } + if received.Timestamp != 1234567890 { + t.Errorf("ts overwritten: %d", received.Timestamp) + } +} + +func TestClient_Call_OKFalseSurfacedNotError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Device returns 500 with body; mimics device_agent capability handler. + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(CapabilityResponse{ + RequestID: "req_x", + OK: false, + Error: "binary not whitelisted", + }) + })) + defer srv.Close() + + c := NewClient(srv.URL) + resp, err := c.Call(context.Background(), CapabilityRequest{Capability: "shell.exec"}) + if err != nil { + t.Fatalf("expected nil error (body parseable), got: %v", err) + } + if resp.OK { + t.Errorf("expected ok=false") + } + if resp.Error == "" { + t.Errorf("expected error message populated") + } +} + +func TestClient_Call_HTTPErrorWithUnparseableBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("nginx html garbage")) + })) + defer srv.Close() + + c := NewClient(srv.URL) + _, err := c.Call(context.Background(), CapabilityRequest{Capability: "shell.exec"}) + if err == nil { + t.Fatalf("expected error for unparseable 502 body") + } +} + +func TestClient_Call_ContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) + })) + defer srv.Close() + + c := NewClient(srv.URL) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := c.Call(ctx, CapabilityRequest{Capability: "shell.exec"}) + if err == nil { + t.Fatalf("expected timeout error, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "deadline") && !strings.Contains(err.Error(), "context") { + t.Errorf("expected context-related error, got: %v", err) + } +} + +func TestClient_Call_RejectsEmptyCapability(t *testing.T) { + c := NewClient("http://nowhere.invalid") + _, err := c.Call(context.Background(), CapabilityRequest{}) + if err == nil { + t.Fatalf("expected error for empty capability") + } + if !strings.Contains(err.Error(), "capability") { + t.Errorf("expected capability-related error, got: %v", err) + } +} + +func TestClient_Call_RejectsEmptyBaseURL(t *testing.T) { + c := &Client{} + _, err := c.Call(context.Background(), CapabilityRequest{Capability: "shell.exec"}) + if err == nil { + t.Fatalf("expected error for empty BaseURL") + } +} + +func TestClient_Health(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Errorf("expected /health, got %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "device_id": "home-wsl", + "version": "0.2.0", + }) + })) + defer srv.Close() + + c := NewClient(srv.URL) + id, v, err := c.Health(context.Background()) + if err != nil { + t.Fatalf("health: %v", err) + } + if id != "home-wsl" { + t.Errorf("device_id mismatch: %q", id) + } + if v != "0.2.0" { + t.Errorf("version mismatch: %q", v) + } +} + +func TestClient_Call_NoRetry(t *testing.T) { + // Confirm that a single failure does NOT trigger a retry — POC behavior + // per the README. The handler counts hits. + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("oops")) + })) + defer srv.Close() + + c := NewClient(srv.URL) + _, _ = c.Call(context.Background(), CapabilityRequest{Capability: "shell.exec"}) + if hits != 1 { + t.Errorf("expected exactly 1 hit (no retry), got %d", hits) + } +} + +func TestRandomRequestID_UniqueAndPrefixed(t *testing.T) { + a, err := randomRequestID() + if err != nil { + t.Fatalf("randomRequestID: %v", err) + } + b, err := randomRequestID() + if err != nil { + t.Fatalf("randomRequestID: %v", err) + } + if a == b { + t.Errorf("collision: %q == %q", a, b) + } + if !strings.HasPrefix(a, "req_") { + t.Errorf("missing req_ prefix: %q", a) + } +} diff --git a/pkg/tools/devicemesh/registry_test.go b/pkg/tools/devicemesh/registry_test.go new file mode 100644 index 0000000..dc95599 --- /dev/null +++ b/pkg/tools/devicemesh/registry_test.go @@ -0,0 +1,147 @@ +package devicemesh + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestToolRegistry_RegisterListGet(t *testing.T) { + reg := NewToolRegistry(nil) + reg.Register(ToolSpec{Name: "a", Capability: "x.a"}) + reg.Register(ToolSpec{Name: "b", Capability: "x.b"}) + + got, ok := reg.Get("a") + if !ok { + t.Fatalf("Get(a) not found") + } + if got.Capability != "x.a" { + t.Errorf("capability: %q", got.Capability) + } + + names := reg.Names() + if len(names) != 2 || names[0] != "a" || names[1] != "b" { + t.Errorf("Names sort: %v", names) + } +} + +func TestToolRegistry_Call_UnknownTool(t *testing.T) { + reg := NewToolRegistry(NewClient("http://nowhere.invalid")) + _, err := reg.Call(context.Background(), "no.such.tool", nil) + if err == nil { + t.Fatalf("expected error for unknown tool") + } + if !strings.Contains(err.Error(), "unknown tool") { + t.Errorf("error message: %v", err) + } +} + +func TestToolRegistry_Call_NilClient(t *testing.T) { + reg := NewToolRegistry(nil) + reg.Register(ToolSpec{Name: "x", Capability: "x.y"}) + _, err := reg.Call(context.Background(), "x", nil) + if err == nil { + t.Fatalf("expected error when client is nil") + } +} + +func TestToolRegistry_Call_InvalidInput(t *testing.T) { + reg := NewToolRegistry(NewClient("http://nowhere.invalid")) + reg.Register(ToolSpec{ + Name: "needs_string", + Capability: "x.y", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"foo"}, + "properties": map[string]any{ + "foo": map[string]any{"type": "string"}, + }, + "additionalProperties": false, + }, + }) + + // Missing required + _, err := reg.Call(context.Background(), "needs_string", map[string]any{}) + if err == nil { + t.Errorf("expected error for missing required field") + } + + // Wrong type + _, err = reg.Call(context.Background(), "needs_string", map[string]any{"foo": 42}) + if err == nil { + t.Errorf("expected error for wrong type") + } + + // Extra field + _, err = reg.Call(context.Background(), "needs_string", map[string]any{"foo": "bar", "extra": 1}) + if err == nil { + t.Errorf("expected error for additional property") + } +} + +func TestToolRegistry_Call_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req CapabilityRequest + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + // Echo back the args under "received". + _ = json.NewEncoder(w).Encode(CapabilityResponse{ + RequestID: req.RequestID, + OK: true, + Result: map[string]any{"received": req.Args}, + }) + })) + defer srv.Close() + + reg := NewToolRegistry(NewClient(srv.URL)) + reg.Register(ToolSpec{ + Name: "echo", + Capability: "x.echo", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"msg"}, + "properties": map[string]any{ + "msg": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(in map[string]any) (map[string]any, error) { + return map[string]any{"upper_msg": strings.ToUpper(in["msg"].(string))}, nil + }, + ResultMapping: func(r map[string]any) (any, error) { + received := r["received"].(map[string]any) + return received["upper_msg"], nil + }, + }) + + out, err := reg.Call(context.Background(), "echo", map[string]any{"msg": "hola"}) + if err != nil { + t.Fatalf("call: %v", err) + } + if out != "HOLA" { + t.Errorf("expected HOLA, got %v", out) + } +} + +func TestToolRegistry_Call_DeviceErrorPropagates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(CapabilityResponse{ + OK: false, + Error: "binary not whitelisted", + }) + })) + defer srv.Close() + + reg := NewToolRegistry(NewClient(srv.URL)) + reg.Register(ToolSpec{Name: "exec", Capability: "shell.exec"}) + _, err := reg.Call(context.Background(), "exec", nil) + if err == nil { + t.Fatalf("expected device-side error to propagate") + } + if !strings.Contains(err.Error(), "binary not whitelisted") { + t.Errorf("error message lost: %v", err) + } +} diff --git a/pkg/tools/devicemesh/schema.go b/pkg/tools/devicemesh/schema.go new file mode 100644 index 0000000..54562c7 --- /dev/null +++ b/pkg/tools/devicemesh/schema.go @@ -0,0 +1,244 @@ +package devicemesh + +import ( + "fmt" + "sort" +) + +// schema.go: minimal JSON-Schema-like validator. We do NOT depend on a full +// JSON Schema implementation — the surface we use is small and stable: +// +// - type: "object" | "string" | "number" | "integer" | "boolean" | "array" +// - required: []string (names of fields that must be present and non-nil) +// - properties: map[string] +// - items: for arrays +// - enum: []any — allowed scalar values +// - additionalProperties: false (strict; default true) +// +// This is enough to catch LLM-induced typos (extra fields, wrong types) and +// gives the runtime a place to grow if we need oneOf/pattern later. + +// ValidateInput checks the spec.InputSchema against the provided input map. +// Returns nil on success, a descriptive error otherwise. The error path is +// surfaced back to the LLM so it can self-correct. +func ValidateInput(spec ToolSpec, input map[string]any) error { + if spec.InputSchema == nil { + // No schema means "anything goes". Tools without a schema are rare + // (mostly internal ones like memory.recall in 0144d). + return nil + } + return validateValue("input", input, spec.InputSchema) +} + +func validateValue(path string, value any, schema map[string]any) error { + typ, _ := schema["type"].(string) + if typ == "" { + // No type declared: accept as-is. + return nil + } + + // nil handling: only allowed if the field is not required (handled by parent). + if value == nil { + return fmt.Errorf("%s: expected %s, got null", path, typ) + } + + switch typ { + case "object": + obj, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s: expected object, got %T", path, value) + } + return validateObject(path, obj, schema) + case "array": + arr, ok := coerceToAnySlice(value) + if !ok { + return fmt.Errorf("%s: expected array, got %T", path, value) + } + return validateArray(path, arr, schema) + case "string": + if _, ok := value.(string); !ok { + return fmt.Errorf("%s: expected string, got %T", path, value) + } + return validateEnum(path, value, schema) + case "integer": + if !isInteger(value) { + return fmt.Errorf("%s: expected integer, got %T (%v)", path, value, value) + } + return validateEnum(path, value, schema) + case "number": + if !isNumber(value) { + return fmt.Errorf("%s: expected number, got %T", path, value) + } + return validateEnum(path, value, schema) + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("%s: expected boolean, got %T", path, value) + } + default: + return fmt.Errorf("%s: unknown schema type %q", path, typ) + } + return nil +} + +func validateObject(path string, obj map[string]any, schema map[string]any) error { + // Required fields must be present and non-nil. + if reqRaw, ok := schema["required"]; ok { + req, _ := asStringSlice(reqRaw) + // Deterministic ordering of errors helps tests and LLM correction. + sort.Strings(req) + for _, name := range req { + v, present := obj[name] + if !present || v == nil { + return fmt.Errorf("%s.%s: required field missing", path, name) + } + } + } + props, _ := schema["properties"].(map[string]any) + + // Strict additionalProperties: reject unknown keys when explicitly false. + additional := true + if ap, ok := schema["additionalProperties"]; ok { + if b, isBool := ap.(bool); isBool { + additional = b + } + } + if !additional && props != nil { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if _, known := props[k]; !known { + return fmt.Errorf("%s.%s: unknown field (additionalProperties=false)", path, k) + } + } + } + + if props == nil { + return nil + } + // Walk known properties. + names := make([]string, 0, len(props)) + for k := range props { + names = append(names, k) + } + sort.Strings(names) + for _, name := range names { + sub, _ := props[name].(map[string]any) + if sub == nil { + continue + } + v, present := obj[name] + if !present { + continue // absent + not required ⇒ ok + } + if v == nil { + continue // nil + not required ⇒ ok + } + if err := validateValue(path+"."+name, v, sub); err != nil { + return err + } + } + return nil +} + +func validateArray(path string, arr []any, schema map[string]any) error { + itemSchema, _ := schema["items"].(map[string]any) + if itemSchema == nil { + return nil + } + for i, v := range arr { + if err := validateValue(fmt.Sprintf("%s[%d]", path, i), v, itemSchema); err != nil { + return err + } + } + return nil +} + +func validateEnum(path string, value any, schema map[string]any) error { + enumRaw, ok := schema["enum"] + if !ok { + return nil + } + enum, _ := enumRaw.([]any) + if len(enum) == 0 { + return nil + } + for _, allowed := range enum { + if fmt.Sprint(allowed) == fmt.Sprint(value) { + return nil + } + } + return fmt.Errorf("%s: value %v not in enum %v", path, value, enum) +} + +func isInteger(v any) bool { + switch n := v.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float32: + return float64(n) == float64(int64(n)) + case float64: + return n == float64(int64(n)) + } + return false +} + +func isNumber(v any) bool { + switch v.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return true + } + return false +} + +// coerceToAnySlice accepts []any or any typed slice ([]string, []int, ...) +// and returns it as []any. This keeps the schema validator forgiving when +// callers pass native Go slices directly (common in tests and ArgMapping +// outputs) instead of JSON-decoded []any. +func coerceToAnySlice(v any) ([]any, bool) { + switch s := v.(type) { + case []any: + return s, true + case []string: + out := make([]any, len(s)) + for i, e := range s { + out[i] = e + } + return out, true + case []int: + out := make([]any, len(s)) + for i, e := range s { + out[i] = e + } + return out, true + case []float64: + out := make([]any, len(s)) + for i, e := range s { + out[i] = e + } + return out, true + } + return nil, false +} + +func asStringSlice(v any) ([]string, bool) { + switch s := v.(type) { + case []string: + out := make([]string, len(s)) + copy(out, s) + return out, true + case []any: + out := make([]string, 0, len(s)) + for _, e := range s { + str, ok := e.(string) + if !ok { + return nil, false + } + out = append(out, str) + } + return out, true + } + return nil, false +} diff --git a/pkg/tools/devicemesh/tools_builtin.go b/pkg/tools/devicemesh/tools_builtin.go new file mode 100644 index 0000000..cb27e89 --- /dev/null +++ b/pkg/tools/devicemesh/tools_builtin.go @@ -0,0 +1,775 @@ +package devicemesh + +import ( + "fmt" + "strings" +) + +// tools_builtin.go: declarative catalog of the standard tools an LLM agent +// gets when its config enables device_mesh. The list mirrors issue 0144 §2.1. +// +// Each ToolSpec is pure data: descriptions for the LLM, JSON-Schema-lite for +// validation, and pure ArgMapping / ResultMapping functions. No I/O. +// +// Mode "user" registers the tools allowed for the unprivileged agent (uid +// lucas in home-wsl). Mode "sudo" registers tools whose underlying +// capability requires_approval: true on the device_agent side. The +// separation is physical, not just RBAC — the user-agent process literally +// never sees pkg.install in its registry, so prompt injection cannot +// surface it (issue 0144 §1.2). + +// RegistrationMode controls which subset of the built-in catalog is +// registered. "user" gets non-approval tools. "sudo" gets only the approval +// gated tools. "all" gets everything (mainly for tests and tooling). +type RegistrationMode string + +const ( + ModeUser RegistrationMode = "user" + ModeSudo RegistrationMode = "sudo" + ModeAll RegistrationMode = "all" +) + +// RegisterBuiltins registers the standard catalog of devicemesh tools into +// the given registry, filtered by the requested mode. +// +// Returns the list of registered tool names so callers can log it. +// +// shell.eval is a special case: it is always registered in BOTH ModeUser and +// ModeSudo, but the sudo variant is rewritten via withApprovalRequired so the +// LLM sees RequiresApproval=true. The real guardrail (blocklist + +// auto-approve patterns + operator approval) lives in the device_agent — the +// flag here is metadata that drives RBAC at the device_mesh edge. +func RegisterBuiltins(reg *ToolRegistry, mode RegistrationMode) []string { + if reg == nil { + return nil + } + all := builtinSpecs() + registered := make([]string, 0, len(all)) + for _, spec := range all { + switch mode { + case ModeUser: + if spec.RequiresApproval { + continue + } + case ModeSudo: + // In sudo mode, force RequiresApproval=true on shell.eval so the + // metadata exposed to the LLM matches the device manifest. Other + // non-approval tools are skipped (sudo agents only see approval + // gated tools). + if spec.Name == "shell.eval" { + spec = withApprovalRequired(spec) + } else if !spec.RequiresApproval { + continue + } + case ModeAll: + // fallthrough — accept everything + default: + // Unknown mode: behave like "user" (safer default). + if spec.RequiresApproval { + continue + } + } + reg.Register(spec) + registered = append(registered, spec.Name) + } + return registered +} + +// withApprovalRequired returns a clone of spec with RequiresApproval set to +// true. Used to upgrade a tool that defaults to "no approval" (user scope) +// into its sudo equivalent without mutating the original spec returned by +// builtinSpecs(). Pure function — no side effects. +func withApprovalRequired(spec ToolSpec) ToolSpec { + spec.RequiresApproval = true + return spec +} + +// builtinSpecs returns the full catalog (both user and sudo). The split into +// scopes happens in RegisterBuiltins. Defined as a function so future +// builders can compose this with host-specific overrides. +func builtinSpecs() []ToolSpec { + return []ToolSpec{ + execSpec(), + shellEvalSpec(), + fsReadSpec(), + fsWriteSpec(), + fsListSpec(), + fsStatSpec(), + gitCloneSpec(), + gitCommitSpec(), + gitPushSpec(), + pkgInstallSpec(), + pkgSearchSpec(), + procListSpec(), + procKillSpec(), + dockerListSpec(), + dockerExecSpec(), + dockerLogsSpec(), + } +} + +// ----- exec ----- + +func execSpec() ToolSpec { + return ToolSpec{ + Name: "exec", + Description: "Execute a command on the remote device. argv is parsed as exec.Command (NO shell). " + + "Returns stdout, stderr, exit_code, duration_ms. Use this for: listing files, running scripts, " + + "invoking CLIs already installed. Do NOT use this for shell redirection, pipes, or globs.", + Capability: "shell.exec", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"argv"}, + "additionalProperties": false, + "properties": map[string]any{ + "argv": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "cwd": map[string]any{"type": "string"}, + "timeout_s": map[string]any{"type": "integer"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + argv, err := requireStringSlice(input, "argv") + if err != nil { + return nil, err + } + if len(argv) == 0 { + return nil, fmt.Errorf("argv must not be empty") + } + out := map[string]any{"argv": argv} + if cwd, ok := input["cwd"].(string); ok && cwd != "" { + out["cwd"] = cwd + } + if timeout, ok := input["timeout_s"]; ok { + out["timeout_s"] = toInt(timeout, 30) + } + return out, nil + }, + ResultMapping: func(result map[string]any) (any, error) { + // Pass through but normalize: ensure exit_code is int. + if result == nil { + return map[string]any{ + "stdout": "", + "stderr": "", + "exit_code": 0, + }, nil + } + out := map[string]any{ + "stdout": getString(result, "stdout"), + "stderr": getString(result, "stderr"), + "exit_code": toInt(result["exit_code"], 0), + } + if dur, ok := result["duration_ms"]; ok { + out["duration_ms"] = toInt(dur, 0) + } + return out, nil + }, + } +} + +// ----- shell.eval ----- + +// shellEvalSpec is the "powerful tool": a free-form shell command evaluator. +// Unlike exec (positional argv, no shell), shell.eval accepts a single string +// passed verbatim to bash or powershell on the device. +// +// Its existence is justified because no structured tool can cover every legal +// shell idiom (pipes, redirects, here-docs, $() expansions, complex globs). +// Without it the LLM resorts to multi-step exec chains and loses fidelity. +// +// Safety: this tool's RequiresApproval default is false in ModeUser. The real +// guardrails live device-side: +// +// - Hardcoded blocklist (rm -rf /, dd, mkfs, fork-bombs, shutdown, ...) +// always rejects regardless of agent or operator. +// - Auto-approve whitelist ('^git ', '^ls ', '^cat ', ...) bypasses the +// operator and executes directly. +// - Anything else returns approval_status='queued' and waits for the +// operator to confirm in #operator-approvals. +// +// For sudo agents, RegisterBuiltins promotes RequiresApproval=true via +// withApprovalRequired so the LLM-facing metadata matches the device manifest. +func shellEvalSpec() ToolSpec { + return ToolSpec{ + Name: "shell.eval", + Description: "Evaluate a free-form shell command on the device. Auto-detects bash (Linux/WSL) or powershell (Windows). " + + "Hardcoded safety blocklist applies (rm -rf /, dd, mkfs, fork-bombs, shutdown, etc.) — these always reject. " + + "Auto-approve patterns ('^git ', '^ls ', '^cat ', etc.) execute directly. Other commands may require operator " + + "approval (returns approval_status='queued' and the operator must confirm in Element).", + Capability: "shell.eval", + // RequiresApproval is false here so user mode picks it up. Sudo mode + // rewrites this via withApprovalRequired in RegisterBuiltins. + RequiresApproval: false, + InputSchema: map[string]any{ + "type": "object", + "required": []string{"cmd"}, + "additionalProperties": false, + "properties": map[string]any{ + "cmd": map[string]any{ + "type": "string", + "description": "Shell command string. Bash or PowerShell syntax depending on device OS.", + "minLength": 1, + }, + "shell": map[string]any{ + "type": "string", + "enum": []any{"auto", "bash", "powershell"}, + "description": "Force shell. 'auto' (default) picks by device OS.", + }, + "cwd": map[string]any{ + "type": "string", + "description": "Optional absolute path to run from.", + }, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + cmd, err := requireString(input, "cmd") + if err != nil { + return nil, err + } + if cmd == "" { + return nil, fmt.Errorf("cmd must not be empty") + } + out := map[string]any{"cmd": cmd} + if s, ok := input["shell"].(string); ok && s != "" { + out["shell"] = s + } + if c, ok := input["cwd"].(string); ok && c != "" { + out["cwd"] = c + } + return out, nil + }, + ResultMapping: func(result map[string]any) (any, error) { + // Pass result through — the LLM sees fields like stdout, stderr, + // exit_code, approval_status, cmd_executed, truncated, duration_ms + // as the device_agent returns them. No normalization here because + // the device contract is richer than exec (approval_status etc.) + // and we do not want to drop fields the device may add later. + if result == nil { + return map[string]any{}, nil + } + return result, nil + }, + } +} + +// ----- fs.read ----- + +func fsReadSpec() ToolSpec { + return ToolSpec{ + Name: "fs.read", + Description: "Read a file on the remote device. Returns content_b64 (base64) or content (utf8), " + + "size, mtime. Use max_bytes to cap large files.", + Capability: "fs.read", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"path"}, + "additionalProperties": false, + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + "max_bytes": map[string]any{"type": "integer"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + path, err := requireString(input, "path") + if err != nil { + return nil, err + } + out := map[string]any{"path": path} + if mb, ok := input["max_bytes"]; ok { + out["max_bytes"] = toInt(mb, 0) + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- fs.write ----- + +func fsWriteSpec() ToolSpec { + return ToolSpec{ + Name: "fs.write", + Description: "Write a file on the remote device. Creates parent dirs if missing. Overwrites if " + + "the file exists. Use content_b64 for binary; use content for utf8. Optional mode (octal int).", + Capability: "fs.write", + // fs.write to system paths requires_approval is enforced device-side by + // the manifest. The tool itself is registered for both modes. + InputSchema: map[string]any{ + "type": "object", + "required": []string{"path"}, + "additionalProperties": false, + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + "content": map[string]any{"type": "string"}, + "content_b64": map[string]any{"type": "string"}, + "mode": map[string]any{"type": "integer"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + path, err := requireString(input, "path") + if err != nil { + return nil, err + } + content, hasContent := input["content"].(string) + contentB64, hasB64 := input["content_b64"].(string) + if !hasContent && !hasB64 { + return nil, fmt.Errorf("fs.write requires content or content_b64") + } + out := map[string]any{"path": path} + if hasContent { + out["content"] = content + } + if hasB64 { + out["content_b64"] = contentB64 + } + if mode, ok := input["mode"]; ok { + out["mode"] = toInt(mode, 0) + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- fs.list ----- + +func fsListSpec() ToolSpec { + return ToolSpec{ + Name: "fs.list", + Description: "List a directory on the remote device. Returns entries: [{name, kind, size, mtime}]. Optional glob filter.", + Capability: "fs.list", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"dir"}, + "additionalProperties": false, + "properties": map[string]any{ + "dir": map[string]any{"type": "string"}, + "glob": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + dir, err := requireString(input, "dir") + if err != nil { + return nil, err + } + out := map[string]any{"dir": dir} + if glob, ok := input["glob"].(string); ok && glob != "" { + out["glob"] = glob + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- fs.stat ----- + +func fsStatSpec() ToolSpec { + return ToolSpec{ + Name: "fs.stat", + Description: "Stat a file or dir on the remote device. Returns kind, size, mtime, mode.", + Capability: "fs.stat", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"path"}, + "additionalProperties": false, + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + path, err := requireString(input, "path") + if err != nil { + return nil, err + } + return map[string]any{"path": path}, nil + }, + ResultMapping: passthrough, + } +} + +// ----- git.clone ----- + +func gitCloneSpec() ToolSpec { + return ToolSpec{ + Name: "git.clone", + Description: "Clone a git repository on the remote device. Returns commit_sha and branch.", + Capability: "git.clone", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"url", "dest"}, + "additionalProperties": false, + "properties": map[string]any{ + "url": map[string]any{"type": "string"}, + "dest": map[string]any{"type": "string"}, + "branch": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + url, err := requireString(input, "url") + if err != nil { + return nil, err + } + dest, err := requireString(input, "dest") + if err != nil { + return nil, err + } + out := map[string]any{"url": url, "dest": dest} + if branch, ok := input["branch"].(string); ok && branch != "" { + out["branch"] = branch + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- git.commit ----- + +func gitCommitSpec() ToolSpec { + return ToolSpec{ + Name: "git.commit", + Description: "Stage and commit changes in a repo on the remote device. Stages all changes by " + + "default; pass files: [\"a\",\"b\"] to stage a subset. Returns commit_sha.", + Capability: "git.commit", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"repo", "message"}, + "additionalProperties": false, + "properties": map[string]any{ + "repo": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string"}, + "files": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + repo, err := requireString(input, "repo") + if err != nil { + return nil, err + } + msg, err := requireString(input, "message") + if err != nil { + return nil, err + } + out := map[string]any{"repo": repo, "message": msg} + if files, ok := input["files"]; ok { + if slice, e := asStringSliceLoose(files); e == nil && len(slice) > 0 { + out["files"] = slice + } + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- git.push ----- + +func gitPushSpec() ToolSpec { + return ToolSpec{ + Name: "git.push", + Description: "Push the current branch of a repo. Optional remote (default origin) and branch (default current).", + Capability: "git.push", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"repo"}, + "additionalProperties": false, + "properties": map[string]any{ + "repo": map[string]any{"type": "string"}, + "remote": map[string]any{"type": "string"}, + "branch": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + repo, err := requireString(input, "repo") + if err != nil { + return nil, err + } + out := map[string]any{"repo": repo} + if r, ok := input["remote"].(string); ok && r != "" { + out["remote"] = r + } + if b, ok := input["branch"].(string); ok && b != "" { + out["branch"] = b + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- pkg.install ----- + +func pkgInstallSpec() ToolSpec { + return ToolSpec{ + Name: "pkg.install", + Description: "Install an OS package (apt/dnf/pacman depending on host). Requires approval — the " + + "operator must accept the action in #operator-approvals before it executes.", + Capability: "pkg.install", + RequiresApproval: true, + InputSchema: map[string]any{ + "type": "object", + "required": []string{"name"}, + "additionalProperties": false, + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + name, err := requireString(input, "name") + if err != nil { + return nil, err + } + return map[string]any{"name": name}, nil + }, + ResultMapping: passthrough, + } +} + +// ----- pkg.search ----- + +func pkgSearchSpec() ToolSpec { + return ToolSpec{ + Name: "pkg.search", + Description: "Search the OS package cache. No install. Returns matching packages.", + Capability: "pkg.search", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"query"}, + "additionalProperties": false, + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + q, err := requireString(input, "query") + if err != nil { + return nil, err + } + return map[string]any{"query": q}, nil + }, + ResultMapping: passthrough, + } +} + +// ----- proc.list ----- + +func procListSpec() ToolSpec { + return ToolSpec{ + Name: "proc.list", + Description: "List processes on the remote device. Optional filters: user, name_like.", + Capability: "proc.list", + InputSchema: map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "user": map[string]any{"type": "string"}, + "name_like": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + out := map[string]any{} + if u, ok := input["user"].(string); ok && u != "" { + out["user"] = u + } + if n, ok := input["name_like"].(string); ok && n != "" { + out["name_like"] = n + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- proc.kill ----- + +func procKillSpec() ToolSpec { + return ToolSpec{ + Name: "proc.kill", + Description: "Send a signal to a process. Signal default TERM. Killing destructive signals on " + + "processes owned by another uid requires approval.", + Capability: "proc.kill", + RequiresApproval: true, + InputSchema: map[string]any{ + "type": "object", + "required": []string{"pid"}, + "additionalProperties": false, + "properties": map[string]any{ + "pid": map[string]any{"type": "integer"}, + "signal": map[string]any{"type": "string"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + pidRaw, ok := input["pid"] + if !ok { + return nil, fmt.Errorf("proc.kill: pid is required") + } + out := map[string]any{"pid": toInt(pidRaw, 0)} + if sig, ok := input["signal"].(string); ok && sig != "" { + out["signal"] = strings.ToUpper(sig) + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- docker.list ----- + +func dockerListSpec() ToolSpec { + return ToolSpec{ + Name: "docker.list", + Description: "List Docker containers on the remote device. Pass all=true to include stopped.", + Capability: "docker.container.list", + InputSchema: map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "all": map[string]any{"type": "boolean"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + out := map[string]any{} + if all, ok := input["all"].(bool); ok { + out["all"] = all + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- docker.exec ----- + +func dockerExecSpec() ToolSpec { + return ToolSpec{ + Name: "docker.exec", + Description: "Exec a command in a Docker container. argv is a string list (no shell).", + Capability: "docker.container.exec", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"container", "argv"}, + "additionalProperties": false, + "properties": map[string]any{ + "container": map[string]any{"type": "string"}, + "argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + container, err := requireString(input, "container") + if err != nil { + return nil, err + } + argv, err := requireStringSlice(input, "argv") + if err != nil { + return nil, err + } + if len(argv) == 0 { + return nil, fmt.Errorf("argv must not be empty") + } + return map[string]any{"container": container, "argv": argv}, nil + }, + ResultMapping: passthrough, + } +} + +// ----- docker.logs ----- + +func dockerLogsSpec() ToolSpec { + return ToolSpec{ + Name: "docker.logs", + Description: "Read the last N lines of a Docker container's logs.", + Capability: "docker.container.logs", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"container"}, + "additionalProperties": false, + "properties": map[string]any{ + "container": map[string]any{"type": "string"}, + "tail": map[string]any{"type": "integer"}, + }, + }, + ArgMapping: func(input map[string]any) (map[string]any, error) { + container, err := requireString(input, "container") + if err != nil { + return nil, err + } + out := map[string]any{"container": container} + if t, ok := input["tail"]; ok { + out["tail"] = toInt(t, 100) + } + return out, nil + }, + ResultMapping: passthrough, + } +} + +// ----- helpers ----- + +func passthrough(result map[string]any) (any, error) { return result, nil } + +func requireString(input map[string]any, key string) (string, error) { + v, ok := input[key] + if !ok || v == nil { + return "", fmt.Errorf("%s is required", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string, got %T", key, v) + } + return s, nil +} + +func requireStringSlice(input map[string]any, key string) ([]string, error) { + v, ok := input[key] + if !ok || v == nil { + return nil, fmt.Errorf("%s is required", key) + } + return asStringSliceLoose(v) +} + +func asStringSliceLoose(v any) ([]string, error) { + switch s := v.(type) { + case []string: + out := make([]string, len(s)) + copy(out, s) + return out, nil + case []any: + out := make([]string, 0, len(s)) + for i, e := range s { + str, ok := e.(string) + if !ok { + return nil, fmt.Errorf("index %d: expected string, got %T", i, e) + } + out = append(out, str) + } + return out, nil + } + return nil, fmt.Errorf("expected array of strings, got %T", v) +} + +func getString(m map[string]any, key string) string { + if m == nil { + return "" + } + s, _ := m[key].(string) + return s +} + +func toInt(v any, def int) int { + switch n := v.(type) { + case int: + return n + case int32: + return int(n) + case int64: + return int(n) + case float32: + return int(n) + case float64: + return int(n) + } + return def +} diff --git a/pkg/tools/devicemesh/tools_builtin_test.go b/pkg/tools/devicemesh/tools_builtin_test.go new file mode 100644 index 0000000..68d9310 --- /dev/null +++ b/pkg/tools/devicemesh/tools_builtin_test.go @@ -0,0 +1,430 @@ +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") + } +} diff --git a/pkg/tools/devicemesh/types.go b/pkg/tools/devicemesh/types.go new file mode 100644 index 0000000..d41601c --- /dev/null +++ b/pkg/tools/devicemesh/types.go @@ -0,0 +1,178 @@ +package devicemesh + +import ( + "context" + "fmt" + "sort" + "sync" +) + +// ToolSpec describes a single tool exposed to the LLM. It mirrors the +// agents_and_robots tool pattern (`tools.Def` + `tools.Tool`) but pinned to +// the device mesh transport: every tool maps to exactly one capability of a +// remote device_agent, with a deterministic input/output mapping. +// +// Fields: +// +// - Name: the dotted name exposed to the LLM ("exec", "fs.read", ...). +// - Description: shown to the LLM. Tells it WHEN to use the tool, NOT how. +// - InputSchema: a minimal JSON-Schema-like map. Used by ValidateInput to +// reject malformed args before they hit the network. See schema.go. +// - Capability: the device_agent capability id ("shell.exec", "fs.read"). +// - ArgMapping: pure transform from tool input (LLM-facing) to capability +// args (device-facing). Defaults to identity if nil. +// - ResultMapping: pure transform from capability result (raw map) to the +// tool output the LLM sees. Defaults to passthrough if nil. +// - RequiresApproval: whether the underlying capability requires the +// human-in-the-loop approval flow on the device_agent side. Used by +// RegisterBuiltins to decide which tools belong to the user vs sudo +// agent registry. This field is metadata; the actual approval gate +// lives in the device_agent manifest (see issue 0144 §3). +type ToolSpec struct { + Name string + Description string + InputSchema map[string]any + Capability string + ArgMapping func(input map[string]any) (map[string]any, error) + ResultMapping func(result map[string]any) (any, error) + RequiresApproval bool +} + +// ToolRegistry holds the set of tools the LLM can invoke via the device mesh. +// One registry per agent process. Lookups are by tool name. +// +// Thread-safe for read while Register may run concurrently — the agent +// runtime registers all tools at startup, but tests do it incrementally. +type ToolRegistry struct { + mu sync.RWMutex + client *Client + tools map[string]ToolSpec +} + +// NewToolRegistry builds an empty registry bound to a Client. The client is +// what tools use to dispatch; it's stored once so tools don't have to know +// about the transport. +func NewToolRegistry(client *Client) *ToolRegistry { + return &ToolRegistry{ + client: client, + tools: make(map[string]ToolSpec), + } +} + +// Register adds or replaces a tool spec. Replacing is allowed by design so +// the agent runtime can override built-ins from config (ex add a custom +// ResultMapping for a host-specific tool). +func (r *ToolRegistry) Register(spec ToolSpec) { + r.mu.Lock() + defer r.mu.Unlock() + r.tools[spec.Name] = spec +} + +// Get returns the ToolSpec for a name. Second return is false when unknown. +func (r *ToolRegistry) Get(name string) (ToolSpec, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + spec, ok := r.tools[name] + return spec, ok +} + +// List returns all registered tool specs sorted by Name. Sort is alpha to +// give the LLM a stable order across turns (useful for prompt caching). +func (r *ToolRegistry) List() []ToolSpec { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]ToolSpec, 0, len(r.tools)) + for _, t := range r.tools { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Len returns the number of registered tools. Useful for logging and +// for callers that want to short-circuit when the registry is empty. +func (r *ToolRegistry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.tools) +} + +// Names returns the sorted list of registered tool names. +func (r *ToolRegistry) Names() []string { + specs := r.List() + out := make([]string, len(specs)) + for i, s := range specs { + out[i] = s.Name + } + return out +} + +// Client returns the bound Client. Useful for tools that compose multiple +// capability calls (project.create, future work in 0144e). +func (r *ToolRegistry) Client() *Client { return r.client } + +// Call resolves a tool by name, validates its input, maps it to a capability +// envelope, dispatches via the bound Client, and returns the mapped result. +// +// The caller is the LLM tool-use loop in the agent runtime. The registry is +// the single entry point for tool invocations so we have one place to plug +// in audit, metrics, retries, etc. +func (r *ToolRegistry) Call(ctx context.Context, toolName string, input map[string]any) (any, error) { + if r == nil { + return nil, fmt.Errorf("devicemesh.ToolRegistry: nil receiver") + } + spec, ok := r.Get(toolName) + if !ok { + return nil, fmt.Errorf("devicemesh: unknown tool %q", toolName) + } + if input == nil { + input = map[string]any{} + } + if err := ValidateInput(spec, input); err != nil { + return nil, fmt.Errorf("devicemesh: invalid input for %q: %w", toolName, err) + } + + // Map LLM-facing input → device-facing args. + var args map[string]any + if spec.ArgMapping != nil { + mapped, err := spec.ArgMapping(input) + if err != nil { + return nil, fmt.Errorf("devicemesh: arg mapping for %q: %w", toolName, err) + } + args = mapped + } else { + args = input + } + + if r.client == nil { + return nil, fmt.Errorf("devicemesh: registry has no Client (cannot dispatch %q)", toolName) + } + + resp, err := r.client.Call(ctx, CapabilityRequest{ + Capability: spec.Capability, + Args: args, + }) + if err != nil { + return nil, fmt.Errorf("devicemesh: dispatch %q: %w", toolName, err) + } + if !resp.OK { + // Surface the device-side error as a plain Go error. The runner is + // in charge of formatting this back to the LLM as a tool result with + // non-zero status; we don't fabricate fake output here. + errMsg := resp.Error + if errMsg == "" { + errMsg = "capability returned ok=false with no error message" + } + return nil, fmt.Errorf("devicemesh: %s: %s", spec.Capability, errMsg) + } + + // Map device result → LLM-facing output. + if spec.ResultMapping != nil { + mapped, err := spec.ResultMapping(resp.Result) + if err != nil { + return nil, fmt.Errorf("devicemesh: result mapping for %q: %w", toolName, err) + } + return mapped, nil + } + return resp.Result, nil +} diff --git a/shell/effects/runner.go b/shell/effects/runner.go index c0b7ff6..adb4ebc 100644 --- a/shell/effects/runner.go +++ b/shell/effects/runner.go @@ -3,15 +3,27 @@ package effects import ( "context" + "encoding/json" "fmt" "log/slog" "time" "github.com/enmanuel/agents/pkg/decision" + "github.com/enmanuel/agents/pkg/tools/devicemesh" "github.com/enmanuel/agents/shell/logger" "github.com/enmanuel/agents/shell/ssh" ) +// DeviceMeshCaller is the minimal interface that the Runner needs from a +// devicemesh.ToolRegistry. It is an interface (rather than a concrete type) +// so tests can mock without spinning up an HTTP server. +type DeviceMeshCaller interface { + Call(ctx context.Context, toolName string, input map[string]any) (any, error) +} + +// Compile-time check: the real registry satisfies the interface. +var _ DeviceMeshCaller = (*devicemesh.ToolRegistry)(nil) + // Result holds the outcome of executing a single action. type Result struct { Action decision.Action @@ -32,16 +44,27 @@ type MatrixSender interface { // Runner interprets actions and executes them. type Runner struct { - matrix MatrixSender - ssh *ssh.Executor - logger *slog.Logger + matrix MatrixSender + ssh *ssh.Executor + deviceMesh DeviceMeshCaller + logger *slog.Logger } // NewRunner creates a Runner with the provided dependencies. +// The device mesh tool registry is left nil; ActionKindDeviceMesh actions +// will be rejected with a clear error. Use NewRunnerWithDeviceMesh to wire +// the mesh caller. func NewRunner(matrix MatrixSender, ssh *ssh.Executor, logger *slog.Logger) *Runner { return &Runner{matrix: matrix, ssh: ssh, logger: logger} } +// NewRunnerWithDeviceMesh wires a Runner with a DeviceMeshCaller, enabling +// ActionKindDeviceMesh dispatch. Used by the launcher when an agent has +// cfg.DeviceMesh.Enabled = true (wiring lives in 0144c). +func NewRunnerWithDeviceMesh(matrix MatrixSender, ssh *ssh.Executor, dm DeviceMeshCaller, logger *slog.Logger) *Runner { + return &Runner{matrix: matrix, ssh: ssh, deviceMesh: dm, logger: logger} +} + // Execute runs each action sequentially and returns results. func (r *Runner) Execute(ctx context.Context, roomID string, actions []decision.Action) []Result { r.logger.Debug("effects_batch", "room", roomID, "count", len(actions)) @@ -89,7 +112,36 @@ func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Actio } return Result{Action: a, Output: output, Err: res.Err} + case decision.ActionKindDeviceMesh: + if a.DeviceMesh == nil { + return Result{Action: a, Err: fmt.Errorf("nil device_mesh action")} + } + if r.deviceMesh == nil { + return Result{Action: a, Err: fmt.Errorf("device_mesh action received but Runner has no DeviceMeshCaller (build with NewRunnerWithDeviceMesh)")} + } + result, err := r.deviceMesh.Call(ctx, a.DeviceMesh.Tool, a.DeviceMesh.Input) + output := formatDeviceMeshResult(result) + return Result{Action: a, Output: output, Err: err} + default: return Result{Action: a, Err: fmt.Errorf("unhandled action kind: %s", a.Kind)} } } + +// formatDeviceMeshResult renders the tool result as a stable JSON string +// suitable for embedding in a tool_result message to the LLM. Errors during +// marshaling collapse to a printable Go representation — never panic, never +// drop data on the floor. +func formatDeviceMeshResult(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} diff --git a/shell/effects/runner_devicemesh_test.go b/shell/effects/runner_devicemesh_test.go new file mode 100644 index 0000000..1d0fdac --- /dev/null +++ b/shell/effects/runner_devicemesh_test.go @@ -0,0 +1,101 @@ +package effects + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + + "github.com/enmanuel/agents/pkg/decision" +) + +// stubMeshCaller is a minimal DeviceMeshCaller for runner tests. +type stubMeshCaller struct { + tool string + input map[string]any + result any + err error +} + +func (s *stubMeshCaller) Call(_ context.Context, toolName string, input map[string]any) (any, error) { + s.tool = toolName + s.input = input + return s.result, s.err +} + +func newSilentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestRunner_DeviceMesh_Success(t *testing.T) { + stub := &stubMeshCaller{result: map[string]any{"stdout": "hello", "exit_code": 0}} + r := NewRunnerWithDeviceMesh(nil, nil, stub, newSilentLogger()) + + results := r.Execute(context.Background(), "!room", []decision.Action{{ + Kind: decision.ActionKindDeviceMesh, + DeviceMesh: &decision.DeviceMeshAction{ + Tool: "exec", + Input: map[string]any{"argv": []string{"echo", "hello"}}, + }, + }}) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + res := results[0] + if res.Err != nil { + t.Fatalf("expected no error, got %v", res.Err) + } + if stub.tool != "exec" { + t.Errorf("stub.tool=%q", stub.tool) + } + if !strings.Contains(res.Output, "hello") { + t.Errorf("output missing 'hello': %q", res.Output) + } + if !strings.Contains(res.Output, "exit_code") { + t.Errorf("output should be JSON containing exit_code: %q", res.Output) + } +} + +func TestRunner_DeviceMesh_PropagatesError(t *testing.T) { + stub := &stubMeshCaller{err: errors.New("approval timeout")} + r := NewRunnerWithDeviceMesh(nil, nil, stub, newSilentLogger()) + results := r.Execute(context.Background(), "!room", []decision.Action{{ + Kind: decision.ActionKindDeviceMesh, + DeviceMesh: &decision.DeviceMeshAction{Tool: "pkg.install", Input: map[string]any{"name": "jq"}}, + }}) + if results[0].Err == nil { + t.Fatalf("expected error to propagate") + } + if !strings.Contains(results[0].Err.Error(), "approval") { + t.Errorf("error mismatch: %v", results[0].Err) + } +} + +func TestRunner_DeviceMesh_NilAction(t *testing.T) { + r := NewRunnerWithDeviceMesh(nil, nil, &stubMeshCaller{}, newSilentLogger()) + results := r.Execute(context.Background(), "!room", []decision.Action{{ + Kind: decision.ActionKindDeviceMesh, + // DeviceMesh field is nil + }}) + if results[0].Err == nil { + t.Fatalf("expected error for nil DeviceMesh field") + } +} + +func TestRunner_DeviceMesh_NoCaller(t *testing.T) { + // Using NewRunner (legacy) — should fail gracefully on DeviceMesh action. + r := NewRunner(nil, nil, newSilentLogger()) + results := r.Execute(context.Background(), "!room", []decision.Action{{ + Kind: decision.ActionKindDeviceMesh, + DeviceMesh: &decision.DeviceMeshAction{Tool: "exec", Input: map[string]any{"argv": []string{"x"}}}, + }}) + if results[0].Err == nil { + t.Fatalf("expected error when Runner has no DeviceMeshCaller") + } + if !strings.Contains(results[0].Err.Error(), "DeviceMeshCaller") { + t.Errorf("error should mention DeviceMeshCaller: %v", results[0].Err) + } +}