feat: add localhost TCP protocol listener

Expose the existing versioned JSON-lines dispatcher over TCP, with an enforced IPv4/IPv6 loopback-only bind, bounded nonblocking client handling, request and response size limits, and a shared single-threaded image. Add a serve-network mise task, subprocess coverage, and document the unauthenticated trusted-local-development boundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 14:50:30 +01:00
parent e5d839f419
commit d96d603286
8 changed files with 399 additions and 55 deletions

View File

@ -41,6 +41,8 @@ All notable changes to this project will be documented in this file.
request limit and explicit malformed-input failures. request limit and explicit malformed-input failures.
- Reusable local child-process client for issuing correlated protocol requests - Reusable local child-process client for issuing correlated protocol requests
and shutting down the server process cleanly. and shutting down the server process cleanly.
- Loopback-only TCP JSON-lines endpoint for local development clients, sharing
the same live image as stdio and limiting concurrent clients.
- Execution-model decision and Phase 1 runtime documentation. - Execution-model decision and Phase 1 runtime documentation.
- Lifecycle tests proving failed-message retry from committed state and - Lifecycle tests proving failed-message retry from committed state and
stop/restart preservation of reserved-first FIFO order. stop/restart preservation of reserved-first FIFO order.

View File

@ -13,16 +13,16 @@ experiment using Rune 0.14.2: script execution, native calls, async suspension,
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
single-worker actor demonstration with bounded mailboxes and explicit message single-worker actor demonstration with bounded mailboxes and explicit message
effects. Source and compiled implementation identities are BLAKE3 content effects. Source and compiled implementation identities are BLAKE3 content
addresses with an in-memory compile cache. Persistence, canonicalization, the addresses with an in-memory compile cache. Persistence, canonicalization,
network development API, graphical world, and VS Code extension are not remote network access, graphical world, and VS Code extension are not
implemented yet. The code library now supports an in-memory versioned behavior implemented yet. The code library now supports an in-memory versioned behavior
registry, and the CLI exposes publishing, history, rollback, safe-boundary registry, and the CLI exposes publishing, history, rollback, safe-boundary
installation, pause/resume, and cancellation through a trusted in-process image installation, pause/resume, and cancellation through a trusted in-process image
API. A versioned transport-neutral protocol contract is now defined, but no API. A versioned transport-neutral protocol contract is now defined, but no
network listener or authentication is implemented. The dispatcher is available authentication is implemented. The dispatcher is available through local
through a local JSON-lines stdio process, with a bounded request size. The JSON-lines stdio and an unauthenticated TCP listener restricted to localhost;
interactive CLI now uses the versioned protocol and dispatcher rather than both use a bounded request size. The interactive CLI now uses the versioned
calling the image API directly. protocol and dispatcher rather than calling the image API directly.
The prototype uses worker-local invocation stacks and cooperative instruction The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native slices, and actor snapshots report basic active-execution progress. Native
@ -53,13 +53,19 @@ mise run run
For a local child-process protocol endpoint, run `mise run serve` and exchange For a local child-process protocol endpoint, run `mise run serve` and exchange
one JSON request per line on stdin; responses are written to stdout. Request one JSON request per line on stdin; responses are written to stdout. Request
lines are limited to 1 MiB. This is not a network server and is intended for a lines are limited to 1 MiB.
trusted parent process.
The `runa-client` crate can launch that process and exchange typed protocol For a TCP endpoint, run `mise run serve-network`. It listens on
requests with request/response correlation. The VS Code extension is intended `127.0.0.1:3737` and accepts the same JSON-lines protocol, with up to 16
to use an authenticated network connection to this same live image; actors simultaneous clients sharing one live image. The listener rejects non-loopback
remain in the image regardless of how clients connect. addresses and has no authentication; any local process that can connect has
full access to the development API. Use it only on a trusted local machine.
The `runa-client` crate can launch the stdio process and exchange typed
protocol requests with request/response correlation. The VS Code extension is
intended to connect over the network to this same live image; actors remain in
the image regardless of how clients connect. Remote network access requires
authentication and authorization, which are not implemented.
The CLI opens an interactive runtime session using the default counter behavior: The CLI opens an interactive runtime session using the default counter behavior:

View File

