bcd246bf85
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval). RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag. Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction. Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner). Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
260 lines
8.4 KiB
Go
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] + "..."
|
|
}
|