From ce4f1d711bbcd1303040ccdd0fe2ed2b217ebc0d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 16 Sep 2026 11:30:47 -0700 Subject: [PATCH] feat(rust): add authenticated node sidecar bridge (#116863) Adds the bounded authenticated sidecar bridge on the merged Rust node runtime. The unique sidecar delta passed focused Rust and process proof, and exact-head ClawSweeper review found no actionable code or security issue. --- crates/Cargo.lock | 27 + crates/openclaw-node-host/Cargo.toml | 2 + crates/openclaw-node-host/SIDECAR_PROTOCOL.md | 229 ++ crates/openclaw-node-host/src/lib.rs | 21 + .../src/sidecar_handshake.rs | 796 ++++++ .../src/sidecar_protocol.rs | 1276 ++++++++++ .../openclaw-node-host/src/sidecar_runtime.rs | 2265 +++++++++++++++++ .../openclaw-node-host/tests/node_session.rs | 260 +- .../tests/sidecar_process.rs | 295 +++ test/fixtures/node-sidecar-handshake-v1.json | 52 + .../fixtures/node-sidecar-negotiation-v1.json | 36 + test/fixtures/node-sidecar-protocol-v1.json | 15 + test/fixtures/node-sidecar-runtime-v1.json | 99 + 13 files changed, 5371 insertions(+), 2 deletions(-) create mode 100644 crates/openclaw-node-host/SIDECAR_PROTOCOL.md create mode 100644 crates/openclaw-node-host/src/sidecar_handshake.rs create mode 100644 crates/openclaw-node-host/src/sidecar_protocol.rs create mode 100644 crates/openclaw-node-host/src/sidecar_runtime.rs create mode 100644 crates/openclaw-node-host/tests/sidecar_process.rs create mode 100644 test/fixtures/node-sidecar-handshake-v1.json create mode 100644 test/fixtures/node-sidecar-negotiation-v1.json create mode 100644 test/fixtures/node-sidecar-protocol-v1.json create mode 100644 test/fixtures/node-sidecar-runtime-v1.json diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 088b780c7fa3..347b16bd6ded 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -56,6 +56,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "const-oid" version = "0.10.2" @@ -96,6 +102,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -138,6 +153,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", + "ctutils", ] [[package]] @@ -270,6 +286,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -469,6 +494,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "getrandom 0.4.3", + "hmac", "openclaw-gateway-client", "serde", "serde_json", @@ -476,6 +502,7 @@ dependencies = [ "thiserror", "tokio", "tokio-tungstenite", + "zeroize", ] [[package]] diff --git a/crates/openclaw-node-host/Cargo.toml b/crates/openclaw-node-host/Cargo.toml index e29305929dc8..8c0a6694f415 100644 --- a/crates/openclaw-node-host/Cargo.toml +++ b/crates/openclaw-node-host/Cargo.toml @@ -12,6 +12,7 @@ futures-util = "0.3.32" base64 = "0.22.1" ed25519-dalek = "3.0.0" getrandom = "0.4.3" +hmac = "0.13.0" openclaw-gateway-client = { path = "../openclaw-gateway-client" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" @@ -26,6 +27,7 @@ tokio = { version = "1.52.3", features = [ "sync", "time", ] } +zeroize = "1.8.2" [lints.rust] unsafe_code = "forbid" diff --git a/crates/openclaw-node-host/SIDECAR_PROTOCOL.md b/crates/openclaw-node-host/SIDECAR_PROTOCOL.md new file mode 100644 index 000000000000..15211259a8d1 --- /dev/null +++ b/crates/openclaw-node-host/SIDECAR_PROTOCOL.md @@ -0,0 +1,229 @@ +# OpenClaw node sidecar protocol v1 + +This document describes the byte contract implemented by +`sidecar_protocol.rs`. It is the portable authenticated channel beneath a +future node-runtime message schema. It does not select named pipes, Unix +sockets, inherited anonymous pipes, or another local transport. + +## Trust before this protocol + +The product supervisor must verify the exact runtime artifact and create a +fresh local-only IPC endpoint before launch. It supplies a random 32-byte +session key, session identifier, and nonzero generation over a protected +bootstrap mechanism. Those values must not be placed in command-line +arguments, broadly inherited environment variables, logs, crash reports, or +world-readable files. + +This crate intentionally does not implement platform artifact verification, +secret delivery, process creation, secure storage, or runtime selection. A +peer identity reported inside this protocol is authenticated by the session +key but is not proof that the executable on disk was trusted. + +Gateway endpoint selection, challenge signing, node-protocol negotiation, and +device-token persistence stay with the platform-owned `NodeLifecycle` +connection factory. In particular, an `IssuedDeviceToken` is never placed in +sidecar configuration or status traffic. Its attempt number can be correlated +with the secret-free lifecycle `attempt` status, but delivery and durable +acknowledgement require a separate protected product channel outside this +protocol. + +## Length prefix and local ceiling + +Each frame is preceded by an unsigned four-byte big-endian length. The length +counts the authenticated frame and excludes the prefix. A receiver must apply +its local hard ceiling to the prefix before allocating or reading the frame. +The prefix is not security authority: all internal lengths and fields are +covered by the authentication tag. + +The bootstrap exchange always uses protocol minor `0`, the same +pre-negotiation ceiling, and a finite deadline. This lets an older peer read a +newer peer's offer before minor-version negotiation. Negotiated limits are the +minimum of both valid local offers and can never raise a local ceiling. After +the final bootstrap frame, both peers apply the independently verified minor +and frame limit to the active authenticated channel so directional sequence +numbers are not reset. + +## Authenticated frame + +All integers are unsigned and big-endian. + +| Field | Bytes | Meaning | +| ------------------ | -------: | ---------------------------------------------------- | +| Magic | 4 | ASCII `OCSC` | +| Protocol major | 2 | `1` | +| Protocol minor | 2 | `0` during bootstrap; negotiated minor afterward | +| Direction | 1 | `1` supervisor-to-runtime, `2` runtime-to-supervisor | +| Generation | 8 | Nonzero process/session generation | +| Sequence | 8 | Strictly increasing per direction, starting at `1` | +| Session ID length | 2 | UTF-8 byte length | +| Payload length | 4 | JSON payload byte length | +| Session ID | variable | Exact bootstrap session identifier | +| Payload | variable | UTF-8 JSON; the next slice defines typed messages | +| Authentication tag | 32 | HMAC-SHA-256 over every preceding frame byte | + +The frame limit includes the authentication tag and excludes the outer length +prefix. The sender and receiver use the same session key; direction is part of +the authenticated header and prevents reflection between peers. + +Outbound JSON is serialized directly into the final frame through the local +ceiling. Serialization stops on the first write that would consume the bytes +reserved for the authentication tag; an oversized payload is never fully +materialized or copied into a second plaintext buffer. + +A receiver verifies the frame-size ceiling and HMAC before interpreting any +untrusted header or payload field. It then verifies version, direction, +generation, exact next sequence, session identifier, internal lengths, and +payload decoding. Any failure retires the channel; callers must not continue +after a framing, authentication, replay, or generation error. The Rust channel +poisons itself on the first inbound validation failure and rejects every later +send or receive. A transport owner calls `retire()` when length-prefix I/O or +its surrounding IPC transport fails. + +A new process gets a new session identifier, key, generation, and sequence +space. A sequence gap or replay is rejected rather than buffered. Rotate the +generation before sequence exhaustion; never reset a sequence in place. + +The authenticated channel generation, immutable manifest generation, +`NodeLifecycle` connection attempt, and Gateway pairing generation are separate +scopes. This protocol carries only the first three. Gateway pairing approval, +committed-policy reconciliation, and pairing-generation leases remain Gateway +authority and must not be inferred from a sidecar generation or manifest. + +## Negotiation + +`SidecarProtocolOffer` carries the peer role and reported identity, protocol +version, additive feature bits, frame/in-flight ceilings, and bootstrap +deadline. Peers must have complementary roles and the same major version. +Features are intersected. The feature mask must be at most `2^53 - 1`, the +largest integer that JSON implementations such as JavaScript can preserve +exactly; larger local or remote offers are invalid. Frame, in-flight, and +deadline values use the lower valid offer. Unknown features remain disabled. +Adapters must perform the intersection with integer arithmetic that preserves +all 53 bits. JavaScript and TypeScript implementations must convert both masks +to `BigInt` before `&` and convert the bounded result back to `Number`; their +native number `&` operator truncates operands to 32 bits and is not conformant. + +The supervisor initiates with an authenticated `offer`. The runtime +independently negotiates against its local offer and replies with one +authenticated `accept` containing its offer and the selected parameters. The +supervisor independently recomputes the selection; a mismatch is terminal. +The runtime remains in `AcceptancePending` and retains the bootstrap ceiling +until the acceptance is written successfully. It then commits the selection; +the supervisor commits only after receiving and verifying that frame. This +prevents active traffic from racing ahead of the acceptance and ensures an +acceptance larger than the negotiated ceiling can still be delivered. Both +peers preserve the existing directional sequence state. + +The `SidecarHandshake` state machine is bound to one exact authenticated +channel instance and accepts only this two-frame ordering. Substituting another +channel is rejected before frame processing or handshake mutation and retires +the supplied replacement; the original bound handshake can continue. +Wrong roles, incompatible versions, malformed/authentication failures, forged +selection, repeated/out-of-order messages, and unencodable bootstrap messages +retire the handshake and channel. Negotiation must complete before accepting +credentials, configuration, capability registration, or invocation traffic. + +## Cross-language vector + +[`node-sidecar-protocol-v1.json`](../../test/fixtures/node-sidecar-protocol-v1.json) +contains a test-only session key, payload, and exact encoded data frame. +[`node-sidecar-negotiation-v1.json`](../../test/fixtures/node-sidecar-negotiation-v1.json) +exercises a feature bit above the 32-bit JavaScript bitwise range. +[`node-sidecar-handshake-v1.json`](../../test/fixtures/node-sidecar-handshake-v1.json) +contains both offers, the independently derived selection, and exact offer and +accept frames. Rust tests reproduce and decode every vector. Every non-Rust +adapter must consume the same vectors before it can be selected as a runtime. + +[`node-sidecar-runtime-v1.json`](../../test/fixtures/node-sidecar-runtime-v1.json) +contains the typed configuration, configured acknowledgement, admission, +invocation, result, cancellation, and status messages plus their exact compact +JSON encodings. These payloads travel inside the already authenticated, +sequenced frames; the message corpus does not replace the frame vectors. +All integer fields, including integers nested inside invocation parameters or +success payloads, must remain in JSON's exact `-(2^53 - 1)..=2^53 - 1` range. +Serialization and deserialization reject values outside that range; the shared +corpus exercises the positive boundary. Integer-valued decimal or exponent +forms follow the same bound; genuine fractional JSON numbers retain their +normal finite IEEE-754 semantics. Runtime adapter traffic reports the +distinct `SIDECAR_NON_PORTABLE_JSON` failure rather than misclassifying these +values as oversized payloads. + +## Runtime bridge + +`SidecarRuntimeBridge` can be constructed only from the runtime side after the +validated configuration acknowledgement has been written successfully. The +runtime remains `AcknowledgementPending` until that delivery is committed, so +invocation work cannot race ahead of the supervisor-visible manifest. The +configuration exchange is consumed from its authenticated handshake, and a +successful activation irreversibly moves it to `Activated`; neither phase can +be replayed to multiply concurrency. Beginning configuration locks the +negotiated frame ceiling for the rest of the channel generation, so bridge +preflight budgets cannot become stale. Activation also requires the exact live +authenticated channel. The bridge carries that channel's retirement signal: +retirement blocks new native work and cancels in-flight adapter work. The bridge +accepts one immutable connection manifest and requires its +concurrency/input/output limits to remain within the negotiated sidecar envelope. Command and capability names +use the shared ASCII grammar `[A-Za-z0-9._-]{1,128}`, are duplicate-free, and +are sorted bytewise before acknowledgement; the OpenClaw-owned `system.*` +namespace remains reserved. + +The configured output limit must also fit the largest bridge-owned stable +failure envelope. This preserves `SIDECAR_MESSAGE_TOO_LARGE`, +`SIDECAR_NON_PORTABLE_JSON`, and `SIDECAR_CHANNEL_RETIRED` instead of allowing +the generic command-runtime output limiter to rewrite them. + +`SidecarConfigurationExchange` permits exactly one supervisor configuration +followed by the runtime's acknowledgement of the independently derived +manifest. It remains bound to the exact channel instance authenticated by the +handshake; a replacement is retired before processing without mutating the +bound exchange. Wrong order, channel role, malformed or unknown fields, +invalid limits/names, and a forged acknowledgement retire the exchange and channel. +It also proves the worst-case secret-free status envelope—including the runtime +version from the authenticated offer—fits the lowered live-channel budget. No +admission or invocation message is accepted by this exchange. + +The bridge builds the existing bounded `CommandRuntime`. Every invocation +therefore passes its normal Gateway-manifest, input, concurrency, timeout, +admission, handler, output, and cancellation gates. The product-owned +`SidecarCapabilityAdapter` receives an untrusted typed invocation first for +local admission and only then for native dispatch. A denial or adapter failure +becomes a bounded structured handler failure. The runtime cancellation token is +passed through both waits so product adapter work can stop on Gateway cancel, +timeout, disconnect, or shutdown. + +The configured command list is an offered connection manifest, not an approval +or policy grant. The Gateway decides whether a declared command is invocable; +only a Gateway-delivered invocation reaches the bridge's additional +fail-closed local admission. Surface widening requires a newly configured +bridge and a new Gateway connection manifest. Committed policy revocation or a +pairing-generation transition can preserve the physical Gateway connection +while cancelling generation-bound work; that cancellation reaches the adapter +through the existing runtime token rather than a new sidecar wire field. + +Logical parameter and result limits are not treated as complete-frame limits. +Before cloning Gateway JSON into an adapter request, and without cloning an +adapter decision/result, the bridge runs a borrowed non-allocating serialization +preflight for the complete admission, invocation, decision, or result message +against the live channel's exact payload budget +(frame ceiling minus fixed header, session identifier, and authentication tag). +Adapter infrastructure errors are first normalized into the same denial or +failure wire shape, so they cannot bypass the complete-message preflight. +A value that fits its logical JSON limit but not its full envelope receives the +stable `SIDECAR_MESSAGE_TOO_LARGE` result without attempting transport. + +The bridge also maps secret-free `NodeLifecycle` events into stable status +states and reasons correlated with the immutable manifest generation. A +capability change requires a new bridge and connection/process generation; +registrations cannot be mutated in place after advertisement. + +## Not yet implemented + +This stack still excludes product IPC selection, protected credential/config +delivery, duplex input/progress transport, a product audit adapter, process +supervision, artifact verification, packaging, Windows integration, +restart/rollback policy, production credential storage, worker/session +hosting, workspace transfer, plugins, host statistics, `system.run`, PTY, MCP, +and skills. The typed runtime messages and adapter boundary do not by +themselves claim production sidecar readiness. Those remaining concerns must +not weaken authentication, bounds, generation, sequencing, cancellation, or +fail-closed behavior. diff --git a/crates/openclaw-node-host/src/lib.rs b/crates/openclaw-node-host/src/lib.rs index 39b6e0ffb946..1e00f6325cd7 100644 --- a/crates/openclaw-node-host/src/lib.rs +++ b/crates/openclaw-node-host/src/lib.rs @@ -14,6 +14,9 @@ mod lifecycle; mod node; mod reconnect; mod runtime; +mod sidecar_handshake; +mod sidecar_protocol; +mod sidecar_runtime; pub use duplex::InvocationIo; pub use host::{run_host, AuthKind, HostConfig, HostCredentials, HostError}; @@ -35,3 +38,21 @@ pub use runtime::{ CancellationToken, CommandRuntime, CommandRuntimeBuilder, HandlerError, InvocationAdmissionContext, InvocationContext, RuntimeBuildError, RuntimeError, }; +pub use sidecar_handshake::{ + SidecarHandshake, SidecarHandshakeError, SidecarHandshakeMessage, SidecarHandshakeState, + SidecarProtocolSelection, +}; +pub use sidecar_protocol::{ + negotiate_sidecar_protocol, read_sidecar_frame, write_sidecar_frame, + AuthenticatedSidecarChannel, NegotiatedSidecarProtocol, SidecarDirection, SidecarFrameError, + SidecarLimits, SidecarPeerIdentity, SidecarPeerRole, SidecarProtocolError, + SidecarProtocolOffer, SidecarSessionKey, SIDECAR_MAX_FEATURE_BITS, SIDECAR_PROTOCOL_MAJOR, + SIDECAR_PROTOCOL_MINOR, +}; +pub use sidecar_runtime::{ + SidecarAdapterError, SidecarAdapterFuture, SidecarAdmissionDecision, SidecarCapabilityAdapter, + SidecarCommandRegistration, SidecarConfigurationError, SidecarConfigurationExchange, + SidecarConfigurationState, SidecarInvocation, SidecarInvocationResult, SidecarRuntimeBridge, + SidecarRuntimeBridgeError, SidecarRuntimeConfiguration, SidecarRuntimeManifest, + SidecarRuntimeMessage, SidecarRuntimeReason, SidecarRuntimeState, SidecarRuntimeStatus, +}; diff --git a/crates/openclaw-node-host/src/sidecar_handshake.rs b/crates/openclaw-node-host/src/sidecar_handshake.rs new file mode 100644 index 000000000000..3a10ebe670ad --- /dev/null +++ b/crates/openclaw-node-host/src/sidecar_handshake.rs @@ -0,0 +1,796 @@ +//! Authenticated offer/accept state machine for the node sidecar protocol. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + negotiate_sidecar_protocol, AuthenticatedSidecarChannel, NegotiatedSidecarProtocol, + SidecarFrameError, SidecarLimits, SidecarPeerRole, SidecarProtocolError, SidecarProtocolOffer, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SidecarHandshakeState { + Starting, + AwaitingAcceptance, + AcceptancePending, + Authenticated, + Failed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SidecarProtocolSelection { + pub protocol_major: u16, + pub protocol_minor: u16, + pub feature_bits: u64, + pub limits: SidecarLimits, +} + +impl From<&NegotiatedSidecarProtocol> for SidecarProtocolSelection { + fn from(negotiated: &NegotiatedSidecarProtocol) -> Self { + Self { + protocol_major: negotiated.protocol_major, + protocol_minor: negotiated.protocol_minor, + feature_bits: negotiated.feature_bits, + limits: negotiated.limits, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum SidecarHandshakeMessage { + Offer { + offer: SidecarProtocolOffer, + }, + Accept { + offer: SidecarProtocolOffer, + selection: SidecarProtocolSelection, + }, +} + +/// Drives the two-frame authenticated protocol handshake for one peer. +pub struct SidecarHandshake { + local_offer: SidecarProtocolOffer, + state: SidecarHandshakeState, + negotiated: Option, + pending_negotiated: Option, + channel_instance_id: Option, +} + +impl SidecarHandshake { + /// Create a handshake for a supervisor or runtime offer. + /// + /// # Errors + /// + /// Returns an error when the local offer is invalid or uses an unsupported + /// protocol version. + pub fn new(local_offer: SidecarProtocolOffer) -> Result { + validate_local_offer(&local_offer)?; + Ok(Self { + local_offer, + state: SidecarHandshakeState::Starting, + negotiated: None, + pending_negotiated: None, + channel_instance_id: None, + }) + } + + #[must_use] + pub const fn state(&self) -> SidecarHandshakeState { + self.state + } + + #[must_use] + pub const fn local_role(&self) -> SidecarPeerRole { + self.local_offer.peer.role + } + + #[must_use] + pub const fn local_peer(&self) -> &crate::SidecarPeerIdentity { + &self.local_offer.peer + } + + pub(crate) const fn bound_channel_instance_id(&self) -> Option { + self.channel_instance_id + } + + #[must_use] + pub const fn negotiated(&self) -> Option<&NegotiatedSidecarProtocol> { + self.negotiated.as_ref() + } + + /// Encode the supervisor's initial authenticated offer. + /// + /// # Errors + /// + /// Returns an error when called by a runtime, called out of order, or when + /// the offer cannot be encoded within the bootstrap frame ceiling. + pub fn start( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + ) -> Result, SidecarHandshakeError> { + if let Err(error) = self.bind_channel(channel) { + channel.retire(); + return Err(error); + } + if channel.role() != self.local_offer.peer.role { + return self.fail(channel, SidecarHandshakeError::ChannelRoleMismatch); + } + if self.local_offer.peer.role != SidecarPeerRole::Supervisor { + return self.fail(channel, SidecarHandshakeError::SupervisorMustInitiate); + } + if self.state != SidecarHandshakeState::Starting { + return self.fail(channel, SidecarHandshakeError::UnexpectedMessage); + } + + let frame = match channel.seal(&SidecarHandshakeMessage::Offer { + offer: self.local_offer.clone(), + }) { + Ok(frame) => frame, + Err(error) => return self.fail(channel, SidecarHandshakeError::Frame(error)), + }; + self.state = SidecarHandshakeState::AwaitingAcceptance; + Ok(frame) + } + + /// Consume one authenticated handshake frame and optionally return the + /// runtime's authenticated acceptance frame. + /// + /// # Errors + /// + /// Returns an error for framing/authentication failure, incompatible or + /// forged negotiation, wrong peer roles, or invalid message ordering. + /// Frame and state-machine errors permanently retire the bound channel and + /// handshake. A different channel instance is rejected before processing: + /// only that supplied replacement is retired, and the bound handshake is + /// unchanged. + pub fn receive( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + frame: &[u8], + ) -> Result>, SidecarHandshakeError> { + if let Err(error) = self.bind_channel(channel) { + channel.retire(); + return Err(error); + } + if channel.role() != self.local_offer.peer.role { + return self.fail(channel, SidecarHandshakeError::ChannelRoleMismatch); + } + let result = self.receive_active(channel, frame); + if result.is_err() { + channel.retire(); + self.state = SidecarHandshakeState::Failed; + self.negotiated = None; + self.pending_negotiated = None; + } + result + } + + /// Commit the runtime's negotiated channel state after its acceptance + /// frame has been written successfully using the bootstrap ceiling. + /// + /// The runtime remains in [`SidecarHandshakeState::AcceptancePending`] + /// until this method succeeds, so active traffic cannot race ahead of the + /// final bootstrap frame. On transport failure, retire the channel before + /// calling this method; completion then fails terminally. + /// + /// # Errors + /// + /// Returns an error for the wrong channel, role, state, a retired channel, + /// or an invalid negotiated selection. State and negotiation errors retire + /// the bound channel and handshake. A different channel instance is + /// rejected before processing and retires only the supplied replacement. + pub fn complete_acceptance( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + ) -> Result<(), SidecarHandshakeError> { + if let Err(error) = self.bind_channel(channel) { + channel.retire(); + return Err(error); + } + if channel.role() != SidecarPeerRole::Runtime { + return self.fail(channel, SidecarHandshakeError::ChannelRoleMismatch); + } + if channel.is_retired() { + return self.fail( + channel, + SidecarHandshakeError::Frame(SidecarFrameError::ChannelRetired), + ); + } + if self.state != SidecarHandshakeState::AcceptancePending { + return self.fail(channel, SidecarHandshakeError::UnexpectedMessage); + } + let Some(negotiated) = self.pending_negotiated.take() else { + return self.fail(channel, SidecarHandshakeError::UnexpectedMessage); + }; + if let Err(error) = channel.apply_negotiated_protocol(&negotiated) { + return self.fail(channel, SidecarHandshakeError::Negotiation(error)); + } + self.negotiated = Some(negotiated); + self.state = SidecarHandshakeState::Authenticated; + Ok(()) + } + + fn receive_active( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + frame: &[u8], + ) -> Result>, SidecarHandshakeError> { + let message = channel + .open::(frame) + .map_err(SidecarHandshakeError::Frame)?; + match (self.local_offer.peer.role, self.state, message) { + ( + SidecarPeerRole::Runtime, + SidecarHandshakeState::Starting, + SidecarHandshakeMessage::Offer { offer }, + ) => self.accept_supervisor(channel, &offer).map(Some), + ( + SidecarPeerRole::Supervisor, + SidecarHandshakeState::AwaitingAcceptance, + SidecarHandshakeMessage::Accept { offer, selection }, + ) => { + self.confirm_runtime(channel, &offer, selection)?; + Ok(None) + } + _ => Err(SidecarHandshakeError::UnexpectedMessage), + } + } + + fn accept_supervisor( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + supervisor_offer: &SidecarProtocolOffer, + ) -> Result, SidecarHandshakeError> { + if supervisor_offer.peer.role != SidecarPeerRole::Supervisor { + return Err(SidecarHandshakeError::WrongPeerRole); + } + validate_peer_identity(supervisor_offer)?; + let negotiated = negotiate_sidecar_protocol(&self.local_offer, supervisor_offer) + .map_err(SidecarHandshakeError::Negotiation)?; + let selection = SidecarProtocolSelection::from(&negotiated); + + // The acceptance is the last bootstrap-ceiling frame. Lowering before + // sealing it could make a valid negotiation impossible to acknowledge. + let acceptance = channel + .seal(&SidecarHandshakeMessage::Accept { + offer: self.local_offer.clone(), + selection, + }) + .map_err(SidecarHandshakeError::Frame)?; + self.pending_negotiated = Some(negotiated); + self.state = SidecarHandshakeState::AcceptancePending; + Ok(acceptance) + } + + fn confirm_runtime( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + runtime_offer: &SidecarProtocolOffer, + claimed: SidecarProtocolSelection, + ) -> Result<(), SidecarHandshakeError> { + if runtime_offer.peer.role != SidecarPeerRole::Runtime { + return Err(SidecarHandshakeError::WrongPeerRole); + } + validate_peer_identity(runtime_offer)?; + let negotiated = negotiate_sidecar_protocol(&self.local_offer, runtime_offer) + .map_err(SidecarHandshakeError::Negotiation)?; + if claimed != SidecarProtocolSelection::from(&negotiated) { + return Err(SidecarHandshakeError::SelectionMismatch); + } + channel + .apply_negotiated_protocol(&negotiated) + .map_err(SidecarHandshakeError::Negotiation)?; + self.negotiated = Some(negotiated); + self.state = SidecarHandshakeState::Authenticated; + Ok(()) + } + + fn fail( + &mut self, + channel: &mut AuthenticatedSidecarChannel, + error: SidecarHandshakeError, + ) -> Result { + channel.retire(); + self.state = SidecarHandshakeState::Failed; + self.negotiated = None; + self.pending_negotiated = None; + Err(error) + } + + fn bind_channel( + &mut self, + channel: &AuthenticatedSidecarChannel, + ) -> Result<(), SidecarHandshakeError> { + let instance_id = channel.instance_id(); + match self.channel_instance_id { + None => { + self.channel_instance_id = Some(instance_id); + Ok(()) + } + Some(bound) if bound == instance_id => Ok(()), + Some(_) => Err(SidecarHandshakeError::ChannelInstanceMismatch), + } + } +} + +fn validate_local_offer(offer: &SidecarProtocolOffer) -> Result<(), SidecarHandshakeError> { + validate_peer_identity(offer)?; + let counterpart = SidecarProtocolOffer { + protocol_major: offer.protocol_major, + protocol_minor: offer.protocol_minor, + peer: crate::SidecarPeerIdentity { + role: match offer.peer.role { + SidecarPeerRole::Supervisor => SidecarPeerRole::Runtime, + SidecarPeerRole::Runtime => SidecarPeerRole::Supervisor, + }, + name: "validation-peer".into(), + version: "0".into(), + artifact_identity: "validation-only".into(), + }, + feature_bits: offer.feature_bits, + limits: offer.limits, + }; + negotiate_sidecar_protocol(offer, &counterpart) + .map(|_| ()) + .map_err(SidecarHandshakeError::Negotiation) +} + +fn validate_peer_identity(offer: &SidecarProtocolOffer) -> Result<(), SidecarHandshakeError> { + if offer.peer.name.trim().is_empty() + || offer.peer.version.trim().is_empty() + || offer.peer.artifact_identity.trim().is_empty() + { + return Err(SidecarHandshakeError::InvalidPeerIdentity); + } + Ok(()) +} + +#[derive(Debug, Error)] +pub enum SidecarHandshakeError { + #[error("sidecar handshake frame failed")] + Frame(#[source] SidecarFrameError), + #[error("sidecar protocol negotiation failed")] + Negotiation(#[source] SidecarProtocolError), + #[error("runtime cannot initiate the sidecar handshake")] + SupervisorMustInitiate, + #[error("sidecar acceptance does not match the independently negotiated selection")] + SelectionMismatch, + #[error("unexpected sidecar handshake message")] + UnexpectedMessage, + #[error("sidecar peer identity fields must be nonempty")] + InvalidPeerIdentity, + #[error("sidecar handshake message came from the wrong peer role")] + WrongPeerRole, + #[error("sidecar handshake role does not match the authenticated channel role")] + ChannelRoleMismatch, + #[error("sidecar handshake cannot move between authenticated channel instances")] + ChannelInstanceMismatch, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SidecarPeerIdentity, SidecarSessionKey}; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + + const KEY: [u8; 32] = [0x3c; 32]; + + fn offer( + role: SidecarPeerRole, + max_frame_bytes: u32, + feature_bits: u64, + ) -> SidecarProtocolOffer { + SidecarProtocolOffer { + protocol_major: crate::SIDECAR_PROTOCOL_MAJOR, + protocol_minor: crate::SIDECAR_PROTOCOL_MINOR, + peer: SidecarPeerIdentity { + role, + name: match role { + SidecarPeerRole::Supervisor => "test-product", + SidecarPeerRole::Runtime => "openclaw-node", + } + .into(), + version: "1.0.0".into(), + artifact_identity: "sha256:test-only".into(), + }, + feature_bits, + limits: SidecarLimits { + max_frame_bytes, + max_in_flight: 8, + bootstrap_timeout_ms: 1_000, + }, + } + } + + fn channel(role: SidecarPeerRole, max_frame_bytes: u32) -> AuthenticatedSidecarChannel { + AuthenticatedSidecarChannel::new( + role, + "handshake-session".into(), + 9, + SidecarSessionKey::from_bytes(KEY), + max_frame_bytes, + ) + .unwrap() + } + + #[test] + fn supervisor_and_runtime_authenticate_the_same_selection() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0b0111)).unwrap(); + let mut runtime = + SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 2048, 0b1011)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let acceptance = runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + assert_eq!(runtime_channel.max_frame_bytes(), 4096); + assert_eq!(runtime.state(), SidecarHandshakeState::AcceptancePending); + assert!(runtime.negotiated().is_none()); + runtime.complete_acceptance(&mut runtime_channel).unwrap(); + assert_eq!(runtime_channel.max_frame_bytes(), 2048); + assert_eq!(runtime.state(), SidecarHandshakeState::Authenticated); + + assert!(supervisor + .receive(&mut supervisor_channel, &acceptance) + .unwrap() + .is_none()); + assert_eq!(supervisor_channel.max_frame_bytes(), 2048); + assert_eq!(supervisor.state(), SidecarHandshakeState::Authenticated); + assert_eq!( + SidecarProtocolSelection::from(supervisor.negotiated().unwrap()), + SidecarProtocolSelection::from(runtime.negotiated().unwrap()) + ); + assert_eq!(supervisor.negotiated().unwrap().feature_bits, 0b0011); + + let active = supervisor_channel.seal(&"active").unwrap(); + assert_eq!( + u16::from_be_bytes(active[6..8].try_into().unwrap()), + supervisor.negotiated().unwrap().protocol_minor + ); + assert_eq!(u64::from_be_bytes(active[17..25].try_into().unwrap()), 2); + assert_eq!(runtime_channel.open::(&active).unwrap(), "active"); + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct HandshakeFixture { + schema_version: u8, + session: FixtureSession, + supervisor_offer: SidecarProtocolOffer, + runtime_offer: SidecarProtocolOffer, + selection: SidecarProtocolSelection, + offer_frame_base64: String, + accept_frame_base64: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct FixtureSession { + id: String, + generation: u64, + key_base64: String, + } + + #[test] + fn cross_language_handshake_vector_is_exact() { + let fixture: HandshakeFixture = serde_json::from_str(include_str!( + "../../../test/fixtures/node-sidecar-handshake-v1.json" + )) + .unwrap(); + assert_eq!(fixture.schema_version, 1); + let key: [u8; 32] = BASE64 + .decode(&fixture.session.key_base64) + .unwrap() + .try_into() + .unwrap(); + let bootstrap_frame_bytes = fixture.supervisor_offer.limits.max_frame_bytes; + let new_channel = |role| { + AuthenticatedSidecarChannel::new( + role, + fixture.session.id.clone(), + fixture.session.generation, + SidecarSessionKey::from_bytes(key), + bootstrap_frame_bytes, + ) + .unwrap() + }; + + let mut supervisor = SidecarHandshake::new(fixture.supervisor_offer).unwrap(); + let mut runtime = SidecarHandshake::new(fixture.runtime_offer).unwrap(); + let mut supervisor_channel = new_channel(SidecarPeerRole::Supervisor); + let mut runtime_channel = new_channel(SidecarPeerRole::Runtime); + + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + assert_eq!( + offer_frame, + BASE64.decode(fixture.offer_frame_base64).unwrap() + ); + let accept_frame = runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + assert_eq!( + accept_frame, + BASE64.decode(fixture.accept_frame_base64).unwrap() + ); + runtime.complete_acceptance(&mut runtime_channel).unwrap(); + assert!(supervisor + .receive(&mut supervisor_channel, &accept_frame) + .unwrap() + .is_none()); + assert_eq!( + SidecarProtocolSelection::from(supervisor.negotiated().unwrap()), + fixture.selection + ); + assert_eq!( + SidecarProtocolSelection::from(runtime.negotiated().unwrap()), + fixture.selection + ); + } + + #[test] + fn forged_selection_fails_and_retires_supervisor() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0b0011)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let mut malicious_runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let _: SidecarHandshakeMessage = malicious_runtime_channel.open(&offer_frame).unwrap(); + + let runtime_offer = offer(SidecarPeerRole::Runtime, 2048, 0b0011); + let forged = malicious_runtime_channel + .seal(&SidecarHandshakeMessage::Accept { + offer: runtime_offer, + selection: SidecarProtocolSelection { + protocol_major: crate::SIDECAR_PROTOCOL_MAJOR, + protocol_minor: crate::SIDECAR_PROTOCOL_MINOR, + feature_bits: u64::MAX, + limits: SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: u16::MAX, + bootstrap_timeout_ms: u32::MAX, + }, + }, + }) + .unwrap(); + + assert!(matches!( + supervisor.receive(&mut supervisor_channel, &forged), + Err(SidecarHandshakeError::SelectionMismatch) + )); + assert_eq!(supervisor.state(), SidecarHandshakeState::Failed); + assert!(supervisor_channel.is_retired()); + } + + #[test] + fn runtime_commits_selection_only_after_acceptance_delivery() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0)).unwrap(); + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 128, 0)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let acceptance = runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + assert!(acceptance.len() > 128); + assert_eq!(runtime.state(), SidecarHandshakeState::AcceptancePending); + assert_eq!(runtime_channel.max_frame_bytes(), 4096); + assert!(runtime.negotiated().is_none()); + + runtime.complete_acceptance(&mut runtime_channel).unwrap(); + assert_eq!(runtime.state(), SidecarHandshakeState::Authenticated); + assert_eq!(runtime_channel.max_frame_bytes(), 128); + } + + #[test] + fn failed_acceptance_delivery_is_terminal() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0)).unwrap(); + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 2048, 0)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + runtime_channel.retire(); + assert!(matches!( + runtime.complete_acceptance(&mut runtime_channel), + Err(SidecarHandshakeError::Frame( + SidecarFrameError::ChannelRetired + )) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(runtime.negotiated().is_none()); + } + + #[test] + fn handshake_cannot_move_to_another_channel_instance() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0b0011)).unwrap(); + let mut original_channel = channel(SidecarPeerRole::Supervisor, 4096); + let _offer_frame = supervisor.start(&mut original_channel).unwrap(); + + let runtime_offer = offer(SidecarPeerRole::Runtime, 2048, 0b0011); + let selection = SidecarProtocolSelection::from( + &negotiate_sidecar_protocol(&supervisor.local_offer, &runtime_offer).unwrap(), + ); + let mut other_runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + let acceptance = other_runtime_channel + .seal(&SidecarHandshakeMessage::Accept { + offer: runtime_offer, + selection, + }) + .unwrap(); + let mut other_supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + + assert!(matches!( + supervisor.receive(&mut other_supervisor_channel, &acceptance), + Err(SidecarHandshakeError::ChannelInstanceMismatch) + )); + assert_eq!( + supervisor.state(), + SidecarHandshakeState::AwaitingAcceptance + ); + assert!(other_supervisor_channel.is_retired()); + assert!(!original_channel.is_retired()); + + assert!(supervisor + .receive(&mut original_channel, &acceptance) + .unwrap() + .is_none()); + assert_eq!(supervisor.state(), SidecarHandshakeState::Authenticated); + } + + #[test] + fn incompatible_offer_fails_and_retires_runtime() { + let mut supervisor_offer = offer(SidecarPeerRole::Supervisor, 4096, 0); + supervisor_offer.protocol_major += 1; + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let incompatible = supervisor_channel + .seal(&SidecarHandshakeMessage::Offer { + offer: supervisor_offer, + }) + .unwrap(); + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 4096, 0)).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + + assert!(matches!( + runtime.receive(&mut runtime_channel, &incompatible), + Err(SidecarHandshakeError::Negotiation( + SidecarProtocolError::UnsupportedMajor { .. } + )) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(runtime_channel.is_retired()); + } + + #[test] + fn wrong_order_is_terminal() { + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 4096, 0)).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + assert!(matches!( + runtime.start(&mut runtime_channel), + Err(SidecarHandshakeError::SupervisorMustInitiate) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(runtime_channel.is_retired()); + } + + #[test] + fn swapped_channel_roles_are_terminal_before_frame_processing() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0)).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + assert!(matches!( + supervisor.start(&mut runtime_channel), + Err(SidecarHandshakeError::ChannelRoleMismatch) + )); + assert_eq!(supervisor.state(), SidecarHandshakeState::Failed); + assert!(runtime_channel.is_retired()); + + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 4096, 0)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + assert!(matches!( + runtime.receive(&mut supervisor_channel, b"ignored"), + Err(SidecarHandshakeError::ChannelRoleMismatch) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(supervisor_channel.is_retired()); + } + + #[test] + fn malformed_frame_retires_handshake_and_channel() { + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 4096, 0)).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + assert!(matches!( + runtime.receive(&mut runtime_channel, b"not-authenticated"), + Err(SidecarHandshakeError::Frame(_)) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(runtime_channel.is_retired()); + } + + #[test] + fn invalid_local_identity_is_rejected_before_channel_use() { + let mut invalid = offer(SidecarPeerRole::Runtime, 4096, 0); + invalid.peer.artifact_identity.clear(); + assert!(matches!( + SidecarHandshake::new(invalid), + Err(SidecarHandshakeError::InvalidPeerIdentity) + )); + } + + #[test] + fn invalid_supervisor_identity_is_terminal_for_runtime() { + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let mut invalid = offer(SidecarPeerRole::Supervisor, 4096, 0); + invalid.peer.name = " ".into(); + let frame = supervisor_channel + .seal(&SidecarHandshakeMessage::Offer { offer: invalid }) + .unwrap(); + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime, 4096, 0)).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + + assert!(matches!( + runtime.receive(&mut runtime_channel, &frame), + Err(SidecarHandshakeError::InvalidPeerIdentity) + )); + assert_eq!(runtime.state(), SidecarHandshakeState::Failed); + assert!(runtime_channel.is_retired()); + } + + #[test] + fn invalid_runtime_identity_is_terminal_for_supervisor() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 4096, 0)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 4096); + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let mut runtime_channel = channel(SidecarPeerRole::Runtime, 4096); + let _: SidecarHandshakeMessage = runtime_channel.open(&offer_frame).unwrap(); + let mut invalid = offer(SidecarPeerRole::Runtime, 4096, 0); + invalid.peer.version.clear(); + let selection = SidecarProtocolSelection::from( + &negotiate_sidecar_protocol(&supervisor.local_offer, &invalid).unwrap(), + ); + let frame = runtime_channel + .seal(&SidecarHandshakeMessage::Accept { + offer: invalid, + selection, + }) + .unwrap(); + + assert!(matches!( + supervisor.receive(&mut supervisor_channel, &frame), + Err(SidecarHandshakeError::InvalidPeerIdentity) + )); + assert_eq!(supervisor.state(), SidecarHandshakeState::Failed); + assert!(supervisor_channel.is_retired()); + } + + #[test] + fn unencodable_initial_offer_is_terminal() { + let mut supervisor = + SidecarHandshake::new(offer(SidecarPeerRole::Supervisor, 128, 0)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor, 128); + assert!(matches!( + supervisor.start(&mut supervisor_channel), + Err(SidecarHandshakeError::Frame( + SidecarFrameError::FrameTooLarge { .. } + )) + )); + assert_eq!(supervisor.state(), SidecarHandshakeState::Failed); + assert!(supervisor_channel.is_retired()); + } +} diff --git a/crates/openclaw-node-host/src/sidecar_protocol.rs b/crates/openclaw-node-host/src/sidecar_protocol.rs new file mode 100644 index 000000000000..561ddd483a2a --- /dev/null +++ b/crates/openclaw-node-host/src/sidecar_protocol.rs @@ -0,0 +1,1276 @@ +//! Transport-neutral authenticated framing for an out-of-process node runtime. +//! +//! The product supervisor owns process creation, artifact verification, the +//! local IPC transport, and delivery of [`SidecarSessionKey`] over a protected +//! bootstrap channel. This module starts after that handoff. It provides the +//! version, limit, session-generation, sequence, and authentication invariants +//! shared by every platform adapter. + +use std::{ + fmt, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; + +use hmac::{Hmac, KeyInit, Mac}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::Sha256; +use thiserror::Error; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::watch, +}; +use zeroize::Zeroize; + +pub const SIDECAR_PROTOCOL_MAJOR: u16 = 1; +pub const SIDECAR_PROTOCOL_MINOR: u16 = 0; +/// Largest feature mask that every JSON implementation can represent exactly. +pub const SIDECAR_MAX_FEATURE_BITS: u64 = (1 << 53) - 1; +const SIDECAR_BOOTSTRAP_MINOR: u16 = 0; +static NEXT_CHANNEL_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +const FRAME_MAGIC: [u8; 4] = *b"OCSC"; +const AUTH_TAG_BYTES: usize = 32; +const FIXED_HEADER_BYTES: usize = 4 + 2 + 2 + 1 + 8 + 8 + 2 + 4; +const PAYLOAD_LENGTH_OFFSET: usize = 4 + 2 + 2 + 1 + 8 + 8 + 2; +const MIN_FRAME_BYTES: u32 = 65; +#[cfg(test)] +const LENGTH_PREFIX_BYTES: usize = 4; + +type HmacSha256 = Hmac; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SidecarPeerRole { + Supervisor, + Runtime, +} + +impl SidecarPeerRole { + const fn outgoing_direction(self) -> SidecarDirection { + match self { + Self::Supervisor => SidecarDirection::SupervisorToRuntime, + Self::Runtime => SidecarDirection::RuntimeToSupervisor, + } + } + + const fn expected_remote(self) -> Self { + match self { + Self::Supervisor => Self::Runtime, + Self::Runtime => Self::Supervisor, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SidecarDirection { + SupervisorToRuntime, + RuntimeToSupervisor, +} + +impl SidecarDirection { + const fn wire_value(self) -> u8 { + match self { + Self::SupervisorToRuntime => 1, + Self::RuntimeToSupervisor => 2, + } + } + + fn from_wire(value: u8) -> Result { + match value { + 1 => Ok(Self::SupervisorToRuntime), + 2 => Ok(Self::RuntimeToSupervisor), + _ => Err(SidecarFrameError::InvalidDirection(value)), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SidecarPeerIdentity { + pub role: SidecarPeerRole, + pub name: String, + pub version: String, + pub artifact_identity: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SidecarLimits { + pub max_frame_bytes: u32, + pub max_in_flight: u16, + pub bootstrap_timeout_ms: u32, +} + +impl SidecarLimits { + fn validate(self) -> Result { + if self.max_frame_bytes < MIN_FRAME_BYTES { + return Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")); + } + if self.max_in_flight == 0 { + return Err(SidecarProtocolError::InvalidLimit("maxInFlight")); + } + if self.bootstrap_timeout_ms == 0 { + return Err(SidecarProtocolError::InvalidLimit("bootstrapTimeoutMs")); + } + Ok(self) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SidecarProtocolOffer { + pub protocol_major: u16, + pub protocol_minor: u16, + pub peer: SidecarPeerIdentity, + pub feature_bits: u64, + pub limits: SidecarLimits, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NegotiatedSidecarProtocol { + pub protocol_major: u16, + pub protocol_minor: u16, + pub feature_bits: u64, + pub limits: SidecarLimits, + pub remote_peer: SidecarPeerIdentity, +} + +/// Negotiate additive features and limits without allowing either peer to +/// raise the other's local ceilings. +/// +/// # Errors +/// +/// Returns an error when roles or major versions are incompatible, or when +/// either peer offers an invalid limit or the local minor version is not +/// implemented. +pub fn negotiate_sidecar_protocol( + local: &SidecarProtocolOffer, + remote: &SidecarProtocolOffer, +) -> Result { + let local_limits = local.limits.validate()?; + let remote_limits = remote.limits.validate()?; + + if local.peer.role == remote.peer.role { + return Err(SidecarProtocolError::InvalidPeerRole); + } + if local.feature_bits > SIDECAR_MAX_FEATURE_BITS + || remote.feature_bits > SIDECAR_MAX_FEATURE_BITS + { + return Err(SidecarProtocolError::InvalidFeatureBits); + } + if local.protocol_major != SIDECAR_PROTOCOL_MAJOR + || remote.protocol_major != SIDECAR_PROTOCOL_MAJOR + { + return Err(SidecarProtocolError::UnsupportedMajor { + local: local.protocol_major, + remote: remote.protocol_major, + }); + } + if local.protocol_minor > SIDECAR_PROTOCOL_MINOR { + return Err(SidecarProtocolError::UnsupportedLocalMinor( + local.protocol_minor, + )); + } + + Ok(NegotiatedSidecarProtocol { + protocol_major: SIDECAR_PROTOCOL_MAJOR, + protocol_minor: local.protocol_minor.min(remote.protocol_minor), + feature_bits: local.feature_bits & remote.feature_bits, + limits: SidecarLimits { + max_frame_bytes: local_limits + .max_frame_bytes + .min(remote_limits.max_frame_bytes), + max_in_flight: local_limits.max_in_flight.min(remote_limits.max_in_flight), + bootstrap_timeout_ms: local_limits + .bootstrap_timeout_ms + .min(remote_limits.bootstrap_timeout_ms), + }, + remote_peer: remote.peer.clone(), + }) +} + +/// A fresh 256-bit key supplied out of band by the product supervisor. +pub struct SidecarSessionKey([u8; 32]); + +impl SidecarSessionKey { + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Generate a fresh key using the operating system random source. + /// + /// # Errors + /// + /// Returns an error when the operating system random source is unavailable. + pub fn generate() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes)?; + Ok(Self(bytes)) + } +} + +impl fmt::Debug for SidecarSessionKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SidecarSessionKey([redacted])") + } +} + +impl Drop for SidecarSessionKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// Authenticates frames for exactly one process session and generation. +/// +/// Create a new instance after every process restart. Sequence numbers are +/// directional and start at one. A rejected frame never advances the receive +/// high-water mark. +pub struct AuthenticatedSidecarChannel { + instance_id: u64, + role: SidecarPeerRole, + protocol_minor: u16, + session_id: String, + generation: u64, + key: SidecarSessionKey, + max_frame_bytes: u32, + frame_limit_locked: bool, + send_sequence: u64, + receive_sequence: u64, + liveness: SidecarChannelLiveness, +} + +#[derive(Clone)] +pub(crate) struct SidecarChannelLiveness(Arc>); + +impl SidecarChannelLiveness { + pub(crate) fn is_retired(&self) -> bool { + *self.0.borrow() + } + + pub(crate) async fn retired(&self) { + if self.is_retired() { + return; + } + let mut receiver = self.0.subscribe(); + while !*receiver.borrow_and_update() { + if receiver.changed().await.is_err() { + return; + } + } + } +} + +impl AuthenticatedSidecarChannel { + /// Create state for one role in one process-session generation. + /// + /// # Errors + /// + /// Returns an error for an empty or oversized session identifier, a zero + /// generation, or an undersized frame limit. + pub fn new( + role: SidecarPeerRole, + session_id: String, + generation: u64, + key: SidecarSessionKey, + max_frame_bytes: u32, + ) -> Result { + if session_id.is_empty() || session_id.len() > usize::from(u16::MAX) { + return Err(SidecarProtocolError::InvalidSessionId); + } + if generation == 0 { + return Err(SidecarProtocolError::InvalidGeneration); + } + if (max_frame_bytes as usize) < minimum_frame_bytes(session_id.len()) { + return Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")); + } + let instance_id = NEXT_CHANNEL_INSTANCE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .map_err(|_| SidecarProtocolError::ChannelInstanceIdExhausted)?; + + Ok(Self { + instance_id, + role, + protocol_minor: SIDECAR_BOOTSTRAP_MINOR, + session_id, + generation, + key, + max_frame_bytes, + frame_limit_locked: false, + send_sequence: 0, + receive_sequence: 0, + liveness: SidecarChannelLiveness(Arc::new(watch::channel(false).0)), + }) + } + + /// Permanently retire this process-session channel. + pub fn retire(&mut self) { + self.key.0.zeroize(); + self.liveness.0.send_replace(true); + } + + #[must_use] + pub fn is_retired(&self) -> bool { + self.liveness.is_retired() + } + + pub(crate) fn liveness(&self) -> SidecarChannelLiveness { + self.liveness.clone() + } + + /// Return an opaque process-local identity for binding a state machine to + /// this exact channel instance. The value has no wire or trust meaning. + #[must_use] + pub const fn instance_id(&self) -> u64 { + self.instance_id + } + + #[must_use] + pub const fn role(&self) -> SidecarPeerRole { + self.role + } + + #[must_use] + pub const fn max_frame_bytes(&self) -> u32 { + self.max_frame_bytes + } + + #[must_use] + pub fn max_payload_bytes(&self) -> usize { + self.max_frame_bytes as usize - FIXED_HEADER_BYTES - self.session_id.len() - AUTH_TAG_BYTES + } + + /// Apply the negotiated frame ceiling without resetting channel sequence. + /// + /// # Errors + /// + /// Returns an error if the new limit cannot hold a minimal frame, would + /// raise the ceiling used to bootstrap this channel, or configuration has + /// locked the negotiated ceiling for the rest of the channel generation. + pub fn lower_frame_limit( + &mut self, + negotiated_max_frame_bytes: u32, + ) -> Result<(), SidecarProtocolError> { + if self.frame_limit_locked { + return Err(SidecarProtocolError::FrameLimitLocked); + } + if (negotiated_max_frame_bytes as usize) < minimum_frame_bytes(self.session_id.len()) { + return Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")); + } + if negotiated_max_frame_bytes > self.max_frame_bytes { + return Err(SidecarProtocolError::LimitIncrease { + current: self.max_frame_bytes, + requested: negotiated_max_frame_bytes, + }); + } + self.max_frame_bytes = negotiated_max_frame_bytes; + Ok(()) + } + + pub(crate) fn lock_frame_limit(&mut self) { + self.frame_limit_locked = true; + } + + /// Apply the authenticated protocol selection after the bootstrap exchange. + /// + /// Bootstrap frames always use minor zero so an older peer can read the + /// offer. Both peers apply the independently verified selection only after + /// the final bootstrap frame, without resetting directional sequence state. + /// + /// # Errors + /// + /// Returns an error if the selection is unsupported or would raise the + /// channel's local frame ceiling. + pub fn apply_negotiated_protocol( + &mut self, + negotiated: &NegotiatedSidecarProtocol, + ) -> Result<(), SidecarProtocolError> { + if negotiated.protocol_major != SIDECAR_PROTOCOL_MAJOR { + return Err(SidecarProtocolError::UnsupportedMajor { + local: SIDECAR_PROTOCOL_MAJOR, + remote: negotiated.protocol_major, + }); + } + if negotiated.protocol_minor > SIDECAR_PROTOCOL_MINOR { + return Err(SidecarProtocolError::UnsupportedLocalMinor( + negotiated.protocol_minor, + )); + } + self.lower_frame_limit(negotiated.limits.max_frame_bytes)?; + self.protocol_minor = negotiated.protocol_minor; + Ok(()) + } + + /// Serialize and authenticate the next outgoing payload. + /// + /// # Errors + /// + /// Returns an error when serialization fails, the encoded frame exceeds + /// the local ceiling, the sequence has been exhausted, or the channel was + /// retired by an inbound or transport failure. + pub fn seal(&mut self, payload: &T) -> Result, SidecarFrameError> { + if self.is_retired() { + return Err(SidecarFrameError::ChannelRetired); + } + let sequence = self + .send_sequence + .checked_add(1) + .ok_or(SidecarFrameError::SequenceExhausted)?; + let session_id = self.session_id.as_bytes(); + let minimum_capacity = FIXED_HEADER_BYTES + .checked_add(session_id.len()) + .and_then(|size| size.checked_add(AUTH_TAG_BYTES)) + .ok_or(SidecarFrameError::FrameTooLarge { + size: u64::MAX, + limit: self.max_frame_bytes, + })?; + if minimum_capacity >= self.max_frame_bytes as usize { + return Err(SidecarFrameError::FrameTooLarge { + size: minimum_capacity.saturating_add(1) as u64, + limit: self.max_frame_bytes, + }); + } + + let mut frame = Vec::with_capacity(minimum_capacity); + frame.extend_from_slice(&FRAME_MAGIC); + frame.extend_from_slice(&SIDECAR_PROTOCOL_MAJOR.to_be_bytes()); + frame.extend_from_slice(&self.protocol_minor.to_be_bytes()); + frame.push(self.role.outgoing_direction().wire_value()); + frame.extend_from_slice(&self.generation.to_be_bytes()); + frame.extend_from_slice(&sequence.to_be_bytes()); + let session_len = + u16::try_from(session_id.len()).map_err(|_| SidecarFrameError::InvalidSessionId)?; + frame.extend_from_slice(&session_len.to_be_bytes()); + frame.extend_from_slice(&0_u32.to_be_bytes()); + frame.extend_from_slice(session_id); + + let payload_start = frame.len(); + let max_authenticated_len = self.max_frame_bytes as usize - AUTH_TAG_BYTES; + let serialization = { + let mut writer = BoundedFrameWriter::new(&mut frame, max_authenticated_len); + match serde_json::to_writer(&mut writer, payload) { + Ok(()) => Ok(()), + Err(_) if writer.exceeded => Err(SidecarFrameError::FrameTooLarge { + size: u64::from(self.max_frame_bytes) + 1, + limit: self.max_frame_bytes, + }), + Err(error) => Err(SidecarFrameError::Serialize(error)), + } + }; + serialization?; + + let payload_len = u32::try_from(frame.len() - payload_start).map_err(|_| { + SidecarFrameError::FrameTooLarge { + size: u64::MAX, + limit: self.max_frame_bytes, + } + })?; + frame[PAYLOAD_LENGTH_OFFSET..PAYLOAD_LENGTH_OFFSET + 4] + .copy_from_slice(&payload_len.to_be_bytes()); + + let mut mac = HmacSha256::new_from_slice(&self.key.0) + .map_err(|_| SidecarFrameError::Authentication)?; + mac.update(&frame); + frame.extend_from_slice(&mac.finalize().into_bytes()); + self.send_sequence = sequence; + Ok(frame) + } + + /// Authenticate, validate, and deserialize the next incoming frame. + /// + /// # Errors + /// + /// Returns an error for authentication, session, generation, direction, + /// sequence, version, size, framing, or payload failures. + pub fn open(&mut self, frame: &[u8]) -> Result { + if self.is_retired() { + return Err(SidecarFrameError::ChannelRetired); + } + + let result = self.open_active(frame); + if result.is_err() { + self.retire(); + } + result + } + + fn open_active(&mut self, frame: &[u8]) -> Result { + if frame.len() > self.max_frame_bytes as usize { + return Err(SidecarFrameError::FrameTooLarge { + size: frame.len() as u64, + limit: self.max_frame_bytes, + }); + } + let minimum = FIXED_HEADER_BYTES + AUTH_TAG_BYTES; + if frame.len() < minimum { + return Err(SidecarFrameError::Truncated); + } + + let authenticated_len = frame.len() - AUTH_TAG_BYTES; + let (authenticated, supplied_tag) = frame.split_at(authenticated_len); + let mut mac = HmacSha256::new_from_slice(&self.key.0) + .map_err(|_| SidecarFrameError::Authentication)?; + mac.update(authenticated); + mac.verify_slice(supplied_tag) + .map_err(|_| SidecarFrameError::Authentication)?; + + let mut cursor = 0; + let magic = take::<4>(authenticated, &mut cursor)?; + if magic != FRAME_MAGIC { + return Err(SidecarFrameError::InvalidMagic); + } + let major = u16::from_be_bytes(take::<2>(authenticated, &mut cursor)?); + let minor = u16::from_be_bytes(take::<2>(authenticated, &mut cursor)?); + if major != SIDECAR_PROTOCOL_MAJOR || minor != self.protocol_minor { + return Err(SidecarFrameError::UnsupportedVersion { major, minor }); + } + let direction = SidecarDirection::from_wire(take::<1>(authenticated, &mut cursor)?[0])?; + if direction != self.role.expected_remote().outgoing_direction() { + return Err(SidecarFrameError::WrongDirection); + } + let generation = u64::from_be_bytes(take::<8>(authenticated, &mut cursor)?); + if generation != self.generation { + return Err(SidecarFrameError::WrongGeneration { + expected: self.generation, + received: generation, + }); + } + let sequence = u64::from_be_bytes(take::<8>(authenticated, &mut cursor)?); + let expected_sequence = self + .receive_sequence + .checked_add(1) + .ok_or(SidecarFrameError::SequenceExhausted)?; + if sequence != expected_sequence { + return Err(SidecarFrameError::UnexpectedSequence { + expected: expected_sequence, + received: sequence, + }); + } + let session_len = usize::from(u16::from_be_bytes(take::<2>(authenticated, &mut cursor)?)); + let payload_len = + usize::try_from(u32::from_be_bytes(take::<4>(authenticated, &mut cursor)?)) + .map_err(|_| SidecarFrameError::Truncated)?; + let session = take_slice(authenticated, &mut cursor, session_len)?; + if session != self.session_id.as_bytes() { + return Err(SidecarFrameError::WrongSession); + } + let payload = take_slice(authenticated, &mut cursor, payload_len)?; + if cursor != authenticated.len() { + return Err(SidecarFrameError::TrailingBytes); + } + + let decoded = serde_json::from_slice(payload).map_err(SidecarFrameError::Deserialize)?; + self.receive_sequence = sequence; + Ok(decoded) + } +} + +impl Drop for AuthenticatedSidecarChannel { + fn drop(&mut self) { + self.liveness.0.send_replace(true); + } +} + +const fn minimum_frame_bytes(session_id_bytes: usize) -> usize { + FIXED_HEADER_BYTES + session_id_bytes + AUTH_TAG_BYTES + 1 +} + +struct BoundedFrameWriter<'a> { + frame: &'a mut Vec, + max_len: usize, + exceeded: bool, +} + +impl<'a> BoundedFrameWriter<'a> { + const fn new(frame: &'a mut Vec, max_len: usize) -> Self { + Self { + frame, + max_len, + exceeded: false, + } + } +} + +impl std::io::Write for BoundedFrameWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let remaining = self.max_len.saturating_sub(self.frame.len()); + if bytes.len() > remaining { + self.exceeded = true; + return Err(std::io::Error::other("sidecar frame limit exceeded")); + } + self.frame.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn take(bytes: &[u8], cursor: &mut usize) -> Result<[u8; N], SidecarFrameError> { + let value = take_slice(bytes, cursor, N)?; + value.try_into().map_err(|_| SidecarFrameError::Truncated) +} + +fn take_slice<'a>( + bytes: &'a [u8], + cursor: &mut usize, + length: usize, +) -> Result<&'a [u8], SidecarFrameError> { + let end = cursor + .checked_add(length) + .ok_or(SidecarFrameError::Truncated)?; + let value = bytes + .get(*cursor..end) + .ok_or(SidecarFrameError::Truncated)?; + *cursor = end; + Ok(value) +} + +/// Read one length-prefixed frame while applying the local ceiling before +/// allocating the payload buffer. +/// +/// # Errors +/// +/// Returns an error for a zero or oversized frame, an I/O failure, or expiry +/// of the local deadline. +pub async fn read_sidecar_frame( + reader: &mut R, + max_frame_bytes: u32, + deadline: Duration, +) -> Result, SidecarFrameError> { + if max_frame_bytes < MIN_FRAME_BYTES { + return Err(SidecarFrameError::InvalidFrameLimit(max_frame_bytes)); + } + let operation = async { + let length = reader.read_u32().await.map_err(SidecarFrameError::Io)?; + if length == 0 || length > max_frame_bytes { + return Err(SidecarFrameError::FrameTooLarge { + size: u64::from(length), + limit: max_frame_bytes, + }); + } + let mut frame = vec![0_u8; length as usize]; + reader + .read_exact(&mut frame) + .await + .map_err(SidecarFrameError::Io)?; + Ok(frame) + }; + tokio::time::timeout(deadline, operation) + .await + .map_err(|_| SidecarFrameError::Deadline)? +} + +/// Write one bounded, length-prefixed frame within a local deadline. +/// +/// # Errors +/// +/// Returns an error for a zero or oversized frame, an I/O failure, or expiry +/// of the local deadline. +pub async fn write_sidecar_frame( + writer: &mut W, + frame: &[u8], + max_frame_bytes: u32, + deadline: Duration, +) -> Result<(), SidecarFrameError> { + if max_frame_bytes < MIN_FRAME_BYTES { + return Err(SidecarFrameError::InvalidFrameLimit(max_frame_bytes)); + } + let length = u32::try_from(frame.len()).map_err(|_| SidecarFrameError::FrameTooLarge { + size: frame.len() as u64, + limit: max_frame_bytes, + })?; + if length == 0 || length > max_frame_bytes { + return Err(SidecarFrameError::FrameTooLarge { + size: u64::from(length), + limit: max_frame_bytes, + }); + } + + let operation = async { + writer + .write_all(&length.to_be_bytes()) + .await + .map_err(SidecarFrameError::Io)?; + writer + .write_all(frame) + .await + .map_err(SidecarFrameError::Io)?; + writer.flush().await.map_err(SidecarFrameError::Io) + }; + tokio::time::timeout(deadline, operation) + .await + .map_err(|_| SidecarFrameError::Deadline)? +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SidecarProtocolError { + #[error("sidecar channel instance id space exhausted")] + ChannelInstanceIdExhausted, + #[error("sidecar feature bits exceed the portable JSON integer range")] + InvalidFeatureBits, + #[error("sidecar peers must have complementary roles")] + InvalidPeerRole, + #[error("invalid sidecar limit: {0}")] + InvalidLimit(&'static str), + #[error("invalid sidecar session id")] + InvalidSessionId, + #[error("negotiated frame limit cannot increase from {current} to {requested}")] + LimitIncrease { current: u32, requested: u32 }, + #[error("sidecar frame limit is locked for this configured channel")] + FrameLimitLocked, + #[error("sidecar generation must be nonzero")] + InvalidGeneration, + #[error("unsupported sidecar major version (local {local}, remote {remote})")] + UnsupportedMajor { local: u16, remote: u16 }, + #[error("local sidecar minor version {0} is not implemented")] + UnsupportedLocalMinor(u16), +} + +#[derive(Debug, Error)] +pub enum SidecarFrameError { + #[error("sidecar frame authentication failed")] + Authentication, + #[error("sidecar channel is retired")] + ChannelRetired, + #[error("sidecar frame deadline exceeded")] + Deadline, + #[error("sidecar frame length {size} exceeds local limit {limit}")] + FrameTooLarge { size: u64, limit: u32 }, + #[error("invalid sidecar frame direction {0}")] + InvalidDirection(u8), + #[error("invalid sidecar frame magic")] + InvalidMagic, + #[error("invalid local sidecar frame limit {0}")] + InvalidFrameLimit(u32), + #[error("invalid sidecar frame session id")] + InvalidSessionId, + #[error( + "sidecar frame belongs to another generation (expected {expected}, received {received})" + )] + WrongGeneration { expected: u64, received: u64 }, + #[error("sidecar frame belongs to another session")] + WrongSession, + #[error("sidecar frame direction is invalid for this peer")] + WrongDirection, + #[error("sidecar sequence exhausted; rotate the generation")] + SequenceExhausted, + #[error("unexpected sidecar sequence (expected {expected}, received {received})")] + UnexpectedSequence { expected: u64, received: u64 }, + #[error("sidecar frame is truncated")] + Truncated, + #[error("sidecar frame contains trailing bytes")] + TrailingBytes, + #[error("unsupported sidecar frame version {major}.{minor}")] + UnsupportedVersion { major: u16, minor: u16 }, + #[error("sidecar payload serialization failed")] + Serialize(#[source] serde_json::Error), + #[error("sidecar payload deserialization failed")] + Deserialize(#[source] serde_json::Error), + #[error("sidecar transport failed")] + Io(#[source] std::io::Error), +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use super::*; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + use serde::ser::SerializeSeq; + use serde::Deserialize; + use serde_json::{json, Value}; + use tokio::io::AsyncWriteExt; + + const KEY: [u8; 32] = [0x5a; 32]; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WireFixture { + schema_version: u8, + session: WireSession, + supervisor_probe: WireProbe, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WireSession { + id: String, + generation: u64, + session_key_base64: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WireProbe { + payload: Value, + frame_base64: String, + } + + struct CountingPayload { + serialized: Arc, + } + + impl Serialize for CountingPayload { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut sequence = serializer.serialize_seq(Some(1_000_000))?; + for value in 0..1_000_000_u32 { + self.serialized.fetch_add(1, Ordering::Relaxed); + sequence.serialize_element(&value)?; + } + sequence.end() + } + } + + fn offer(role: SidecarPeerRole, limits: SidecarLimits) -> SidecarProtocolOffer { + SidecarProtocolOffer { + protocol_major: SIDECAR_PROTOCOL_MAJOR, + protocol_minor: SIDECAR_PROTOCOL_MINOR, + peer: SidecarPeerIdentity { + role, + name: match role { + SidecarPeerRole::Supervisor => "test-supervisor", + SidecarPeerRole::Runtime => "openclaw-node", + } + .into(), + version: "1.0.0".into(), + artifact_identity: "sha256:test-only".into(), + }, + feature_bits: 0b0111, + limits, + } + } + + fn channel(role: SidecarPeerRole, generation: u64) -> AuthenticatedSidecarChannel { + AuthenticatedSidecarChannel::new( + role, + "session-7".into(), + generation, + SidecarSessionKey::from_bytes(KEY), + 4096, + ) + .unwrap() + } + + #[test] + fn frame_limit_must_fit_the_specific_session_identifier() { + let session_id = "longer-session-id"; + assert!(matches!( + AuthenticatedSidecarChannel::new( + SidecarPeerRole::Runtime, + session_id.into(), + 1, + SidecarSessionKey::from_bytes(KEY), + MIN_FRAME_BYTES, + ), + Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")) + )); + + let exact_minimum = u32::try_from(minimum_frame_bytes(session_id.len())).unwrap(); + let mut channel = AuthenticatedSidecarChannel::new( + SidecarPeerRole::Runtime, + session_id.into(), + 1, + SidecarSessionKey::from_bytes(KEY), + exact_minimum, + ) + .unwrap(); + assert_eq!(channel.max_payload_bytes(), 1); + assert!(matches!( + channel.lower_frame_limit(exact_minimum - 1), + Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")) + )); + assert_eq!(channel.seal(&0_u8).unwrap().len(), exact_minimum as usize); + } + + #[test] + fn shared_wire_fixture_is_byte_exact_and_decodable() { + let fixture: WireFixture = serde_json::from_str(include_str!( + "../../../test/fixtures/node-sidecar-protocol-v1.json" + )) + .unwrap(); + assert_eq!(fixture.schema_version, 1); + let key: [u8; 32] = BASE64 + .decode(fixture.session.session_key_base64) + .unwrap() + .try_into() + .unwrap(); + let expected_frame = BASE64 + .decode(fixture.supervisor_probe.frame_base64) + .unwrap(); + + let mut supervisor = AuthenticatedSidecarChannel::new( + SidecarPeerRole::Supervisor, + fixture.session.id.clone(), + fixture.session.generation, + SidecarSessionKey::from_bytes(key), + 4096, + ) + .unwrap(); + assert_eq!( + supervisor.seal(&fixture.supervisor_probe.payload).unwrap(), + expected_frame + ); + + let mut runtime = AuthenticatedSidecarChannel::new( + SidecarPeerRole::Runtime, + fixture.session.id, + fixture.session.generation, + SidecarSessionKey::from_bytes(key), + 4096, + ) + .unwrap(); + assert_eq!( + runtime.open::(&expected_frame).unwrap(), + fixture.supervisor_probe.payload + ); + } + + #[test] + fn negotiation_intersects_features_and_uses_lower_limits() { + let local = offer( + SidecarPeerRole::Supervisor, + SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 8, + bootstrap_timeout_ms: 2_000, + }, + ); + let mut remote = offer( + SidecarPeerRole::Runtime, + SidecarLimits { + max_frame_bytes: 2048, + max_in_flight: 16, + bootstrap_timeout_ms: 1_000, + }, + ); + remote.feature_bits = 0b1011; + + let negotiated = negotiate_sidecar_protocol(&local, &remote).unwrap(); + assert_eq!(negotiated.feature_bits, 0b0011); + assert_eq!(negotiated.limits.max_frame_bytes, 2048); + assert_eq!(negotiated.limits.max_in_flight, 8); + assert_eq!(negotiated.limits.bootstrap_timeout_ms, 1_000); + assert_eq!(negotiated.remote_peer, remote.peer); + } + + #[test] + fn negotiation_rejects_unknown_major_and_same_role() { + let limits = SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 8, + bootstrap_timeout_ms: 1_000, + }; + let local = offer(SidecarPeerRole::Supervisor, limits); + let mut remote = offer(SidecarPeerRole::Runtime, limits); + remote.protocol_major += 1; + assert!(matches!( + negotiate_sidecar_protocol(&local, &remote), + Err(SidecarProtocolError::UnsupportedMajor { .. }) + )); + + let same_role = offer(SidecarPeerRole::Supervisor, limits); + assert_eq!( + negotiate_sidecar_protocol(&local, &same_role), + Err(SidecarProtocolError::InvalidPeerRole) + ); + + let mut unsupported_local = local.clone(); + unsupported_local.protocol_minor += 1; + assert_eq!( + negotiate_sidecar_protocol(&unsupported_local, &remote), + Err(SidecarProtocolError::UnsupportedMajor { + local: SIDECAR_PROTOCOL_MAJOR, + remote: SIDECAR_PROTOCOL_MAJOR + 1 + }) + ); + + let compatible_remote = offer(SidecarPeerRole::Runtime, limits); + assert_eq!( + negotiate_sidecar_protocol(&unsupported_local, &compatible_remote), + Err(SidecarProtocolError::UnsupportedLocalMinor(1)) + ); + } + + #[test] + fn negotiation_rejects_feature_bits_that_json_cannot_preserve() { + let limits = SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 8, + bootstrap_timeout_ms: 1_000, + }; + let mut local = offer(SidecarPeerRole::Supervisor, limits); + let mut remote = offer(SidecarPeerRole::Runtime, limits); + + local.feature_bits = SIDECAR_MAX_FEATURE_BITS; + remote.feature_bits = SIDECAR_MAX_FEATURE_BITS; + assert_eq!( + negotiate_sidecar_protocol(&local, &remote) + .unwrap() + .feature_bits, + SIDECAR_MAX_FEATURE_BITS + ); + + remote.feature_bits += 1; + assert_eq!( + negotiate_sidecar_protocol(&local, &remote), + Err(SidecarProtocolError::InvalidFeatureBits) + ); + } + + #[test] + fn shared_negotiation_fixture_preserves_features_above_32_bits() { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Fixture { + schema_version: u8, + local_offer: SidecarProtocolOffer, + remote_offer: SidecarProtocolOffer, + selected_feature_bits: u64, + } + + let fixture: Fixture = serde_json::from_str(include_str!( + "../../../test/fixtures/node-sidecar-negotiation-v1.json" + )) + .unwrap(); + assert_eq!(fixture.schema_version, 1); + assert!(fixture.selected_feature_bits > u64::from(u32::MAX)); + assert_eq!( + negotiate_sidecar_protocol(&fixture.local_offer, &fixture.remote_offer) + .unwrap() + .feature_bits, + fixture.selected_feature_bits + ); + } + + #[test] + fn negotiated_limit_can_only_lower_an_active_channel() { + let mut channel = channel(SidecarPeerRole::Supervisor, 7); + channel.lower_frame_limit(2048).unwrap(); + assert_eq!( + channel.lower_frame_limit(4096), + Err(SidecarProtocolError::LimitIncrease { + current: 2048, + requested: 4096 + }) + ); + assert_eq!( + channel.lower_frame_limit(MIN_FRAME_BYTES - 1), + Err(SidecarProtocolError::InvalidLimit("maxFrameBytes")) + ); + } + + #[test] + fn authenticated_frame_round_trips_and_replay_fails_closed() { + let mut supervisor = channel(SidecarPeerRole::Supervisor, 7); + let mut runtime = channel(SidecarPeerRole::Runtime, 7); + let frame = supervisor + .seal(&json!({"type":"probe","requestId":"abc"})) + .unwrap(); + + let decoded: Value = runtime.open(&frame).unwrap(); + assert_eq!(decoded["requestId"], "abc"); + assert!(matches!( + runtime.open::(&frame), + Err(SidecarFrameError::UnexpectedSequence { + expected: 2, + received: 1 + }) + )); + } + + #[test] + fn negotiated_protocol_updates_active_header_without_resetting_sequence() { + let local = offer( + SidecarPeerRole::Supervisor, + SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 8, + bootstrap_timeout_ms: 1_000, + }, + ); + let remote = offer( + SidecarPeerRole::Runtime, + SidecarLimits { + max_frame_bytes: 2048, + max_in_flight: 4, + bootstrap_timeout_ms: 500, + }, + ); + let negotiated = negotiate_sidecar_protocol(&local, &remote).unwrap(); + let mut supervisor = channel(SidecarPeerRole::Supervisor, 7); + let mut runtime = channel(SidecarPeerRole::Runtime, 7); + + let bootstrap = supervisor.seal(&json!({"type":"offer"})).unwrap(); + assert_eq!( + runtime.open::(&bootstrap).unwrap(), + json!({"type":"offer"}) + ); + supervisor.apply_negotiated_protocol(&negotiated).unwrap(); + runtime.apply_negotiated_protocol(&negotiated).unwrap(); + + let active = supervisor.seal(&json!({"type":"active"})).unwrap(); + assert_eq!( + u16::from_be_bytes(active[6..8].try_into().unwrap()), + negotiated.protocol_minor + ); + assert_eq!(u64::from_be_bytes(active[17..25].try_into().unwrap()), 2); + assert_eq!( + runtime.open::(&active).unwrap(), + json!({"type":"active"}) + ); + } + + #[test] + fn outbound_serialization_stops_at_frame_ceiling_without_advancing_sequence() { + let serialized = Arc::new(AtomicUsize::new(0)); + let mut supervisor = AuthenticatedSidecarChannel::new( + SidecarPeerRole::Supervisor, + "session-7".into(), + 7, + SidecarSessionKey::from_bytes(KEY), + 128, + ) + .unwrap(); + let oversized = supervisor.seal(&CountingPayload { + serialized: Arc::clone(&serialized), + }); + assert!(matches!( + oversized, + Err(SidecarFrameError::FrameTooLarge { + size: 129, + limit: 128 + }) + )); + assert!(serialized.load(Ordering::Relaxed) < 100); + + let frame = supervisor.seal(&json!({"ok":true})).unwrap(); + let mut runtime = AuthenticatedSidecarChannel::new( + SidecarPeerRole::Runtime, + "session-7".into(), + 7, + SidecarSessionKey::from_bytes(KEY), + 128, + ) + .unwrap(); + assert_eq!(runtime.open::(&frame).unwrap(), json!({"ok":true})); + } + + #[test] + fn authentication_failure_permanently_retires_channel() { + let mut supervisor = channel(SidecarPeerRole::Supervisor, 7); + let mut runtime = channel(SidecarPeerRole::Runtime, 7); + let valid = supervisor.seal(&json!({"type":"probe"})).unwrap(); + let mut tampered = valid.clone(); + tampered[FIXED_HEADER_BYTES] ^= 1; + + assert!(matches!( + runtime.open::(&tampered), + Err(SidecarFrameError::Authentication) + )); + assert!(runtime.is_retired()); + assert!(matches!( + runtime.open::(&valid), + Err(SidecarFrameError::ChannelRetired) + )); + assert!(matches!( + runtime.seal(&json!({"type":"status"})), + Err(SidecarFrameError::ChannelRetired) + )); + } + + #[test] + fn retired_generation_and_wrong_direction_are_rejected() { + let mut old_supervisor = channel(SidecarPeerRole::Supervisor, 6); + let mut current_runtime = channel(SidecarPeerRole::Runtime, 7); + let old = old_supervisor.seal(&json!({"type":"probe"})).unwrap(); + assert!(matches!( + current_runtime.open::(&old), + Err(SidecarFrameError::WrongGeneration { + expected: 7, + received: 6 + }) + )); + + let mut another_supervisor = channel(SidecarPeerRole::Supervisor, 7); + let outgoing = another_supervisor.seal(&json!({"type":"probe"})).unwrap(); + assert!(matches!( + supervisor_open(&outgoing), + Err(SidecarFrameError::WrongDirection) + )); + } + + fn supervisor_open(frame: &[u8]) -> Result { + channel(SidecarPeerRole::Supervisor, 7).open(frame) + } + + #[tokio::test] + async fn bounded_reader_rejects_length_before_payload_allocation() { + let (mut writer, mut reader) = tokio::io::duplex(32); + writer.write_all(&4097_u32.to_be_bytes()).await.unwrap(); + + assert!(matches!( + read_sidecar_frame(&mut reader, 4096, Duration::from_secs(1)).await, + Err(SidecarFrameError::FrameTooLarge { + size: 4097, + limit: 4096 + }) + )); + + assert!(matches!( + read_sidecar_frame(&mut reader, MIN_FRAME_BYTES - 1, Duration::from_secs(1)).await, + Err(SidecarFrameError::InvalidFrameLimit(_)) + )); + } + + #[tokio::test] + async fn bounded_transport_round_trips_and_enforces_deadline() { + let (mut writer, mut reader) = tokio::io::duplex(128); + write_sidecar_frame(&mut writer, b"frame", 128, Duration::from_secs(1)) + .await + .unwrap(); + assert_eq!( + read_sidecar_frame(&mut reader, 128, Duration::from_secs(1)) + .await + .unwrap(), + b"frame" + ); + + assert!(matches!( + read_sidecar_frame(&mut reader, 128, Duration::from_millis(1)).await, + Err(SidecarFrameError::Deadline) + )); + } + + #[test] + fn session_key_debug_is_redacted() { + assert_eq!( + format!("{:?}", SidecarSessionKey::from_bytes(KEY)), + "SidecarSessionKey([redacted])" + ); + } + + #[test] + fn frame_prefix_constant_matches_wire_contract() { + assert_eq!(LENGTH_PREFIX_BYTES, std::mem::size_of::()); + assert_eq!( + MIN_FRAME_BYTES as usize, + FIXED_HEADER_BYTES + AUTH_TAG_BYTES + 2 + ); + } +} diff --git a/crates/openclaw-node-host/src/sidecar_runtime.rs b/crates/openclaw-node-host/src/sidecar_runtime.rs new file mode 100644 index 000000000000..86201b63a583 --- /dev/null +++ b/crates/openclaw-node-host/src/sidecar_runtime.rs @@ -0,0 +1,2265 @@ +//! Product-neutral bridge from an authenticated sidecar session into the +//! bounded node command runtime. + +use std::{ + collections::BTreeSet, + future::Future, + io::{self, Write}, + pin::Pin, + sync::Arc, + time::Duration, +}; + +use serde::{de::Error as _, ser::Error as _, Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use thiserror::Error; + +use crate::sidecar_protocol::SidecarChannelLiveness; +use crate::{ + CancellationToken, ClientErrorClass, CommandRuntime, HandlerError, InvocationContext, + InvocationResult, LifecycleDisconnectReason, LifecycleEvent, NodeInvocation, RuntimeBuildError, + RuntimeErrorClass, SidecarHandshake, SidecarHandshakeState, SidecarPeerRole, + SidecarProtocolSelection, +}; + +const MAX_PORTABLE_JSON_INTEGER: u64 = crate::SIDECAR_MAX_FEATURE_BITS; +const MIN_PORTABLE_JSON_INTEGER: i64 = -9_007_199_254_740_991; +const MAX_PORTABLE_JSON_INTEGER_F64: f64 = 9_007_199_254_740_991.0; + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde `serialize_with` contract +fn serialize_portable_u64(value: &u64, serializer: S) -> Result +where + S: Serializer, +{ + if *value > MAX_PORTABLE_JSON_INTEGER { + return Err(S::Error::custom("integer exceeds portable JSON range")); + } + serializer.serialize_u64(*value) +} + +fn deserialize_portable_u64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = u64::deserialize(deserializer)?; + if value > MAX_PORTABLE_JSON_INTEGER { + return Err(D::Error::custom("integer exceeds portable JSON range")); + } + Ok(value) +} + +#[allow(clippy::ref_option)] // serde `serialize_with` contract +fn serialize_portable_optional_u64(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + if value.is_some_and(|value| value > MAX_PORTABLE_JSON_INTEGER) { + return Err(S::Error::custom("integer exceeds portable JSON range")); + } + value.serialize(serializer) +} + +fn deserialize_portable_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + if value.is_some_and(|value| value > MAX_PORTABLE_JSON_INTEGER) { + return Err(D::Error::custom("integer exceeds portable JSON range")); + } + Ok(value) +} + +fn portable_json_value(value: &Value) -> bool { + match value { + Value::Null | Value::Bool(_) | Value::String(_) => true, + Value::Number(number) => { + if let Some(value) = number.as_u64() { + value <= MAX_PORTABLE_JSON_INTEGER + } else if let Some(value) = number.as_i64() { + value >= MIN_PORTABLE_JSON_INTEGER + } else { + number.as_f64().is_some_and(|value| { + value.fract() != 0.0 || value.abs() <= MAX_PORTABLE_JSON_INTEGER_F64 + }) + } + } + Value::Array(values) => values.iter().all(portable_json_value), + Value::Object(values) => values.values().all(portable_json_value), + } +} + +fn serialize_portable_value(value: &Value, serializer: S) -> Result +where + S: Serializer, +{ + if !portable_json_value(value) { + return Err(S::Error::custom("integer exceeds portable JSON range")); + } + value.serialize(serializer) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde passes a reference to the borrowed field +fn serialize_portable_value_ref(value: &&Value, serializer: S) -> Result +where + S: Serializer, +{ + serialize_portable_value(value, serializer) +} + +fn deserialize_portable_value<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + if !portable_json_value(&value) { + return Err(D::Error::custom("integer exceeds portable JSON range")); + } + Ok(value) +} + +pub type SidecarAdapterFuture = Pin + Send>>; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SidecarCommandRegistration { + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SidecarRuntimeConfiguration { + #[serde( + serialize_with = "serialize_portable_u64", + deserialize_with = "deserialize_portable_u64" + )] + pub manifest_generation: u64, + pub capabilities: Vec, + pub commands: Vec, + pub max_concurrency: u16, + pub max_input_bytes: u32, + pub max_output_bytes: u32, + pub default_timeout_ms: u32, + pub max_timeout_ms: u32, + pub result_grace_ms: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SidecarRuntimeManifest { + #[serde( + serialize_with = "serialize_portable_u64", + deserialize_with = "deserialize_portable_u64" + )] + pub manifest_generation: u64, + pub capabilities: Vec, + pub commands: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SidecarInvocation { + pub id: String, + pub node_id: String, + pub command: String, + #[serde( + serialize_with = "serialize_portable_value", + deserialize_with = "deserialize_portable_value" + )] + pub params: Value, + #[serde( + serialize_with = "serialize_portable_optional_u64", + deserialize_with = "deserialize_portable_optional_u64" + )] + pub timeout_ms: Option, + pub idempotency_key: Option, + pub session_key: Option, +} + +impl From<&NodeInvocation> for SidecarInvocation { + fn from(invocation: &NodeInvocation) -> Self { + Self { + id: invocation.id.clone(), + node_id: invocation.node_id.clone(), + command: invocation.command.clone(), + params: invocation.params.clone(), + timeout_ms: invocation.timeout_ms, + idempotency_key: invocation.idempotency_key.clone(), + session_key: invocation.session_key.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "outcome", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SidecarAdmissionDecision { + Allow, + Deny { code: String, message: String }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "outcome", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SidecarInvocationResult { + Success { + #[serde( + serialize_with = "serialize_portable_value", + deserialize_with = "deserialize_portable_value" + )] + payload: Value, + }, + Failure { + code: String, + message: String, + }, +} + +impl From for Result { + fn from(result: SidecarInvocationResult) -> Self { + match result { + SidecarInvocationResult::Success { payload } => Ok(payload), + SidecarInvocationResult::Failure { code, message } => { + Err(HandlerError::new(code, message)) + } + } + } +} + +impl From for SidecarInvocationResult { + fn from(result: InvocationResult) -> Self { + match result { + InvocationResult::Success(payload) => Self::Success { payload }, + InvocationResult::Failure { code, message } => Self::Failure { code, message }, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SidecarRuntimeState { + Configured, + Connecting, + Ready, + BackingOff, + Paused, + Draining, + Stopped, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SidecarRuntimeReason { + Transport, + Gateway, + RequestTimeout, + EventLagged, + Activation, + DeliverySaturated, + ResultTask, + RuntimeEnded, + Shutdown, + Pairing, + Authentication, + Protocol, + Configuration, + Identity, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SidecarRuntimeStatus { + pub state: SidecarRuntimeState, + #[serde( + serialize_with = "serialize_portable_u64", + deserialize_with = "deserialize_portable_u64" + )] + pub manifest_generation: u64, + pub runtime_version: String, + #[serde( + serialize_with = "serialize_portable_u64", + deserialize_with = "deserialize_portable_u64" + )] + pub attempt: u64, + pub reason: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum SidecarRuntimeMessage { + Configure { + configuration: SidecarRuntimeConfiguration, + }, + Configured { + manifest: SidecarRuntimeManifest, + }, + AdmissionRequest { + invocation: SidecarInvocation, + }, + AdmissionDecision { + invocation_id: String, + decision: SidecarAdmissionDecision, + }, + Invoke { + invocation: SidecarInvocation, + }, + Result { + invocation_id: String, + result: SidecarInvocationResult, + }, + Cancel { + invocation_id: String, + }, + Status { + status: SidecarRuntimeStatus, + }, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +struct SidecarInvocationRef<'a> { + id: &'a str, + node_id: &'a str, + command: &'a str, + #[serde(serialize_with = "serialize_portable_value_ref")] + params: &'a Value, + #[serde(serialize_with = "serialize_portable_optional_u64")] + timeout_ms: Option, + idempotency_key: Option<&'a str>, + session_key: Option<&'a str>, +} + +impl<'a> From<&'a NodeInvocation> for SidecarInvocationRef<'a> { + fn from(invocation: &'a NodeInvocation) -> Self { + Self { + id: &invocation.id, + node_id: &invocation.node_id, + command: &invocation.command, + params: &invocation.params, + timeout_ms: invocation.timeout_ms, + idempotency_key: invocation.idempotency_key.as_deref(), + session_key: invocation.session_key.as_deref(), + } + } +} + +#[derive(Serialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +enum SidecarRuntimeMessageRef<'a> { + AdmissionRequest { + invocation: SidecarInvocationRef<'a>, + }, + AdmissionDecision { + invocation_id: &'a str, + decision: &'a SidecarAdmissionDecision, + }, + Invoke { + invocation: SidecarInvocationRef<'a>, + }, + Result { + invocation_id: &'a str, + result: &'a SidecarInvocationResult, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SidecarConfigurationState { + Starting, + AwaitingConfiguration, + AwaitingAcknowledgement, + AcknowledgementPending, + Configured, + Activated, + Failed, +} + +/// Enforces the one-time configuration exchange immediately after the +/// authenticated offer/accept handshake. +pub struct SidecarConfigurationExchange { + role: SidecarPeerRole, + channel_instance_id: u64, + selection: SidecarProtocolSelection, + runtime_version: String, + state: SidecarConfigurationState, + configuration: Option, + expected_manifest: Option, + max_payload_bytes: Option, +} + +impl SidecarConfigurationExchange { + /// Bind a configuration exchange to one authenticated handshake. + /// + /// # Errors + /// + /// Returns an error until the supplied handshake is authenticated. + #[expect( + clippy::needless_pass_by_value, + reason = "consuming the authenticated handshake enforces a one-shot phase transition" + )] + pub fn new(handshake: SidecarHandshake) -> Result { + if handshake.state() != SidecarHandshakeState::Authenticated { + return Err(SidecarConfigurationError::HandshakeNotAuthenticated); + } + let negotiated = handshake + .negotiated() + .ok_or(SidecarConfigurationError::HandshakeNotAuthenticated)?; + let channel_instance_id = handshake + .bound_channel_instance_id() + .ok_or(SidecarConfigurationError::HandshakeNotAuthenticated)?; + let role = handshake.local_role(); + let runtime_version = match role { + SidecarPeerRole::Runtime => handshake.local_peer().version.clone(), + SidecarPeerRole::Supervisor => negotiated.remote_peer.version.clone(), + }; + Ok(Self { + role, + channel_instance_id, + selection: SidecarProtocolSelection::from(negotiated), + runtime_version, + state: match role { + SidecarPeerRole::Supervisor => SidecarConfigurationState::Starting, + SidecarPeerRole::Runtime => SidecarConfigurationState::AwaitingConfiguration, + }, + configuration: None, + expected_manifest: None, + max_payload_bytes: None, + }) + } + + #[must_use] + pub const fn state(&self) -> SidecarConfigurationState { + self.state + } + + /// Return the independently derived manifest after configuration has + /// passed validation. + #[must_use] + pub const fn validated_manifest(&self) -> Option<&SidecarRuntimeManifest> { + self.expected_manifest.as_ref() + } + + /// Seal the supervisor's single runtime configuration. + /// + /// # Errors + /// + /// Every wrong role, state, invalid configuration, or encoding error + /// retires the exchange and authenticated channel. A replacement channel + /// instance is retired before processing and leaves the exchange intact. + pub fn start( + &mut self, + channel: &mut crate::AuthenticatedSidecarChannel, + configuration: &SidecarRuntimeConfiguration, + ) -> Result, SidecarConfigurationError> { + self.ensure_channel(channel)?; + if self.role != SidecarPeerRole::Supervisor { + return self.fail(channel, SidecarConfigurationError::SupervisorMustInitiate); + } + if channel.role() != self.role { + return self.fail(channel, SidecarConfigurationError::ChannelRoleMismatch); + } + if self.state != SidecarConfigurationState::Starting { + return self.fail(channel, SidecarConfigurationError::UnexpectedMessage); + } + channel.lock_frame_limit(); + if let Err(error) = validate_configuration(configuration, self.selection) { + return self.fail(channel, SidecarConfigurationError::Configuration(error)); + } + if let Err(error) = validate_status_budget( + &self.runtime_version, + configuration.manifest_generation, + channel.max_payload_bytes(), + ) { + return self.fail(channel, SidecarConfigurationError::Configuration(error)); + } + let frame = match channel.seal(&SidecarRuntimeMessage::Configure { + configuration: configuration.clone(), + }) { + Ok(frame) => frame, + Err(error) => return self.fail(channel, SidecarConfigurationError::Frame(error)), + }; + self.configuration = Some(configuration.clone()); + self.expected_manifest = Some(manifest_from_configuration(configuration)); + self.max_payload_bytes = Some(channel.max_payload_bytes()); + self.state = SidecarConfigurationState::AwaitingAcknowledgement; + Ok(frame) + } + + /// Receive the runtime configuration or the configured acknowledgement. + /// A runtime returns `Some(configuration)`; a supervisor returns `None`. + /// + /// # Errors + /// + /// Wrong ordering, roles, malformed frames, invalid configuration, and a + /// forged acknowledgement are terminal for the exchange and channel. + pub fn receive( + &mut self, + channel: &mut crate::AuthenticatedSidecarChannel, + frame: &[u8], + ) -> Result, SidecarConfigurationError> { + self.ensure_channel(channel)?; + if channel.role() != self.role { + return self.fail(channel, SidecarConfigurationError::ChannelRoleMismatch); + } + channel.lock_frame_limit(); + let message = match channel.open::(frame) { + Ok(message) => message, + Err(error) => return self.fail(channel, SidecarConfigurationError::Frame(error)), + }; + match (self.role, self.state, message) { + ( + SidecarPeerRole::Runtime, + SidecarConfigurationState::AwaitingConfiguration, + SidecarRuntimeMessage::Configure { configuration }, + ) => { + if let Err(error) = validate_configuration(&configuration, self.selection) { + return self.fail(channel, SidecarConfigurationError::Configuration(error)); + } + if let Err(error) = validate_status_budget( + &self.runtime_version, + configuration.manifest_generation, + channel.max_payload_bytes(), + ) { + return self.fail(channel, SidecarConfigurationError::Configuration(error)); + } + self.configuration = Some(configuration.clone()); + self.expected_manifest = Some(manifest_from_configuration(&configuration)); + self.max_payload_bytes = Some(channel.max_payload_bytes()); + self.state = SidecarConfigurationState::AwaitingAcknowledgement; + Ok(Some(configuration)) + } + ( + SidecarPeerRole::Supervisor, + SidecarConfigurationState::AwaitingAcknowledgement, + SidecarRuntimeMessage::Configured { manifest }, + ) => { + if self.expected_manifest.as_ref() != Some(&manifest) { + return self.fail(channel, SidecarConfigurationError::ManifestMismatch); + } + self.state = SidecarConfigurationState::Configured; + Ok(None) + } + _ => self.fail(channel, SidecarConfigurationError::UnexpectedMessage), + } + } + + /// Seal the runtime's acknowledgement of the exact validated manifest. + /// + /// # Errors + /// + /// Returns an error for a wrong role/state/channel or mismatched manifest. + pub fn acknowledge( + &mut self, + channel: &mut crate::AuthenticatedSidecarChannel, + manifest: &SidecarRuntimeManifest, + ) -> Result, SidecarConfigurationError> { + self.ensure_channel(channel)?; + if self.role != SidecarPeerRole::Runtime { + return self.fail(channel, SidecarConfigurationError::RuntimeMustAcknowledge); + } + if channel.role() != self.role { + return self.fail(channel, SidecarConfigurationError::ChannelRoleMismatch); + } + if self.state != SidecarConfigurationState::AwaitingAcknowledgement { + return self.fail(channel, SidecarConfigurationError::UnexpectedMessage); + } + if self.expected_manifest.as_ref() != Some(manifest) { + return self.fail(channel, SidecarConfigurationError::ManifestMismatch); + } + let frame = match channel.seal(&SidecarRuntimeMessage::Configured { + manifest: manifest.clone(), + }) { + Ok(frame) => frame, + Err(error) => return self.fail(channel, SidecarConfigurationError::Frame(error)), + }; + self.state = SidecarConfigurationState::AcknowledgementPending; + Ok(frame) + } + + /// Commit runtime configuration after the acknowledgement frame has been + /// written successfully. + /// + /// # Errors + /// + /// Returns an error for the wrong channel, role, state, or a retired + /// channel. Bound-channel failures retire the exchange and channel; a + /// replacement channel is retired before processing without mutation. + pub fn complete_acknowledgement( + &mut self, + channel: &mut crate::AuthenticatedSidecarChannel, + ) -> Result<(), SidecarConfigurationError> { + self.ensure_channel(channel)?; + if self.role != SidecarPeerRole::Runtime { + return self.fail(channel, SidecarConfigurationError::RuntimeMustAcknowledge); + } + if channel.is_retired() { + return self.fail( + channel, + SidecarConfigurationError::Frame(crate::SidecarFrameError::ChannelRetired), + ); + } + if self.state != SidecarConfigurationState::AcknowledgementPending { + return self.fail(channel, SidecarConfigurationError::UnexpectedMessage); + } + self.state = SidecarConfigurationState::Configured; + Ok(()) + } + + fn ensure_channel( + &self, + channel: &mut crate::AuthenticatedSidecarChannel, + ) -> Result<(), SidecarConfigurationError> { + if channel.instance_id() == self.channel_instance_id { + return Ok(()); + } + channel.retire(); + Err(SidecarConfigurationError::ChannelInstanceMismatch) + } + + fn fail( + &mut self, + channel: &mut crate::AuthenticatedSidecarChannel, + error: SidecarConfigurationError, + ) -> Result { + channel.retire(); + self.configuration = None; + self.expected_manifest = None; + self.max_payload_bytes = None; + self.state = SidecarConfigurationState::Failed; + Err(error) + } +} + +/// Product adapter invoked only after the Gateway and local runtime bounds +/// have accepted an invocation. Implementations own product policy and native +/// dispatch; they must observe the supplied cancellation token while waiting. +pub trait SidecarCapabilityAdapter: Send + Sync + 'static { + fn admit( + &self, + invocation: SidecarInvocation, + cancellation: CancellationToken, + ) -> SidecarAdapterFuture>; + + fn invoke( + &self, + invocation: SidecarInvocation, + cancellation: CancellationToken, + ) -> SidecarAdapterFuture>; +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("{code}: {message}")] +pub struct SidecarAdapterError { + pub code: String, + pub message: String, +} + +impl SidecarAdapterError { + #[must_use] + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +/// A validated, immutable connection manifest plus the bounded runtime that +/// enforces it. A capability update creates a new bridge/process generation; +/// this type intentionally has no mutation API for registrations. +pub struct SidecarRuntimeBridge { + runtime: CommandRuntime, + manifest: SidecarRuntimeManifest, + status: SidecarRuntimeStatus, +} + +impl SidecarRuntimeBridge { + /// Build a runtime bridge only from the runtime side of a successfully + /// authenticated and validated configuration exchange. + /// + /// # Errors + /// + /// Returns an error unless the exact runtime configuration acknowledgement + /// has been delivered successfully, or for a command-runtime registration + /// failure. + pub fn activate( + exchange: &mut SidecarConfigurationExchange, + channel: &mut crate::AuthenticatedSidecarChannel, + adapter: &Arc, + ) -> Result { + if channel.instance_id() != exchange.channel_instance_id { + channel.retire(); + return Err(SidecarRuntimeBridgeError::ChannelInstanceMismatch); + } + if channel.is_retired() { + exchange.configuration = None; + exchange.expected_manifest = None; + exchange.max_payload_bytes = None; + exchange.state = SidecarConfigurationState::Failed; + return Err(SidecarRuntimeBridgeError::ChannelRetired); + } + if exchange.role != SidecarPeerRole::Runtime { + return Err(SidecarRuntimeBridgeError::RuntimeRoleRequired); + } + let configuration = exchange + .configuration + .as_ref() + .ok_or(SidecarRuntimeBridgeError::ConfigurationNotValidated)?; + let manifest = manifest_from_configuration(configuration); + if exchange.state != SidecarConfigurationState::Configured + || exchange.expected_manifest.as_ref() != Some(&manifest) + { + return Err(SidecarRuntimeBridgeError::ConfigurationNotValidated); + } + let max_payload_bytes = exchange + .max_payload_bytes + .ok_or(SidecarRuntimeBridgeError::ConfigurationNotValidated)?; + let liveness = channel.liveness(); + let runtime = build_command_runtime( + configuration, + &manifest, + adapter, + max_payload_bytes, + &liveness, + )?; + let bridge = Self { + runtime, + manifest: manifest.clone(), + status: SidecarRuntimeStatus { + state: SidecarRuntimeState::Configured, + manifest_generation: manifest.manifest_generation, + runtime_version: exchange.runtime_version.clone(), + attempt: 0, + reason: None, + }, + }; + exchange.state = SidecarConfigurationState::Activated; + Ok(bridge) + } + + #[must_use] + pub const fn runtime(&self) -> &CommandRuntime { + &self.runtime + } + + #[must_use] + pub fn into_runtime(self) -> CommandRuntime { + self.runtime + } + + #[must_use] + pub const fn manifest(&self) -> &SidecarRuntimeManifest { + &self.manifest + } + + #[must_use] + pub const fn status(&self) -> &SidecarRuntimeStatus { + &self.status + } + + #[must_use] + pub fn configured_message(&self) -> SidecarRuntimeMessage { + SidecarRuntimeMessage::Configured { + manifest: self.manifest.clone(), + } + } + + #[must_use] + pub fn status_message(&self) -> SidecarRuntimeMessage { + SidecarRuntimeMessage::Status { + status: self.status.clone(), + } + } + + /// Apply one secret-free lifecycle event to the status projected to the + /// product supervisor. + pub fn observe_lifecycle(&mut self, event: &LifecycleEvent) { + let (state, attempt, reason) = match event { + LifecycleEvent::Connecting { attempt } | LifecycleEvent::Connected { attempt, .. } => { + (SidecarRuntimeState::Connecting, *attempt, None) + } + LifecycleEvent::Ready { attempt } => (SidecarRuntimeState::Ready, *attempt, None), + LifecycleEvent::Disconnected { attempt, reason } => { + let state = if *reason == LifecycleDisconnectReason::Shutdown { + SidecarRuntimeState::Draining + } else { + SidecarRuntimeState::Connecting + }; + (state, *attempt, Some(disconnect_reason(*reason))) + } + LifecycleEvent::BackingOff { + attempt, reason, .. + } => ( + SidecarRuntimeState::BackingOff, + *attempt, + Some(disconnect_reason(*reason)), + ), + LifecycleEvent::Paused { attempt, reason } => ( + SidecarRuntimeState::Paused, + *attempt, + Some(match reason { + crate::ReconnectPause::DevicePairing(_) => SidecarRuntimeReason::Pairing, + crate::ReconnectPause::Authentication { .. } => { + SidecarRuntimeReason::Authentication + } + crate::ReconnectPause::Protocol { .. } => SidecarRuntimeReason::Protocol, + crate::ReconnectPause::Configuration => SidecarRuntimeReason::Configuration, + crate::ReconnectPause::LocalIdentity => SidecarRuntimeReason::Identity, + }), + ), + LifecycleEvent::Stopped { attempt, .. } => ( + SidecarRuntimeState::Stopped, + *attempt, + Some(SidecarRuntimeReason::Shutdown), + ), + }; + let attempt = attempt.min(MAX_PORTABLE_JSON_INTEGER); + self.status = SidecarRuntimeStatus { + state, + manifest_generation: self.manifest.manifest_generation, + runtime_version: self.status.runtime_version.clone(), + attempt, + reason, + }; + } +} + +fn build_command_runtime( + configuration: &SidecarRuntimeConfiguration, + manifest: &SidecarRuntimeManifest, + adapter: &Arc, + max_payload_bytes: usize, + liveness: &SidecarChannelLiveness, +) -> Result { + let mut builder = CommandRuntime::builder() + .max_concurrency(usize::from(configuration.max_concurrency)) + .max_input_bytes(configuration.max_input_bytes as usize) + .max_output_bytes(configuration.max_output_bytes as usize) + .default_timeout(Duration::from_millis(u64::from( + configuration.default_timeout_ms, + ))) + .max_timeout(Duration::from_millis(u64::from( + configuration.max_timeout_ms, + ))) + .result_grace(Duration::from_millis(u64::from( + configuration.result_grace_ms, + ))); + for capability in &manifest.capabilities { + builder = builder.capability(capability.clone()); + } + let admission_adapter = Arc::clone(adapter); + let admission_liveness = liveness.clone(); + builder = builder.admission_policy(move |context| { + evaluate_sidecar_admission( + Arc::clone(&admission_adapter), + context, + max_payload_bytes, + admission_liveness.clone(), + ) + }); + for command in &manifest.commands { + let command_adapter = Arc::clone(adapter); + let command_liveness = liveness.clone(); + builder = builder.command(command.clone(), move |context| { + evaluate_sidecar_invocation( + Arc::clone(&command_adapter), + context, + max_payload_bytes, + command_liveness.clone(), + ) + }); + } + builder.build() +} + +async fn evaluate_sidecar_admission( + adapter: Arc, + context: crate::InvocationAdmissionContext, + max_payload_bytes: usize, + liveness: SidecarChannelLiveness, +) -> Result<(), HandlerError> { + if liveness.is_retired() { + context.cancellation.cancel(); + return Err(channel_retired()); + } + if !node_invocation_is_portable(&context.invocation) { + return Err(nonportable_json()); + } + let invocation_ref = SidecarInvocationRef::from(&context.invocation); + if !runtime_message_within_limit( + &SidecarRuntimeMessageRef::AdmissionRequest { + invocation: invocation_ref, + }, + max_payload_bytes, + ) { + return Err(message_too_large()); + } + let cancellation = context.cancellation; + let adapter_future = adapter.admit( + SidecarInvocation::from(&context.invocation), + cancellation.clone(), + ); + tokio::pin!(adapter_future); + let decision = tokio::select! { + biased; + () = liveness.retired() => { + cancellation.cancel(); + return Err(channel_retired()); + }, + result = &mut adapter_future => match result { + Ok(decision) => decision, + Err(error) => { + let error = adapter_failure(&error); + SidecarAdmissionDecision::Deny { + code: error.code, + message: error.message, + } + } + }, + }; + if !runtime_message_within_limit( + &SidecarRuntimeMessageRef::AdmissionDecision { + invocation_id: &context.invocation.id, + decision: &decision, + }, + max_payload_bytes, + ) { + return Err(message_too_large()); + } + match decision { + SidecarAdmissionDecision::Allow => Ok(()), + SidecarAdmissionDecision::Deny { code, message } => Err(HandlerError::new(code, message)), + } +} + +async fn evaluate_sidecar_invocation( + adapter: Arc, + context: InvocationContext, + max_payload_bytes: usize, + liveness: SidecarChannelLiveness, +) -> Result { + if liveness.is_retired() { + context.cancellation.cancel(); + return Err(channel_retired()); + } + if !node_invocation_is_portable(&context.invocation) { + return Err(nonportable_json()); + } + let invocation_ref = SidecarInvocationRef::from(&context.invocation); + if !runtime_message_within_limit( + &SidecarRuntimeMessageRef::Invoke { + invocation: invocation_ref, + }, + max_payload_bytes, + ) { + return Err(message_too_large()); + } + let cancellation = context.cancellation; + let adapter_future = adapter.invoke( + SidecarInvocation::from(&context.invocation), + cancellation.clone(), + ); + tokio::pin!(adapter_future); + let result = tokio::select! { + biased; + () = liveness.retired() => { + cancellation.cancel(); + return Err(channel_retired()); + }, + result = &mut adapter_future => match result { + Ok(result) => result, + Err(error) => { + let error = adapter_failure(&error); + SidecarInvocationResult::Failure { + code: error.code, + message: error.message, + } + } + }, + }; + if !invocation_result_is_portable(&result) { + return Err(nonportable_json()); + } + if !runtime_message_within_limit( + &SidecarRuntimeMessageRef::Result { + invocation_id: &context.invocation.id, + result: &result, + }, + max_payload_bytes, + ) { + return Err(message_too_large()); + } + result.into() +} + +fn validate_configuration( + configuration: &SidecarRuntimeConfiguration, + negotiated: SidecarProtocolSelection, +) -> Result<(), SidecarRuntimeBridgeError> { + if configuration.manifest_generation == 0 + || configuration.manifest_generation > MAX_PORTABLE_JSON_INTEGER + { + return Err(SidecarRuntimeBridgeError::InvalidManifestGeneration); + } + if configuration.max_concurrency == 0 + || configuration.max_concurrency > negotiated.limits.max_in_flight + { + return Err(SidecarRuntimeBridgeError::InvalidLimit("maxConcurrency")); + } + if configuration.max_input_bytes == 0 + || configuration.max_input_bytes > negotiated.limits.max_frame_bytes + { + return Err(SidecarRuntimeBridgeError::InvalidLimit("maxInputBytes")); + } + if configuration.max_output_bytes == 0 + || (configuration.max_output_bytes as usize) < minimum_bridge_failure_bytes() + || configuration.max_output_bytes > negotiated.limits.max_frame_bytes + { + return Err(SidecarRuntimeBridgeError::InvalidLimit("maxOutputBytes")); + } + if configuration.default_timeout_ms == 0 + || configuration.max_timeout_ms == 0 + || configuration.default_timeout_ms > configuration.max_timeout_ms + || configuration.result_grace_ms >= configuration.default_timeout_ms + { + return Err(SidecarRuntimeBridgeError::InvalidLimit("timeouts")); + } + validate_names(&configuration.capabilities, false)?; + validate_names( + &configuration + .commands + .iter() + .map(|command| command.name.clone()) + .collect::>(), + true, + ) +} + +fn validate_status_budget( + runtime_version: &str, + manifest_generation: u64, + max_payload_bytes: usize, +) -> Result<(), SidecarRuntimeBridgeError> { + let worst_case = SidecarRuntimeMessage::Status { + status: SidecarRuntimeStatus { + state: SidecarRuntimeState::BackingOff, + manifest_generation, + runtime_version: runtime_version.to_owned(), + attempt: MAX_PORTABLE_JSON_INTEGER, + reason: Some(SidecarRuntimeReason::DeliverySaturated), + }, + }; + if runtime_message_within_limit(&worst_case, max_payload_bytes) { + Ok(()) + } else { + Err(SidecarRuntimeBridgeError::StatusMessageTooLarge) + } +} + +fn manifest_from_configuration( + configuration: &SidecarRuntimeConfiguration, +) -> SidecarRuntimeManifest { + SidecarRuntimeManifest { + manifest_generation: configuration.manifest_generation, + capabilities: sorted(configuration.capabilities.clone()), + commands: sorted( + configuration + .commands + .iter() + .map(|command| command.name.clone()) + .collect(), + ), + } +} + +fn validate_names(names: &[String], commands: bool) -> Result<(), SidecarRuntimeBridgeError> { + let mut unique = BTreeSet::new(); + for name in names { + if name.is_empty() + || name.len() > 128 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(SidecarRuntimeBridgeError::InvalidName(name.clone())); + } + if commands && (name == "system" || name.starts_with("system.")) { + return Err(SidecarRuntimeBridgeError::ReservedCommand(name.clone())); + } + if !unique.insert(name) { + return Err(SidecarRuntimeBridgeError::DuplicateName(name.clone())); + } + } + Ok(()) +} + +fn sorted(mut values: Vec) -> Vec { + values.sort(); + values +} + +fn adapter_failure(error: &SidecarAdapterError) -> HandlerError { + let code = if error.code.trim().is_empty() { + "SIDECAR_ADAPTER" + } else { + error.code.as_str() + }; + let message = if error.message.trim().is_empty() { + "sidecar capability adapter failed" + } else { + error.message.as_str() + }; + HandlerError::new(code, message) +} + +fn message_too_large() -> HandlerError { + HandlerError::new( + "SIDECAR_MESSAGE_TOO_LARGE", + "complete sidecar message exceeds the authenticated payload limit", + ) +} + +fn nonportable_json() -> HandlerError { + HandlerError::new( + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range", + ) +} + +fn node_invocation_is_portable(invocation: &NodeInvocation) -> bool { + invocation + .timeout_ms + .is_none_or(|value| value <= MAX_PORTABLE_JSON_INTEGER) + && portable_json_value(&invocation.params) +} + +fn invocation_result_is_portable(result: &SidecarInvocationResult) -> bool { + match result { + SidecarInvocationResult::Success { payload } => portable_json_value(payload), + SidecarInvocationResult::Failure { .. } => true, + } +} + +fn channel_retired() -> HandlerError { + HandlerError::new( + "SIDECAR_CHANNEL_RETIRED", + "authenticated sidecar channel is no longer live", + ) +} + +fn minimum_bridge_failure_bytes() -> usize { + [message_too_large(), nonportable_json(), channel_retired()] + .iter() + .map(|error| { + serde_json::json!({"code": &error.code, "message": &error.message}) + .to_string() + .len() + }) + .max() + .unwrap_or(1) +} + +fn runtime_message_within_limit(message: &T, limit: usize) -> bool { + let mut writer = PayloadSizeLimiter { written: 0, limit }; + serde_json::to_writer(&mut writer, message).is_ok() +} + +struct PayloadSizeLimiter { + written: usize, + limit: usize, +} + +impl Write for PayloadSizeLimiter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let remaining = self.limit.saturating_sub(self.written); + if bytes.len() > remaining { + return Err(io::Error::other("sidecar payload limit exceeded")); + } + self.written += bytes.len(); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +const fn disconnect_reason(reason: LifecycleDisconnectReason) -> SidecarRuntimeReason { + match reason { + LifecycleDisconnectReason::Client(class) => match class { + ClientErrorClass::Configuration => SidecarRuntimeReason::Configuration, + ClientErrorClass::Transport => SidecarRuntimeReason::Transport, + ClientErrorClass::Protocol => SidecarRuntimeReason::Protocol, + ClientErrorClass::Identity => SidecarRuntimeReason::Identity, + ClientErrorClass::Gateway => SidecarRuntimeReason::Gateway, + ClientErrorClass::RequestTimeout => SidecarRuntimeReason::RequestTimeout, + ClientErrorClass::EventLagged => SidecarRuntimeReason::EventLagged, + ClientErrorClass::Activation => SidecarRuntimeReason::Activation, + }, + LifecycleDisconnectReason::Runtime(class) => match class { + RuntimeErrorClass::DeliverySaturated => SidecarRuntimeReason::DeliverySaturated, + RuntimeErrorClass::ResultTask => SidecarRuntimeReason::ResultTask, + }, + LifecycleDisconnectReason::RuntimeEnded => SidecarRuntimeReason::RuntimeEnded, + LifecycleDisconnectReason::Shutdown => SidecarRuntimeReason::Shutdown, + } +} + +#[derive(Debug, Error)] +pub enum SidecarRuntimeBridgeError { + #[error("sidecar runtime configuration has not been authenticated and validated")] + ConfigurationNotValidated, + #[error("sidecar runtime bridge cannot move between authenticated channel instances")] + ChannelInstanceMismatch, + #[error("sidecar runtime bridge requires a live authenticated channel")] + ChannelRetired, + #[error("sidecar runtime status cannot fit the authenticated payload limit")] + StatusMessageTooLarge, + #[error("sidecar runtime bridge requires the runtime handshake role")] + RuntimeRoleRequired, + #[error("sidecar manifest generation must be nonzero")] + InvalidManifestGeneration, + #[error("invalid sidecar runtime limit: {0}")] + InvalidLimit(&'static str), + #[error("invalid sidecar runtime name: {0}")] + InvalidName(String), + #[error("duplicate sidecar runtime name: {0}")] + DuplicateName(String), + #[error("OpenClaw-owned system command namespace is reserved: {0}")] + ReservedCommand(String), + #[error(transparent)] + Runtime(#[from] RuntimeBuildError), +} + +#[derive(Debug, Error)] +pub enum SidecarConfigurationError { + #[error("sidecar handshake must be authenticated before configuration")] + HandshakeNotAuthenticated, + #[error("sidecar configuration frame failed")] + Frame(#[source] crate::SidecarFrameError), + #[error("sidecar runtime configuration is invalid")] + Configuration(#[source] SidecarRuntimeBridgeError), + #[error("supervisor must initiate sidecar configuration")] + SupervisorMustInitiate, + #[error("runtime must acknowledge sidecar configuration")] + RuntimeMustAcknowledge, + #[error("sidecar configuration role does not match authenticated channel role")] + ChannelRoleMismatch, + #[error("sidecar configuration cannot move between authenticated channel instances")] + ChannelInstanceMismatch, + #[error("sidecar configured manifest does not match the validated configuration")] + ManifestMismatch, + #[error("unexpected sidecar configuration message")] + UnexpectedMessage, +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }; + + use serde_json::json; + use tokio::sync::Notify; + + use super::*; + use crate::{ + AuthenticatedSidecarChannel, SidecarLimits, SidecarPeerIdentity, SidecarProtocolOffer, + SidecarSessionKey, + }; + + const KEY: [u8; 32] = [0x55; 32]; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct RuntimeFixture { + schema_version: u8, + messages: Vec, + canonical_json: Vec, + } + + fn offer(role: SidecarPeerRole) -> SidecarProtocolOffer { + SidecarProtocolOffer { + protocol_major: crate::SIDECAR_PROTOCOL_MAJOR, + protocol_minor: crate::SIDECAR_PROTOCOL_MINOR, + peer: SidecarPeerIdentity { + role, + name: match role { + SidecarPeerRole::Supervisor => "test-supervisor", + SidecarPeerRole::Runtime => "openclaw-node", + } + .into(), + version: "1.0.0".into(), + artifact_identity: "sha256:test-only".into(), + }, + feature_bits: crate::SIDECAR_MAX_FEATURE_BITS, + limits: SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 4, + bootstrap_timeout_ms: 1_000, + }, + } + } + + fn channel(role: SidecarPeerRole) -> AuthenticatedSidecarChannel { + AuthenticatedSidecarChannel::new( + role, + "runtime-session".into(), + 11, + SidecarSessionKey::from_bytes(KEY), + 4096, + ) + .unwrap() + } + + fn authenticated_pair() -> ( + SidecarHandshake, + SidecarHandshake, + AuthenticatedSidecarChannel, + AuthenticatedSidecarChannel, + ) { + let mut supervisor = SidecarHandshake::new(offer(SidecarPeerRole::Supervisor)).unwrap(); + let mut runtime = SidecarHandshake::new(offer(SidecarPeerRole::Runtime)).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor); + let mut runtime_channel = channel(SidecarPeerRole::Runtime); + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let accept_frame = runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + runtime.complete_acceptance(&mut runtime_channel).unwrap(); + supervisor + .receive(&mut supervisor_channel, &accept_frame) + .unwrap(); + (supervisor, runtime, supervisor_channel, runtime_channel) + } + + fn validated_runtime_exchange( + configuration: &SidecarRuntimeConfiguration, + ) -> ( + SidecarConfigurationExchange, + AuthenticatedSidecarChannel, + SidecarRuntimeConfiguration, + ) { + let (supervisor, runtime, mut supervisor_channel, mut runtime_channel) = + authenticated_pair(); + let mut supervisor_exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + let mut runtime_exchange = SidecarConfigurationExchange::new(runtime).unwrap(); + let frame = supervisor_exchange + .start(&mut supervisor_channel, configuration) + .unwrap(); + let received = runtime_exchange + .receive(&mut runtime_channel, &frame) + .unwrap() + .unwrap(); + let manifest = runtime_exchange.validated_manifest().unwrap().clone(); + let acknowledgement = runtime_exchange + .acknowledge(&mut runtime_channel, &manifest) + .unwrap(); + runtime_exchange + .complete_acknowledgement(&mut runtime_channel) + .unwrap(); + supervisor_exchange + .receive(&mut supervisor_channel, &acknowledgement) + .unwrap(); + (runtime_exchange, runtime_channel, received) + } + + fn configuration() -> SidecarRuntimeConfiguration { + SidecarRuntimeConfiguration { + manifest_generation: 3, + capabilities: vec!["native.settings".into(), "native.status".into()], + commands: vec![ + SidecarCommandRegistration { + name: "product.status".into(), + }, + SidecarCommandRegistration { + name: "product.settings".into(), + }, + ], + max_concurrency: 2, + max_input_bytes: 1024, + max_output_bytes: 1024, + default_timeout_ms: 1_000, + max_timeout_ms: 5_000, + result_grace_ms: 50, + } + } + + #[derive(Default)] + struct RecordingAdapter { + admissions: AtomicUsize, + invocations: AtomicUsize, + denied_command: Mutex>, + } + + impl SidecarCapabilityAdapter for RecordingAdapter { + fn admit( + &self, + invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + self.admissions.fetch_add(1, Ordering::SeqCst); + let denied = self + .denied_command + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_deref() + == Some(invocation.command.as_str()); + Box::pin(async move { + Ok(if denied { + SidecarAdmissionDecision::Deny { + code: "LOCAL_DENY".into(), + message: "denied by product policy".into(), + } + } else { + SidecarAdmissionDecision::Allow + }) + }) + } + + fn invoke( + &self, + invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + self.invocations.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + Ok(SidecarInvocationResult::Success { + payload: json!({"command": invocation.command, "params": invocation.params}), + }) + }) + } + } + + #[tokio::test] + async fn bridge_routes_admission_then_native_invocation() { + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&configuration()); + let adapter = Arc::new(RecordingAdapter::default()); + let bridge = SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + + assert_eq!( + bridge.runtime().command_names().collect::>(), + vec!["product.settings", "product.status"] + ); + assert_eq!( + bridge.runtime().capability_names().collect::>(), + vec!["native.settings", "native.status"] + ); + let result = bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-1", + "node-1", + "product.status", + json!({"verbose": true}), + )) + .await; + assert_eq!( + result, + InvocationResult::success(json!({ + "command": "product.status", + "params": {"verbose": true} + })) + ); + assert_eq!(adapter.admissions.load(Ordering::SeqCst), 1); + assert_eq!(adapter.invocations.load(Ordering::SeqCst), 1); + assert_eq!( + bridge.configured_message(), + SidecarRuntimeMessage::Configured { + manifest: bridge.manifest().clone() + } + ); + } + + #[tokio::test] + async fn admission_denial_never_dispatches_native_work() { + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&configuration()); + let adapter = Arc::new(RecordingAdapter::default()); + *adapter + .denied_command + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some("product.settings".into()); + let bridge = SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + + assert_eq!( + bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-2", + "node-1", + "product.settings", + Value::Null, + )) + .await, + InvocationResult::failure("LOCAL_DENY", "denied by product policy") + ); + assert_eq!(adapter.admissions.load(Ordering::SeqCst), 1); + assert_eq!(adapter.invocations.load(Ordering::SeqCst), 0); + } + + struct OversizedResultAdapter; + + impl SidecarCapabilityAdapter for OversizedResultAdapter { + fn admit( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { Ok(SidecarAdmissionDecision::Allow) }) + } + + fn invoke( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { + Ok(SidecarInvocationResult::Success { + payload: json!({"data": "x".repeat(3_950)}), + }) + }) + } + } + + struct OversizedAdapterError { + fail_admission: bool, + } + + impl SidecarCapabilityAdapter for OversizedAdapterError { + fn admit( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + let fail_admission = self.fail_admission; + Box::pin(async move { + if fail_admission { + Err(SidecarAdapterError::new( + "C".repeat(2_000), + "M".repeat(2_000), + )) + } else { + Ok(SidecarAdmissionDecision::Allow) + } + }) + } + + fn invoke( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { + Err(SidecarAdapterError::new( + "C".repeat(2_000), + "M".repeat(2_000), + )) + }) + } + } + + #[tokio::test] + async fn adapter_errors_obey_complete_sidecar_message_budget() { + let handler_payload = json!({ + "code": "C".repeat(2_000), + "message": "M".repeat(2_000), + }); + assert!(serde_json::to_vec(&handler_payload).unwrap().len() <= 4_096); + + for fail_admission in [true, false] { + let mut bounded = configuration(); + bounded.max_output_bytes = 4_096; + let (mut exchange, mut channel, _configuration) = validated_runtime_exchange(&bounded); + let adapter = Arc::new(OversizedAdapterError { fail_admission }); + let bridge = + SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + + assert_eq!( + bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-adapter-error", + "node-1", + "product.status", + Value::Null, + )) + .await, + InvocationResult::failure( + "SIDECAR_MESSAGE_TOO_LARGE", + "complete sidecar message exceeds the authenticated payload limit" + ) + ); + } + } + + struct NonPortableResultAdapter; + + impl SidecarCapabilityAdapter for NonPortableResultAdapter { + fn admit( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { Ok(SidecarAdmissionDecision::Allow) }) + } + + fn invoke( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { + Ok(SidecarInvocationResult::Success { + payload: json!({"unsafe": MAX_PORTABLE_JSON_INTEGER + 1}), + }) + }) + } + } + + #[tokio::test] + async fn nonportable_json_has_a_distinct_runtime_failure() { + let mut failure_bounded = configuration(); + failure_bounded.max_output_bytes = u32::try_from(minimum_bridge_failure_bytes()).unwrap(); + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&failure_bounded); + let adapter = Arc::new(RecordingAdapter::default()); + let bridge = SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + assert_eq!( + bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-unsafe-input", + "node-1", + "product.status", + json!({"unsafe": MAX_PORTABLE_JSON_INTEGER + 1}), + )) + .await, + InvocationResult::failure( + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range" + ) + ); + assert_eq!(adapter.admissions.load(Ordering::SeqCst), 0); + + let (mut result_exchange, mut result_channel, _configuration) = + validated_runtime_exchange(&failure_bounded); + let result_adapter = Arc::new(NonPortableResultAdapter); + let result_bridge = SidecarRuntimeBridge::activate( + &mut result_exchange, + &mut result_channel, + &result_adapter, + ) + .unwrap(); + assert_eq!( + result_bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-unsafe-result", + "node-1", + "product.status", + Value::Null, + )) + .await, + InvocationResult::failure( + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range" + ) + ); + } + + #[tokio::test] + async fn complete_message_budget_rejects_boundary_values_before_transport() { + let mut bounded = configuration(); + bounded.max_input_bytes = 4096; + bounded.max_output_bytes = 4096; + let (mut exchange, mut runtime_channel, _received) = validated_runtime_exchange(&bounded); + let adapter = Arc::new(RecordingAdapter::default()); + let bridge = + SidecarRuntimeBridge::activate(&mut exchange, &mut runtime_channel, &adapter).unwrap(); + let invocation = NodeInvocation::new( + "invoke-boundary", + "node-1", + "product.status", + json!({"data": "x".repeat(3_900)}), + ); + let sidecar_invocation = SidecarInvocation::from(&invocation); + assert!(matches!( + runtime_channel.seal(&SidecarRuntimeMessage::AdmissionRequest { + invocation: sidecar_invocation, + }), + Err(crate::SidecarFrameError::FrameTooLarge { .. }) + )); + assert_eq!( + bridge.runtime().evaluate(invocation).await, + InvocationResult::failure( + "SIDECAR_MESSAGE_TOO_LARGE", + "complete sidecar message exceeds the authenticated payload limit", + ) + ); + assert_eq!(adapter.admissions.load(Ordering::SeqCst), 0); + assert_eq!(adapter.invocations.load(Ordering::SeqCst), 0); + + let (mut result_exchange, mut result_channel, _received) = + validated_runtime_exchange(&bounded); + let result_adapter = Arc::new(OversizedResultAdapter); + let result_bridge = SidecarRuntimeBridge::activate( + &mut result_exchange, + &mut result_channel, + &result_adapter, + ) + .unwrap(); + assert_eq!( + result_bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-result", + "node-1", + "product.status", + Value::Null, + )) + .await, + InvocationResult::failure( + "SIDECAR_MESSAGE_TOO_LARGE", + "complete sidecar message exceeds the authenticated payload limit", + ) + ); + } + + struct CancellationAdapter { + invoked: Arc, + cancelled: Arc, + } + + impl SidecarCapabilityAdapter for CancellationAdapter { + fn admit( + &self, + _invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + Box::pin(async { Ok(SidecarAdmissionDecision::Allow) }) + } + + fn invoke( + &self, + _invocation: SidecarInvocation, + cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + let invoked = Arc::clone(&self.invoked); + let cancelled = Arc::clone(&self.cancelled); + Box::pin(async move { + invoked.notify_one(); + tokio::spawn(async move { + cancellation.cancelled().await; + cancelled.notify_one(); + }) + .await + .unwrap(); + std::future::pending().await + }) + } + } + + #[tokio::test] + async fn runtime_timeout_reaches_the_product_adapter() { + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&configuration()); + let invoked = Arc::new(Notify::new()); + let cancelled = Arc::new(Notify::new()); + let adapter = Arc::new(CancellationAdapter { + invoked: Arc::clone(&invoked), + cancelled: Arc::clone(&cancelled), + }); + let bridge = SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + let mut invocation = + NodeInvocation::new("invoke-3", "node-1", "product.status", Value::Null); + invocation.timeout_ms = Some(100); + let result = bridge.runtime().evaluate(invocation).await; + assert_eq!( + result, + InvocationResult::failure("HANDLER_TIMEOUT", "command handler exceeded its deadline") + ); + tokio::time::timeout(Duration::from_secs(1), cancelled.notified()) + .await + .unwrap(); + } + + #[tokio::test] + async fn retired_or_dropped_channel_cancels_and_blocks_native_work() { + for drop_channel in [false, true] { + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&configuration()); + let invoked = Arc::new(Notify::new()); + let cancelled = Arc::new(Notify::new()); + let adapter = Arc::new(CancellationAdapter { + invoked: Arc::clone(&invoked), + cancelled: Arc::clone(&cancelled), + }); + let bridge = Arc::new( + SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(), + ); + let evaluation_bridge = Arc::clone(&bridge); + let evaluation = tokio::spawn(async move { + evaluation_bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-retired", + "node-1", + "product.status", + Value::Null, + )) + .await + }); + invoked.notified().await; + if drop_channel { + drop(channel); + } else { + channel.retire(); + } + + assert_eq!( + evaluation.await.unwrap(), + InvocationResult::failure( + "SIDECAR_CHANNEL_RETIRED", + "authenticated sidecar channel is no longer live" + ) + ); + tokio::time::timeout(Duration::from_secs(1), cancelled.notified()) + .await + .unwrap(); + assert_eq!( + bridge + .runtime() + .evaluate(NodeInvocation::new( + "invoke-after-retire", + "node-1", + "product.status", + Value::Null, + )) + .await, + InvocationResult::failure( + "SIDECAR_CHANNEL_RETIRED", + "authenticated sidecar channel is no longer live" + ) + ); + assert!( + tokio::time::timeout(Duration::from_millis(20), invoked.notified()) + .await + .is_err() + ); + } + } + + #[test] + fn configuration_requires_authenticated_runtime_and_bounded_manifest() { + let adapter = Arc::new(RecordingAdapter::default()); + let starting = SidecarHandshake::new(offer(SidecarPeerRole::Runtime)).unwrap(); + assert!(matches!( + SidecarConfigurationExchange::new(starting), + Err(SidecarConfigurationError::HandshakeNotAuthenticated) + )); + + let (_supervisor, handshake, _supervisor_channel, mut runtime_channel) = + authenticated_pair(); + let selection = SidecarProtocolSelection::from(handshake.negotiated().unwrap()); + let mut exchange = SidecarConfigurationExchange::new(handshake).unwrap(); + assert!(matches!( + SidecarRuntimeBridge::activate(&mut exchange, &mut runtime_channel, &adapter), + Err(SidecarRuntimeBridgeError::ConfigurationNotValidated) + )); + let mut invalid = configuration(); + invalid.manifest_generation = 0; + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::InvalidManifestGeneration) + )); + let mut invalid = configuration(); + invalid.max_concurrency = 5; + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::InvalidLimit("maxConcurrency")) + )); + let mut invalid = configuration(); + invalid.max_output_bytes = u32::try_from(minimum_bridge_failure_bytes() - 1).unwrap(); + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::InvalidLimit("maxOutputBytes")) + )); + let mut invalid = configuration(); + invalid.commands.push(SidecarCommandRegistration { + name: "product.status".into(), + }); + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::DuplicateName(_)) + )); + let mut invalid = configuration(); + invalid.commands[0].name = "system.run".into(); + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::ReservedCommand(_)) + )); + let mut invalid = configuration(); + invalid.commands[0].name = "product.\u{1f980}".into(); + assert!(matches!( + validate_configuration(&invalid, selection), + Err(SidecarRuntimeBridgeError::InvalidName(_)) + )); + } + + #[test] + fn configuration_exchange_preserves_channel_and_manifest_state() { + let (supervisor, runtime, mut supervisor_channel, mut runtime_channel) = + authenticated_pair(); + let mut supervisor_exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + let mut runtime_exchange = SidecarConfigurationExchange::new(runtime).unwrap(); + let configuration = configuration(); + + let frame = supervisor_exchange + .start(&mut supervisor_channel, &configuration) + .unwrap(); + let received = runtime_exchange + .receive(&mut runtime_channel, &frame) + .unwrap() + .unwrap(); + assert_eq!(received, configuration); + + let manifest = runtime_exchange.validated_manifest().unwrap().clone(); + let acknowledgement = runtime_exchange + .acknowledge(&mut runtime_channel, &manifest) + .unwrap(); + assert_eq!( + runtime_exchange.state(), + SidecarConfigurationState::AcknowledgementPending + ); + assert!(matches!( + SidecarRuntimeBridge::activate( + &mut runtime_exchange, + &mut runtime_channel, + &Arc::new(RecordingAdapter::default()) + ), + Err(SidecarRuntimeBridgeError::ConfigurationNotValidated) + )); + runtime_exchange + .complete_acknowledgement(&mut runtime_channel) + .unwrap(); + assert!(supervisor_exchange + .receive(&mut supervisor_channel, &acknowledgement) + .unwrap() + .is_none()); + let adapter = Arc::new(RecordingAdapter::default()); + let bridge = + SidecarRuntimeBridge::activate(&mut runtime_exchange, &mut runtime_channel, &adapter) + .unwrap(); + assert_eq!(bridge.manifest(), &manifest); + assert_eq!( + supervisor_exchange.state(), + SidecarConfigurationState::Configured + ); + assert_eq!( + runtime_exchange.state(), + SidecarConfigurationState::Activated + ); + assert!(!supervisor_channel.is_retired()); + assert!(!runtime_channel.is_retired()); + } + + #[test] + fn configuration_exchange_rejects_channel_substitution_without_mutation() { + let (supervisor, _runtime, mut supervisor_channel, _runtime_channel) = authenticated_pair(); + let mut exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + let mut replacement = channel(SidecarPeerRole::Supervisor); + + assert!(matches!( + exchange.start(&mut replacement, &configuration()), + Err(SidecarConfigurationError::ChannelInstanceMismatch) + )); + assert!(replacement.is_retired()); + assert_eq!(exchange.state(), SidecarConfigurationState::Starting); + assert!(!supervisor_channel.is_retired()); + + assert!(exchange + .start(&mut supervisor_channel, &configuration()) + .is_ok()); + assert_eq!( + exchange.state(), + SidecarConfigurationState::AwaitingAcknowledgement + ); + } + + #[test] + fn failed_configuration_acknowledgement_delivery_is_terminal() { + let (supervisor, runtime, mut supervisor_channel, mut runtime_channel) = + authenticated_pair(); + let mut supervisor_exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + let mut runtime_exchange = SidecarConfigurationExchange::new(runtime).unwrap(); + let frame = supervisor_exchange + .start(&mut supervisor_channel, &configuration()) + .unwrap(); + runtime_exchange + .receive(&mut runtime_channel, &frame) + .unwrap(); + let manifest = runtime_exchange.validated_manifest().unwrap().clone(); + runtime_exchange + .acknowledge(&mut runtime_channel, &manifest) + .unwrap(); + + runtime_channel.retire(); + assert!(matches!( + runtime_exchange.complete_acknowledgement(&mut runtime_channel), + Err(SidecarConfigurationError::Frame( + crate::SidecarFrameError::ChannelRetired + )) + )); + assert_eq!(runtime_exchange.state(), SidecarConfigurationState::Failed); + assert!(runtime_exchange.validated_manifest().is_none()); + } + + #[test] + fn bridge_uses_the_exact_authenticated_configuration() { + let (mut exchange, mut channel, mut caller_copy) = + validated_runtime_exchange(&configuration()); + caller_copy.max_concurrency = u16::MAX; + caller_copy.max_input_bytes = u32::MAX; + assert_eq!(exchange.configuration.as_ref().unwrap().max_concurrency, 2); + assert_eq!( + exchange.configuration.as_ref().unwrap().max_input_bytes, + 1024 + ); + + let adapter = Arc::new(RecordingAdapter::default()); + let frame_limit = channel.max_frame_bytes(); + assert_eq!( + channel.lower_frame_limit(frame_limit - 1), + Err(crate::SidecarProtocolError::FrameLimitLocked) + ); + let bridge = SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + assert_eq!( + channel.lower_frame_limit(frame_limit - 1), + Err(crate::SidecarProtocolError::FrameLimitLocked) + ); + assert_eq!(bridge.manifest().manifest_generation, 3); + assert!(matches!( + SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter), + Err(SidecarRuntimeBridgeError::ConfigurationNotValidated) + )); + } + + #[test] + fn forged_configuration_acknowledgement_is_terminal() { + let (supervisor, _runtime, mut supervisor_channel, mut runtime_channel) = + authenticated_pair(); + let mut supervisor_exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + supervisor_exchange + .start(&mut supervisor_channel, &configuration()) + .unwrap(); + let forged = runtime_channel + .seal(&SidecarRuntimeMessage::Configured { + manifest: SidecarRuntimeManifest { + manifest_generation: 4, + capabilities: vec![], + commands: vec![], + }, + }) + .unwrap(); + + assert!(matches!( + supervisor_exchange.receive(&mut supervisor_channel, &forged), + Err(SidecarConfigurationError::ManifestMismatch) + )); + assert_eq!( + supervisor_exchange.state(), + SidecarConfigurationState::Failed + ); + assert!(supervisor_channel.is_retired()); + } + + #[test] + fn configuration_rejects_status_that_cannot_fit_the_negotiated_channel() { + let mut supervisor_offer = offer(SidecarPeerRole::Supervisor); + supervisor_offer.limits.max_frame_bytes = 1024; + let mut runtime_offer = offer(SidecarPeerRole::Runtime); + runtime_offer.peer.version = "v".repeat(900); + let mut supervisor = SidecarHandshake::new(supervisor_offer).unwrap(); + let mut runtime = SidecarHandshake::new(runtime_offer).unwrap(); + let mut supervisor_channel = channel(SidecarPeerRole::Supervisor); + let mut runtime_channel = channel(SidecarPeerRole::Runtime); + let offer_frame = supervisor.start(&mut supervisor_channel).unwrap(); + let accept_frame = runtime + .receive(&mut runtime_channel, &offer_frame) + .unwrap() + .unwrap(); + runtime.complete_acceptance(&mut runtime_channel).unwrap(); + supervisor + .receive(&mut supervisor_channel, &accept_frame) + .unwrap(); + assert_eq!(supervisor_channel.max_frame_bytes(), 1024); + + let mut exchange = SidecarConfigurationExchange::new(supervisor).unwrap(); + assert!(matches!( + exchange.start(&mut supervisor_channel, &configuration()), + Err(SidecarConfigurationError::Configuration( + SidecarRuntimeBridgeError::StatusMessageTooLarge + )) + )); + assert_eq!(exchange.state(), SidecarConfigurationState::Failed); + assert!(supervisor_channel.is_retired()); + } + + #[test] + fn configuration_rejects_unknown_fields_instead_of_ignoring_secrets() { + for field in [ + "auth", + "credential", + "deviceToken", + "endpoint", + "headers", + "signature", + "token", + ] { + let mut value = serde_json::to_value(configuration()).unwrap(); + value[field] = json!("must-not-be-ignored"); + assert!( + serde_json::from_value::(value).is_err(), + "accepted forbidden configuration field {field}" + ); + } + } + + #[test] + fn lifecycle_events_keep_attempt_and_manifest_generations_distinct() { + let (mut exchange, mut channel, _configuration) = + validated_runtime_exchange(&configuration()); + let adapter = Arc::new(RecordingAdapter::default()); + let mut bridge = + SidecarRuntimeBridge::activate(&mut exchange, &mut channel, &adapter).unwrap(); + bridge.observe_lifecycle(&LifecycleEvent::Connected { + attempt: 2, + protocol: Some(3), + server_version: Some("current-gateway".into()), + }); + assert_eq!( + bridge.status(), + &SidecarRuntimeStatus { + state: SidecarRuntimeState::Connecting, + manifest_generation: 3, + runtime_version: "1.0.0".into(), + attempt: 2, + reason: None, + } + ); + bridge.observe_lifecycle(&LifecycleEvent::Ready { attempt: 2 }); + assert_eq!( + bridge.status(), + &SidecarRuntimeStatus { + state: SidecarRuntimeState::Ready, + manifest_generation: 3, + runtime_version: "1.0.0".into(), + attempt: 2, + reason: None, + } + ); + bridge.observe_lifecycle(&LifecycleEvent::Disconnected { + attempt: 2, + reason: LifecycleDisconnectReason::Shutdown, + }); + assert_eq!(bridge.status().state, SidecarRuntimeState::Draining); + assert_eq!(bridge.status().reason, Some(SidecarRuntimeReason::Shutdown)); + } + + #[test] + fn runtime_messages_use_stable_tagged_json() { + let message = SidecarRuntimeMessage::AdmissionDecision { + invocation_id: "invoke-1".into(), + decision: SidecarAdmissionDecision::Deny { + code: "LOCAL_DENY".into(), + message: "approval required".into(), + }, + }; + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({ + "type": "admission-decision", + "invocationId": "invoke-1", + "decision": { + "outcome": "deny", + "code": "LOCAL_DENY", + "message": "approval required" + } + }) + ); + } + + #[test] + fn runtime_messages_reject_nonportable_json_integers() { + let above_max = MAX_PORTABLE_JSON_INTEGER + 1; + let status = json!({ + "type": "status", + "status": { + "state": "ready", + "manifestGeneration": above_max, + "runtimeVersion": "1.0.0", + "attempt": 1, + "reason": null + } + }); + assert!(serde_json::from_value::(status).is_err()); + + let invalid_payload = SidecarRuntimeMessage::Result { + invocation_id: "invoke-unsafe".into(), + result: SidecarInvocationResult::Success { + payload: json!({"nested": [above_max]}), + }, + }; + assert!(serde_json::to_string(&invalid_payload).is_err()); + + let float_encoded_integer = format!( + "{{\"type\":\"result\",\"invocationId\":\"invoke-float\",\"result\":{{\"outcome\":\"success\",\"payload\":{{\"unsafe\":{}.0}}}}}}", + MAX_PORTABLE_JSON_INTEGER + 1 + ); + assert!(serde_json::from_str::(&float_encoded_integer).is_err()); + + let mut invalid_configuration = configuration(); + invalid_configuration.manifest_generation = above_max; + let selection = SidecarProtocolSelection { + protocol_major: crate::SIDECAR_PROTOCOL_MAJOR, + protocol_minor: crate::SIDECAR_PROTOCOL_MINOR, + feature_bits: 0, + limits: SidecarLimits { + max_frame_bytes: 4096, + max_in_flight: 4, + bootstrap_timeout_ms: 1_000, + }, + }; + assert!(matches!( + validate_configuration(&invalid_configuration, selection), + Err(SidecarRuntimeBridgeError::InvalidManifestGeneration) + )); + } + + #[test] + fn cross_language_runtime_message_corpus_is_exact() { + let fixture: RuntimeFixture = serde_json::from_str(include_str!( + "../../../test/fixtures/node-sidecar-runtime-v1.json" + )) + .unwrap(); + assert_eq!(fixture.schema_version, 1); + assert_eq!(fixture.messages.len(), fixture.canonical_json.len()); + for (message, canonical) in fixture.messages.iter().zip(fixture.canonical_json) { + assert_eq!(serde_json::to_string(message).unwrap(), canonical); + assert_eq!( + serde_json::from_str::(&canonical).unwrap(), + *message + ); + } + } +} diff --git a/crates/openclaw-node-host/tests/node_session.rs b/crates/openclaw-node-host/tests/node_session.rs index 1aafaef8e716..6bf37f1c18c7 100644 --- a/crates/openclaw-node-host/tests/node_session.rs +++ b/crates/openclaw-node-host/tests/node_session.rs @@ -1,8 +1,13 @@ use ed25519_dalek::{Signer, SigningKey}; use futures_util::{SinkExt, StreamExt}; use openclaw_node_host::{ - CommandRuntime, ConnectAuth, HandlerError, NodeClient, NodeClientConfig, NodeConnectOptions, - NodeIdentity, NodeProtocolVersion, NodeSession, + AuthenticatedSidecarChannel, CancellationToken, CommandRuntime, ConnectAuth, HandlerError, + NodeClient, NodeClientConfig, NodeConnectOptions, NodeIdentity, NodeProtocolVersion, + NodeSession, SidecarAdapterError, SidecarAdapterFuture, SidecarAdmissionDecision, + SidecarCapabilityAdapter, SidecarCommandRegistration, SidecarConfigurationExchange, + SidecarHandshake, SidecarInvocation, SidecarInvocationResult, SidecarLimits, + SidecarPeerIdentity, SidecarPeerRole, SidecarProtocolOffer, SidecarRuntimeBridge, + SidecarRuntimeConfiguration, SidecarSessionKey, SIDECAR_PROTOCOL_MAJOR, SIDECAR_PROTOCOL_MINOR, }; use serde_json::{json, Value}; use std::{ @@ -311,6 +316,257 @@ async fn wire_cancellation_during_admission_prevents_handler_construction() { assert!(!handler_constructed.load(Ordering::SeqCst)); } +#[derive(Default)] +struct AuthorityAdapter { + admissions: AtomicUsize, + invocations: AtomicUsize, + native_effects: AtomicUsize, + retiring_invocation_started: Notify, +} + +impl SidecarCapabilityAdapter for AuthorityAdapter { + fn admit( + &self, + invocation: SidecarInvocation, + _cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + self.admissions.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + if invocation.command == "product.settings" { + Ok(SidecarAdmissionDecision::Deny { + code: "LOCAL_DENY".into(), + message: "denied by product policy".into(), + }) + } else { + Ok(SidecarAdmissionDecision::Allow) + } + }) + } + + fn invoke( + &self, + invocation: SidecarInvocation, + cancellation: CancellationToken, + ) -> SidecarAdapterFuture> { + self.invocations.fetch_add(1, Ordering::SeqCst); + if invocation.params == json!({"retire": true}) { + self.retiring_invocation_started.notify_one(); + return Box::pin(async move { + cancellation.cancelled().await; + Err(SidecarAdapterError::new( + "CANCELLED", + "retired before native effects", + )) + }); + } + self.native_effects.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + Ok(SidecarInvocationResult::Success { + payload: json!({"handledBy": "sidecar-bridge"}), + }) + }) + } +} + +#[tokio::test] +async fn sidecar_bridge_preserves_authority_through_the_public_runtime() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(tcp).await.unwrap(); + send_json( + &mut socket, + json!({"type":"event","event":"connect.challenge", + "payload":{"nonce":"node-nonce","ts":1_700_000_000_123_u64}}), + ) + .await; + let connect = receive_json(&mut socket).await; + send_json( + &mut socket, + json!({"type":"res","id":connect["id"],"ok":true, + "payload":{"type":"hello-ok","protocol":4}}), + ) + .await; + + for (id, command, params, expected) in [ + ( + "allowed", + "product.status", + json!({}), + ("", json!({"handledBy": "sidecar-bridge"})), + ), + ( + "denied", + "product.settings", + json!({}), + ("LOCAL_DENY", Value::Null), + ), + ( + "retired", + "product.status", + json!({"retire": true}), + ("SIDECAR_CHANNEL_RETIRED", Value::Null), + ), + ] { + send_json( + &mut socket, + json!({"type":"event","event":"node.invoke.request","payload":{ + "id":id,"nodeId":"node-1","command":command, + "paramsJSON":params.to_string() + }}), + ) + .await; + let result = receive_json(&mut socket).await; + assert_eq!(result["method"], "node.invoke.result"); + assert_eq!(result["params"]["id"], id); + if expected.0.is_empty() { + assert_eq!(result["params"]["payload"], expected.1); + } else { + assert_eq!(result["params"]["ok"], false); + assert_eq!(result["params"]["error"]["code"], expected.0); + } + acknowledge(&mut socket, &result).await; + } + socket.close(None).await.unwrap(); + }); + + let adapter = Arc::new(AuthorityAdapter::default()); + let (bridge, mut channel) = activated_sidecar_bridge(&adapter); + let runtime = bridge.into_runtime(); + let connect_runtime = runtime.clone(); + let session = NodeClient::connect( + NodeClientConfig::new(format!("ws://{address}")), + move |_challenge| { + let connect_runtime = connect_runtime.clone(); + async move { + Ok::<_, io::Error>( + connect_runtime.activate( + NodeConnectOptions::new("test", "linux") + .auth(ConnectAuth::token("test-token")) + .identity(NodeIdentity::from_secret_bytes([7; 32])), + ), + ) + } + }, + ) + .await + .unwrap(); + let run = tokio::spawn(async move { runtime.run(session).await }); + tokio::time::timeout( + Duration::from_secs(1), + adapter.retiring_invocation_started.notified(), + ) + .await + .expect("retiring sidecar invocation did not reach the adapter boundary"); + channel.retire(); + + assert!(run.await.unwrap().is_err()); + server.await.unwrap(); + assert_eq!(adapter.admissions.load(Ordering::SeqCst), 3); + assert_eq!(adapter.invocations.load(Ordering::SeqCst), 2); + assert_eq!(adapter.native_effects.load(Ordering::SeqCst), 1); +} + +fn activated_sidecar_bridge( + adapter: &Arc, +) -> (SidecarRuntimeBridge, AuthenticatedSidecarChannel) { + let mut supervisor_handshake = + SidecarHandshake::new(sidecar_offer(SidecarPeerRole::Supervisor)).unwrap(); + let mut runtime_handshake = + SidecarHandshake::new(sidecar_offer(SidecarPeerRole::Runtime)).unwrap(); + let mut supervisor_channel = sidecar_channel(SidecarPeerRole::Supervisor); + let mut runtime_channel = sidecar_channel(SidecarPeerRole::Runtime); + let offer = supervisor_handshake.start(&mut supervisor_channel).unwrap(); + let acceptance = runtime_handshake + .receive(&mut runtime_channel, &offer) + .unwrap() + .unwrap(); + runtime_handshake + .complete_acceptance(&mut runtime_channel) + .unwrap(); + supervisor_handshake + .receive(&mut supervisor_channel, &acceptance) + .unwrap(); + + let mut supervisor_exchange = SidecarConfigurationExchange::new(supervisor_handshake).unwrap(); + let mut runtime_exchange = SidecarConfigurationExchange::new(runtime_handshake).unwrap(); + let configuration = SidecarRuntimeConfiguration { + manifest_generation: 1, + capabilities: vec!["native.status".into()], + commands: vec![ + SidecarCommandRegistration { + name: "product.settings".into(), + }, + SidecarCommandRegistration { + name: "product.status".into(), + }, + ], + max_concurrency: 2, + max_input_bytes: 1_024, + max_output_bytes: 1_024, + default_timeout_ms: 1_000, + max_timeout_ms: 5_000, + result_grace_ms: 50, + }; + let configure = supervisor_exchange + .start(&mut supervisor_channel, &configuration) + .unwrap(); + runtime_exchange + .receive(&mut runtime_channel, &configure) + .unwrap() + .unwrap(); + let manifest = runtime_exchange.validated_manifest().unwrap().clone(); + let configured = runtime_exchange + .acknowledge(&mut runtime_channel, &manifest) + .unwrap(); + runtime_exchange + .complete_acknowledgement(&mut runtime_channel) + .unwrap(); + supervisor_exchange + .receive(&mut supervisor_channel, &configured) + .unwrap(); + + let bridge = + SidecarRuntimeBridge::activate(&mut runtime_exchange, &mut runtime_channel, adapter) + .unwrap(); + (bridge, runtime_channel) +} + +fn sidecar_channel(role: SidecarPeerRole) -> AuthenticatedSidecarChannel { + AuthenticatedSidecarChannel::new( + role, + "authority-session".into(), + 1, + SidecarSessionKey::from_bytes([0x55; 32]), + 4_096, + ) + .unwrap() +} + +fn sidecar_offer(role: SidecarPeerRole) -> SidecarProtocolOffer { + SidecarProtocolOffer { + protocol_major: SIDECAR_PROTOCOL_MAJOR, + protocol_minor: SIDECAR_PROTOCOL_MINOR, + peer: SidecarPeerIdentity { + role, + name: match role { + SidecarPeerRole::Supervisor => "test-supervisor", + SidecarPeerRole::Runtime => "test-runtime", + } + .into(), + version: "test".into(), + artifact_identity: "sha256:test-only".into(), + }, + feature_bits: 0, + limits: SidecarLimits { + max_frame_bytes: 4_096, + max_in_flight: 4, + bootstrap_timeout_ms: 1_000, + }, + } +} + #[tokio::test] async fn node_protocol_fallback_uses_fresh_legacy_connect_material_and_recovers_to_v4() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/openclaw-node-host/tests/sidecar_process.rs b/crates/openclaw-node-host/tests/sidecar_process.rs new file mode 100644 index 000000000000..d1fceeab0e0d --- /dev/null +++ b/crates/openclaw-node-host/tests/sidecar_process.rs @@ -0,0 +1,295 @@ +use std::{ + env, + process::{Command, Stdio}, + time::Duration, +}; + +use openclaw_node_host::{ + read_sidecar_frame, write_sidecar_frame, AuthenticatedSidecarChannel, SidecarAdmissionDecision, + SidecarCommandRegistration, SidecarConfigurationExchange, SidecarHandshake, + SidecarHandshakeState, SidecarInvocation, SidecarInvocationResult, SidecarLimits, + SidecarPeerIdentity, SidecarPeerRole, SidecarProtocolOffer, SidecarRuntimeConfiguration, + SidecarRuntimeMessage, SidecarSessionKey, SIDECAR_PROTOCOL_MAJOR, SIDECAR_PROTOCOL_MINOR, +}; +use serde_json::json; +use tokio::net::{TcpListener, TcpStream}; + +const CHILD_ENV: &str = "OPENCLAW_SIDECAR_PROCESS_CHILD"; +const CHILD_ADDRESS_ENV: &str = "OPENCLAW_SIDECAR_PROCESS_ADDRESS"; +const FRAME_LIMIT: u32 = 4_096; +const SESSION_KEY: [u8; 32] = [0x5a; 32]; +const IO_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "the parent transcript intentionally keeps the full ordered process exchange visible" +)] +async fn authenticated_sidecar_crosses_a_real_process_boundary() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let child = Command::new(env::current_exe().unwrap()) + .arg("--exact") + .arg("sidecar_process_child") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env(CHILD_ADDRESS_ENV, address.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let (stream, _) = tokio::time::timeout(IO_TIMEOUT, listener.accept()) + .await + .expect("sidecar child did not connect") + .unwrap(); + let (mut input, mut output) = stream.into_split(); + let mut channel = channel(SidecarPeerRole::Supervisor); + let mut handshake = SidecarHandshake::new(offer(SidecarPeerRole::Supervisor)).unwrap(); + + let offered = handshake.start(&mut channel).unwrap(); + write_sidecar_frame(&mut output, &offered, FRAME_LIMIT, IO_TIMEOUT) + .await + .unwrap(); + let accepted = read_sidecar_frame(&mut input, FRAME_LIMIT, IO_TIMEOUT) + .await + .unwrap(); + assert!(handshake + .receive(&mut channel, &accepted) + .unwrap() + .is_none()); + assert_eq!(handshake.state(), SidecarHandshakeState::Authenticated); + + let mut exchange = SidecarConfigurationExchange::new(handshake).unwrap(); + let configuration = configuration(); + let configure = exchange.start(&mut channel, &configuration).unwrap(); + write_sidecar_frame( + &mut output, + &configure, + channel.max_frame_bytes(), + IO_TIMEOUT, + ) + .await + .unwrap(); + let configured = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + assert!(exchange + .receive(&mut channel, &configured) + .unwrap() + .is_none()); + + let invocation = invocation(); + let admission = channel + .seal(&SidecarRuntimeMessage::AdmissionRequest { + invocation: invocation.clone(), + }) + .unwrap(); + write_sidecar_frame( + &mut output, + &admission, + channel.max_frame_bytes(), + IO_TIMEOUT, + ) + .await + .unwrap(); + let decision = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + assert_eq!( + channel.open::(&decision).unwrap(), + SidecarRuntimeMessage::AdmissionDecision { + invocation_id: invocation.id.clone(), + decision: SidecarAdmissionDecision::Allow, + } + ); + + let invoke = channel + .seal(&SidecarRuntimeMessage::Invoke { + invocation: invocation.clone(), + }) + .unwrap(); + write_sidecar_frame(&mut output, &invoke, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + let result = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + assert_eq!( + channel.open::(&result).unwrap(), + SidecarRuntimeMessage::Result { + invocation_id: invocation.id, + result: SidecarInvocationResult::Success { + payload: json!({"handledBy": "process-runtime", "value": 42}), + }, + } + ); + + let output = tokio::task::spawn_blocking(move || child.wait_with_output()) + .await + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[tokio::test] +async fn sidecar_process_child() { + if env::var_os(CHILD_ENV).is_none() { + return; + } + let address = env::var(CHILD_ADDRESS_ENV).expect("child address"); + let stream = tokio::time::timeout(IO_TIMEOUT, TcpStream::connect(address)) + .await + .expect("supervisor was unavailable") + .unwrap(); + let (mut input, mut output) = stream.into_split(); + let mut channel = channel(SidecarPeerRole::Runtime); + let mut handshake = SidecarHandshake::new(offer(SidecarPeerRole::Runtime)).unwrap(); + + let offered = read_sidecar_frame(&mut input, FRAME_LIMIT, IO_TIMEOUT) + .await + .unwrap(); + let accepted = handshake + .receive(&mut channel, &offered) + .unwrap() + .expect("supervisor offer must produce acceptance"); + write_sidecar_frame(&mut output, &accepted, FRAME_LIMIT, IO_TIMEOUT) + .await + .unwrap(); + handshake.complete_acceptance(&mut channel).unwrap(); + assert_eq!(handshake.state(), SidecarHandshakeState::Authenticated); + + let mut exchange = SidecarConfigurationExchange::new(handshake).unwrap(); + let configure = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + let received = exchange + .receive(&mut channel, &configure) + .unwrap() + .expect("runtime must receive configuration"); + assert_eq!(received, configuration()); + let manifest = exchange.validated_manifest().unwrap().clone(); + let configured = exchange.acknowledge(&mut channel, &manifest).unwrap(); + write_sidecar_frame( + &mut output, + &configured, + channel.max_frame_bytes(), + IO_TIMEOUT, + ) + .await + .unwrap(); + exchange.complete_acknowledgement(&mut channel).unwrap(); + + let admission = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + let SidecarRuntimeMessage::AdmissionRequest { invocation } = + channel.open::(&admission).unwrap() + else { + panic!("expected admission request"); + }; + assert_eq!(invocation.command, "product.status"); + let decision = channel + .seal(&SidecarRuntimeMessage::AdmissionDecision { + invocation_id: invocation.id, + decision: SidecarAdmissionDecision::Allow, + }) + .unwrap(); + write_sidecar_frame( + &mut output, + &decision, + channel.max_frame_bytes(), + IO_TIMEOUT, + ) + .await + .unwrap(); + + let invoke = read_sidecar_frame(&mut input, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); + let SidecarRuntimeMessage::Invoke { invocation } = + channel.open::(&invoke).unwrap() + else { + panic!("expected invocation"); + }; + assert_eq!(invocation.params, json!({"value": 42})); + let result = channel + .seal(&SidecarRuntimeMessage::Result { + invocation_id: invocation.id, + result: SidecarInvocationResult::Success { + payload: json!({"handledBy": "process-runtime", "value": 42}), + }, + }) + .unwrap(); + write_sidecar_frame(&mut output, &result, channel.max_frame_bytes(), IO_TIMEOUT) + .await + .unwrap(); +} + +fn configuration() -> SidecarRuntimeConfiguration { + SidecarRuntimeConfiguration { + manifest_generation: 3, + capabilities: vec!["native.status".into()], + commands: vec![SidecarCommandRegistration { + name: "product.status".into(), + }], + max_concurrency: 1, + max_input_bytes: 1_024, + max_output_bytes: 1_024, + default_timeout_ms: 1_000, + max_timeout_ms: 5_000, + result_grace_ms: 50, + } +} + +fn invocation() -> SidecarInvocation { + SidecarInvocation { + id: "invoke-process-1".into(), + node_id: "node-process-1".into(), + command: "product.status".into(), + params: json!({"value": 42}), + timeout_ms: Some(1_000), + idempotency_key: Some("process-idempotency-1".into()), + session_key: Some("agent:main:process".into()), + } +} + +fn channel(role: SidecarPeerRole) -> AuthenticatedSidecarChannel { + AuthenticatedSidecarChannel::new( + role, + "process-session".into(), + 7, + SidecarSessionKey::from_bytes(SESSION_KEY), + FRAME_LIMIT, + ) + .unwrap() +} + +fn offer(role: SidecarPeerRole) -> SidecarProtocolOffer { + SidecarProtocolOffer { + protocol_major: SIDECAR_PROTOCOL_MAJOR, + protocol_minor: SIDECAR_PROTOCOL_MINOR, + peer: SidecarPeerIdentity { + role, + name: match role { + SidecarPeerRole::Supervisor => "process-supervisor", + SidecarPeerRole::Runtime => "process-runtime", + } + .into(), + version: "test".into(), + artifact_identity: "sha256:process-fixture".into(), + }, + feature_bits: 0b0011, + limits: SidecarLimits { + max_frame_bytes: 2_048, + max_in_flight: 8, + bootstrap_timeout_ms: 1_000, + }, + } +} diff --git a/test/fixtures/node-sidecar-handshake-v1.json b/test/fixtures/node-sidecar-handshake-v1.json new file mode 100644 index 000000000000..0b22c949fa53 --- /dev/null +++ b/test/fixtures/node-sidecar-handshake-v1.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": 1, + "session": { + "id": "handshake-session", + "generation": 9, + "keyBase64": "PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw=" + }, + "supervisorOffer": { + "protocolMajor": 1, + "protocolMinor": 0, + "peer": { + "role": "supervisor", + "name": "test-product", + "version": "1.0.0", + "artifactIdentity": "sha256:test-only" + }, + "featureBits": 7, + "limits": { + "maxFrameBytes": 4096, + "maxInFlight": 8, + "bootstrapTimeoutMs": 1000 + } + }, + "runtimeOffer": { + "protocolMajor": 1, + "protocolMinor": 0, + "peer": { + "role": "runtime", + "name": "openclaw-node", + "version": "1.0.0", + "artifactIdentity": "sha256:test-only" + }, + "featureBits": 11, + "limits": { + "maxFrameBytes": 2048, + "maxInFlight": 8, + "bootstrapTimeoutMs": 1000 + } + }, + "selection": { + "protocolMajor": 1, + "protocolMinor": 0, + "featureBits": 3, + "limits": { + "maxFrameBytes": 2048, + "maxInFlight": 8, + "bootstrapTimeoutMs": 1000 + } + }, + "offerFrameBase64": "T0NTQwABAAABAAAAAAAAAAkAAAAAAAAAAQARAAABA2hhbmRzaGFrZS1zZXNzaW9ueyJ0eXBlIjoib2ZmZXIiLCJvZmZlciI6eyJwcm90b2NvbE1ham9yIjoxLCJwcm90b2NvbE1pbm9yIjowLCJwZWVyIjp7InJvbGUiOiJzdXBlcnZpc29yIiwibmFtZSI6InRlc3QtcHJvZHVjdCIsInZlcnNpb24iOiIxLjAuMCIsImFydGlmYWN0SWRlbnRpdHkiOiJzaGEyNTY6dGVzdC1vbmx5In0sImZlYXR1cmVCaXRzIjo3LCJsaW1pdHMiOnsibWF4RnJhbWVCeXRlcyI6NDA5NiwibWF4SW5GbGlnaHQiOjgsImJvb3RzdHJhcFRpbWVvdXRNcyI6MTAwMH19fTSxSPVri786qBe92/qj1NGhn0efEyqVJfiZrCOdLbr1", + "acceptFrameBase64": "T0NTQwABAAACAAAAAAAAAAkAAAAAAAAAAQARAAABj2hhbmRzaGFrZS1zZXNzaW9ueyJ0eXBlIjoiYWNjZXB0Iiwib2ZmZXIiOnsicHJvdG9jb2xNYWpvciI6MSwicHJvdG9jb2xNaW5vciI6MCwicGVlciI6eyJyb2xlIjoicnVudGltZSIsIm5hbWUiOiJvcGVuY2xhdy1ub2RlIiwidmVyc2lvbiI6IjEuMC4wIiwiYXJ0aWZhY3RJZGVudGl0eSI6InNoYTI1Njp0ZXN0LW9ubHkifSwiZmVhdHVyZUJpdHMiOjExLCJsaW1pdHMiOnsibWF4RnJhbWVCeXRlcyI6MjA0OCwibWF4SW5GbGlnaHQiOjgsImJvb3RzdHJhcFRpbWVvdXRNcyI6MTAwMH19LCJzZWxlY3Rpb24iOnsicHJvdG9jb2xNYWpvciI6MSwicHJvdG9jb2xNaW5vciI6MCwiZmVhdHVyZUJpdHMiOjMsImxpbWl0cyI6eyJtYXhGcmFtZUJ5dGVzIjoyMDQ4LCJtYXhJbkZsaWdodCI6OCwiYm9vdHN0cmFwVGltZW91dE1zIjoxMDAwfX19DY3CiBg6igkouQFlG07FuMhFJuNEFCONtdVA6glc5tU=" +} diff --git a/test/fixtures/node-sidecar-negotiation-v1.json b/test/fixtures/node-sidecar-negotiation-v1.json new file mode 100644 index 000000000000..fad7e133a2b2 --- /dev/null +++ b/test/fixtures/node-sidecar-negotiation-v1.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "localOffer": { + "protocolMajor": 1, + "protocolMinor": 0, + "peer": { + "role": "supervisor", + "name": "fixture-supervisor", + "version": "1.0.0", + "artifactIdentity": "sha256:fixture-supervisor" + }, + "featureBits": 4503599627370499, + "limits": { + "maxFrameBytes": 4096, + "maxInFlight": 8, + "bootstrapTimeoutMs": 1000 + } + }, + "remoteOffer": { + "protocolMajor": 1, + "protocolMinor": 0, + "peer": { + "role": "runtime", + "name": "fixture-runtime", + "version": "1.0.0", + "artifactIdentity": "sha256:fixture-runtime" + }, + "featureBits": 4503599627370501, + "limits": { + "maxFrameBytes": 2048, + "maxInFlight": 4, + "bootstrapTimeoutMs": 500 + } + }, + "selectedFeatureBits": 4503599627370497 +} diff --git a/test/fixtures/node-sidecar-protocol-v1.json b/test/fixtures/node-sidecar-protocol-v1.json new file mode 100644 index 000000000000..5081e7321a74 --- /dev/null +++ b/test/fixtures/node-sidecar-protocol-v1.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "session": { + "id": "session-7", + "generation": 7, + "sessionKeyBase64": "WlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlo=" + }, + "supervisorProbe": { + "payload": { + "requestId": "abc", + "type": "probe" + }, + "frameBase64": "T0NTQwABAAABAAAAAAAAAAcAAAAAAAAAAQAJAAAAInNlc3Npb24tN3sicmVxdWVzdElkIjoiYWJjIiwidHlwZSI6InByb2JlIn04my4v5goF1qHj7BfwsC4o3oTzJCbGaE5jWu5WdqOVlQ==" + } +} diff --git a/test/fixtures/node-sidecar-runtime-v1.json b/test/fixtures/node-sidecar-runtime-v1.json new file mode 100644 index 000000000000..33c1435763ad --- /dev/null +++ b/test/fixtures/node-sidecar-runtime-v1.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "messages": [ + { + "type": "configure", + "configuration": { + "manifestGeneration": 3, + "capabilities": ["native.settings", "native.status"], + "commands": [ + { "name": "product.settings" }, + { "name": "product.status" } + ], + "maxConcurrency": 2, + "maxInputBytes": 1024, + "maxOutputBytes": 1024, + "defaultTimeoutMs": 1000, + "maxTimeoutMs": 5000, + "resultGraceMs": 50 + } + }, + { + "type": "configured", + "manifest": { + "manifestGeneration": 3, + "capabilities": ["native.settings", "native.status"], + "commands": ["product.settings", "product.status"] + } + }, + { + "type": "admission-request", + "invocation": { + "id": "invoke-1", + "nodeId": "node-1", + "command": "product.status", + "params": { "verbose": true }, + "timeoutMs": 1000, + "idempotencyKey": "idem-1", + "sessionKey": "agent:main:main" + } + }, + { + "type": "admission-decision", + "invocationId": "invoke-1", + "decision": { "outcome": "allow" } + }, + { + "type": "invoke", + "invocation": { + "id": "invoke-1", + "nodeId": "node-1", + "command": "product.status", + "params": { "verbose": true }, + "timeoutMs": 1000, + "idempotencyKey": "idem-1", + "sessionKey": "agent:main:main" + } + }, + { + "type": "result", + "invocationId": "invoke-1", + "result": { + "outcome": "success", + "payload": { "ready": true } + } + }, + { "type": "cancel", "invocationId": "invoke-1" }, + { + "type": "status", + "status": { + "state": "ready", + "manifestGeneration": 3, + "runtimeVersion": "1.0.0", + "attempt": 1, + "reason": null + } + }, + { + "type": "status", + "status": { + "state": "ready", + "manifestGeneration": 9007199254740991, + "runtimeVersion": "1.0.0", + "attempt": 9007199254740991, + "reason": null + } + } + ], + "canonicalJson": [ + "{\"type\":\"configure\",\"configuration\":{\"manifestGeneration\":3,\"capabilities\":[\"native.settings\",\"native.status\"],\"commands\":[{\"name\":\"product.settings\"},{\"name\":\"product.status\"}],\"maxConcurrency\":2,\"maxInputBytes\":1024,\"maxOutputBytes\":1024,\"defaultTimeoutMs\":1000,\"maxTimeoutMs\":5000,\"resultGraceMs\":50}}", + "{\"type\":\"configured\",\"manifest\":{\"manifestGeneration\":3,\"capabilities\":[\"native.settings\",\"native.status\"],\"commands\":[\"product.settings\",\"product.status\"]}}", + "{\"type\":\"admission-request\",\"invocation\":{\"id\":\"invoke-1\",\"nodeId\":\"node-1\",\"command\":\"product.status\",\"params\":{\"verbose\":true},\"timeoutMs\":1000,\"idempotencyKey\":\"idem-1\",\"sessionKey\":\"agent:main:main\"}}", + "{\"type\":\"admission-decision\",\"invocationId\":\"invoke-1\",\"decision\":{\"outcome\":\"allow\"}}", + "{\"type\":\"invoke\",\"invocation\":{\"id\":\"invoke-1\",\"nodeId\":\"node-1\",\"command\":\"product.status\",\"params\":{\"verbose\":true},\"timeoutMs\":1000,\"idempotencyKey\":\"idem-1\",\"sessionKey\":\"agent:main:main\"}}", + "{\"type\":\"result\",\"invocationId\":\"invoke-1\",\"result\":{\"outcome\":\"success\",\"payload\":{\"ready\":true}}}", + "{\"type\":\"cancel\",\"invocationId\":\"invoke-1\"}", + "{\"type\":\"status\",\"status\":{\"state\":\"ready\",\"manifestGeneration\":3,\"runtimeVersion\":\"1.0.0\",\"attempt\":1,\"reason\":null}}", + "{\"type\":\"status\",\"status\":{\"state\":\"ready\",\"manifestGeneration\":9007199254740991,\"runtimeVersion\":\"1.0.0\",\"attempt\":9007199254740991,\"reason\":null}}" + ] +}