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.
This commit is contained in:
@@ -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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user