Skip to content

feat(sdk,net): full std::net::TcpStream surface#42

Merged
joshuajbouw merged 5 commits into
mainfrom
feat/net-connect-tcp
May 19, 2026
Merged

feat(sdk,net): full std::net::TcpStream surface#42
joshuajbouw merged 5 commits into
mainfrom
feat/net-connect-tcp

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

Summary

Outbound TCP for capsules. Mirrors std::net::TcpStream end-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 over Read + 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 against Capsule.toml net_connect allowlist; 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 by socket2::SockRef for full direction support.
  • peer_addr / local_addrip:port strings.
  • set_nodelay / nodelay — TCP_NODELAY.
  • set_read_timeout / read_timeoutOption<Duration>.
  • set_write_timeout / write_timeout.
  • set_ttl / ttl — IP TTL.

TcpStream facade

std::net::TcpStream-shaped wrapper around StreamHandle with std::io::Read + Write impls, RAII close-on-drop, and matching inherent methods (set_nodelay, peer_addr, shutdown(Shutdown::*), peek, timeouts, set_ttl).

use astrid_sdk::net::TcpStream;
use std::io::{Read, Write};

let mut sock = TcpStream::connect("fulcrum.unicity.network:443")?;
sock.set_nodelay(true)?;
sock.write_all(b"GET / HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n")?;

Shutdown enum

Local mirror of std::net::Shutdown (Read / Write / Both), wired to the WIT shutdown-how enum.

Submodule

contracts/ bumped to feat/net-connect-tcp tip 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-sdk clean
  • Doctest on TcpStream compiles

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread astrid-sdk/src/net.rs
Comment thread astrid-sdk/src/net.rs
Comment thread astrid-sdk/src/net.rs
Comment thread astrid-sdk/src/net.rs
Comment thread astrid-sdk/src/net.rs
joshuajbouw added a commit to astrid-runtime/astrid that referenced this pull request May 19, 2026
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).
@joshuajbouw joshuajbouw self-assigned this May 19, 2026
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.
joshuajbouw added a commit to astrid-runtime/astrid that referenced this pull request May 19, 2026
)

## 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]`
@joshuajbouw joshuajbouw merged commit 85d9860 into main May 19, 2026
8 checks passed
@joshuajbouw joshuajbouw deleted the feat/net-connect-tcp branch May 19, 2026 12:50
joshuajbouw added a commit to astrid-runtime/sdk-js that referenced this pull request May 19, 2026
## 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/net@0.1.0` 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant