From a04dec59d21fb3b39ef8a1f520b2e5f77325f382 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 21:28:56 +0400 Subject: [PATCH 1/3] build(sys): bump contracts to astrid:http@1.1.0; stage WIT per name@version Bump the contracts submodule pin to 812c833 (canonical wit main: #17 astrid:http@1.1.0, #16 session 1.1.0, plus the already-landed #14/#15 process changes) and re-sync astrid-sdk/wit/astrid-contracts.wit. The bundle diff is the new session 1.1.0 records (list/get_meta/update/delete/search); astrid:http is a host/ package and is not part of the interfaces/ bundle. astrid:http now ships two frozen versions side by side. build.rs keyed each deps dir by package name alone, so http@1.0.0.wit and http@1.1.0.wit both landed in deps/astrid-http/ and wit-bindgen rejected the second (package identifier astrid:http@1.1.0 does not match previous package name of astrid:http@1.0.0). Key each deps dir by name@version so multiple frozen versions of one package coexist; the inline world imports only astrid:http/host@1.1.0. --- astrid-sdk/wit/astrid-contracts.wit | 196 ++++++++++++ astrid-sys/build.rs | 16 +- astrid-sys/src/lib.rs | 16 +- .../approval@1.0.0.wit | 0 .../elicit@1.0.0.wit | 0 .../fs@1.0.0.wit | 0 .../guest@1.0.0.wit | 0 .../http@1.0.0.wit | 0 .../deps/astrid-http@1.1.0/http@1.1.0.wit | 284 ++++++++++++++++++ .../identity@1.0.0.wit | 0 .../io@1.0.0.wit | 0 .../ipc@1.0.0.wit | 0 .../kv@1.0.0.wit | 0 .../net@1.0.0.wit | 0 .../process@1.0.0.wit | 0 .../sys@1.0.0.wit | 0 .../uplink@1.0.0.wit | 0 contracts | 2 +- 18 files changed, 503 insertions(+), 11 deletions(-) rename astrid-sys/wit-staging/deps/{astrid-approval => astrid-approval@1.0.0}/approval@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-elicit => astrid-elicit@1.0.0}/elicit@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-fs => astrid-fs@1.0.0}/fs@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-guest => astrid-guest@1.0.0}/guest@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-http => astrid-http@1.0.0}/http@1.0.0.wit (100%) create mode 100644 astrid-sys/wit-staging/deps/astrid-http@1.1.0/http@1.1.0.wit rename astrid-sys/wit-staging/deps/{astrid-identity => astrid-identity@1.0.0}/identity@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-io => astrid-io@1.0.0}/io@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-ipc => astrid-ipc@1.0.0}/ipc@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-kv => astrid-kv@1.0.0}/kv@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-net => astrid-net@1.0.0}/net@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-process => astrid-process@1.0.0}/process@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-sys => astrid-sys@1.0.0}/sys@1.0.0.wit (100%) rename astrid-sys/wit-staging/deps/{astrid-uplink => astrid-uplink@1.0.0}/uplink@1.0.0.wit (100%) diff --git a/astrid-sdk/wit/astrid-contracts.wit b/astrid-sdk/wit/astrid-contracts.wit index b568adb..ac46cf7 100644 --- a/astrid-sdk/wit/astrid-contracts.wit +++ b/astrid-sdk/wit/astrid-contracts.wit @@ -575,6 +575,26 @@ interface registry { /// A capsule exporting this interface handles `session.v1.request.*` /// events and publishes `session.v1.response.*` replies scoped by /// correlation ID. +/// +/// ## 1.1.0 — conversation management +/// +/// Additive over 1.0.0 (existing records unchanged). Adds the full +/// thread-management surface a multi-device client and the agent need: +/// - `list` (`session.v1.request.list`) — paginated thread metadata. +/// - `get-meta` (`session.v1.request.get_meta`) — one thread's metadata. +/// - `update` (`session.v1.request.update`) — patch title / archived / +/// client `meta`; `rename` and `archive` are both expressed through it. +/// - `delete` (`session.v1.request.delete`) — hard-purge a thread. +/// - `search` (`session.v1.request.search`) — keyword/substring across the +/// caller's transcripts. +/// +/// All scoped per principal by the kernel KV namespace — no cross-principal +/// access. Mutations also fan out a `session-event` on +/// `session.v1.event.`, stamped with the acting principal, so other +/// devices update their thread list live. `session-summary` and `update` +/// carry an opaque `meta` string (client-defined JSON — tags, pin, +/// read-state) the capsule stores but never interprets: the forward-compat +/// escape hatch so new per-thread attributes need no contract bump. interface session { use types.{message}; @@ -619,6 +639,182 @@ interface session { /// Session ID that was cleared. session-id: string, } + + /// Request to list the caller's sessions (paginated). The handler + /// enumerates only the invoking principal's sessions — the kernel scopes + /// the capsule's key-value namespace per principal, so there is no + /// cross-principal enumeration. Topic: `session.v1.request.list`. + record list-request { + correlation-id: string, + /// Opaque pagination cursor from a previous `list-response`, or absent + /// for the first page. + cursor: option, + /// Maximum sessions to return in this page. The host caps the + /// effective value; absent means the server default. + limit: option, + /// Include archived threads. Default (false) returns only active ones. + include-archived: bool, + } + + /// Metadata describing one session — no transcript body. Display name + /// fallback chain: `title` → `preview` → session-id. + record session-summary { + /// Session identifier (e.g. `default` or a UUID for chained sessions). + session-id: string, + /// User/operator-set thread title. Absent until set via `update`. + title: option, + /// Preview of the FIRST user message, truncated — a stable auto-name + /// when no `title` is set. + preview: option, + /// Preview of the MOST RECENT message, truncated — the recent-activity + /// snippet a thread-list row shows. + last-message-preview: option, + /// Number of messages currently in the session. + message-count: u32, + /// Unix epoch seconds the session was first written, if known. Absent + /// for legacy sessions predating timestamp tracking. + created-at: option, + /// Unix epoch seconds of the most recent write, if known. Absent for + /// legacy sessions until their next write. + updated-at: option, + /// True if archived (hidden from the default list, data retained). + /// Toggle via `update`. + archived: bool, + /// Parent session id if this session descends from a cleared or + /// compacted ancestor. + parent-session-id: option, + /// Opaque client-defined JSON (tags, pin, per-device read-state, …), + /// stored verbatim and never interpreted by the capsule, size-bounded. + /// The forward-compat escape hatch for per-thread attributes. + meta: option, + } + + /// Response with a page of session summaries, scoped by correlation ID. + /// Topic: `session.v1.response.list.`. + record list-response { + correlation-id: string, + /// The page of sessions, ordered by session key. Each carries + /// `updated-at` for client-side recency sorting within the page. + sessions: list, + /// Opaque cursor for the next page, or absent on the last page. + next-cursor: option, + /// Total thread count for the principal when cheaply known (absent if + /// the set is too large to count in one pass). + total: option, + } + + /// Request for one thread's metadata (no transcript). + /// Topic: `session.v1.request.get_meta`. + record get-meta-request { + correlation-id: string, + session-id: string, + } + + /// Response with one thread's metadata, or absent if no such thread in the + /// caller's namespace. Topic: `session.v1.response.get_meta.`. + record get-meta-response { + correlation-id: string, + session: option, + } + + /// Patch a thread's mutable metadata: a present field is applied, an absent + /// field left unchanged; `title`/`meta` set to an empty string clear that + /// attribute. `rename` and `archive` are both expressed through this verb. + /// Topic: `session.v1.request.update`. + record update-request { + correlation-id: string, + session-id: string, + /// New title; empty string clears it; absent leaves it unchanged. + title: option, + /// New archived state; absent leaves it unchanged. + archived: option, + /// New opaque client `meta`; empty string clears; absent unchanged. + meta: option, + } + + /// Response with the updated metadata, or absent if no such thread. + /// Topic: `session.v1.response.update.`. + record update-response { + correlation-id: string, + session: option, + } + + /// Hard-delete a thread: transcript and metadata are purged from the store + /// (irreversible), scoped to the caller's namespace. To hide a thread + /// reversibly, `update` its `archived` flag instead. + /// Topic: `session.v1.request.delete`. + record delete-request { + correlation-id: string, + session-id: string, + } + + /// Response confirming deletion. `deleted` is false if no such thread + /// existed. Topic: `session.v1.response.delete.`. + record delete-response { + correlation-id: string, + deleted: bool, + } + + /// Keyword/substring search across the caller's transcripts + /// (case-insensitive). v1 is literal matching — relevance ranking is a + /// separate concern. Topic: `session.v1.request.search`. + record search-request { + correlation-id: string, + query: string, + /// Max hits to return; the host caps the effective value. + limit: option, + /// Opaque cursor from a previous `search-response`, or absent for the + /// first page. + cursor: option, + /// Search archived threads too. Default (false) skips them. + include-archived: bool, + } + + /// One search hit: the thread plus the matching excerpt. + record search-result { + session-id: string, + /// Thread title if set, for display. + title: option, + /// Truncated excerpt around the first match in the thread. + snippet: option, + /// Number of matching messages in the thread. + match-count: u32, + /// Recency, for client-side sort of the hit list. + updated-at: option, + } + + /// Response with the search hits, scoped by correlation ID. + /// Topic: `session.v1.response.search.`. + record search-response { + correlation-id: string, + results: list, + /// Opaque cursor for the next page of hits, or absent on the last. + next-cursor: option, + } + + /// The kind of lifecycle change a `session-event` reports. A `variant` + /// (not a string) so new kinds extend the type cleanly. + variant session-event-kind { + /// A new thread was created (e.g. via `clear`). + created, + /// A thread's metadata changed (title / archived / meta). + updated, + /// A thread was hard-deleted. + deleted, + } + + /// Fan-out notification that a thread's lifecycle changed, published by the + /// session capsule after a successful mutation and stamped with the acting + /// principal so a per-principal subscriber (e.g. the gateway's multi-device + /// live feed) sees only its own. Topic: `session.v1.event.` + /// (`created` / `updated` / `deleted`). + record session-event { + kind: session-event-kind, + session-id: string, + /// Post-change metadata, so a subscriber updates its thread list + /// without a re-fetch. Absent for `deleted`. + summary: option, + } } // ── spark ────────────────────────────────────────────────────────── diff --git a/astrid-sys/build.rs b/astrid-sys/build.rs index c8f71d7..02f2849 100644 --- a/astrid-sys/build.rs +++ b/astrid-sys/build.rs @@ -106,10 +106,20 @@ fn main() { { continue; } + // Key each deps dir by the FULL `@` stem, not just + // ``. A package may now ship multiple frozen versions side by + // side (e.g. `http@1.0.0.wit` + `http@1.1.0.wit`). wit-bindgen + // resolves one package per `deps//`, and rejects two files + // declaring different `(package, version)` identifiers in the same + // dir ("package identifier `astrid:http@1.1.0` does not match + // previous package name of `astrid:http@1.0.0`"). Staging each + // version in its own `deps/astrid-@/` keeps every + // frozen version independently resolvable, so a capsule pinned at + // the old version keeps its old interface while the inline `world` + // imports whichever version it names. let stem = file_name.trim_end_matches(".wit"); - let pkg_name = stem.split('@').next().unwrap(); - let dst_dir = deps.join(format!("astrid-{pkg_name}")); - fs::create_dir_all(&dst_dir).expect("mkdir deps/astrid-"); + let dst_dir = deps.join(format!("astrid-{stem}")); + fs::create_dir_all(&dst_dir).expect("mkdir deps/astrid-@"); let dst = dst_dir.join(file_name); fs::copy(&path, &dst).expect("copy host wit"); println!("cargo:rerun-if-changed={}", path.display()); diff --git a/astrid-sys/src/lib.rs b/astrid-sys/src/lib.rs index ff4f04b..9b5ed31 100644 --- a/astrid-sys/src/lib.rs +++ b/astrid-sys/src/lib.rs @@ -28,12 +28,14 @@ //! //! ## ABI evolution //! -//! Every package imported here is pinned at `@1.0.0`. When a new -//! frozen version ships (e.g. `host/ipc@1.1.0.wit`), add it to the -//! inline world as an additional `import` — the Component Model -//! linker enforces exact `(package, version)` matches, so capsules -//! pinned at the older version continue to resolve their old -//! interface unchanged. +//! Most packages imported here are pinned at `@1.0.0`; `astrid:http` +//! moves to `@1.1.0` (an additive superset of `@1.0.0`). When a new +//! frozen version ships (e.g. `host/http@1.1.0.wit`), point the inline +//! world's `import` at the new version — the Component Model linker +//! enforces exact `(package, version)` matches. `build.rs` stages each +//! frozen version in its own `deps/astrid-@/` dir, so +//! multiple versions of one package coexist without a package-name +//! collision and an older pin keeps resolving its old interface. #![deny(clippy::all)] #![deny(unreachable_pub)] @@ -73,7 +75,7 @@ mod generated { import astrid:ipc/host@1.0.0; import astrid:kv/host@1.0.0; import astrid:net/host@1.0.0; - import astrid:http/host@1.0.0; + import astrid:http/host@1.1.0; import astrid:sys/host@1.0.0; import astrid:process/host@1.0.0; import astrid:uplink/host@1.0.0; diff --git a/astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-approval@1.0.0/approval@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-approval@1.0.0/approval@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-elicit@1.0.0/elicit@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-elicit@1.0.0/elicit@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-fs@1.0.0/fs@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-fs@1.0.0/fs@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-guest@1.0.0/guest@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-guest@1.0.0/guest@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-http@1.0.0/http@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-http@1.0.0/http@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-http@1.1.0/http@1.1.0.wit b/astrid-sys/wit-staging/deps/astrid-http@1.1.0/http@1.1.0.wit new file mode 100644 index 0000000..6ee6190 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-http@1.1.0/http@1.1.0.wit @@ -0,0 +1,284 @@ +/// HTTP client operations with SSRF protection — version 1.1.0. +/// +/// 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) 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 +/// 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; +} diff --git a/astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-identity@1.0.0/identity@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-identity@1.0.0/identity@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-io@1.0.0/io@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-io@1.0.0/io@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-ipc@1.0.0/ipc@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-ipc@1.0.0/ipc@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-kv@1.0.0/kv@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-kv@1.0.0/kv@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-net@1.0.0/net@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-net@1.0.0/net@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-sys@1.0.0/sys@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-sys@1.0.0/sys@1.0.0.wit diff --git a/astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-uplink@1.0.0/uplink@1.0.0.wit similarity index 100% rename from astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit rename to astrid-sys/wit-staging/deps/astrid-uplink@1.0.0/uplink@1.0.0.wit diff --git a/contracts b/contracts index 0a50cfc..812c833 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 0a50cfc771b32d78a8bc0780917796c3e2a9e699 +Subproject commit 812c833892060517f9fc23551867f1971bef5506 From 993b87a18a39c680cf2c3c1a1334f9860f414ab7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 21:29:19 +0400 Subject: [PATCH 2/3] feat(http): astrid:http@1.1.0 per-request control surface Move the http module to astrid:http/host@1.1.0 (additive superset of @1.0.0) and add the per-request control surface. An unset option reproduces @1.0.0 behaviour exactly, so the change is backward-compatible. Request gains fluent builders: .timeout/.connect_timeout/.first_byte_timeout/.read_timeout (Duration -> ms, saturating), .redirect(RedirectPolicy{Follow,Error,Manual})/.max_redirects, .max_response_bytes/.max_decompressed_bytes/.auto_decompress, .https_only/.integrity. send and stream_start build request-options from the stored controls and call http-request-opts / http-stream-start-opts. Response gains the @1.1.0 metadata accessors final_url/redirect_count/elapsed/wire_bytes. New error-code arms (redirect-blocked, too-many-redirects, integrity-mismatch, scheme-denied, decompression-bomb, dns-error, tls-error) flow through host_err -> SysError::HostError carrying the arm name, consistent with the existing http error mapping. http-upload-start and stream trailers are deferred until the host implements them (TODO notes left in place). --- CHANGELOG.md | 36 +++- astrid-sdk/src/http.rs | 412 ++++++++++++++++++++++++++++++++++++++++- astrid-sdk/src/lib.rs | 6 +- 3 files changed, 439 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db2bf60..cab10b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added +- **`astrid:http@1.1.0` per-request controls.** The `http` module moves to the additive `@1.1.0` + host interface; an unset option reproduces `@1.0.0` behaviour exactly. New `Request` builders: + `.timeout(Duration)` (total), `.connect_timeout`, `.first_byte_timeout`, `.read_timeout` + (between-bytes / per-chunk on the streaming path); `.redirect(RedirectPolicy)` with the public + `RedirectPolicy { Follow, Error, Manual }` and `.max_redirects(u32)`; `.max_response_bytes(u64)`, + `.max_decompressed_bytes(u64)` (decompression-bomb cap), `.auto_decompress(bool)`; `.https_only()` + and `.integrity(impl Into)` (`sha256-` subresource integrity). Timeouts are + defaults the host may clamp; redirects / sizes / scheme are ceilings the host enforces. `send` and + `stream_start` now thread these through `http-request-opts` / `http-stream-start-opts`. `Response` + gains `@1.1.0` metadata accessors: `.final_url()`, `.redirect_count()`, `.elapsed()`, + `.wire_bytes()`. The new `error-code` arms (`redirect-blocked`, `too-many-redirects`, + `integrity-mismatch`, `scheme-denied`, `decompression-bomb`, `dns-error`, `tls-error`) surface + through `SysError::HostError` carrying the arm name, consistent with the existing http error + mapping. The streaming-upload (`http-upload-start`) and stream `trailers` host surfaces are + deferred until the host implements them. (`unicity-astrid/wit#17`) + - **Read-only file injection on the spawn builder.** `Command::inject_env_file(env_var, content)` (OS-agnostic: host materializes the bytes at a host-owned path and points the named env var at it), `Command::inject_file_at(path, content)` (Linux-only ro-bind at an absolute in-sandbox path; @@ -21,12 +37,20 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Changed -- **Bumped the `contracts` submodule to `0a50cfc`** (canonical wit `main` with #14 un-stubbed - persistent stdin and #15 process file-injection). `build.rs` re-stages `wit-staging` from - `contracts/host`, so `astrid:process@1.0.0` now carries `spawn-request.file-injections`. This - restores WIT/linker lockstep with core: a process-tier capsule built against the SDK previously - got an older `process@1.0.0` than the host exposes. The `astrid-contracts.wit` events bundle is - unchanged (file-injection is a `host/` contract, not an `interfaces/` one). +- **Bumped the `contracts` submodule to `812c833`** (canonical wit `main` with #14 un-stubbed + persistent stdin, #15 process file-injection, #17 `astrid:http@1.1.0`, and #16 session 1.1.0). + `build.rs` re-stages `wit-staging` from `contracts/host`, so `astrid:process@1.0.0` carries + `spawn-request.file-injections` and `astrid:http@1.1.0` is now linkable, restoring WIT/linker + lockstep with core. The `astrid-contracts.wit` events bundle now reflects the new session 1.1.0 + records (`session.v1.{list,get_meta,update,delete,search}`); `astrid:http` is a `host/` package and + is not in the `interfaces/` bundle. + +- **`astrid-sys` stages each frozen WIT version in its own `deps/astrid-@/` dir.** + `astrid:http` now ships two versions side by side (`http@1.0.0.wit` + `http@1.1.0.wit`); the prior + staging keyed every dep dir by package name alone, so both files landed in `deps/astrid-http/` and + `wit-bindgen` rejected the second (`package identifier astrid:http@1.1.0 does not match previous + package name of astrid:http@1.0.0`). Keying by `name@version` lets multiple frozen versions of one + package coexist; the SDK imports only `@1.1.0`. No public-API impact. ## [0.7.1] - 2026-06-10 diff --git a/astrid-sdk/src/http.rs b/astrid-sdk/src/http.rs index 8e936ff..74e276a 100644 --- a/astrid-sdk/src/http.rs +++ b/astrid-sdk/src/http.rs @@ -1,14 +1,53 @@ //! HTTP client with SSRF protection and streaming. //! -//! Backed by `astrid:http/host@1.0.0`. The buffered path -//! ([`send`]) returns the full response in memory (10 MB cap); the -//! streaming path ([`stream_start`]) returns a RAII [`HttpStream`] +//! Backed by `astrid:http/host@1.1.0`. The buffered path +//! ([`send`]) returns the full response in memory (10 MB default cap); +//! the streaming path ([`stream_start`]) returns a RAII [`HttpStream`] //! that drains response chunks until EOF and drops the kernel-side //! resource automatically. +//! +//! Every request can carry per-request controls — timeouts, redirect +//! policy, size / decompression caps, scheme restriction, subresource +//! integrity — set through the [`Request`] fluent builders. An unset +//! option reproduces `astrid:http@1.0.0` behaviour exactly: the host +//! applies its default for that field. use super::*; use serde::Serialize; use std::collections::HashMap; +use std::time::Duration; + +/// How the host treats a `3xx` redirect response. +/// +/// Maps to the WIT `astrid:http/host.redirect-policy`. When a redirect +/// is followed, every hop is re-validated through the SSRF airlock and +/// `Authorization` / `Cookie` are stripped on a cross-origin hop. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum RedirectPolicy { + /// Follow redirects (bounded by [`Request::max_redirects`]). + Follow, + /// Do not follow; fail with a `redirect-blocked` host error. + Error, + /// Do not follow; return the `3xx` response to the caller as-is. + Manual, +} + +impl RedirectPolicy { + fn to_wit(self) -> wit_http::RedirectPolicy { + match self { + Self::Follow => wit_http::RedirectPolicy::Follow, + Self::Error => wit_http::RedirectPolicy::Error, + Self::Manual => wit_http::RedirectPolicy::Manual, + } + } +} + +/// Convert a [`Duration`] to whole milliseconds for a WIT +/// `timeout-config` field, saturating at [`u64::MAX`] rather than +/// wrapping (an absurdly long timeout still clamps to the host ceiling). +fn duration_to_ms(d: Duration) -> u64 { + u64::try_from(d.as_millis()).unwrap_or(u64::MAX) +} /// An HTTP request. /// @@ -16,6 +55,12 @@ use std::collections::HashMap; /// [`Request::new`] for arbitrary methods. Bodies can be raw bytes /// (see [`Request::body_bytes`]) or a UTF-8 string (see /// [`Request::body`]). +/// +/// Per-request controls (timeouts, redirects, size / decompression +/// caps, scheme restriction, integrity) are set through the fluent +/// builder methods below. Every control is optional; an unset control +/// selects the host default for that field, reproducing +/// `astrid:http@1.0.0` behaviour. #[derive(Debug, Clone, Serialize)] pub struct Request { url: String, @@ -24,6 +69,31 @@ pub struct Request { headers: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] body: Option>, + + // Per-request controls (@1.1.0 `request-options`). Each `None` + // selects the host default for that field. + #[serde(skip_serializing_if = "Option::is_none")] + connect_timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + first_byte_timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + read_timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + redirect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_redirects: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_response_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_decompressed_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auto_decompress: Option, + #[serde(skip_serializing_if = "Option::is_none")] + https_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + integrity: Option, } impl Request { @@ -34,6 +104,17 @@ impl Request { method: method.into(), headers: HashMap::new(), body: None, + connect_timeout: None, + first_byte_timeout: None, + read_timeout: None, + total_timeout: None, + redirect: None, + max_redirects: None, + max_response_bytes: None, + max_decompressed_bytes: None, + auto_decompress: None, + https_only: None, + integrity: None, } } @@ -84,7 +165,123 @@ impl Request { Ok(self.header("Content-Type", "application/json").body(json)) } - /// Convert to the WIT `HttpRequestData` type. + // ---- Per-request controls (@1.1.0) ------------------------------- + + /// Set the whole-request hard deadline (connect + send + receive). + /// + /// A default the host may clamp to its own ceiling. Unset selects + /// the host default (30 s on the buffered path). + #[must_use] + pub fn timeout(mut self, dur: Duration) -> Self { + self.total_timeout = Some(dur); + self + } + + /// Set the TCP + TLS connection-establishment deadline. + /// + /// A default; the host may clamp it. Unset selects the host default. + #[must_use] + pub fn connect_timeout(mut self, dur: Duration) -> Self { + self.connect_timeout = Some(dur); + self + } + + /// Set the deadline from request fully sent to first response byte + /// (catches an accept-then-hang server). + /// + /// A default; the host may clamp it. Unset selects the host default. + #[must_use] + pub fn first_byte_timeout(mut self, dur: Duration) -> Self { + self.first_byte_timeout = Some(dur); + self + } + + /// Set the maximum idle gap between successive body chunks on the + /// streaming path (the between-bytes / per-chunk timeout). + /// + /// A default; the host may clamp it. Unset selects the host default + /// (120 s per chunk). + #[must_use] + pub fn read_timeout(mut self, dur: Duration) -> Self { + self.read_timeout = Some(dur); + self + } + + /// Set how the host treats a `3xx` redirect. + /// + /// A ceiling the host clamps: the host default for an untrusted + /// capsule is [`RedirectPolicy::Error`] — never a silent follow. + #[must_use] + pub fn redirect(mut self, policy: RedirectPolicy) -> Self { + self.redirect = Some(policy); + self + } + + /// Set the redirect hop limit (applies under + /// [`RedirectPolicy::Follow`]). + /// + /// A ceiling the host clamps to its own maximum. Unset selects the + /// host default. + #[must_use] + pub fn max_redirects(mut self, max: u32) -> Self { + self.max_redirects = Some(max); + self + } + + /// Cap the buffered response body in bytes. + /// + /// A ceiling the host clamps: the host enforces a hard maximum above + /// which `body-too-large` is returned regardless of this value. + /// Unset selects the host 10 MB default. + #[must_use] + pub fn max_response_bytes(mut self, max: u64) -> Self { + self.max_response_bytes = Some(max); + self + } + + /// Cap the DECOMPRESSED body in bytes (decompression-bomb defence). + /// + /// A ceiling the host clamps to its own maximum. Unset selects the + /// host default ceiling. + #[must_use] + pub fn max_decompressed_bytes(mut self, max: u64) -> Self { + self.max_decompressed_bytes = Some(max); + self + } + + /// Toggle automatic gzip / brotli / deflate / zstd decompression. + /// + /// `true` (the host default) decodes the body; `false` delivers the + /// raw wire bytes — required for binary or already-compressed + /// downloads. Unset selects the host default (`true`). + #[must_use] + pub fn auto_decompress(mut self, on: bool) -> Self { + self.auto_decompress = Some(on); + self + } + + /// Refuse any non-`https` scheme with a `scheme-denied` host error. + /// + /// A ceiling: the host rejects the request before any connection. + #[must_use] + pub fn https_only(mut self) -> Self { + self.https_only = Some(true); + self + } + + /// Require the response body to match a subresource-integrity digest. + /// + /// Formatted `sha256-` (also `sha384-` / `sha512-`). The + /// host verifies the body against the digest and returns an + /// `integrity-mismatch` host error on failure. + #[must_use] + pub fn integrity(mut self, digest: impl Into) -> Self { + self.integrity = Some(digest.into()); + self + } + + /// Convert to the WIT `http-request-data` type (shape unchanged from + /// @1.0.0 — per-request behaviour rides in [`Self::to_options`]). fn to_wit(&self) -> wit_http::HttpRequestData { wit_http::HttpRequestData { url: self.url.clone(), @@ -100,6 +297,37 @@ impl Request { body: self.body.clone(), } } + + /// Build the WIT `request-options` from the stored per-request + /// controls. An all-unset `Request` yields an all-`None` + /// `request-options`, which the host treats as @1.0.0 defaults. + fn to_options(&self) -> wit_http::RequestOptions { + let timeouts = if self.connect_timeout.is_some() + || self.first_byte_timeout.is_some() + || self.read_timeout.is_some() + || self.total_timeout.is_some() + { + Some(wit_http::TimeoutConfig { + connect_ms: self.connect_timeout.map(duration_to_ms), + first_byte_ms: self.first_byte_timeout.map(duration_to_ms), + between_bytes_ms: self.read_timeout.map(duration_to_ms), + total_ms: self.total_timeout.map(duration_to_ms), + }) + } else { + None + }; + + wit_http::RequestOptions { + timeouts, + redirect: self.redirect.map(RedirectPolicy::to_wit), + max_redirects: self.max_redirects, + max_response_bytes: self.max_response_bytes, + max_decompressed_bytes: self.max_decompressed_bytes, + auto_decompress: self.auto_decompress, + https_only: self.https_only, + integrity: self.integrity.clone(), + } + } } /// Parse a textual HTTP method to the typed @@ -120,6 +348,31 @@ fn parse_method(method: &str) -> wit_http::HttpMethod { } } +/// Post-request metadata carried on a buffered [`Response`] (@1.1.0). +/// +/// Constructed from the WIT `response-meta`. Read through the +/// [`Response`] accessors ([`Response::final_url`], +/// [`Response::redirect_count`], [`Response::elapsed`], +/// [`Response::wire_bytes`]). +#[derive(Debug, Clone)] +struct ResponseMeta { + final_url: String, + redirect_count: u32, + elapsed_ms: u64, + wire_bytes: u64, +} + +impl From for ResponseMeta { + fn from(m: wit_http::ResponseMeta) -> Self { + Self { + final_url: m.final_url, + redirect_count: m.redirect_count, + elapsed_ms: m.elapsed_ms, + wire_bytes: m.wire_bytes, + } + } +} + /// An HTTP response from a non-streaming request. /// /// All fields are private — use accessor methods to read them. @@ -128,6 +381,7 @@ pub struct Response { status: u16, headers: HashMap, body: Vec, + meta: ResponseMeta, } impl Response { @@ -164,15 +418,44 @@ impl Response { pub fn is_success(&self) -> bool { (200..300).contains(&self.status) } + + /// The final URL after any redirects were followed. + /// + /// Equals the request URL when no redirect occurred. New in @1.1.0. + #[must_use] + pub fn final_url(&self) -> &str { + &self.meta.final_url + } + + /// The number of redirect hops the host followed. New in @1.1.0. + #[must_use] + pub fn redirect_count(&self) -> u32 { + self.meta.redirect_count + } + + /// Wall-clock duration of the request (best-effort). New in @1.1.0. + #[must_use] + pub fn elapsed(&self) -> Duration { + Duration::from_millis(self.meta.elapsed_ms) + } + + /// Bytes received on the wire, before decompression (best-effort). + /// New in @1.1.0. + #[must_use] + pub fn wire_bytes(&self) -> u64 { + self.meta.wire_bytes + } } /// Send an HTTP request and wait for the full response. /// -/// 30 s timeout, 10 MB max response body. For larger / chunked bodies -/// use [`stream_start`]. +/// Default 30 s timeout and 10 MB max response body unless overridden +/// via the [`Request`] builders. For larger / chunked bodies use +/// [`stream_start`]. pub fn send(request: &Request) -> Result { let wit_req = request.to_wit(); - let result = wit_http::http_request(&wit_req).map_err(host_err)?; + let opts = request.to_options(); + let result = wit_http::http_request_opts(&wit_req, &opts).map_err(host_err)?; let headers: HashMap = result .headers .into_iter() @@ -182,6 +465,7 @@ pub fn send(request: &Request) -> Result { status: result.status, headers, body: result.body, + meta: result.meta.into(), }) } @@ -190,6 +474,11 @@ pub fn send(request: &Request) -> Result { /// Owns the kernel-side `http-stream` resource. Drop closes the stream /// automatically — capsules don't need to call `close` explicitly. /// Per-capsule cap: 4 concurrent HTTP streams. +// +// TODO(http@1.1.0): the host `http-stream.trailers` accessor is not yet +// surfaced here — the host returns `none` (no trailers) for now, so a +// trailers() wrapper would be a non-functional API. Add it once the host +// populates trailers. #[derive(Debug)] pub struct HttpStream { inner: wit_http::HttpStream, @@ -232,9 +521,17 @@ impl HttpStream { /// /// Sends the request and waits for the status/headers to arrive. /// Returns an [`HttpStream`] from which the body is read in chunks. +/// Per-request controls set on the [`Request`] (the between-bytes +/// timeout, redirect policy, scheme restriction, etc.) apply. +// +// TODO(http@1.1.0): the host also exposes `http-upload-start` for a +// streaming request body (upload). It is not surfaced here — the host +// returns not-implemented for now. Add the upload path once the host +// implements it. pub fn stream_start(request: &Request) -> Result { let wit_req = request.to_wit(); - let stream = wit_http::http_stream_start(&wit_req).map_err(host_err)?; + let opts = request.to_options(); + let stream = wit_http::http_stream_start_opts(&wit_req, &opts).map_err(host_err)?; let status = stream.status(); let headers: HashMap = stream .headers() @@ -247,3 +544,102 @@ pub fn stream_start(request: &Request) -> Result { headers, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duration_to_ms_converts_whole_millis() { + assert_eq!(duration_to_ms(Duration::from_millis(250)), 250); + assert_eq!(duration_to_ms(Duration::from_secs(30)), 30_000); + assert_eq!(duration_to_ms(Duration::ZERO), 0); + } + + #[test] + fn duration_to_ms_saturates_at_u64_max() { + // A Duration whose millis exceed u64::MAX must clamp, not wrap. + let huge = Duration::new(u64::MAX, 0); + assert_eq!(duration_to_ms(huge), u64::MAX); + } + + #[test] + fn redirect_policy_maps_to_wit() { + assert!(matches!( + RedirectPolicy::Follow.to_wit(), + wit_http::RedirectPolicy::Follow + )); + assert!(matches!( + RedirectPolicy::Error.to_wit(), + wit_http::RedirectPolicy::Error + )); + assert!(matches!( + RedirectPolicy::Manual.to_wit(), + wit_http::RedirectPolicy::Manual + )); + } + + #[test] + fn unset_request_builds_empty_options() { + // An untouched request must produce all-None options so the host + // applies @1.0.0 defaults across the board. + let req = Request::get("https://example.com"); + let opts = req.to_options(); + assert!(opts.timeouts.is_none()); + assert!(opts.redirect.is_none()); + assert!(opts.max_redirects.is_none()); + assert!(opts.max_response_bytes.is_none()); + assert!(opts.max_decompressed_bytes.is_none()); + assert!(opts.auto_decompress.is_none()); + assert!(opts.https_only.is_none()); + assert!(opts.integrity.is_none()); + } + + #[test] + fn timeout_builders_populate_timeout_config() { + let req = Request::get("https://example.com") + .connect_timeout(Duration::from_millis(100)) + .first_byte_timeout(Duration::from_millis(200)) + .read_timeout(Duration::from_millis(300)) + .timeout(Duration::from_secs(5)); + let opts = req.to_options(); + let t = opts.timeouts.expect("timeouts populated"); + assert_eq!(t.connect_ms, Some(100)); + assert_eq!(t.first_byte_ms, Some(200)); + assert_eq!(t.between_bytes_ms, Some(300)); + assert_eq!(t.total_ms, Some(5_000)); + } + + #[test] + fn partial_timeouts_leave_other_fields_none() { + let req = Request::get("https://example.com").timeout(Duration::from_secs(2)); + let t = req.to_options().timeouts.expect("timeouts populated"); + assert_eq!(t.total_ms, Some(2_000)); + assert!(t.connect_ms.is_none()); + assert!(t.first_byte_ms.is_none()); + assert!(t.between_bytes_ms.is_none()); + } + + #[test] + fn control_builders_populate_request_options() { + let req = Request::get("https://example.com") + .redirect(RedirectPolicy::Follow) + .max_redirects(7) + .max_response_bytes(1024) + .max_decompressed_bytes(2048) + .auto_decompress(false) + .https_only() + .integrity("sha256-abc"); + let opts = req.to_options(); + assert!(matches!( + opts.redirect, + Some(wit_http::RedirectPolicy::Follow) + )); + assert_eq!(opts.max_redirects, Some(7)); + assert_eq!(opts.max_response_bytes, Some(1024)); + assert_eq!(opts.max_decompressed_bytes, Some(2048)); + assert_eq!(opts.auto_decompress, Some(false)); + assert_eq!(opts.https_only, Some(true)); + assert_eq!(opts.integrity.as_deref(), Some("sha256-abc")); + } +} diff --git a/astrid-sdk/src/lib.rs b/astrid-sdk/src/lib.rs index 3697784..98daecb 100644 --- a/astrid-sdk/src/lib.rs +++ b/astrid-sdk/src/lib.rs @@ -47,7 +47,11 @@ use astrid_sys::astrid::approval::host as wit_approval; use astrid_sys::astrid::elicit::host as wit_elicit; use astrid_sys::astrid::fs::host as wit_fs; -use astrid_sys::astrid::http::host as wit_http; +// `http` moved to @1.1.0. With both `http@1.0.0` and `http@1.1.0` +// staged side by side, wit-bindgen disambiguates the generated module +// by version (`http1_1_0`). The alias absorbs that name so the http +// wrappers keep referring to `wit_http`. +use astrid_sys::astrid::http1_1_0::host as wit_http; use astrid_sys::astrid::identity::host as wit_identity; use astrid_sys::astrid::ipc::host as wit_ipc; use astrid_sys::astrid::kv::host as wit_kv; From 5bd6c63413da2037a7b66a9f48258b97d0b9a7d6 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 21:42:40 +0400 Subject: [PATCH 3/3] docs(sys): correct WIT staging layout to per-version deps dirs The staging moved to deps/astrid-@/ (multi-version support), but the build.rs top comment, the astrid-sys crate doc, and CLAUDE.md still described the old deps/astrid-/ layout. Per Copilot review on #60. --- astrid-sys/build.rs | 4 +++- astrid-sys/src/lib.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/astrid-sys/build.rs b/astrid-sys/build.rs index 02f2849..8a2e9d5 100644 --- a/astrid-sys/build.rs +++ b/astrid-sys/build.rs @@ -7,7 +7,9 @@ //! //! wit-bindgen expects a single root directory with one package per //! `deps//` subdir, so we copy each `host/@.wit` into -//! `wit-staging/deps/astrid-/@.wit`. The synthetic +//! `wit-staging/deps/astrid-@/@.wit` — keyed by the +//! full `@` stem so a package can ship multiple frozen versions +//! (e.g. `http@1.0.0` + `http@1.1.0`) side by side. The synthetic //! `capsule` world that imports every host package and includes every //! guest export world is supplied via the `inline:` option in //! `src/lib.rs`. diff --git a/astrid-sys/src/lib.rs b/astrid-sys/src/lib.rs index 9b5ed31..42048e3 100644 --- a/astrid-sys/src/lib.rs +++ b/astrid-sys/src/lib.rs @@ -2,7 +2,7 @@ //! //! This crate generates typed guest bindings from the per-domain WIT //! packages in `contracts/host/` (the `unicity-astrid/wit` submodule). -//! `build.rs` stages those files into `wit-staging/deps/astrid-/`; +//! `build.rs` stages those files into `wit-staging/deps/astrid-@/`; //! a single [`wit_bindgen::generate!`] invocation then emits one Rust //! module per package under a synthetic `capsule` world that imports //! every host package and includes every guest export world.