@ -1,7 +1,10 @@
//! Transport-neutral dispatch from protocol requests to image API operations. //! Transport-neutral dispatch from protocol requests to image API operations.
use std::io::{self, BufRead, Write}; use std::io::{self, BufRead, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::thread;
use std::time::Duration;
use runa_actor::{ActorId, Event, Message, Status}; use runa_actor::{ActorId, Event, Message, Status};
use runa_image_api::ImageApi; use runa_image_api::ImageApi;
@ -12,6 +15,7 @@ use runa_protocol::{
}; };
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024; pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
const MAX_NETWORK_CLIENTS: usize = 16;
pub struct Server { pub struct Server {
image: ImageApi, image: ImageApi,
@ -23,18 +27,204 @@ pub struct Server {
/// oversized input terminates the stream with `InvalidData`. /// oversized input terminates the stream with `InvalidData`.
pub fn serve_json_lines<R: BufRead, W: Write>( pub fn serve_json_lines<R: BufRead, W: Write>(
server: &mut Server, server: &mut Server,
mut reader: R, reader: R,
mut writer: W, writer: W,
) -> io::Result<()> { ) -> io::Result<()> {
serve_json_lines_with(reader, writer, |request| Ok(server.dispatch(request)))
}
/// Bind the protocol listener to an IPv4 or IPv6 loopback address only.
pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
if !address.ip().is_loopback() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"network listener address must be a loopback address",
));
}
TcpListener::bind(address)
}
/// Serve JSON-lines requests from loopback clients using one shared image.
///
/// The nonblocking event loop keeps the non-`Send` Rune runtime on one thread.
/// At most `MAX_NETWORK_CLIENTS` are active.
pub fn serve_tcp(listener: TcpListener, mut server: Server) -> io::Result<()> {
let address = listener.local_addr()?;
if !address.ip().is_loopback() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"network listener must be bound to a loopback address",
));
}
listener.set_nonblocking(true)?;
eprintln!("runa-server: listening on {address}");
let mut clients = Vec::new();
loop {
loop {
match listener.accept() {
Ok((stream, peer_address)) => {
if clients.len() >= MAX_NETWORK_CLIENTS {
eprintln!("runa-server: rejecting {peer_address}; client limit reached");
continue;
}
stream.set_nonblocking(true)?;
clients.push(NetworkClient::new(stream, peer_address));
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
let mut index = 0;
while index < clients.len() {
match clients[index].poll(&mut server) {
Ok(true) => index += 1,
Ok(false) => {
clients.swap_remove(index);
}
Err(error) => {
eprintln!("runa-server: client {}: {error}", clients[index].address);
clients.swap_remove(index);
}
}
}
thread::sleep(Duration::from_millis(5));
}
}
struct NetworkClient {
stream: TcpStream,
address: SocketAddr,
input: Vec<u8>,
output: Vec<u8>,
output_offset: usize,
read_closed: bool,
}
impl NetworkClient {
fn new(stream: TcpStream, address: SocketAddr) -> Self {
Self {
stream,
address,
input: Vec::new(),
output: Vec::new(),
output_offset: 0,
read_closed: false,
}
}
fn poll(&mut self, server: &mut Server) -> io::Result<bool> {
if self.output_offset == self.output.len() {
self.output.clear();
self.output_offset = 0;
self.process_pending_requests(server, self.read_closed)?;
if self.output.is_empty() && !self.read_closed {
let mut buffer = [0; 8192];
match self.stream.read(&mut buffer) {
Ok(0) => {
self.read_closed = true;
self.process_pending_requests(server, true)?;
}
Ok(length) => {
self.input.extend_from_slice(&buffer[..length]);
self.process_pending_requests(server, false)?;
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
if self.output_offset < self.output.len() {
match self.stream.write(&self.output[self.output_offset..]) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"failed to write protocol response",
));
}
Ok(length) => {
self.output_offset += length;
if self.output_offset == self.output.len() {
self.output.clear();
self.output_offset = 0;
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
Ok(!self.read_closed || !self.input.is_empty() || self.output_offset < self.output.len())
}
fn process_pending_requests(
&mut self,
server: &mut Server,
final_line: bool,
) -> io::Result<()> {
let Some(line_end) = self.input.iter().position(|byte| *byte == b'\n') else {
if self.input.len() > MAX_REQUEST_LINE_BYTES {
return Err(invalid_data(format!(
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
)));
}
if !final_line || self.input.is_empty() {
return Ok(());
}
let line = self.input.clone();
self.input.clear();
self.dispatch_line(server, &line)?;
return Ok(());
};
if line_end > MAX_REQUEST_LINE_BYTES {
return Err(invalid_data(format!(
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
)));
}
let line = self.input[..line_end].to_vec();
self.input.drain(..=line_end);
self.dispatch_line(server, &line)
}
fn dispatch_line(&mut self, server: &mut Server, line: &[u8]) -> io::Result<()> {
let request: Request = serde_json::from_slice(line)
.map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
let response = server.dispatch(request);
let mut encoded = serde_json::to_vec(&response).map_err(io::Error::other)?;
if encoded.len() > MAX_REQUEST_LINE_BYTES {
return Err(invalid_data(format!(
"response exceeds {MAX_REQUEST_LINE_BYTES} bytes"
)));
}
encoded.push(b'\n');
self.output.extend_from_slice(&encoded);
Ok(())
}
}
fn invalid_data(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn serve_json_lines_with<R, W, F>(mut reader: R, mut writer: W, mut dispatch: F) -> io::Result<()>
where
R: BufRead,
W: Write,
F: FnMut(Request) -> io::Result<Response>,
{
let mut line = Vec::new(); let mut line = Vec::new();
while read_bounded_line(&mut reader, &mut line)? { while read_bounded_line(&mut reader, &mut line)? {
let request = serde_json::from_slice(&line).map_err(|error| { let request: Request = serde_json::from_slice(&line)
io::Error::new( .map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
io::ErrorKind::InvalidData, let response = dispatch(request)?;
format!("invalid protocol request JSON: {error}"),
)
})?;
let response = server.dispatch(request);
serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?; serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?;
writer.write_all(b"\n")?; writer.write_all(b"\n")?;
writer.flush()?; writer.flush()?;
@ -347,7 +537,8 @@ fn invalid_request(message: impl Into<String>) -> ProtocolError {
mod tests { mod tests {
use super::*; use super::*;
use runa_protocol::{Command, PROTOCOL_VERSION}; use runa_protocol::{Command, PROTOCOL_VERSION};
use std::io::Cursor; use std::io::{BufRead, BufReader, Cursor};
use std::net::Shutdown;
fn request(command: Command) -> Request { fn request(command: Command) -> Request {
Request { Request {
@ -545,4 +736,50 @@ mod tests {
let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err(); let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert_eq!(error.kind(), io::ErrorKind::InvalidData);
} }
#[test]
fn loopback_listener_rejects_non_loopback_addresses() {
let error = bind_loopback_listener("0.0.0.0:0".parse().unwrap()).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
let listener = bind_loopback_listener("127.0.0.1:0".parse().unwrap()).unwrap();
assert!(listener.local_addr().unwrap().ip().is_loopback());
}
#[test]
fn tcp_transport_dispatches_requests_to_the_shared_server() {
let listener = bind_loopback_listener("127.0.0.1:0".parse().unwrap()).unwrap();
let address = listener.local_addr().unwrap();
let mut client = TcpStream::connect(address).unwrap();
let (server_stream, peer_address) = listener.accept().unwrap();
let mut network_client = NetworkClient::new(server_stream, peer_address);
let first_request = request(Command::ListActors);
let mut second_request = request(Command::ListActors);
second_request.request_id = "second-request".to_owned();
writeln!(
client,
"{}\n{}",
serde_json::to_string(&first_request).unwrap(),
serde_json::to_string(&second_request).unwrap()
)
.unwrap();
client.shutdown(Shutdown::Write).unwrap();
assert!(network_client.poll(&mut Server::new()).unwrap());
let mut response_line = String::new();
let mut client_reader = BufReader::new(client);
client_reader.read_line(&mut response_line).unwrap();
let first_response: Response = serde_json::from_str(&response_line).unwrap();
assert_eq!(first_response.request_id, first_request.request_id);
assert!(network_client.poll(&mut Server::new()).unwrap());
response_line.clear();
client_reader.read_line(&mut response_line).unwrap();
let second_response: Response = serde_json::from_str(&response_line).unwrap();
assert_eq!(second_response.request_id, second_request.request_id);
assert_eq!(second_response.result, Ok(Reply::Actors(Vec::new())));
assert!(!network_client.poll(&mut Server::new()).unwrap());
}
} }

View File

@ -1,12 +1,11 @@
use std::io; use std::io;
use std::net::SocketAddr;
use std::process::ExitCode; use std::process::ExitCode;
use runa_server::{Server, serve_json_lines}; use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_tcp};
fn main() -> ExitCode { fn main() -> ExitCode {
let stdin = io::stdin(); match run() {
let stdout = io::stdout();
match serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock()) {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
eprintln!("runa-server: {error}"); eprintln!("runa-server: {error}");
@ -14,3 +13,41 @@ fn main() -> ExitCode {
} }
} }
} }
fn run() -> io::Result<()> {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
None => serve_stdio(),
Some("--listen") => {
let address = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
)
})?;
if args.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
));
}
let address = address.parse::<SocketAddr>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid listener address: {error}"),
)
})?;
serve_tcp(bind_loopback_listener(address)?, Server::new())
}
Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
)),
}
}
fn serve_stdio() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock())
}

