feat: add local JSON-lines server transport

Expose the runa-server protocol dispatcher over newline-delimited JSON on stdin/stdout for a trusted parent process. Bound request lines to 1 MiB, return one correlated response for each valid request, and fail explicitly on malformed or oversized input. Add the mise serve task and document that this is not a network listener or an authenticated service.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 14:18:45 +01:00
parent adf0eaf4f8
commit 3146423f78
11 changed files with 170 additions and 16 deletions

View File

@ -68,7 +68,7 @@ proposed capabilities as plans, not existing behavior.
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
- `crates/runa-image-api/`: trusted in-process services for operating on an image. - `crates/runa-image-api/`: trusted in-process services for operating on an image.
- `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs. - `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs.
- `crates/runa-server/`: transport-neutral dispatch from protocol requests to image API services. - `crates/runa-server/`: protocol dispatch and local JSON-lines stdio transport.
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
- `docs/decisions/`: architectural decisions. - `docs/decisions/`: architectural decisions.

View File

@ -37,6 +37,8 @@ All notable changes to this project will be documented in this file.
DTOs with serde support and request validation. DTOs with serde support and request validation.
- Transport-neutral `runa-server` dispatcher routing protocol commands through - Transport-neutral `runa-server` dispatcher routing protocol commands through
the image API with typed replies and error codes. the image API with typed replies and error codes.
- Local JSON-lines stdio transport for the protocol dispatcher, with a 1 MiB
request limit and explicit malformed-input failures.
- 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.

1
Cargo.lock generated
View File

@ -465,6 +465,7 @@ dependencies = [
"runa-code", "runa-code",
"runa-image-api", "runa-image-api",
"runa-protocol", "runa-protocol",
"serde_json",
] ]
[[package]] [[package]]

View File

@ -19,8 +19,8 @@ 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 transport or authentication is implemented. A transport-neutral network listener or authentication is implemented. The dispatcher is available
dispatcher routes protocol requests through the same image API. through a local JSON-lines stdio process, with a bounded request size.
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
@ -49,7 +49,12 @@ Run the current executable from the repository root:
mise run run mise run run
``` ```
This opens an interactive runtime session using the default counter behavior: 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
lines are limited to 1 MiB. This is not a network server and is intended for a
trusted parent process.
The CLI opens an interactive runtime session using the default counter behavior:
```text ```text
runa> spawn 0 runa> spawn 0
@ -110,7 +115,7 @@ test task runs `cargo test --workspace`.
│ ├── runa-runtime/ # Single-worker actor prototype │ ├── runa-runtime/ # Single-worker actor prototype
│ ├── runa-image-api/ # Trusted in-process image operations │ ├── runa-image-api/ # Trusted in-process image operations
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs │ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
│ └── runa-server/ # Transport-neutral protocol dispatcher │ └── runa-server/ # Protocol dispatcher and local JSON-lines transport
├── docs/ ├── docs/
│ ├── architecture.md │ ├── architecture.md
│ ├── protocol.md │ ├── protocol.md

View File

@ -8,3 +8,4 @@ runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" } runa-code = { path = "../runa-code" }
runa-image-api = { path = "../runa-image-api" } runa-image-api = { path = "../runa-image-api" }
runa-protocol = { path = "../runa-protocol" } runa-protocol = { path = "../runa-protocol" }
serde_json = "1"

View File

@ -1,5 +1,6 @@
//! 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::num::NonZeroUsize; use std::num::NonZeroUsize;
use runa_actor::{ActorId, Event, Message, Status}; use runa_actor::{ActorId, Event, Message, Status};
@ -10,10 +11,63 @@ use runa_protocol::{
Response, Response,
}; };
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
pub struct Server { pub struct Server {
image: ImageApi, image: ImageApi,
} }
/// Serve newline-delimited protocol requests over an already-connected stream.
///
/// Each valid request line receives one JSON response line. Malformed or
/// oversized input terminates the stream with `InvalidData`.
pub fn serve_json_lines<R: BufRead, W: Write>(
server: &mut Server,
mut reader: R,
mut writer: W,
) -> io::Result<()> {
let mut line = Vec::new();
while read_bounded_line(&mut reader, &mut line)? {
let request = serde_json::from_slice(&line).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid protocol request JSON: {error}"),
)
})?;
let response = server.dispatch(request);
serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?;
writer.write_all(b"\n")?;
writer.flush()?;
}
Ok(())
}
fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut Vec<u8>) -> io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let payload_len = newline.unwrap_or(available.len());
if line.len().saturating_add(payload_len) > MAX_REQUEST_LINE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"),
));
}
line.extend_from_slice(&available[..payload_len]);
let consumed = payload_len + usize::from(newline.is_some());
reader.consume(consumed);
if newline.is_some() {
return Ok(true);
}
}
}
impl Default for Server { impl Default for Server {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@ -293,6 +347,7 @@ 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;
fn request(command: Command) -> Request { fn request(command: Command) -> Request {
Request { Request {
@ -428,4 +483,66 @@ mod tests {
Ok(Reply::BehaviorInstalled { .. }) Ok(Reply::BehaviorInstalled { .. })
)); ));
} }
#[test]
fn json_lines_transport_dispatches_multiple_requests_and_accepts_final_line_without_newline() {
let publish_request = request(Command::PublishBehavior {
name: "counter".to_owned(),
expected_revision: None,
source: "pub fn handle(state, kind, value) { (state + value, []) }".to_owned(),
});
let mut spawn_request = request(Command::Spawn {
behavior_name: "counter".to_owned(),
state: 4,
mailbox_capacity: 2,
});
spawn_request.request_id = "spawn-request".to_owned();
let mut list_request = request(Command::ListActors);
list_request.request_id = "list-request".to_owned();
let mut input = [publish_request, spawn_request]
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()
.unwrap()
.join("\n");
input.push('\n');
input.push_str(&serde_json::to_string(&list_request).unwrap());
let mut output = Vec::new();
serve_json_lines(&mut Server::new(), Cursor::new(input), &mut output).unwrap();
let responses = output
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(serde_json::from_slice::<Response>)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(responses.len(), 3);
assert_eq!(
responses
.iter()
.map(|response| response.request_id.as_str())
.collect::<Vec<_>>(),
vec!["test-request", "spawn-request", "list-request"]
);
assert!(matches!(responses[0].result, Ok(Reply::Behavior(_))));
assert_eq!(responses[1].result, Ok(Reply::ActorSpawned { actor_id: 1 }));
assert!(matches!(
&responses[2].result,
Ok(Reply::Actors(actors)) if actors.len() == 1 && actors[0].state == 4
));
}
#[test]
fn json_lines_transport_rejects_malformed_and_oversized_input() {
let mut server = Server::new();
let malformed =
serve_json_lines(&mut server, Cursor::new(b"{not-json}\n"), Vec::new()).unwrap_err();
assert_eq!(malformed.kind(), io::ErrorKind::InvalidData);
let mut oversized = vec![b'a'; MAX_REQUEST_LINE_BYTES + 1];
oversized.push(b'\n');
let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
} }

