Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Outbound TCP host fn (net.connect-tcp) to unblock persistent-network capsules #745

Description

@joshuajbouw

Summary

Add an outbound TCP connect(host, port) → stream-handle to the astrid:capsule/net interface, capability-gated against per-capsule allowlists in Capsule.toml. This unblocks any capsule that needs persistent TCP — WebSocket clients, Discord/Telegram bridges, MQTT, IRC, postgres / redis, and the immediate motivator: a Unicity-network capsule that wraps Sphere SDK.

Mirrors the std analogy exactly: TCP belongs in the host ABI (like std::net::TcpStream), WebSocket and protocol-specific clients live in user-space (like tungstenite does).

Motivation

Working on a JS/TS capsule that ports openclaw-unicity — Unicity wallet + Nostr DMs via Sphere SDK. Two load-bearing transports needed:

  • Fulcrum WebSocket — ALPHA blockchain JSON-RPC (every wallet / L1 op).
  • Nostr relays — NIP-29 groups + NIP-17 gift-wrapped DMs (channel uplink, messaging, payment requests, swaps).

Both are persistent WebSocket over TCP. The current host surface has no path:

Interface What it does Why insufficient
astrid:capsule/http one-shot fetch + streaming reads request/response only, no persistent connection, no protocol upgrade
astrid:capsule/net Unix-domain listener (bind-unix + accept/read/write) inbound only — no connect, no TCP

The reduced-scope option (port Sphere features that don't need WebSocket) drops 12 of 15 tools and most of the value. The hybrid option (spawn a Node sidecar via process.spawnBackground) re-creates OpenClaw Tier 2 with extra wrapping. Both are workarounds for a missing primitive.

This issue argues for adding the primitive.

Proposed design

Extend the existing astrid:capsule/net interface with one new function:

/// Open an outbound TCP connection. Returns a stream handle compatible
/// with the existing net-read / net-write / net-close-stream functions.
///
/// Security-gated: requires the destination (host:port or host:*) to be
/// in the capsule's `net_connect` capability allowlist. SSRF protection
/// (private / loopback / link-local rejection) mirrors the http airlock.
net-connect-tcp: func(host: string, port: u16) -> result<u64, string>;

Capability surface in Capsule.toml:

[capabilities]
net_connect = [
  "fulcrum.unicity.network:443",
  "relay.unicity.network:*",
]

Reuses the existing stream-handle shape — net-read returns net-read-status, net-write takes bytes, net-close-stream releases. One new host fn, zero new types.

Why not WebSocket in the host ABI

WebSocket is a protocol on top of TCP — RFC 6455 framing, handshake, ping/pong. ~500 LOC of JS or Rust. Lives naturally in user-space:

  • A common @astrid-os/ws package for JS capsules that need it (or capsule authors bring their own).
  • A tokio-tungstenite equivalent for Rust capsules.
  • A node:net shim (~200 LOC, one-time) that lets the npm ws library work unmodified on Astrid via astrid.net.connect. Unlocks any npm-ecosystem library that uses node:net.

Putting WebSocket in the host ABI would couple a protocol implementation into the kernel and make it harder to support other persistent-network protocols (Discord gateway, Nostr, MQTT, IRC) cleanly.

Out of scope (separate issues if pursued)

  • wasi:sockets/tcp adoption — the standard WASI interface. Aligns with WASI ecosystem libraries but uses wasmtime's preopen-based authority model rather than ours. Worth considering later if a real demand emerges from ecosystem libraries; not needed for this work.
  • Outbound UDP — different connection model. File when needed.
  • TLS — capsules can use a user-space TLS library (e.g. rustls, or one of the pure-JS implementations) over the raw TCP stream. SDKs could ship convenience wrappers later.

Scope (kernel + SDKs together)

  • WIT: add net-connect-tcp to astrid:capsule/net in unicity-astrid/wit (canonical).
  • WIT: add the corresponding capability declaration shape to the documented Capsule.toml schema.
  • Kernel: net::Host impl for net_connect_tcp — capability check against parsed allowlist, SSRF / private-IP rejection (reuse http airlock validators), connection cap per capsule (analogous to MAX_ACTIVE_STREAMS), tokio::net::TcpStream under the hood, stream-handle table entry that the existing net_read / net_write / net_close_stream already handles.
  • Kernel: capability registry / parser changes to accept net_connect = [...] in Capsule.toml [capabilities].
  • Tests: integration test that connects to a local TCP echo server, writes, reads back, closes. Plus SSRF/capability rejection tests.
  • Rust SDK: astrid_sdk::net::connect(host: &str, port: u16) → Result<StreamHandle, SysError>. The returned StreamHandle is the same type as the existing accept return — recv / try_recv / send / close Just Work.
  • JS SDK: astrid.net.connect(host: string, port: number) → Stream with matching AsyncIterable ergonomics.
  • CHANGELOG entries in kernel + both SDKs.

Acceptance criteria

  • A capsule with net_connect = ["example.com:443"] in its manifest can net.connect("example.com", 443), write/read bytes, close.
  • A capsule with net_connect = [] (or omitted) gets a clear capability denial when it tries to connect.
  • Connecting to a private / loopback / link-local IP is rejected at the kernel before any TCP attempt.
  • Capsule per-instance concurrent-connection cap is enforced.
  • The same StreamHandle works through the existing net_read / net_write / net_close_stream host fns.

Follow-ups (separate PRs after this lands)

  • @astrid-os/ws package: pure-TS WebSocket client over astrid.net.connect. Or, alternatively, a node:net shim to make the npm ws library work unmodified.
  • Port openclaw-unicity to an Astrid-native capsule on top of this primitive. Tracks the original ask that prompted this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions