Skip to content

Commit 1ed7cd9

Browse files
simonovic86claude
andauthored
feat(runtime): implement HTTP hostcall — agents can call external APIs (#24)
Add http_request hostcall to the capability membrane so agents can make HTTP requests to allowed external services. This is the single feature that makes agents actually useful (Product Phase 2, Task P2-1). Host-side (internal/hostcall/http.go): - ABI: http_request(method, url, headers, body, resp_buf) -> i32 - Security: allowed_hosts enforcement from manifest options - Timeout (10s default), max response size (1MB default) - Two-call pattern: returns -5 with size hint when buffer too small - Eventlog recording for deterministic replay (CM-4) SDK WASM wrapper (sdk/igor/hostcalls_http_wasm.go): - HTTPRequest, HTTPGet, HTTPPost convenience functions - Auto-retry with larger buffer on -5 (response too large) SDK native stubs (sdk/igor/hostcalls_http_stub.go): - Dispatches to MockBackend for native testing Mock support (sdk/igor/mock/mock.go): - HTTPHandler type + SetHTTPHandler for test configuration Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7b5281a commit 1ed7cd9

9 files changed

Lines changed: 662 additions & 8 deletions

File tree

internal/eventlog/eventlog.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
WalletReceiptCount HostcallID = 5
1919
WalletReceipt HostcallID = 6
2020
NodePrice HostcallID = 7
21+
HTTPRequest HostcallID = 8
2122
)
2223

2324
// Entry is a single observation recorded during a tick.

