Skip to content

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

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

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

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

Summary

JS/TS mirror of unicity-astrid/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 unicity-astrid/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

Adds `connect(host, port)` to the JS SDK's net module with the exact
shape RFC astrid-runtime/rfcs#27 specifies — returns a StreamHandle that
flows through the existing recv / tryRecv / send / close surface, same
type as the accept path returns.

The host fn (`astrid:capsule/net.net-connect-tcp`) hasn't landed yet
(tracking astrid-runtime/astrid#745). The body throws SysError.api with
the tracking URLs so capsules attempting to use it before the host
side ships get a clear, actionable error.

Why land the stub now: capsules being built against the final
unicity-astrid/wit contract (Unicity / Sphere SDK port, Discord
bridges, MQTT, etc.) can import `connect` and write against the final
API today. When astrid#745 merges, this commit becomes a one-line
swap: replace the `throw SysError.api(...)` with the commented-in
`callHost(...)` line above it. No callers change.
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/net@0.1.0 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
Mirrors the kernel + sdk-rust expansion in unicity-astrid/sdk-rust
feat/net-connect-tcp.

Ambient module additions (astrid:capsule/net@0.1.0):
- 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
- toHostTimeout helper validates + converts. Rejects 0, NaN, negative,
  non-finite — matches std::net::TcpStream::set_{read,write}_timeout
  semantics where Duration::ZERO is an error.
- setReadTimeout / setWriteTimeout both go through the helper.
- readBytes / peek docstrings made the EOF-vs-would-block contract
  explicit (empty Uint8Array = EOF; would-block surfaces as a
  SysError caller can pattern-match).

IPv6 bracket fix from the sdk-rust review doesn't apply here —
JS connect(host, port) takes them as separate args.
Was pinned at f5039e3 — the FIRST commit on the wit PR branch, which
added only the single net-connect-tcp fn. The expanded surface (14
more fns + shutdown-how enum) landed in the subsequent commit
215ba1c that this submodule reference had not been updated to.
JS-side bindings in wit-imports.d.ts were already declared for the
full surface, so this was a documentation lag rather than a runtime
mismatch, but the submodule should reference the canonical main
SHA (now 9244c74 post squash-merge).
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 e3b7f25 into main May 19, 2026
@joshuajbouw joshuajbouw deleted the feat/net-connect-tcp branch May 19, 2026 12:50
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