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

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

260 lines
8.4 KiB
Go

// 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] + "..."
}