From b6d7c5759b872468a6cf4517aa8016417ec3a7bb Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 10:05:12 +0400 Subject: [PATCH 1/3] fix(validate): stage each package version in its own deps dir validate-wit.sh collapsed every version of a package into one deps/astrid- dir. wasm-tools treats a deps directory as a single package, so the first second-version (astrid:http@1.1.0 alongside @1.0.0) made every validation fail with 'package identifier does not match previous package name'. Key each staged dep dir by the full name@version so coexisting versions resolve independently. --- scripts/validate-wit.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/validate-wit.sh b/scripts/validate-wit.sh index 72a3323..e055000 100755 --- a/scripts/validate-wit.sh +++ b/scripts/validate-wit.sh @@ -50,9 +50,13 @@ stage_other_astrid_deps() { for f in "$sibling_dir"/*.wit; do [[ "$f" == "$main_file" ]] && continue base="$(basename "$f" .wit)" - pkg="${base%@*}" - mkdir -p "$staging/deps/astrid-$pkg" - cp "$f" "$staging/deps/astrid-$pkg/$base.wit" + # Stage each version in its OWN deps subdir. wasm-tools treats a single + # deps directory as one package, so two versions of the same package + # (e.g. http@1.0.0 + http@1.1.0) must not share a dir or it errors with + # "package identifier ... does not match previous package name". The dir + # name is cosmetic — wasm-tools resolves by the `package` decl inside. + mkdir -p "$staging/deps/astrid-${base//@/-}" + cp "$f" "$staging/deps/astrid-${base//@/-}/$base.wit" done } From 39a3d0e249a2abc53f4b4fcbbc861c9f8d39a1c9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 10:05:13 +0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(http):=20add=20astrid:http@1.1.0=20?= =?UTF-8?q?=E2=80=94=20per-request=20control,=20streaming=20uploads,=20tra?= =?UTF-8?q?ilers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive successor to astrid:http@1.0.0 (frozen, untouched). Adds an all-optional request-options record (per-request connect/first-byte/between-bytes/total timeouts, redirect policy with mandatory per-hop airlock re-validation, response/decompression size caps, auto-decompress toggle, https-only, subresource integrity), response-meta on the buffered response (final URL, redirect count, timing, wire bytes), a streaming request body via the http-upload resource (mirrors wasi:http outgoing-body), and http-stream.trailers for gRPC-web. An empty request-options reproduces @1.0.0 exactly, so the host backs both from one impl. SSRF-sensitive knobs (custom DNS, resolve overrides, proxy, source binding, TLS-verification bypass) are excluded by construction. Design record: RFC #22 (host_abi). --- README.md | 1 + host/http@1.1.0.wit | 282 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 host/http@1.1.0.wit diff --git a/README.md b/README.md index 6616fbf..755f933 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. | | `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, inbound TCP listener, gated UDP (unconnected + connected mode), gated DNS resolution. | | `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | +| `host/http@1.1.0.wit` | `astrid:http@1.1.0` | Additive successor to `@1.0.0` — caller-set per-request timeouts, redirect policy, response/decompression size caps, scheme restriction, subresource integrity, response metadata, streaming request bodies (uploads), and response trailers. An empty `request-options` reproduces `@1.0.0` behaviour. | | `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | | `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), wait, signal, kill, read-logs, stdin streaming. | | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | diff --git a/host/http@1.1.0.wit b/host/http@1.1.0.wit new file mode 100644 index 0000000..61faafc --- /dev/null +++ b/host/http@1.1.0.wit @@ -0,0 +1,282 @@ +/// HTTP client operations with SSRF protection — version 1.1.0. +/// +/// Additive successor to `astrid:http@1.0.0`. Every @1.0.0 type and function is +/// carried forward unchanged in shape; @1.1.0 adds caller-set per-request +/// control (timeouts, redirect policy, size / decompression caps, scheme +/// restriction, subresource integrity), response metadata, a streaming request +/// body (upload), and response trailers. An empty `request-options` reproduces +/// @1.0.0 behaviour exactly, so the host backs both versions from one +/// implementation with the @1.0.0 entrypoints delegating to the @1.1.0 path +/// with defaulted options. +/// +/// SSRF discipline (unchanged from @1.0.0, strengthened here): DNS resolution +/// blocks private, loopback, link-local, multicast, and unspecified IP +/// addresses (IPv4-mapped / IPv4-compatible IPv6 included); the security gate +/// enforces per-capsule allow-lists and rate limits. When redirects are +/// followed, every hop is re-validated through the same airlock and +/// `Authorization` / `Cookie` are stripped on a cross-origin hop. The host — +/// never the capsule — owns DNS resolution and the connect path: no option here +/// lets a guest choose a resolver, a pre-resolved address, a proxy, a source +/// interface, or relax TLS verification. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:http@1.1.0; + +interface host { + use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream, output-stream}; + + /// Typed error returned from every fallible http operation. Carries the + /// @1.0.0 arms plus @1.1.0 additions for the new control surface. + variant error-code { + /// Capsule lacks the `net` capability. + capability-denied, + /// Hostname failed validation (NUL bytes, control chars, etc.) + /// or URL doesn't parse. + invalid-request, + /// DNS could not resolve the hostname. + dns-error, + /// All resolved IPs were SSRF-blocked (private/loopback/etc). + airlock-rejected, + /// TLS handshake / certificate problem. + tls-error, + /// Request or per-chunk read exceeded its timeout. + timeout, + /// Connection refused or reset by peer. + connection-error, + /// Response body exceeded the caller's `max-response-bytes`, or the + /// host's 10 MB default on buffered requests (use a stream for larger). + body-too-large, + /// Stream handle has been closed. + closed, + /// Server-side resource quota exhausted (4 concurrent streams). + quota, + /// A redirect was encountered under `redirect-policy.error`, or a + /// redirect target failed the SSRF airlock. New in @1.1.0. + redirect-blocked, + /// Redirect chain exceeded `max-redirects` (or the host ceiling). + /// New in @1.1.0. + too-many-redirects, + /// Response bytes did not match the `integrity` digest. New in @1.1.0. + integrity-mismatch, + /// URL scheme was rejected by `https-only` or the host scheme + /// allow-list. New in @1.1.0. + scheme-denied, + /// Decompressed body exceeded `max-decompressed-bytes` (decompression + /// bomb defence). New in @1.1.0. + decompression-bomb, + /// Unspecific HTTP-protocol-level error from the host. + protocol(string), + /// Unspecific I/O error from the host; detail is best-effort. + unknown(string), + } + + /// HTTP request method. Matches `wasi:http/types.method`. `other` + /// carries non-standard methods (PROPFIND, etc.) by name. + variant http-method { + get, + head, + post, + put, + %delete, + connect, + options, + trace, + patch, + other(string), + } + + /// A key-value pair used for typed header lists. + record key-value-pair { + key: string, + value: string, + } + + /// HTTP request sent by a capsule to the host. Unchanged in shape from + /// @1.0.0; per-request behaviour is tuned via `request-options`. + record http-request-data { + /// Target URL. + url: string, + /// HTTP method. + method: http-method, + /// Request headers as key-value pairs. Duplicates allowed + /// (e.g. multiple `Cookie` lines). + headers: list, + /// Optional request body as raw bytes. JSON/text callers encode their + /// payload to UTF-8 bytes; binary uploads pass their bytes directly. + /// For bodies too large to hold in guest memory, leave this `none` and + /// stream the body via `http-upload-start`. + body: option>, + } + + /// Caller-set deadlines, each capped by a host ceiling. `none` per field + /// selects the host default for that phase. The four phases are + /// independent: a short connect deadline fails a dead host fast while a long + /// total deadline still allows a big download. + record timeout-config { + /// TCP + TLS establishment. + connect-ms: option, + /// Request fully sent -> first response byte (catches accept-then-hang). + first-byte-ms: option, + /// Maximum idle gap between successive body chunks (streaming path). + between-bytes-ms: option, + /// Whole-request hard deadline (connect + send + receive). + total-ms: option, + } + + /// How the host treats a 3xx response. + enum redirect-policy { + /// Follow, re-validating every hop through the SSRF airlock and + /// stripping `Authorization` / `Cookie` on a cross-origin hop. Bounded + /// by `max-redirects`. + follow, + /// Do not follow; return `redirect-blocked`. + error, + /// Do not follow; return the 3xx response to the caller as-is. + manual, + } + + /// Per-request controls. Every field is optional; an empty value reproduces + /// `astrid:http@1.0.0` behaviour exactly. Threaded through the `*-opts` + /// functions and `http-upload-start`. + record request-options { + /// Caller deadlines (see `timeout-config`). + timeouts: option, + /// Redirect handling. The host default for an untrusted capsule is + /// `error` — never a silent follow. + redirect: option, + /// Hop limit when `redirect-policy.follow`. `none` => host default. + max-redirects: option, + /// Cap on the buffered response body in bytes. `none` => host 10 MB + /// default. The host enforces a hard ceiling above which + /// `body-too-large` is returned regardless of this value. + max-response-bytes: option, + /// Cap on DECOMPRESSED body bytes — decompression-bomb defence. + /// `none` => host default ceiling. + max-decompressed-bytes: option, + /// Automatically decompress gzip / brotli / deflate / zstd responses + /// (default true). `false` delivers the raw wire bytes — required for + /// binary or already-compressed downloads. + auto-decompress: option, + /// Refuse any non-`https` scheme with `scheme-denied`. + https-only: option, + /// Subresource integrity, formatted `sha256-` (also sha384 / + /// sha512). The host verifies the response body against the digest and + /// returns `integrity-mismatch` on failure. + integrity: option, + } + + /// Response metadata the @1.0.0 `{status, headers, body}` shape could not + /// carry. New in @1.1.0; populated on every buffered response. + record response-meta { + /// Final URL after any redirects (equals the request URL if none). + final-url: string, + /// Number of redirect hops followed. + redirect-count: u32, + /// Wall-clock duration of the request, milliseconds (best-effort). + elapsed-ms: u64, + /// Bytes received on the wire, before decompression (best-effort). + wire-bytes: u64, + } + + /// Buffered HTTP response. Carries the @1.0.0 fields plus `meta`. + record http-response-data { + /// HTTP status code. + status: u16, + /// Response headers. + headers: list, + /// Response body as raw bytes. + body: list, + /// Post-request metadata. New in @1.1.0. + meta: response-meta, + } + + /// HTTP streaming response handle. The kernel buffers chunks server-side; + /// the capsule reads them via `read-chunk` until EOF or `close`. The + /// per-chunk timeout defaults to 120s and is overridable via + /// `timeout-config.between-bytes-ms`. + /// + /// Per-capsule cap: 4 concurrent HTTP streams. Drop is automatic — capsules + /// don't need to call close explicitly. + resource http-stream { + /// HTTP status code returned at stream start. + status: func() -> u16; + + /// Response headers from the initial response. + headers: func() -> list; + + /// Read the next chunk. Returns an empty list at EOF; callers loop + /// until the list is empty. + read-chunk: func() -> result, error-code>; + + /// Close the stream explicitly. Idempotent. Equivalent to dropping + /// the resource. + close: func() -> result<_, error-code>; + + /// Pollable that fires when the next chunk is ready to read (or EOF has + /// arrived). Compose with other pollables for multiplexed I/O — e.g. a + /// capsule streaming an HTTP response while also handling IPC requests. + subscribe-readable: func() -> pollable; + + /// The response body as an `input-stream`. Use this instead of + /// `read-chunk` when forwarding the body to another sink (e.g. a TCP + /// stream via `output-stream.splice`) — the splice path moves bytes + /// host-side without crossing the WASM boundary per chunk. + /// + /// `body-stream` and `read-chunk` share the underlying response cursor; + /// the kernel serializes access. Pick one. + body-stream: func() -> input-stream; + + /// Response trailers (e.g. gRPC-web `grpc-status` / `grpc-message`). + /// Available only after the body has been fully read (EOF); `none` if + /// the response carried no trailers. New in @1.1.0. + trailers: func() -> result>, error-code>; + } + + /// Streaming request body (upload). @1.0.0 forces the whole body into guest + /// memory via `http-request-data.body`; this lets the caller stream it. The + /// caller writes the body to `body-sink` (an `output-stream`), then `finish` + /// seals it and yields the response stream. Mirrors the `wasi:http` + /// outgoing-body pattern. + /// + /// Shares the per-capsule 4 concurrent-stream budget. New in @1.1.0. + resource http-upload { + /// The request-body sink. Write chunks here; the host streams them + /// upstream without buffering the whole payload in guest memory. + body-sink: func() -> output-stream; + + /// Pollable that fires when the sink can accept more bytes + /// (backpressure-aware composition). + subscribe-writable: func() -> pollable; + + /// Seal the request body and obtain the response stream (status, + /// headers, chunks, trailers). Consumes the upload handle. + finish: func() -> result; + } + + /// Buffered HTTP request with default options (full response in memory). + /// Equivalent to `http-request-opts` with an empty `request-options`. + /// Host default timeout 30s, max response body 10 MB. + /// Audit: recorded (URL host + method + status; body bytes not logged). + http-request: func(request: http-request-data) -> result; + + /// Buffered HTTP request with caller-set options. + /// Audit: recorded (as `http-request`). + http-request-opts: func(request: http-request-data, options: request-options) -> result; + + /// Start a streaming HTTP request with default options (headers on the + /// resource; body streamed via `read-chunk`). + /// Audit: recorded (start + close). + http-stream-start: func(request: http-request-data) -> result; + + /// Start a streaming HTTP request with caller-set options. + http-stream-start-opts: func(request: http-request-data, options: request-options) -> result; + + /// Start a streaming-upload request: write the body incrementally via the + /// returned `http-upload`, then `finish` to obtain the response stream. The + /// `body` field of `request` is ignored (the body comes from the sink). + /// Audit: recorded (start + finish). + http-upload-start: func(request: http-request-data, options: request-options) -> result; +} From 206a09aa2b08d7bbbabd3cae13bba15c2eaf3b6e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 10:10:30 +0400 Subject: [PATCH 3/3] docs(http): precise @1.1.0 header wording; dedupe validate dep-dir expr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Copilot review on PR #17: the header said every @1.0.0 type was carried forward unchanged, but http-response-data/http-stream/error-code are extended — reworded to state the request shape and function signatures are preserved while the surface is extended. Also hoist the staged dep-dir path into a local in validate-wit.sh (computed twice). --- host/http@1.1.0.wit | 16 +++++++++------- scripts/validate-wit.sh | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/host/http@1.1.0.wit b/host/http@1.1.0.wit index 61faafc..6ee6190 100644 --- a/host/http@1.1.0.wit +++ b/host/http@1.1.0.wit @@ -1,13 +1,15 @@ /// HTTP client operations with SSRF protection — version 1.1.0. /// -/// Additive successor to `astrid:http@1.0.0`. Every @1.0.0 type and function is -/// carried forward unchanged in shape; @1.1.0 adds caller-set per-request +/// Additive successor to `astrid:http@1.0.0`. The @1.0.0 request shape and the +/// `http-request` / `http-stream-start` signatures are preserved, so porting is +/// mechanical; @1.1.0 then EXTENDS the surface: the buffered `http-response-data` +/// gains a `meta` field, `http-stream` gains `trailers`, `error-code` gains arms +/// for the new failure modes, and new functions add caller-set per-request /// control (timeouts, redirect policy, size / decompression caps, scheme -/// restriction, subresource integrity), response metadata, a streaming request -/// body (upload), and response trailers. An empty `request-options` reproduces -/// @1.0.0 behaviour exactly, so the host backs both versions from one -/// implementation with the @1.0.0 entrypoints delegating to the @1.1.0 path -/// with defaulted options. +/// restriction, subresource integrity) plus a streaming request body (upload). +/// An empty `request-options` reproduces @1.0.0 behaviour exactly, so the host +/// backs both versions from one implementation with the @1.0.0 entrypoints +/// delegating to the @1.1.0 path with defaulted options. /// /// SSRF discipline (unchanged from @1.0.0, strengthened here): DNS resolution /// blocks private, loopback, link-local, multicast, and unspecified IP diff --git a/scripts/validate-wit.sh b/scripts/validate-wit.sh index e055000..8314e11 100755 --- a/scripts/validate-wit.sh +++ b/scripts/validate-wit.sh @@ -55,8 +55,9 @@ stage_other_astrid_deps() { # (e.g. http@1.0.0 + http@1.1.0) must not share a dir or it errors with # "package identifier ... does not match previous package name". The dir # name is cosmetic — wasm-tools resolves by the `package` decl inside. - mkdir -p "$staging/deps/astrid-${base//@/-}" - cp "$f" "$staging/deps/astrid-${base//@/-}/$base.wit" + local depdir="$staging/deps/astrid-${base//@/-}" + mkdir -p "$depdir" + cp "$f" "$depdir/$base.wit" done }