Skip to content

feat(net): add net-connect-tcp for outbound TCP#5

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

feat(net): add net-connect-tcp for outbound TCP#5
joshuajbouw merged 2 commits into
mainfrom
feat/net-connect-tcp

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

Summary

Adds one host function — net-connect-tcp(host, port) -> stream-handle — to the existing astrid:capsule/net interface. Returns the same handle type as net-accept, so the existing net-read / net-write / net-close-stream already operate on it without further WIT changes.

Motivation

The host ABI today has http-request / http-stream-start for one-shot fetch and net-accept for inbound Unix sockets. There is no path for outbound persistent TCP — blocking WebSocket clients, MQTT, Discord/Telegram gateways, postgres/redis, and the immediate forcing function: a Unicity-network capsule wrapping Sphere SDK that needs Fulcrum + Nostr WebSocket transports.

This adds the missing primitive. WebSocket and every higher protocol stay in user-space (tungstenite / npm ws); the host gives capsules what std::net::TcpStream gives a Rust binary.

Design

  • Wire shape: net-connect-tcp(host: string, port: u16) -> result<u64, string>. Returns a stream-handle (u64), same type as net-accept. Existing read/write/close already accept this handle.
  • No new types, no new interface. Strictly additive to astrid:capsule/net. Capsules that don't call this fn are byte-identical at the wit-bindgen layer.
  • Security gates (all kernel-side, documented in WIT doc comment):
    • Per-capsule net_connect = ["host:port"] allowlist in Capsule.toml (kernel-side, separate PR). Fail-closed when missing or empty.
    • SSRF airlock on resolved IP — reuses the is_safe_ip already gating http-request / http-stream-start.
    • Per-capsule active-stream cap (MAX_ACTIVE_STREAMS = 8) shared with net-accept.
    • Connect timeout (10s default) to avoid indefinite host-fn holds.

RFC

Full design rationale, alternatives, and SDK shape in unicity-astrid/rfcs#27.

Tracking issue: unicity-astrid/astrid#745.

After merge

  • Bump submodule in sdk-rust + regenerate bindings + add astrid_sdk::net::connect + TcpStream facade
  • Bump submodule in sdk-js + add connect() + Stream type
  • Add kernel impl in astrid-capsule/src/engine/wasm/host/net.rs + manifest parser net_connect field

Adds one host fn — net-connect-tcp(host, port) -> stream-handle — to
the astrid:capsule/net interface. Returns the same handle type as
net-accept, so net-read / net-write / net-close-stream already work
with it.

Capability gate: per-capsule net_connect = ["host:port"] allowlist
in Capsule.toml (defined kernel-side, this RFC describes the wire
shape only). SSRF airlock runs on the resolved IP, matching the gate
on http-request. Connect timeout bounded to 10s default.

Unblocks WebSocket, MQTT, Discord/Telegram gateways, postgres/redis,
and the immediate forcing function: a Unicity-network capsule
wrapping Sphere SDK (Fulcrum + Nostr persistent WebSocket).

RFC: astrid-runtime/rfcs#27
Tracking issue: astrid-runtime/astrid#745
Adds 14 functions + shutdown-how enum to astrid:capsule/net for parity
with std::net::TcpStream:

- net-read-bytes / net-write-bytes: byte-stream read/write (no
  length-prefix framing — distinct from the legacy framed net-read /
  net-write used by the inbound Unix-accept proxy)
- net-peek: peek without consuming
- net-shutdown(read|write|both): half-close
- net-peer-addr / net-local-addr: address introspection
- net-set-nodelay / net-nodelay: TCP_NODELAY
- net-set-read-timeout / net-read-timeout
- net-set-write-timeout / net-write-timeout
- net-set-ttl / net-ttl: IP TTL

Existing net-read / net-write keep their framed semantics (the CLI
proxy uplink remains structurally framed). Both pairs operate on the
same stream-handle type — capsule code picks the matching pair for
its protocol.

Unblocks generic Read+Write consumers — tungstenite, rustls, postgres
drivers — to drop in unmodified.

RFC: astrid-runtime/rfcs#27
joshuajbouw added a commit to astrid-runtime/sdk-rust that referenced this pull request May 19, 2026
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
joshuajbouw added a commit to astrid-runtime/astrid that referenced this pull request May 19, 2026
Implements 14 new host fns to give capsules complete std::net::TcpStream
parity. All are kernel-side; the SDK wraps them in unicity-astrid/sdk-rust.

New fns on net::Host:
- net_read_bytes / net_write_bytes: byte-stream read/write (no
  length-prefix framing). Read returns empty Vec on EOF; timeout
  surfaces as 'read would block' / 'write would block' to match
  std::io::ErrorKind::WouldBlock.
- net_peek: peek without consuming
- net_shutdown(read|write|both): half-close (write supported; read/both
  return clear errors pending socket2 integration)
- net_peer_addr / net_local_addr: address introspection
- net_set_nodelay / net_nodelay: TCP_NODELAY toggle
- net_set_read_timeout / net_read_timeout (stored per-stream)
- net_set_write_timeout / net_write_timeout (stored per-stream)
- net_set_ttl / net_ttl: IP TTL

Refactor:
- NetStream::Tcp now holds TcpStreamSlot { stream, read_timeout,
  write_timeout } instead of a bare Arc<Mutex<TcpStream>>. Per-stream
  timeout state lives next to the socket so the byte-stream
  read/write fns can apply tokio::time::timeout per call without
  global state.
