feat(sdk,net): full std::net::TcpStream surface - #42
Conversation
Adds astrid_sdk::net::connect(host, port) — the outbound-TCP analogue of the existing accept — and a std::net::TcpStream-shaped facade with std::io::Read + Write impls. RAII close-on-drop. Generic code over Read + Write (tungstenite for WebSocket, rustls, postgres drivers) works unmodified. Submodule bump pulls astrid-runtime/wit#5 (the host-side net-connect-tcp fn). Capability gate (per-capsule `net_connect` allowlist in Capsule.toml) and SSRF airlock are kernel-side and land in a separate core PR. The SDK wrapper passes host/port straight through. RFC: astrid-runtime/rfcs#27 Tracking issue: astrid-runtime/astrid#745
Extends astrid_sdk::net to mirror std::net::TcpStream completely. Wraps the 14 new host fns added in astrid-runtime/wit#5 amendment: Free fns: - read_bytes / write_bytes (byte-stream, no framing) - peek - shutdown(Shutdown::{Read,Write,Both}) — std::net::Shutdown analogue - peer_addr / local_addr - set_nodelay / nodelay - set_read_timeout / read_timeout (Duration) - set_write_timeout / write_timeout (Duration) - set_ttl / ttl TcpStream methods: - All of the above, returning std::io::Error - std::io::Read uses read_bytes (no length-prefix surplus buffering) - std::io::Write uses write_bytes (no implicit length prefix) - Drop calls close() Generic Read+Write consumers (tungstenite for WebSocket, rustls, postgres drivers) work unmodified now that the SDK's Read/Write operate on byte-stream semantics. RFC: astrid-runtime/rfcs#27 WIT: astrid-runtime/wit#5 Tracking issue: astrid-runtime/astrid#745
There was a problem hiding this comment.
Code Review
This pull request introduces outbound TCP support to the astrid-sdk, adding a low-level connect function and a high-level TcpStream facade that implements std::io::Read and std::io::Write. The WIT interface has been expanded to include new functions for byte-stream operations, peeking, and socket options like TTL and Nagle's algorithm. Feedback identifies a critical issue where the Read and peek implementations incorrectly return Ok(0) when no data is ready, which is ambiguous with EOF. Other recommendations include refactoring timeout validation into a shared helper function and improving IPv6 address parsing to handle square brackets.
Addresses Gemini code-review feedback on astrid-runtime/sdk-rust#42. Before: - timeout=None falls through to a 50ms internal timeout. - Timeout expiry with no data returned Ok(empty Vec). - Caller can't distinguish 'transient no data' from 'peer disconnected'. After: - timeout=None drops the tokio::time::timeout wrapper entirely. Reads block until data, EOF, or capsule unload (via the outer bounded_block_on_cancellable cancellation token). - Empty Vec is now ALWAYS EOF — matches std::io::Read's Ok(0) contract. - 'read would block' error only surfaces when the caller has explicitly set a read timeout that expired. SDK-side, that maps to std::io::ErrorKind::WouldBlock so std::Read callers can distinguish. The peek and write_bytes paths already had the right shape; only net_read_bytes carried the bug.
- to_host_timeout helper rejects Some(Duration::ZERO) to match
std::net::TcpStream::set_{read,write}_timeout. set_read_timeout
and set_write_timeout now go through this helper.
- parse_host_port strips IPv6 brackets — `[::1]:443` correctly
yields ("::1", 443). Handles bracket-less ambiguous v6 (multiple
colons, no port) as a clean error.
- io_error_from_net_op maps the host's 'read would block' /
'write would block' / 'peek would block' sentinel to
std::io::ErrorKind::WouldBlock so std::Read / Write / peek callers
can distinguish timeout-fired from EOF. Combined with the kernel
fix that empty Vec now unambiguously means EOF, std::Read::read
returning Ok(0) is no longer a false EOF.
- 9 new unit tests cover parse_host_port (v4, v6, malformed),
to_host_timeout (None / Zero / passthrough), and
io_error_from_net_op (WouldBlock mapping).
astrid-runtime/wit#5 (full std::net::TcpStream surface) merged to main as commit 9244c74 (squash). Submodule was pinned at the pre-squash PR head 215ba1c — content-identical but referencing a non-canonical SHA. Bump to main keeps the reference auditable. astrid-sys/wit/astrid-capsule.wit is unchanged — the squash didn't modify the file content, and sync-host-wit.sh produced an empty diff.
) ## Linked Issue Closes #745 ## Summary Adds outbound TCP to the host ABI: `net-connect-tcp` plus 14 more host fns that give capsules full `std::net::TcpStream` parity. Unblocks every capsule that needs persistent TCP — WebSocket clients (Fulcrum, Nostr), MQTT, Discord/Telegram gateways, postgres / redis. Strictly additive to the existing `net` interface; **TLS is not in scope** (capsules ship their own crate, matching the `std::net` model where TLS lives in user-space). ## Changes - **WIT**: syncs canonical [astrid-runtime/wit#5](astrid-runtime/wit#5) into in-tree `wit/astrid-capsule.wit`. Adds the `shutdown-how` enum + 15 new `net-*` fns (`connect-tcp`, `read-bytes`, `write-bytes`, `peek`, `shutdown`, `peer-addr`, `local-addr`, `set-nodelay` / `nodelay`, `set-read-timeout` / `read-timeout`, `set-write-timeout` / `write-timeout`, `set-ttl` / `ttl`) and brings in `ipc-message.principal` (canonical PR #4) which had drifted from the in-tree copy. - **Manifest**: `CapabilitiesDef.net_connect: Vec<String>` — per-capsule allowlist for outbound TCP destinations. Each entry is `"host:port"` (exact) or `"host:*"` (any port). Empty / missing denies all outbound TCP (fail-closed). Independent of `net_bind`, `http`, `host_process`. - **Kernel** (`engine/wasm/host/net/`): `net_connect_tcp` runs capability check → host string validation → DNS resolve → SSRF airlock (`is_safe_ip`, same gate as `http-request`) → bounded 10s connect. `read_bytes` / `write_bytes` are byte-stream variants honouring per-stream timeouts (empty Vec = EOF unambiguously; timeout fires as `would block` for SDK-side `ErrorKind::WouldBlock` mapping). `peek` uses tokio's native `TcpStream::peek`. `shutdown` uses `socket2::SockRef` for full `Read|Write|Both` direction support. - **Security caps**: per-call read/peek buffer capped at `MAX_BYTES_PER_CALL = 10 MB` (guards against `u32::MAX` OOM). Host string validation (non-empty, ≤255 bytes per RFC 1035, no null bytes) at the host-fn boundary. - **Security gate**: new `CapsuleSecurityGate::check_net_connect` trait method, default-deny. `ManifestSecurityGate` impl matches `host:port` against the manifest allowlist (case-insensitive host, exact-or-`*` port). - **Refactors**: `net.rs` split into `net/{mod,handshake,stream}.rs` and `manifest.rs` split into `manifest/{mod,capabilities,topics}.rs` to stay under the 1000-line CI cap. `pub use` re-exports preserve the public API. ## Test Plan ### Automated - [x] `cargo test --workspace` passes (296 lib tests in astrid-capsule — was 285, + 11 new covering net_connect pattern matching, socket2 shutdown round-trip, validate_host gates) - [x] No new clippy warnings (`cargo clippy --workspace -- -D warnings` clean) - [x] `cargo +nightly fmt --all --check` clean ### Manual - [ ] Tag a SDK release (separate PR per CLAUDE.md) so capsule authors can bump - [ ] Smoke-test a capsule against a public `ws://` endpoint over the new surface (deferred to a follow-up capsule PR) ## Refs - RFC: [astrid-runtime/rfcs#27](astrid-runtime/rfcs#27) - WIT: [astrid-runtime/wit#5](astrid-runtime/wit#5) - SDK (rust): [astrid-runtime/sdk-rust#42](astrid-runtime/sdk-rust#42) - SDK (js): [astrid-runtime/sdk-js#3](astrid-runtime/sdk-js#3) ## Checklist - [x] Linked to an issue - [x] CHANGELOG.md updated under `[Unreleased]`
## Summary JS/TS mirror of [astrid-runtime/sdk-rust#42](astrid-runtime/sdk-rust#42). Adds outbound TCP for capsules — `connect()` plus the full set of methods a Rust binary's `std::net::TcpStream` exposes — on the `StreamHandle` class. ## Changes ### Ambient module (`wit-imports.d.ts`) Declares the 14 new host fns + `ShutdownHow` type in the `astrid:capsule/[email protected]` module, matching the canonical WIT in [astrid-runtime/wit#5](astrid-runtime/wit#5). ### `net.ts` - `connect(host, port)` — outbound TCP (already in this branch's tip, now upgraded from a stub). - New `StreamHandle` methods: - `readBytes(maxBytes)` / `writeBytes(data)` — byte-stream read/write (no length-prefix framing). - `peek(maxBytes)` — non-destructive read. - `shutdown(how)` — half-close (`'read' | 'write' | 'both'`). - `peerAddr()` / `localAddr()` — `ip:port` strings. - `setNodelay(b)` / `nodelay()` — TCP_NODELAY. - `setReadTimeout(ms)` / `readTimeout()`. - `setWriteTimeout(ms)` / `writeTimeout()`. - `setTtl(n)` / `ttl()` — IP TTL. ### Submodule `contracts/` bumped to `feat/net-connect-tcp` tip (matches WIT PR head). ### Unchanged The existing framed surface (`recv` / `tryRecv` / `send` / `close` + `AsyncIterable<Uint8Array>`) keeps its semantics — that's the right shape for the inbound CLI proxy. Outbound TCP capsules use the byte-stream pair (`readBytes` / `writeBytes`). ## TLS Out of scope. JS-side capsules ship their own WebSocket / TLS library over `readBytes` / `writeBytes`, same as native Rust binaries do — std::net::TcpStream doesn't ship TLS, and we're holding that line. ## Verification - `npm run -w packages/astrid-sdk build` clean (tsc -b) - 64 contract types regenerate cleanly via the prebuild script ## Refs - RFC: [astrid-runtime/rfcs#27](astrid-runtime/rfcs#27) - WIT: [astrid-runtime/wit#5](astrid-runtime/wit#5) - SDK (rust): [astrid-runtime/sdk-rust#42](astrid-runtime/sdk-rust#42) - Core: [astrid-runtime/astrid#746](astrid-runtime/astrid#746) - Tracking issue: [astrid-runtime/astrid#745](astrid-runtime/astrid#745)
Summary
Outbound TCP for capsules. Mirrors
std::net::TcpStreamend-to-end — every method a Rust binary expects on a connected TCP stream is available on the SDK side. WebSocket libraries, TLS wrappers, postgres drivers — anything generic overRead + Write— drops in unmodified.Added
Free functions (low-level, one per WIT host fn)
connect(host, port) -> StreamHandle— opens an outbound TCP connection. Capability-gated againstCapsule.tomlnet_connectallowlist; SSRF airlock on resolved IP; 10s connect timeout.read_bytes(stream, max) -> Vec<u8>/write_bytes(stream, data) -> u32— byte-stream read/write (no length-prefix framing).peek(stream, max) -> Vec<u8>— non-destructive read.shutdown(stream, Shutdown::{Read,Write,Both})— half-close. Backed bysocket2::SockReffor full direction support.peer_addr/local_addr—ip:portstrings.set_nodelay/nodelay— TCP_NODELAY.set_read_timeout/read_timeout—Option<Duration>.set_write_timeout/write_timeout.set_ttl/ttl— IP TTL.TcpStreamfacadestd::net::TcpStream-shaped wrapper aroundStreamHandlewith std::io::Read + Write impls, RAII close-on-drop, and matching inherent methods (set_nodelay,peer_addr,shutdown(Shutdown::*),peek, timeouts,set_ttl).ShutdownenumLocal mirror of
std::net::Shutdown(Read / Write / Both), wired to the WITshutdown-howenum.Submodule
contracts/bumped tofeat/net-connect-tcptip on canonical unicity-astrid/wit#5.TLS
Out of scope — std::net::TcpStream doesn't ship TLS either. Capsules that need TLS bring their own crate (
rustls, etc.) over this byte-stream surface, matching how native Rust binaries do it.Refs
Verification
cargo build/test/clippy -p astrid-sdkcleanTcpStreamcompiles