internal/hostcall/http.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package hostcall
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/binary"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"strings"
14+
"time"
15+
16+
"github.com/simonovic86/igor/internal/eventlog"
17+
"github.com/simonovic86/igor/pkg/manifest"
18+
"github.com/tetratelabs/wazero"
19+
"github.com/tetratelabs/wazero/api"
20+
)
21+
22+
const (
23+
// Default limits for HTTP hostcall.
24+
defaultHTTPTimeoutMs = 10_000
25+
defaultMaxResponseBytes = 1 << 20 // 1 MB
26+
maxURLBytes = 8192
27+
maxMethodBytes = 16
28+
maxHeadersBytes = 32768
29+
maxRequestBodyBytes = 1 << 20 // 1 MB
30+
31+
// HTTP hostcall error codes (negative i32).
32+
httpErrNetwork int32 = -1
33+
httpErrInputTooLong int32 = -2
34+
httpErrHostBlocked int32 = -3
35+
httpErrTimeout int32 = -4
36+
httpErrRespTooLarge int32 = -5
37+
)
38+
39+
// HTTPClient is the interface for executing HTTP requests.
40+
// Defaults to http.DefaultClient; override via SetHTTPClient for testing.
41+
type HTTPClient interface {
42+
Do(req *http.Request) (*http.Response, error)
43+
}
44+
45+
// httpParams holds the parsed inputs from WASM memory for an HTTP request.
46+
type httpParams struct {
47+
method string
48+
url string
49+
headers map[string]string
50+
body io.Reader
51+
}
52+
53+
// readHTTPParams reads and validates the HTTP request parameters from WASM memory.
54+
// Returns nil params and an error code on failure.
55+
func readHTTPParams(mem api.Memory,
56+
methodPtr, methodLen, urlPtr, urlLen,
57+
headersPtr, headersLen, bodyPtr, bodyLen uint32,
58+
) (*httpParams, int32) {
59+
if methodLen > maxMethodBytes || urlLen > maxURLBytes ||
60+
headersLen > maxHeadersBytes || bodyLen > maxRequestBodyBytes {
61+
return nil, httpErrInputTooLong
62+
}
63+
64+
methodData, ok := mem.Read(methodPtr, methodLen)
65+
if !ok {
66+
return nil, httpErrNetwork
67+
}
68+
urlData, ok := mem.Read(urlPtr, urlLen)
69+
if !ok {
70+
return nil, httpErrNetwork
71+
}
72+
73+
p := &httpParams{
74+
method: string(methodData),
75+
url: string(urlData),
76+
}
77+
78+
if headersLen > 0 {
79+
headersData, ok := mem.Read(headersPtr, headersLen)
80+
if !ok {
81+
return nil, httpErrNetwork
82+
}
83+
p.headers = parseHeaders(string(headersData))
84+
}
85+
86+
if bodyLen > 0 {
87+
bodyData, ok := mem.Read(bodyPtr, bodyLen)
88+
if !ok {
89+
return nil, httpErrNetwork
90+
}
91+
p.body = bytes.NewReader(bodyData)
92+
}
93+
94+
return p, 0
95+
}
96+
97+
// writeSizeHint writes the response body length to the first 4 bytes of the
98+
// response buffer so the agent can retry with a larger allocation.
99+
func writeSizeHint(mem api.Memory, respPtr, respCap uint32, size int) {
100+
if respCap >= 4 {
101+
sizeBuf := make([]byte, 4)
102+
binary.LittleEndian.PutUint32(sizeBuf, uint32(size))
103+
mem.Write(respPtr, sizeBuf)
104+
}
105+
}
106+
107+
// registerHTTP registers the http_request hostcall on the igor WASM host module.
108+
//
109+
// ABI:
110+
//
111+
// http_request(
112+
// method_ptr, method_len,
113+
// url_ptr, url_len,
114+
// headers_ptr, headers_len,
115+
// body_ptr, body_len,
116+
// resp_ptr, resp_cap
117+
// ) -> i32
118+
//
119+
// Returns HTTP status code (>0) on success, negative error code on failure.
120+
// Response layout: [body_len: 4 bytes LE][body: N bytes].
121+
func (r *Registry) registerHTTP(builder wazero.HostModuleBuilder, capCfg manifest.CapabilityConfig) {
122+
client := r.httpClient
123+
if client == nil {
124+
client = http.DefaultClient
125+
}
126+
127+
allowedHosts := extractAllowedHosts(capCfg)
128+
timeoutMs := extractIntOption(capCfg.Options, "timeout_ms", defaultHTTPTimeoutMs)
129+
maxRespBytes := extractIntOption(capCfg.Options, "max_response_bytes", defaultMaxResponseBytes)
130+
131+
builder.NewFunctionBuilder().
132+
WithFunc(func(ctx context.Context, m api.Module,
133+
methodPtr, methodLen,
134+
urlPtr, urlLen,
135+
headersPtr, headersLen,
136+
bodyPtr, bodyLen,
137+
respPtr, respCap uint32,
138+
) int32 {
139+
params, errCode := readHTTPParams(m.Memory(),
140+
methodPtr, methodLen, urlPtr, urlLen,
141+
headersPtr, headersLen, bodyPtr, bodyLen)
142+
if params == nil {
143+
return errCode
144+
}
145+
146+
if err := checkAllowedHost(params.url, allowedHosts); err != nil {
147+
r.logger.Warn("HTTP request blocked", "url", params.url, "error", err)
148+
return httpErrHostBlocked
149+
}
150+
151+
timeout := time.Duration(timeoutMs) * time.Millisecond
152+
reqCtx, cancel := context.WithTimeout(ctx, timeout)
153+
defer cancel()
154+
155+
req, err := http.NewRequestWithContext(reqCtx, params.method, params.url, params.body)
156+
if err != nil {
157+
r.logger.Error("HTTP request creation failed", "error", err)
158+
return httpErrNetwork
159+
}
160+
for k, v := range params.headers {
161+
req.Header.Set(k, v)
162+
}
163+
164+
resp, err := client.Do(req)
165+
if err != nil {
166+
if reqCtx.Err() != nil {
167+
return httpErrTimeout
168+
}
169+
r.logger.Error("HTTP request failed", "error", err)
170+
return httpErrNetwork
171+
}
172+
defer resp.Body.Close()
173+
174+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxRespBytes)+1))
175+
if err != nil {
176+
r.logger.Error("HTTP response read failed", "error", err)
177+
return httpErrNetwork
178+
}
179+
180+
if len(respBody) > maxRespBytes {
181+
writeSizeHint(m.Memory(), respPtr, respCap, len(respBody))
182+
return httpErrRespTooLarge
183+
}
184+
185+
needed := uint32(4 + len(respBody))
186+
if needed > respCap {
187+
writeSizeHint(m.Memory(), respPtr, respCap, len(respBody))
188+
return httpErrRespTooLarge
189+
}
190+
191+
// Write response: [body_len: 4 bytes LE][body: N bytes].
192+
out := make([]byte, needed)
193+
binary.LittleEndian.PutUint32(out[:4], uint32(len(respBody)))
194+
copy(out[4:], respBody)
195+
if !m.Memory().Write(respPtr, out) {
196+
return httpErrNetwork
197+
}
198+
199+
// Record observation for replay (CM-4).
200+
obsPayload := make([]byte, 4+len(respBody))
201+
binary.LittleEndian.PutUint32(obsPayload[:4], uint32(resp.StatusCode))
202+
copy(obsPayload[4:], respBody)
203+
r.eventLog.Record(eventlog.HTTPRequest, obsPayload)
204+
205+
return int32(resp.StatusCode)
206+
}).
207+
Export("http_request")
208+
}
209+
210+
// extractAllowedHosts reads the allowed_hosts option from the capability config.
211+
func extractAllowedHosts(cfg manifest.CapabilityConfig) []string {
212+
if cfg.Options == nil {
213+
return nil
214+
}
215+
raw, ok := cfg.Options["allowed_hosts"]
216+
if !ok {
217+
return nil
218+
}
219+
slice, ok := raw.([]any)
220+
if !ok {
221+
return nil
222+
}
223+
hosts := make([]string, 0, len(slice))
224+
for _, v := range slice {
225+
if s, ok := v.(string); ok {
226+
hosts = append(hosts, strings.ToLower(s))
227+
}
228+
}
229+
return hosts
230+
}
231+
232+
// extractIntOption reads an integer option with a default fallback.
233+
func extractIntOption(opts map[string]any, key string, defaultVal int) int {
234+
if opts == nil {
235+
return defaultVal
236+
}
237+
raw, ok := opts[key]
238+
if !ok {
239+
return defaultVal
240+
}
241+
switch v := raw.(type) {
242+
case float64:
243+
return int(v) // JSON numbers decode as float64.
244+
case int:
245+
return v
246+
default:
247+
return defaultVal
248+
}
249+
}
250+
251+
// checkAllowedHost validates the request URL against the allowed hosts list.
252+
// If allowedHosts is empty, all hosts are permitted.
253+
func checkAllowedHost(rawURL string, allowedHosts []string) error {
254+
if len(allowedHosts) == 0 {
255+
return nil
256+
}
257+
parsed, err := url.Parse(rawURL)
258+
if err != nil {
259+
return fmt.Errorf("invalid URL: %w", err)
260+
}
261+
host := strings.ToLower(parsed.Hostname())
262+
for _, allowed := range allowedHosts {
263+
if host == allowed {
264+
return nil
265+
}
266+
}
267+
return fmt.Errorf("host %q not in allowed_hosts", host)
268+
}
269+
270+
// parseHeaders parses "Key: Value\n" delimited headers into a map.
271+
func parseHeaders(raw string) map[string]string {
272+
headers := make(map[string]string)
273+
for _, line := range strings.Split(raw, "\n") {
274+
line = strings.TrimSpace(line)
275+
if line == "" {
276+
continue
277+
}
278+
parts := strings.SplitN(line, ":", 2)
279+
if len(parts) != 2 {
280+
continue
281+
}
282+
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
283+
}
284+
return headers
285+
}

0 commit comments

Comments
 (0)