diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go index 2985048..f1ff9dd 100644 --- a/internal/eventlog/eventlog.go +++ b/internal/eventlog/eventlog.go @@ -18,6 +18,7 @@ const ( WalletReceiptCount HostcallID = 5 WalletReceipt HostcallID = 6 NodePrice HostcallID = 7 + HTTPRequest HostcallID = 8 ) // Entry is a single observation recorded during a tick. diff --git a/internal/hostcall/http.go b/internal/hostcall/http.go new file mode 100644 index 0000000..bcc8b7e --- /dev/null +++ b/internal/hostcall/http.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hostcall + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/simonovic86/igor/internal/eventlog" + "github.com/simonovic86/igor/pkg/manifest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const ( + // Default limits for HTTP hostcall. + defaultHTTPTimeoutMs = 10_000 + defaultMaxResponseBytes = 1 << 20 // 1 MB + maxURLBytes = 8192 + maxMethodBytes = 16 + maxHeadersBytes = 32768 + maxRequestBodyBytes = 1 << 20 // 1 MB + + // HTTP hostcall error codes (negative i32). + httpErrNetwork int32 = -1 + httpErrInputTooLong int32 = -2 + httpErrHostBlocked int32 = -3 + httpErrTimeout int32 = -4 + httpErrRespTooLarge int32 = -5 +) + +// HTTPClient is the interface for executing HTTP requests. +// Defaults to http.DefaultClient; override via SetHTTPClient for testing. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// httpParams holds the parsed inputs from WASM memory for an HTTP request. +type httpParams struct { + method string + url string + headers map[string]string + body io.Reader +} + +// readHTTPParams reads and validates the HTTP request parameters from WASM memory. +// Returns nil params and an error code on failure. +func readHTTPParams(mem api.Memory, + methodPtr, methodLen, urlPtr, urlLen, + headersPtr, headersLen, bodyPtr, bodyLen uint32, +) (*httpParams, int32) { + if methodLen > maxMethodBytes || urlLen > maxURLBytes || + headersLen > maxHeadersBytes || bodyLen > maxRequestBodyBytes { + return nil, httpErrInputTooLong + } + + methodData, ok := mem.Read(methodPtr, methodLen) + if !ok { + return nil, httpErrNetwork + } + urlData, ok := mem.Read(urlPtr, urlLen) + if !ok { + return nil, httpErrNetwork + } + + p := &httpParams{ + method: string(methodData), + url: string(urlData), + } + + if headersLen > 0 { + headersData, ok := mem.Read(headersPtr, headersLen) + if !ok { + return nil, httpErrNetwork + } + p.headers = parseHeaders(string(headersData)) + } + + if bodyLen > 0 { + bodyData, ok := mem.Read(bodyPtr, bodyLen) + if !ok { + return nil, httpErrNetwork + } + p.body = bytes.NewReader(bodyData) + } + + return p, 0 +} + +// writeSizeHint writes the response body length to the first 4 bytes of the +// response buffer so the agent can retry with a larger allocation. +func writeSizeHint(mem api.Memory, respPtr, respCap uint32, size int) { + if respCap >= 4 { + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(size)) + mem.Write(respPtr, sizeBuf) + } +} + +// registerHTTP registers the http_request hostcall on the igor WASM host module. +// +// ABI: +// +// http_request( +// method_ptr, method_len, +// url_ptr, url_len, +// headers_ptr, headers_len, +// body_ptr, body_len, +// resp_ptr, resp_cap +// ) -> i32 +// +// Returns HTTP status code (>0) on success, negative error code on failure. +// Response layout: [body_len: 4 bytes LE][body: N bytes]. +func (r *Registry) registerHTTP(builder wazero.HostModuleBuilder, capCfg manifest.CapabilityConfig) { + client := r.httpClient + if client == nil { + client = http.DefaultClient + } + + allowedHosts := extractAllowedHosts(capCfg) + timeoutMs := extractIntOption(capCfg.Options, "timeout_ms", defaultHTTPTimeoutMs) + maxRespBytes := extractIntOption(capCfg.Options, "max_response_bytes", defaultMaxResponseBytes) + + builder.NewFunctionBuilder(). + WithFunc(func(ctx context.Context, m api.Module, + methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, respCap uint32, + ) int32 { + params, errCode := readHTTPParams(m.Memory(), + methodPtr, methodLen, urlPtr, urlLen, + headersPtr, headersLen, bodyPtr, bodyLen) + if params == nil { + return errCode + } + + if err := checkAllowedHost(params.url, allowedHosts); err != nil { + r.logger.Warn("HTTP request blocked", "url", params.url, "error", err) + return httpErrHostBlocked + } + + timeout := time.Duration(timeoutMs) * time.Millisecond + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, params.method, params.url, params.body) + if err != nil { + r.logger.Error("HTTP request creation failed", "error", err) + return httpErrNetwork + } + for k, v := range params.headers { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + if reqCtx.Err() != nil { + return httpErrTimeout + } + r.logger.Error("HTTP request failed", "error", err) + return httpErrNetwork + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxRespBytes)+1)) + if err != nil { + r.logger.Error("HTTP response read failed", "error", err) + return httpErrNetwork + } + + if len(respBody) > maxRespBytes { + writeSizeHint(m.Memory(), respPtr, respCap, len(respBody)) + return httpErrRespTooLarge + } + + needed := uint32(4 + len(respBody)) + if needed > respCap { + writeSizeHint(m.Memory(), respPtr, respCap, len(respBody)) + return httpErrRespTooLarge + } + + // Write response: [body_len: 4 bytes LE][body: N bytes]. + out := make([]byte, needed) + binary.LittleEndian.PutUint32(out[:4], uint32(len(respBody))) + copy(out[4:], respBody) + if !m.Memory().Write(respPtr, out) { + return httpErrNetwork + } + + // Record observation for replay (CM-4). + obsPayload := make([]byte, 4+len(respBody)) + binary.LittleEndian.PutUint32(obsPayload[:4], uint32(resp.StatusCode)) + copy(obsPayload[4:], respBody) + r.eventLog.Record(eventlog.HTTPRequest, obsPayload) + + return int32(resp.StatusCode) + }). + Export("http_request") +} + +// extractAllowedHosts reads the allowed_hosts option from the capability config. +func extractAllowedHosts(cfg manifest.CapabilityConfig) []string { + if cfg.Options == nil { + return nil + } + raw, ok := cfg.Options["allowed_hosts"] + if !ok { + return nil + } + slice, ok := raw.([]any) + if !ok { + return nil + } + hosts := make([]string, 0, len(slice)) + for _, v := range slice { + if s, ok := v.(string); ok { + hosts = append(hosts, strings.ToLower(s)) + } + } + return hosts +} + +// extractIntOption reads an integer option with a default fallback. +func extractIntOption(opts map[string]any, key string, defaultVal int) int { + if opts == nil { + return defaultVal + } + raw, ok := opts[key] + if !ok { + return defaultVal + } + switch v := raw.(type) { + case float64: + return int(v) // JSON numbers decode as float64. + case int: + return v + default: + return defaultVal + } +} + +// checkAllowedHost validates the request URL against the allowed hosts list. +// If allowedHosts is empty, all hosts are permitted. +func checkAllowedHost(rawURL string, allowedHosts []string) error { + if len(allowedHosts) == 0 { + return nil + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + host := strings.ToLower(parsed.Hostname()) + for _, allowed := range allowedHosts { + if host == allowed { + return nil + } + } + return fmt.Errorf("host %q not in allowed_hosts", host) +} + +// parseHeaders parses "Key: Value\n" delimited headers into a map. +func parseHeaders(raw string) map[string]string { + headers := make(map[string]string) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return headers +} diff --git a/internal/hostcall/http_test.go b/internal/hostcall/http_test.go new file mode 100644 index 0000000..9946dcd --- /dev/null +++ b/internal/hostcall/http_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hostcall + +import ( + "testing" + + "github.com/simonovic86/igor/pkg/manifest" +) + +func TestExtractAllowedHosts(t *testing.T) { + tests := []struct { + name string + cfg manifest.CapabilityConfig + want []string + }{ + { + name: "nil options", + cfg: manifest.CapabilityConfig{}, + want: nil, + }, + { + name: "no allowed_hosts key", + cfg: manifest.CapabilityConfig{Options: map[string]any{"timeout_ms": 5000}}, + want: nil, + }, + { + name: "valid hosts", + cfg: manifest.CapabilityConfig{ + Options: map[string]any{ + "allowed_hosts": []any{"api.example.com", "httpbin.org"}, + }, + }, + want: []string{"api.example.com", "httpbin.org"}, + }, + { + name: "mixed types filtered", + cfg: manifest.CapabilityConfig{ + Options: map[string]any{ + "allowed_hosts": []any{"valid.com", 123, "also-valid.com"}, + }, + }, + want: []string{"valid.com", "also-valid.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractAllowedHosts(tt.cfg) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("host[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCheckAllowedHost(t *testing.T) { + tests := []struct { + name string + url string + hosts []string + wantErr bool + }{ + { + name: "empty allowlist permits all", + url: "https://anything.com/path", + hosts: nil, + wantErr: false, + }, + { + name: "allowed host", + url: "https://api.example.com/v1/data", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "blocked host", + url: "https://evil.com/steal", + hosts: []string{"api.example.com"}, + wantErr: true, + }, + { + name: "case insensitive", + url: "https://API.Example.COM/v1", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "port stripped from hostname", + url: "https://api.example.com:8443/v1", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "invalid URL", + url: "://not-a-url", + hosts: []string{"anything.com"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkAllowedHost(tt.url, tt.hosts) + if (err != nil) != tt.wantErr { + t.Errorf("checkAllowedHost(%q, %v) error = %v, wantErr %v", tt.url, tt.hosts, err, tt.wantErr) + } + }) + } +} + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + raw string + want map[string]string + }{ + { + name: "single header", + raw: "Content-Type: application/json\n", + want: map[string]string{"Content-Type": "application/json"}, + }, + { + name: "multiple headers", + raw: "Authorization: Bearer token\nAccept: text/plain\n", + want: map[string]string{"Authorization": "Bearer token", "Accept": "text/plain"}, + }, + { + name: "empty string", + raw: "", + want: map[string]string{}, + }, + { + name: "trailing newlines", + raw: "X-Custom: value\n\n\n", + want: map[string]string{"X-Custom": "value"}, + }, + { + name: "no colon skipped", + raw: "garbage\nX-Valid: yes\n", + want: map[string]string{"X-Valid": "yes"}, + }, + { + name: "value with colon", + raw: "Authorization: Basic dXNlcjpwYXNz\n", + want: map[string]string{"Authorization": "Basic dXNlcjpwYXNz"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseHeaders(tt.raw) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for k, wantV := range tt.want { + if gotV, ok := got[k]; !ok || gotV != wantV { + t.Errorf("header[%q] = %q, want %q", k, gotV, wantV) + } + } + }) + } +} + +func TestExtractIntOption(t *testing.T) { + tests := []struct { + name string + opts map[string]any + key string + defaultVal int + want int + }{ + {"nil opts", nil, "timeout_ms", 10000, 10000}, + {"missing key", map[string]any{"other": 5}, "timeout_ms", 10000, 10000}, + {"float64 value", map[string]any{"timeout_ms": float64(5000)}, "timeout_ms", 10000, 5000}, + {"int value", map[string]any{"timeout_ms": 3000}, "timeout_ms", 10000, 3000}, + {"wrong type", map[string]any{"timeout_ms": "fast"}, "timeout_ms", 10000, 10000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractIntOption(tt.opts, tt.key, tt.defaultVal) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/hostcall/registry.go b/internal/hostcall/registry.go index b7111b2..ac0b4f5 100644 --- a/internal/hostcall/registry.go +++ b/internal/hostcall/registry.go @@ -22,6 +22,7 @@ type Registry struct { eventLog *eventlog.EventLog walletState WalletState // optional; nil = wallet hostcalls not available pricingState PricingState // optional; nil = pricing hostcalls not available + httpClient HTTPClient // optional; nil = use http.DefaultClient } // NewRegistry creates a hostcall registry bound to the given event log. @@ -44,6 +45,12 @@ func (r *Registry) SetPricingState(ps PricingState) { r.pricingState = ps } +// SetHTTPClient installs a custom HTTP client for the http_request hostcall. +// If not set, http.DefaultClient is used. Useful for testing. +func (r *Registry) SetHTTPClient(c HTTPClient) { + r.httpClient = c +} + // RegisterHostModule builds and instantiates the "igor" WASM host module // with only the capabilities declared in the manifest. // Must be called after WASI instantiation and before agent module instantiation. @@ -90,6 +97,12 @@ func (r *Registry) RegisterHostModule( registered++ } + if m.Has("http") { + capCfg := m.Capabilities["http"] + r.registerHTTP(builder, capCfg) + registered++ + } + // Only instantiate if at least one capability was registered. // If the agent has an empty manifest, skip module creation entirely. // If the agent's WASM imports from "igor", instantiation will fail diff --git a/pkg/manifest/parse.go b/pkg/manifest/parse.go index 1175909..12128b2 100644 --- a/pkg/manifest/parse.go +++ b/pkg/manifest/parse.go @@ -12,7 +12,7 @@ import ( ) // NodeCapabilities lists capabilities available on the current node. -var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing"} +var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing", "http"} // ParseCapabilityManifest parses a capability manifest from JSON bytes. // An empty or nil input returns an empty manifest (no capabilities declared). diff --git a/sdk/igor/hostcalls_http_stub.go b/sdk/igor/hostcalls_http_stub.go new file mode 100644 index 0000000..b9405be --- /dev/null +++ b/sdk/igor/hostcalls_http_stub.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !tinygo && !wasip1 + +package igor + +// HTTPRequest performs an HTTP request through the runtime. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) { + if activeMock != nil { + return activeMock.HTTPRequest(method, url, headers, body) + } + panic("igor: HTTPRequest requires WASM runtime or mock (see sdk/igor/mock)") +} + +// HTTPGet performs an HTTP GET request. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPGet(url string) (statusCode int, body []byte, err error) { + return HTTPRequest("GET", url, nil, nil) +} + +// HTTPPost performs an HTTP POST request with the given content type and body. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPPost(url, contentType string, body []byte) (statusCode int, respBody []byte, err error) { + headers := map[string]string{"Content-Type": contentType} + return HTTPRequest("POST", url, headers, body) +} diff --git a/sdk/igor/hostcalls_http_wasm.go b/sdk/igor/hostcalls_http_wasm.go new file mode 100644 index 0000000..7056bd3 --- /dev/null +++ b/sdk/igor/hostcalls_http_wasm.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +package igor + +import ( + "encoding/binary" + "fmt" + "unsafe" +) + +// Raw WASM import for the HTTP hostcall from the igor host module. + +//go:wasmimport igor http_request +func httpRequest( + methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, respCap uint32, +) int32 + +// HTTPRequest performs an HTTP request through the runtime. +// Requires the "http" capability in the agent manifest. +// Returns the HTTP status code, response body, and any error. +// Headers are passed as a map; nil means no custom headers. +func HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) { + methodBuf := []byte(method) + urlBuf := []byte(url) + + // Encode headers as "Key: Value\n" delimited. + var headersBuf []byte + if len(headers) > 0 { + var sb []byte + for k, v := range headers { + sb = append(sb, k...) + sb = append(sb, ": "...) + sb = append(sb, v...) + sb = append(sb, '\n') + } + headersBuf = sb + } + + // Initial response buffer: 8KB. + respBuf := make([]byte, 8192) + + rc := doHTTPRequest(methodBuf, urlBuf, headersBuf, body, respBuf) + + // If response too large, retry with the size hint. + if rc == -5 && len(respBuf) >= 4 { + needed := binary.LittleEndian.Uint32(respBuf[:4]) + if needed > 0 && needed <= 4*1024*1024 { // Cap retry at 4MB. + respBuf = make([]byte, needed+4) // +4 for the length prefix. + rc = doHTTPRequest(methodBuf, urlBuf, headersBuf, body, respBuf) + } + } + + if rc < 0 { + return 0, nil, fmt.Errorf("http_request failed: code %d", rc) + } + + // Parse response: [body_len: 4 bytes LE][body: N bytes]. + if len(respBuf) < 4 { + return int(rc), nil, nil + } + bodyLen := binary.LittleEndian.Uint32(respBuf[:4]) + if int(bodyLen)+4 > len(respBuf) { + return int(rc), nil, fmt.Errorf("http_request: response body truncated") + } + return int(rc), respBuf[4 : 4+bodyLen], nil +} + +// doHTTPRequest is the low-level call with pre-allocated buffers. +func doHTTPRequest(method, url, headers, body, resp []byte) int32 { + var methodPtr, urlPtr, headersPtr, bodyPtr, respPtr uint32 + var headersLen, bodyLen uint32 + + methodPtr = uint32(uintptr(unsafe.Pointer(&method[0]))) + urlPtr = uint32(uintptr(unsafe.Pointer(&url[0]))) + + if len(headers) > 0 { + headersPtr = uint32(uintptr(unsafe.Pointer(&headers[0]))) + headersLen = uint32(len(headers)) + } + if len(body) > 0 { + bodyPtr = uint32(uintptr(unsafe.Pointer(&body[0]))) + bodyLen = uint32(len(body)) + } + respPtr = uint32(uintptr(unsafe.Pointer(&resp[0]))) + + return httpRequest( + methodPtr, uint32(len(method)), + urlPtr, uint32(len(url)), + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, uint32(len(resp)), + ) +} + +// HTTPGet performs an HTTP GET request. +// Convenience wrapper around HTTPRequest. +func HTTPGet(url string) (statusCode int, body []byte, err error) { + return HTTPRequest("GET", url, nil, nil) +} + +// HTTPPost performs an HTTP POST request with the given content type and body. +// Convenience wrapper around HTTPRequest. +func HTTPPost(url, contentType string, body []byte) (statusCode int, respBody []byte, err error) { + headers := map[string]string{"Content-Type": contentType} + return HTTPRequest("POST", url, headers, body) +} diff --git a/sdk/igor/mock/mock.go b/sdk/igor/mock/mock.go index b20bdb2..786ec6c 100644 --- a/sdk/igor/mock/mock.go +++ b/sdk/igor/mock/mock.go @@ -28,15 +28,19 @@ import ( igor "github.com/simonovic86/igor/sdk/igor" ) +// HTTPHandler is a function that handles mock HTTP requests. +type HTTPHandler func(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) + // Runtime provides mock implementations of Igor hostcalls for native testing. type Runtime struct { - mu sync.Mutex - clock func() int64 - randSrc *rand.Rand - logs []string - budget int64 - receipts [][]byte - nodePrice int64 + mu sync.Mutex + clock func() int64 + randSrc *rand.Rand + logs []string + budget int64 + receipts [][]byte + nodePrice int64 + httpHandler HTTPHandler } // New creates a mock runtime using the real system clock and crypto-seeded rand. @@ -184,3 +188,21 @@ func (r *Runtime) SetNodePrice(p int64) { defer r.mu.Unlock() r.nodePrice = p } + +// HTTPRequest implements MockBackend. +func (r *Runtime) HTTPRequest(method, url string, headers map[string]string, body []byte) (int, []byte, error) { + r.mu.Lock() + handler := r.httpHandler + r.mu.Unlock() + if handler != nil { + return handler(method, url, headers, body) + } + return 0, nil, fmt.Errorf("no HTTP handler configured (use SetHTTPHandler)") +} + +// SetHTTPHandler configures a function to handle mock HTTP requests. +func (r *Runtime) SetHTTPHandler(h HTTPHandler) { + r.mu.Lock() + defer r.mu.Unlock() + r.httpHandler = h +} diff --git a/sdk/igor/mock_backend.go b/sdk/igor/mock_backend.go index b5c0708..532706a 100644 --- a/sdk/igor/mock_backend.go +++ b/sdk/igor/mock_backend.go @@ -12,6 +12,7 @@ type MockBackend interface { WalletReceiptCount() int WalletReceipt(index int) ([]byte, error) NodePrice() int64 + HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) } // activeMock is set by mock.Enable() and cleared by mock.Disable().