- net.rs split into net/ submodule (handshake.rs + stream.rs +
  mod.rs) to stay under the 1000-line CI cap.

Unix variants reject TCP-only ops (nodelay, ttl, addresses, timeouts)
with clear errors — those are TCP socket options that don't apply.

RFC: astrid-runtime/rfcs#27
WIT: astrid-runtime/wit#5
Tracking issue: closes #745
joshuajbouw added a commit to astrid-runtime/sdk-js that referenced this pull request May 19, 2026
Mirrors the kernel + sdk-rust expansion in unicity-astrid/sdk-rust
feat/net-connect-tcp.

Ambient module additions (astrid:capsule/[email protected]):
- ShutdownHow type ('read' | 'write' | 'both')
- 14 new functions: netReadBytes, netWriteBytes, netPeek,
  netShutdown, netPeerAddr, netLocalAddr, netSetNodelay, netNodelay,
  netSetReadTimeout, netReadTimeout, netSetWriteTimeout,
  netWriteTimeout, netSetTtl, netTtl

StreamHandle methods:
- readBytes(maxBytes), writeBytes(data), peek(maxBytes)
- shutdown(how)
- peerAddr(), localAddr()
- setNodelay(b), nodelay()
- setReadTimeout(ms), readTimeout()
- setWriteTimeout(ms), writeTimeout()
- setTtl(n), ttl()

The existing recv / send / close framed surface is unchanged — it's
the right shape for the inbound CLI proxy. Outbound TCP capsules use
the byte-stream pair.

RFC: astrid-runtime/rfcs#27
WIT: astrid-runtime/wit#5
SDK (rust): unicity-astrid/sdk-rust feat/net-connect-tcp
Tracking issue: astrid-runtime/astrid#745
@joshuajbouw joshuajbouw merged commit 9244c74 into main May 19, 2026
@joshuajbouw joshuajbouw deleted the feat/net-connect-tcp branch May 19, 2026 11:55
joshuajbouw added a commit to astrid-runtime/sdk-js that referenced this pull request May 19, 2026
Replaces the stub from 5a12d7d with the real impl now that the host
side has landed (astrid-runtime/wit#5 + astrid#745 feat/net-connect-tcp).

Changes:
- packages/astrid-sdk/src/wit-imports.d.ts: declare netConnectTcp in
  the astrid:capsule/[email protected] ambient module.
- packages/astrid-sdk/src/net.ts: connect() now calls hostConnectTcp
  through the existing callHost wrapper; throws SysError on capability
  denial, SSRF rejection, DNS failure, or connect timeout. JSDoc lists
  the three kernel-side checks operators care about (allowlist, SSRF
  airlock, active-stream cap) so capsule authors don't have to read
  the host source to understand failure modes.
- contracts/: submodule bumped to feat/net-connect-tcp tip (matches the
  WIT PR head).
- packages/astrid-sdk/wit-contracts/astrid-contracts.wit: regenerated by
  scripts/sync-contracts-wit.sh (no diff vs main beyond canonical updates).

Returns the same StreamHandle type as accept() — recv / tryRecv / send /
close all Just Work without runtime branching.

RFC: astrid-runtime/rfcs#27
WIT: astrid-runtime/wit#5
SDK (rust): unicity-astrid/sdk-rust feat/net-connect-tcp
Tracking issue: astrid-runtime/astrid#745
joshuajbouw added a commit to astrid-runtime/sdk-js that referenced this pull request May 19, 2026
Mirrors the kernel + sdk-rust expansion in unicity-astrid/sdk-rust
feat/net-connect-tcp.

Ambient module additions (astrid:capsule/[email protected]):
- ShutdownHow type ('read' | 'write' | 'both')
- 14 new functions: netReadBytes, netWriteBytes, netPeek,
  netShutdown, netPeerAddr, netLocalAddr, netSetNodelay, netNodelay,
  netSetReadTimeout, netReadTimeout, netSetWriteTimeout,
  netWriteTimeout, netSetTtl, netTtl

StreamHandle methods:
- readBytes(maxBytes), writeBytes(data), peek(maxBytes)
- shutdown(how)
- peerAddr(), localAddr()
- setNodelay(b), nodelay()
- setReadTimeout(ms), readTimeout()
- setWriteTimeout(ms), writeTimeout()
- setTtl(n), ttl()

The existing recv / send / close framed surface is unchanged — it's
the right shape for the inbound CLI proxy. Outbound TCP capsules use
the byte-stream pair.

RFC: astrid-runtime/rfcs#27
WIT: astrid-runtime/wit#5
SDK (rust): unicity-astrid/sdk-rust feat/net-connect-tcp
Tracking issue: astrid-runtime/astrid#745
joshuajbouw added a commit to astrid-runtime/sdk-rust that referenced this pull request 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 added a commit to astrid-runtime/sdk-rust that referenced this pull request May 19, 2026
## 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_addr` — `ip:port` strings.
- `set_nodelay` / `nodelay` — TCP_NODELAY.
- `set_read_timeout` / `read_timeout` — `Option<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`).

```rust
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
[astrid-runtime/wit#5](astrid-runtime/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

- RFC:
[astrid-runtime/rfcs#27](astrid-runtime/rfcs#27)
- WIT:
[astrid-runtime/wit#5](astrid-runtime/wit#5)
- Tracking issue:
[astrid-runtime/astrid#745](astrid-runtime/astrid#745)

## Verification

- `cargo build/test/clippy -p astrid-sdk` clean
- Doctest on `TcpStream` compiles
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/[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)
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