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