diff --git a/AGENTS.md b/AGENTS.md index 7a0a8ea..81d2b26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e13e4b2..83a547d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index ab2b760..d777d87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,7 @@ dependencies = [ "runa-code", "runa-image-api", "runa-protocol", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index ec03710..ab888d1 100644 --- a/README.md +++ b/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 diff --git a/crates/runa-server/Cargo.toml b/crates/runa-server/Cargo.toml index 5c357f5..183f463 100644 --- a/crates/runa-server/Cargo.toml +++ b/crates/runa-server/Cargo.toml @@ -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" diff --git a/crates/runa-server/src/lib.rs b/crates/runa-server/src/lib.rs index 71d7f41..523b65b 100644 --- a/crates/runa-server/src/lib.rs +++ b/crates/runa-server/src/lib.rs @@ -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( + 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(reader: &mut R, line: &mut Vec) -> io::Result { + 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) -> 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::, _>>() + .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::) + .collect::, _>>() + .unwrap(); + assert_eq!(responses.len(), 3); + assert_eq!( + responses + .iter() + .map(|response| response.request_id.as_str()) + .collect::>(), + 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); + } } diff --git a/crates/runa-server/src/main.rs b/crates/runa-server/src/main.rs new file mode 100644 index 0000000..875f8e2 --- /dev/null +++ b/crates/runa-server/src/main.rs @@ -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 + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index a6aa41a..f0a52e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index 87dec90..ab9e3fa 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/docs/runtime.md b/docs/runtime.md index 658e5f5..d2356ac 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 diff --git a/mise.toml b/mise.toml index 7070835..ae9528c 100644 --- a/mise.toml +++ b/mise.toml @@ -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"