View File

@ -0,0 +1,16 @@
use std::io;
use std::process::ExitCode;
use runa_server::{Server, serve_json_lines};
fn main() -> ExitCode {
let stdin = io::stdin();
let stdout = io::stdout();
match serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("runa-server: {error}");
ExitCode::FAILURE
}
}
}

View File

@ -228,7 +228,7 @@ 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. For the initial local bridge, bind to loopback and use a defensible authentication mechanism. Avoid 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 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.
## 9. VS Code Extension ## 9. VS Code Extension
@ -310,7 +310,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 request dispatch and, in a later increment, transport, sessions, authentication, subscriptions | `runa-protocol`, `runa-image-api` | | `runa-server` | Protocol dispatch and local JSON-lines stdio transport; later network transports, sessions, authentication, subscriptions | `runa-protocol`, `runa-image-api` |
| `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` |
| `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates | | `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates |
@ -330,7 +330,9 @@ 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. It remains an in-process dispatcher; there
is no network transport, session model, authentication, or authorization. is no network listener, session model, authentication, or authorization. The
dispatcher is exposed through local child-process JSON-lines stdio with a 1
MiB request-line limit.
**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.
@ -435,9 +437,9 @@ inspection remain future work.
- CLI client that can spawn, inspect, send messages, and replace behaviors. - CLI client that can spawn, inspect, send messages, and replace behaviors.
The first Phase 5 increments are a trusted in-process application API used by The first Phase 5 increments are a trusted in-process application API used by
the CLI, a transport-neutral versioned protocol contract, and an in-process the CLI, a transport-neutral versioned protocol contract, and a dispatcher
dispatcher from protocol commands to application services. A network server exposed through bounded local JSON-lines stdio. A network server, sessions,
and authentication are not yet implemented. authentication, and authorization are not yet implemented.
### Phase 6 — Graphical world ### Phase 6 — Graphical world

View File

@ -2,8 +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 server currently dispatches requests and runtime implementation. The `runa-server` executable currently accepts
in-process, with no network endpoint or authentication. JSON Lines over stdin and emits one response line per valid request to stdout.
It is a local child-process transport, not a network endpoint, and provides no
authentication.
## Version 1 ## Version 1
@ -44,8 +46,11 @@ 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.
Any future network transport must also impose message and request-size limits The stdio transport limits each request line to 1 MiB before parsing. Malformed
before dispatch; the in-process protocol crate does not impose byte limits. JSON or an oversized line terminates the stream with an error on stderr; no
protocol response is emitted when a request cannot be decoded. EOF cleanly
terminates the process. Any future network transport must impose its own
message and request-size limits before dispatch.
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

@ -52,7 +52,8 @@ The default per-actor slice budget is 32 instructions.
- `runa-protocol` defines versioned transport-neutral request, response, and - `runa-protocol` defines versioned transport-neutral request, response, and
event DTOs; it does not dispatch requests or access runtime state. event DTOs; it does not dispatch requests or access runtime state.
- `runa-server` dispatches those requests through `runa-image-api` and maps - `runa-server` dispatches those requests through `runa-image-api` and maps
results to protocol replies; it currently has no network transport. results to protocol replies; its executable uses newline-delimited JSON over
stdin/stdout with a 1 MiB request-line limit, not a network listener.
- `runa` provides the local CLI client for that boundary. - `runa` provides the local CLI client for that boundary.
The image API is process-local and intended for trusted callers. It has no The image API is process-local and intended for trusted callers. It has no

View File

@ -13,6 +13,10 @@ run = "cargo test --workspace"
description = "Run the local Runa CLI" description = "Run the local Runa CLI"
run = "cargo run -p runa" run = "cargo run -p runa"
[tasks.serve]
description = "Run the local JSON-lines protocol server"
run = "cargo run -p runa-server"
[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"