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:
parent
adf0eaf4f8
commit
3146423f78
|
|
@ -68,7 +68,7 @@ proposed capabilities as plans, not existing behavior.
|
|||
- `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-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.
|
||||
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
|
||||
- `docs/decisions/`: architectural decisions.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ All notable changes to this project will be documented in this file.
|
|||
DTOs with serde support and request validation.
|
||||
- Transport-neutral `runa-server` dispatcher routing protocol commands through
|
||||
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.
|
||||
- Lifecycle tests proving failed-message retry from committed state and
|
||||
stop/restart preservation of reserved-first FIFO order.
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ dependencies = [
|
|||
"runa-code",
|
||||
"runa-image-api",
|
||||
"runa-protocol",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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
|
||||
installation, pause/resume, and cancellation through a trusted in-process image
|
||||
API. A versioned transport-neutral protocol contract is now defined, but no
|
||||
network transport or authentication is implemented. A transport-neutral
|
||||
dispatcher routes protocol requests through the same image API.
|
||||
network listener or authentication is implemented. The dispatcher is available
|
||||
through a local JSON-lines stdio process, with a bounded request size.
|
||||
|
||||
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
runa> spawn 0
|
||||
|
|
@ -110,7 +115,7 @@ test task runs `cargo test --workspace`.
|
|||
│ ├── runa-runtime/ # Single-worker actor prototype
|
||||
│ ├── runa-image-api/ # Trusted in-process image operations
|
||||
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
|
||||
│ └── runa-server/ # Transport-neutral protocol dispatcher
|
||||
│ └── runa-server/ # Protocol dispatcher and local JSON-lines transport
|
||||
├── docs/
|
||||
│ ├── architecture.md
|
||||
│ ├── protocol.md
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ runa-actor = { path = "../runa-actor" }
|
|||
runa-code = { path = "../runa-code" }
|
||||
runa-image-api = { path = "../runa-image-api" }
|
||||
runa-protocol = { path = "../runa-protocol" }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Transport-neutral dispatch from protocol requests to image API operations.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use runa_actor::{ActorId, Event, Message, Status};
|
||||
|
|
@ -10,10 +11,63 @@ use runa_protocol::{
|
|||
Response,
|
||||
};
|
||||
|
||||
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub struct Server {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
@ -293,6 +347,7 @@ fn invalid_request(message: impl Into<String>) -> ProtocolError {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use runa_protocol::{Command, PROTOCOL_VERSION};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn request(command: Command) -> Request {
|
||||
Request {
|
||||
|
|
@ -428,4 +483,66 @@ mod tests {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ These are desired capabilities, not a requirement to implement every endpoint im
|
|||
|
||||
### 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
|
||||
|
||||
|
|
@ -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-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-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-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 |
|
||||
|
|
@ -330,7 +330,9 @@ defines versioned, serializable request, response, and event DTOs without
|
|||
depending on a transport or the runtime implementation. `runa-server` now
|
||||
dispatches those commands through the image API and maps service results into
|
||||
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.
|
||||
|
||||
|
|
@ -435,9 +437,9 @@ inspection remain future work.
|
|||
- 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 CLI, a transport-neutral versioned protocol contract, and an in-process
|
||||
dispatcher from protocol commands to application services. A network server
|
||||
and authentication are not yet implemented.
|
||||
the CLI, a transport-neutral versioned protocol contract, and a dispatcher
|
||||
exposed through bounded local JSON-lines stdio. A network server, sessions,
|
||||
authentication, and authorization are not yet implemented.
|
||||
|
||||
### Phase 6 — Graphical world
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
`runa-protocol` defines versioned request, response, and event data contracts
|
||||
for clients and the `runa-server` dispatcher. It is independent of transport
|
||||
and runtime implementation; the server currently dispatches requests
|
||||
in-process, with no network endpoint or authentication.
|
||||
and runtime implementation. The `runa-server` executable currently accepts
|
||||
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
|
||||
|
||||
|
|
@ -44,8 +46,11 @@ rather than claiming a more specific category.
|
|||
|
||||
`get_events` drains and returns the events currently accumulated by this
|
||||
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
|
||||
before dispatch; the in-process protocol crate does not impose byte limits.
|
||||
The stdio transport limits each request line to 1 MiB before parsing. Malformed
|
||||
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
|
||||
actor/revision identifiers where required, non-empty behavior names, mailbox
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ The default per-actor slice budget is 32 instructions.
|
|||
- `runa-protocol` defines versioned transport-neutral request, response, and
|
||||
event DTOs; it does not dispatch requests or access runtime state.
|
||||
- `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.
|
||||
|
||||
The image API is process-local and intended for trusted callers. It has no
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ run = "cargo test --workspace"
|
|||
description = "Run the local Runa CLI"
|
||||
run = "cargo run -p runa"
|
||||
|
||||
[tasks.serve]
|
||||
description = "Run the local JSON-lines protocol server"
|
||||
run = "cargo run -p runa-server"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Lint all workspace targets with Clippy"
|
||||
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
||||
|
|
|
|||
Loading…
Reference in New Issue