View File

@ -1,5 +1,19 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, Command as ProcessCommand, Stdio};
use std::time::Duration;
use runa_client::LocalClient; use runa_client::LocalClient;
use runa_protocol::{Command, MessageDto, Reply}; use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
struct ServerProcess(Child);
impl Drop for ServerProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test] #[test]
fn local_client_launches_server_and_operates_the_same_live_image() { fn local_client_launches_server_and_operates_the_same_live_image() {
@ -46,3 +60,41 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
)); ));
assert!(client.shutdown().unwrap().success()); assert!(client.shutdown().unwrap().success());
} }
#[test]
fn tcp_server_accepts_local_protocol_requests() {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
.args(["--listen", "127.0.0.1:0"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut server = ServerProcess(child);
let stderr = server.0.stderr.take().unwrap();
let mut stderr = BufReader::new(stderr);
let mut startup = String::new();
stderr.read_line(&mut startup).unwrap();
let address = startup
.strip_prefix("runa-server: listening on ")
.expect("server should report its bound address")
.trim()
.parse::<SocketAddr>()
.unwrap();
let mut client = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
let request = Request {
version: PROTOCOL_VERSION,
request_id: "tcp-process-test".to_owned(),
command: Command::ListActors,
};
writeln!(client, "{}", serde_json::to_string(&request).unwrap()).unwrap();
client.shutdown(Shutdown::Write).unwrap();
let mut response_line = String::new();
BufReader::new(client)
.read_line(&mut response_line)
.unwrap();
let response: Response = serde_json::from_str(&response_line).unwrap();
assert_eq!(response.request_id, "tcp-process-test");
assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
}

View File

@ -5,7 +5,7 @@
**Initial embedded language:** Rune **Initial embedded language:** Rune
**Inspirations:** Smalltalk/Squeak/Pharo, Morphic, Erlang/OTP **Inspirations:** Smalltalk/Squeak/Pharo, Morphic, Erlang/OTP
**Primary interfaces:** Self-contained graphical world and VS Code extension **Primary interfaces:** Self-contained graphical world and VS Code extension
connected to the same live image through an authenticated network API connected to the same live image through a network API
## 1. Vision ## 1. Vision
@ -63,7 +63,7 @@ Rust supplies a minimal, reliable substrate; increasingly much of the actor fram
+-----------------------------------------------------------+ +-----------------------------------------------------------+
``` ```
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The network boundary must authenticate and authorize clients; it does not create a separate actor runtime or image. The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The initial TCP transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Neither transport creates a separate actor runtime or image.
## 4. Fundamental Actor Model ## 4. Fundamental Actor Model
@ -229,11 +229,11 @@ These are desired capabilities, not a requirement to implement every endpoint im
### 8.1 Security ### 8.1 Security
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The current stdio bridge is limited to a trusted parent process. Any future network bridge must bind locally by default and use a defensible authentication mechanism; never expose unauthenticated remote code execution endpoints. Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The stdio bridge is intended for a trusted parent process. The initial TCP bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented.
## 9. VS Code Extension ## 9. VS Code Extension
VS Code is an external client of the same live image, not a separate runtime. It connects through an authenticated versioned network transport, initially planned as JSON-RPC over WebSocket. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. Network security, connection lifecycle, and event subscription semantics must be designed before exposing the listener. VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON Lines over TCP, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. Connection lifecycle and event subscription semantics still need design.
Required extension UX: Required extension UX:
@ -311,7 +311,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` | | `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` | | `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` | | `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
| `runa-server` | Protocol dispatch and local JSON-lines stdio transport; authenticated network transport for VS Code clients | `runa-protocol`, `runa-image-api` | | `runa-server` | Protocol dispatch, JSON-lines stdio, and loopback-only TCP transport | `runa-protocol`, `runa-image-api` |
| `runa-client` | Local child-process client for the JSON-lines stdio protocol | `runa-protocol` | | `runa-client` | Local child-process client for the JSON-lines stdio protocol | `runa-protocol` |
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary | | `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` | | `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
@ -331,14 +331,15 @@ than reaching into the code store and runtime directly. `runa-protocol` now
defines versioned, serializable request, response, and event DTOs without defines versioned, serializable request, response, and event DTOs without
depending on a transport or the runtime implementation. `runa-server` now depending on a transport or the runtime implementation. `runa-server` now
dispatches those commands through the image API and maps service results into dispatches those commands through the image API and maps service results into
protocol replies and typed errors. It remains an in-process dispatcher; there protocol replies and typed errors. The dispatcher is exposed through local
is no network listener, session model, authentication, or authorization. The child-process JSON-lines stdio and an unauthenticated TCP listener restricted
dispatcher is exposed through local child-process JSON-lines stdio with a 1 to IPv4 or IPv6 loopback; both enforce a 1 MiB request-line limit. The TCP
MiB request-line limit. In-process consumers call `runa-image-api`, and listener shares one image across at most 16 concurrent clients. Every local
client receives full development API access, so the endpoint is for trusted
local development only. In-process consumers call `runa-image-api`, and
external local tools can use `runa-client` to launch the stdio server. Actors external local tools can use `runa-client` to launch the stdio server. Actors
do not depend on network transport to coexist in one live image; the network do not depend on network transport to coexist in one live image. Remote
boundary exists for external clients such as VS Code. Authenticated network network access will require authentication and authorization.
transport is a planned integration requirement, not a runtime prerequisite.
**Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation. **Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation.
@ -447,9 +448,11 @@ The first Phase 5 increments are a trusted in-process application API used by
the CLI, a transport-neutral versioned protocol contract, and a dispatcher the CLI, a transport-neutral versioned protocol contract, and a dispatcher
exposed through bounded local JSON-lines stdio. The interactive CLI uses the exposed through bounded local JSON-lines stdio. The interactive CLI uses the
same protocol commands and dispatcher in-process. `runa-client` launches the same protocol commands and dispatcher in-process. `runa-client` launches the
stdio server for external local tools. The next server increment for the VS stdio server for external local tools. The server also provides an
Code integration is an authenticated network transport over the same API and unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS
live image; it is not needed for actors to share that image. Code development. Remote access will require authentication and
authorization; neither network transport nor client location changes the
single-image actor model.
### Phase 6 — Graphical world ### Phase 6 — Graphical world

View File

@ -2,10 +2,10 @@
`runa-protocol` defines versioned request, response, and event data contracts `runa-protocol` defines versioned request, response, and event data contracts
for clients and the `runa-server` dispatcher. It is independent of transport for clients and the `runa-server` dispatcher. It is independent of transport
and runtime implementation. The `runa-server` executable currently accepts and runtime implementation. `runa-server` accepts JSON Lines over stdio or,
JSON Lines over stdin and emits one response line per valid request to stdout. when started with `--listen`, over TCP bound to an IPv4 or IPv6 loopback
It is a local child-process transport, not a network endpoint, and provides no address. The TCP listener is unauthenticated and is intended only for trusted
authentication. local development.
## Version 1 ## Version 1
@ -46,14 +46,17 @@ rather than claiming a more specific category.
`get_events` drains and returns the events currently accumulated by this `get_events` drains and returns the events currently accumulated by this
single-worker image. It is not a durable subscription or replayable event log. single-worker image. It is not a durable subscription or replayable event log.
The stdio transport limits each request line to 1 MiB before parsing. Malformed Both transports limit each request line to 1 MiB before parsing. Malformed
JSON or an oversized line terminates the stream with an error on stderr; no JSON or an oversized line terminates that stream with an error; no protocol
protocol response is emitted when a request cannot be decoded. EOF cleanly response is emitted when a request cannot be decoded. Stdio EOF terminates the
terminates the process. The `runa-client` crate launches this server locally process, while TCP EOF closes only that client connection. The TCP listener
and validates response protocol versions and request IDs. The VS Code allows up to 16 simultaneous clients and serializes each dispatch through the
extension is planned to use a separate authenticated network transport to same image. TCP response payloads are capped at 1 MiB, with at most one
the same image; any such transport must impose its own message and response queued per client. Every local client can use the full development
request-size limits before dispatch. API; loopback binding is not authentication or authorization. Remote access
must not be enabled until appropriate authentication and authorization exist.
The `runa-client` crate launches the stdio server locally and validates
response protocol versions and request IDs.
The current validation checks the protocol version, request ID, positive The current validation checks the protocol version, request ID, positive
actor/revision identifiers where required, non-empty behavior names, mailbox actor/revision identifiers where required, non-empty behavior names, mailbox

View File

@ -17,6 +17,10 @@ run = "cargo run -p runa"
description = "Run the local JSON-lines protocol server" description = "Run the local JSON-lines protocol server"
run = "cargo run -p runa-server" run = "cargo run -p runa-server"
[tasks.serve-network]
description = "Run the protocol server on localhost"
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
[tasks.lint] [tasks.lint]
description = "Lint all workspace targets with Clippy" description = "Lint all workspace targets with Clippy"
run = "cargo clippy --workspace --all-targets -- -D warnings" run = "cargo clippy --workspace --all-targets -- -D warnings"