mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-09 06:47:59 -06:00
Split protocol and leaf surfaces into crates
Move the protocol runtime into unshell-protocol and remote shell leaf code into unshell-leaves so endpoint and TUI roles can compile independently without circular dependencies.
This commit is contained in:
Generated
+19
-1
@@ -1440,11 +1440,21 @@ name = "unshell"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"portable-pty",
|
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"static_init",
|
"static_init",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"unshell-leaves",
|
||||||
"unshell-macros",
|
"unshell-macros",
|
||||||
|
"unshell-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unshell-leaves"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"portable-pty",
|
||||||
|
"rkyv",
|
||||||
|
"unshell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1456,6 +1466,14 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unshell-protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rkyv",
|
||||||
|
"unshell-macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ush-obfuscate"
|
name = "ush-obfuscate"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+8
-1
@@ -5,6 +5,8 @@ members = [
|
|||||||
"ush-obfuscate",
|
"ush-obfuscate",
|
||||||
"base62",
|
"base62",
|
||||||
"unshell-macros",
|
"unshell-macros",
|
||||||
|
"unshell-protocol",
|
||||||
|
"unshell-leaves",
|
||||||
"treetest",
|
"treetest",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -27,6 +29,8 @@ quote = "1.0.45"
|
|||||||
proc-macro2 = "1.0.106"
|
proc-macro2 = "1.0.106"
|
||||||
portable-pty = "0.9.0"
|
portable-pty = "0.9.0"
|
||||||
unshell = { path = "." }
|
unshell = { path = "." }
|
||||||
|
unshell-protocol = { path = "./unshell-protocol" }
|
||||||
|
unshell-leaves = { path = "./unshell-leaves" }
|
||||||
# ush-obfuscate = { path = "./ush-obfuscate" }
|
# ush-obfuscate = { path = "./ush-obfuscate" }
|
||||||
# base62 = { path = "./base62" }
|
# base62 = { path = "./base62" }
|
||||||
|
|
||||||
@@ -50,7 +54,10 @@ chrono = { workspace = true, optional = true }
|
|||||||
# ush-obfuscate = { workspace = true }
|
# ush-obfuscate = { workspace = true }
|
||||||
static_init = { workspace = true }
|
static_init = { workspace = true }
|
||||||
unshell-macros = { path = "./unshell-macros" }
|
unshell-macros = { path = "./unshell-macros" }
|
||||||
portable-pty = { workspace = true }
|
unshell-protocol = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
unshell-leaves = { workspace = true, features = ["endpoint"] }
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "leaf_derive"
|
name = "leaf_derive"
|
||||||
|
|||||||
@@ -4,15 +4,13 @@
|
|||||||
//! example over TCP, feeds inbound frames into the `ProcedureRuntime`, and flushes any resulting
|
//! example over TCP, feeds inbound frames into the `ProcedureRuntime`, and flushes any resulting
|
||||||
//! protocol frames back to the controller.
|
//! protocol frames back to the controller.
|
||||||
|
|
||||||
#[path = "../../src/leaf/remote_shell/mod.rs"]
|
|
||||||
mod remote_shell;
|
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::sync::mpsc::RecvTimeoutError;
|
use std::sync::mpsc::RecvTimeoutError;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use unshell::protocol::tree::Ingress;
|
use unshell::protocol::tree::Ingress;
|
||||||
|
use unshell_leaves::remote_shell;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let mut stream = TcpStream::connect(remote_shell::LISTEN_ADDR)?;
|
let mut stream = TcpStream::connect(remote_shell::LISTEN_ADDR)?;
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
//! This binary listens for the endpoint example, opens one remote shell session, sends a few
|
//! This binary listens for the endpoint example, opens one remote shell session, sends a few
|
||||||
//! commands, and prints returned hook data until the shell closes.
|
//! commands, and prints returned hook data until the shell closes.
|
||||||
|
|
||||||
#[path = "../../src/leaf/remote_shell/mod.rs"]
|
|
||||||
mod remote_shell;
|
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
|
||||||
use unshell::protocol::tree::{Endpoint, EndpointOutcome, Ingress, LocalEvent};
|
use unshell::protocol::tree::{Endpoint, EndpointOutcome, Ingress, LocalEvent};
|
||||||
|
use unshell_leaves::remote_shell;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let listener = TcpListener::bind(remote_shell::LISTEN_ADDR)?;
|
let listener = TcpListener::bind(remote_shell::LISTEN_ADDR)?;
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
//! a shell process, so it is the easiest place to see how the endpoint and leaf metadata fit
|
//! a shell process, so it is the easiest place to see how the endpoint and leaf metadata fit
|
||||||
//! together.
|
//! together.
|
||||||
|
|
||||||
#[path = "../../src/leaf/remote_shell/mod.rs"]
|
|
||||||
mod remote_shell;
|
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
use unshell::protocol::tree::{EndpointOutcome, LocalEvent, ProtocolEndpoint};
|
use unshell::protocol::tree::{EndpointOutcome, LocalEvent, ProtocolEndpoint};
|
||||||
use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection};
|
use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection};
|
||||||
|
use unshell_leaves::remote_shell;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let mut endpoint = ProtocolEndpoint::new(
|
let mut endpoint = ProtocolEndpoint::new(
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub mod remote_shell;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//! Stateful remote shell leaf used by the protocol examples.
|
|
||||||
//!
|
|
||||||
//! # Design
|
|
||||||
//!
|
|
||||||
//! The leaf owns all live hook sessions explicitly in `sessions`. Each entry in
|
|
||||||
//! that map is one `ProcedureOpen`, keyed by the caller-owned hook identity.
|
|
||||||
//! The protocol runtime still owns packet validation and transport close state,
|
|
||||||
//! while the procedure session owns application resources such as the spawned
|
|
||||||
//! shell process.
|
|
||||||
//!
|
|
||||||
//! This keeps the storage obvious:
|
|
||||||
//! - the leaf owns session maps
|
|
||||||
//! - the procedure type owns one hook conversation
|
|
||||||
//! - the runtime routes later `Data` and `Fault` packets automatically
|
|
||||||
|
|
||||||
mod errors;
|
|
||||||
mod session;
|
|
||||||
mod transport;
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use unshell::Leaf;
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub use errors::ShellLeafError;
|
|
||||||
pub use session::ProcedureOpen;
|
|
||||||
pub use transport::LISTEN_ADDR;
|
|
||||||
|
|
||||||
/// Leaf state for the remote shell example.
|
|
||||||
///
|
|
||||||
/// The map is explicit on purpose. Stateful procedures are easier to debug when
|
|
||||||
/// the leaf clearly owns its live sessions instead of relying on generated hidden
|
|
||||||
/// enums or side tables.
|
|
||||||
#[derive(Default, Leaf)]
|
|
||||||
#[leaf(leaf_name = "remote_shell")]
|
|
||||||
pub struct RemoteShellLeaf {
|
|
||||||
sessions: BTreeMap<HookKey, ProcedureOpen>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureStore<ProcedureOpen> for RemoteShellLeaf {
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
|
|
||||||
&mut self.sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Procedure<RemoteShellLeaf> for ProcedureOpen {
|
|
||||||
type Error = ShellLeafError;
|
|
||||||
type Input = ();
|
|
||||||
|
|
||||||
fn open(_leaf: &mut RemoteShellLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
|
||||||
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
|
|
||||||
ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_data(
|
|
||||||
_leaf: &mut RemoteShellLeaf,
|
|
||||||
session: &mut Self,
|
|
||||||
data: unshell::protocol::tree::IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
session.on_data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_fault(
|
|
||||||
_leaf: &mut RemoteShellLeaf,
|
|
||||||
_session: &mut Self,
|
|
||||||
_fault: unshell::protocol::tree::IncomingFault,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll(
|
|
||||||
_leaf: &mut RemoteShellLeaf,
|
|
||||||
session: &mut Self,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
session.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(_leaf: &mut RemoteShellLeaf, mut session: Self) -> Result<(), Self::Error> {
|
|
||||||
session.terminate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the example endpoint path used by both shell binaries.
|
|
||||||
pub fn agent_path() -> Vec<String> {
|
|
||||||
path(&["agent"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the controller endpoint used by the receiver example.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn build_controller_endpoint() -> ProtocolEndpoint {
|
|
||||||
ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![unshell::protocol::tree::ChildRoute::registered(agent_path())],
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the stateful shell runtime used by the endpoint example.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn build_agent_runtime() -> ProcedureRuntime<RemoteShellLeaf, ProcedureOpen> {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
agent_path(),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![unshell::protocol::tree::LeafSpec {
|
|
||||||
name: RemoteShellLeaf::protocol_leaf_name(),
|
|
||||||
procedures: vec![ProcedureOpen::protocol_procedure_id()],
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
ProcedureRuntime::new(endpoint, RemoteShellLeaf::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the canonical leaf id used by the receiver example.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn shell_leaf_name() -> String {
|
|
||||||
RemoteShellLeaf::protocol_leaf_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the opening `procedure_id` used to create one shell session.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn shell_open_procedure() -> String {
|
|
||||||
ProcedureOpen::protocol_procedure_id()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the empty opening payload used by the shell example.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn shell_open_payload() -> Vec<u8> {
|
|
||||||
unshell::protocol::tree::encode_call_reply(&()).expect("unit shell open payload should encode")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn send_forward(
|
|
||||||
stream: &mut std::net::TcpStream,
|
|
||||||
outcome: unshell::protocol::tree::EndpointOutcome,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
transport::send_forward(stream, outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn write_frames(
|
|
||||||
stream: &mut std::net::TcpStream,
|
|
||||||
frames: &[unshell::protocol::FrameBytes],
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
transport::write_frames(stream, frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn spawn_frame_reader(
|
|
||||||
stream: std::net::TcpStream,
|
|
||||||
) -> std::sync::mpsc::Receiver<std::io::Result<unshell::protocol::FrameBytes>> {
|
|
||||||
transport::spawn_frame_reader(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
+4
-1
@@ -18,7 +18,10 @@ pub extern crate alloc;
|
|||||||
extern crate self as unshell;
|
extern crate self as unshell;
|
||||||
|
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
pub mod protocol;
|
|
||||||
|
/// Re-export the protocol crate behind the historical `unshell::protocol` path so
|
||||||
|
/// proc-macro output and downstream code do not need a second migration.
|
||||||
|
pub use unshell_protocol as protocol;
|
||||||
|
|
||||||
pub use unshell_macros::{Leaf, Procedure, procedures};
|
pub use unshell_macros::{Leaf, Procedure, procedures};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "unshell-leaves"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Application-layer UnShell leaves and client surfaces"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
endpoint = ["dep:portable-pty"]
|
||||||
|
tui = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rkyv = { workspace = true }
|
||||||
|
portable-pty = { workspace = true, optional = true }
|
||||||
|
unshell = { workspace = true }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
elided_lifetimes_in_paths = "warn"
|
||||||
|
future_incompatible = { level = "warn", priority = -1 }
|
||||||
|
nonstandard_style = { level = "warn", priority = -1 }
|
||||||
|
rust_2018_idioms = { level = "warn", priority = -1 }
|
||||||
|
rust_2021_prelude_collisions = "warn"
|
||||||
|
semicolon_in_expressions_from_macros = "warn"
|
||||||
|
unsafe_op_in_unsafe_fn = "warn"
|
||||||
|
unused_import_braces = "warn"
|
||||||
|
unused_lifetimes = "warn"
|
||||||
|
trivial_casts = "allow"
|
||||||
|
missing_docs = "warn"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Application-layer leaves and user-facing surfaces built on top of the UnShell
|
||||||
|
//! protocol runtime.
|
||||||
|
//!
|
||||||
|
//! Each leaf module always exports its shared protocol-facing types. Role-specific
|
||||||
|
//! implementations are selected with the crate-wide `endpoint` and `tui`
|
||||||
|
//! features, and can optionally be re-exported behind one stable alias.
|
||||||
|
|
||||||
|
use unshell::protocol::DataMessage;
|
||||||
|
|
||||||
|
/// Re-exports one role-specific type behind a stable public alias.
|
||||||
|
///
|
||||||
|
/// This keeps consumers on a single name such as `RemoteShell` while still
|
||||||
|
/// compiling only the role implementation needed by the current binary.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! role_leaf {
|
||||||
|
(
|
||||||
|
$(#[$meta:meta])*
|
||||||
|
$vis:vis type $alias:ident {
|
||||||
|
endpoint => $endpoint:path,
|
||||||
|
tui => $tui:path $(,)?
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
#[cfg(all(feature = "endpoint", feature = "tui"))]
|
||||||
|
compile_error!(concat!(
|
||||||
|
"`",
|
||||||
|
stringify!($alias),
|
||||||
|
"` can only alias one concrete role at a time; enable either `endpoint` or `tui`, not both"
|
||||||
|
));
|
||||||
|
|
||||||
|
#[cfg(feature = "endpoint")]
|
||||||
|
$(#[$meta])*
|
||||||
|
$vis type $alias = $endpoint;
|
||||||
|
|
||||||
|
#[cfg(all(not(feature = "endpoint"), feature = "tui"))]
|
||||||
|
$(#[$meta])*
|
||||||
|
$vis type $alias = $tui;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal leaf-specific TUI contract.
|
||||||
|
///
|
||||||
|
/// The initial implementation intentionally stays transport-agnostic. A CLI can
|
||||||
|
/// feed validated protocol `DataMessage` values into a leaf TUI and ask it for a
|
||||||
|
/// textual frame without depending on a specific rendering crate yet.
|
||||||
|
pub trait LeafTui {
|
||||||
|
/// Returns the canonical protocol leaf name this UI understands.
|
||||||
|
fn leaf_name(&self) -> String;
|
||||||
|
|
||||||
|
/// Applies one inbound hook payload to the local UI state.
|
||||||
|
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError>;
|
||||||
|
|
||||||
|
/// Produces the current textual frame for the leaf.
|
||||||
|
fn render(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight error used by the leaf TUI surface.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TuiError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiError {
|
||||||
|
/// Creates one UI-surface error from owned text.
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for TuiError {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::error::Error for TuiError {}
|
||||||
|
|
||||||
|
pub mod remote_shell;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! PTY-backed endpoint implementation for the remote shell leaf.
|
||||||
|
|
||||||
|
mod errors;
|
||||||
|
mod session;
|
||||||
|
mod transport;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use unshell::Leaf;
|
||||||
|
use unshell::protocol::tree::{
|
||||||
|
Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use errors::ShellLeafError;
|
||||||
|
pub use session::ProcedureOpen;
|
||||||
|
pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames};
|
||||||
|
|
||||||
|
use super::{OpenRequest, agent_path};
|
||||||
|
|
||||||
|
/// Leaf state for the remote shell endpoint runtime.
|
||||||
|
///
|
||||||
|
/// The endpoint keeps each live shell session in an explicit map keyed by the
|
||||||
|
/// caller-owned hook identity. That makes ownership and cleanup of hook-backed
|
||||||
|
/// shell processes easy to inspect during debugging.
|
||||||
|
#[derive(Default, Leaf)]
|
||||||
|
#[leaf(leaf_name = "remote_shell")]
|
||||||
|
pub struct RemoteShellEndpoint {
|
||||||
|
sessions: BTreeMap<HookKey, ProcedureOpen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcedureStore<ProcedureOpen> for RemoteShellEndpoint {
|
||||||
|
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
|
||||||
|
&mut self.sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Procedure<RemoteShellEndpoint> for ProcedureOpen {
|
||||||
|
type Error = ShellLeafError;
|
||||||
|
type Input = OpenRequest;
|
||||||
|
|
||||||
|
fn open(_leaf: &mut RemoteShellEndpoint, call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
||||||
|
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
|
||||||
|
ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_data(
|
||||||
|
_leaf: &mut RemoteShellEndpoint,
|
||||||
|
session: &mut Self,
|
||||||
|
data: unshell::protocol::tree::IncomingData,
|
||||||
|
) -> Result<ProcedureEffect, Self::Error> {
|
||||||
|
session.on_data(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_fault(
|
||||||
|
_leaf: &mut RemoteShellEndpoint,
|
||||||
|
_session: &mut Self,
|
||||||
|
_fault: unshell::protocol::tree::IncomingFault,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(
|
||||||
|
_leaf: &mut RemoteShellEndpoint,
|
||||||
|
session: &mut Self,
|
||||||
|
) -> Result<ProcedureEffect, Self::Error> {
|
||||||
|
session.poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(_leaf: &mut RemoteShellEndpoint, mut session: Self) -> Result<(), Self::Error> {
|
||||||
|
session.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the controller endpoint used by the receiver example.
|
||||||
|
pub fn build_controller_endpoint() -> ProtocolEndpoint {
|
||||||
|
ProtocolEndpoint::new(
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
vec![unshell::protocol::tree::ChildRoute::registered(agent_path())],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the stateful shell runtime used by the endpoint example.
|
||||||
|
pub fn build_agent_runtime() -> ProcedureRuntime<RemoteShellEndpoint, ProcedureOpen> {
|
||||||
|
let endpoint = ProtocolEndpoint::new(
|
||||||
|
agent_path(),
|
||||||
|
Some(Vec::new()),
|
||||||
|
Vec::new(),
|
||||||
|
vec![unshell::protocol::tree::LeafSpec {
|
||||||
|
name: RemoteShellEndpoint::protocol_leaf_name(),
|
||||||
|
procedures: vec![ProcedureOpen::protocol_procedure_id()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
ProcedureRuntime::new(endpoint, RemoteShellEndpoint::default())
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
/// Error produced by the remote shell endpoint implementation.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ShellLeafError {
|
pub enum ShellLeafError {
|
||||||
|
/// Underlying PTY or I/O failure.
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
|
/// Shell open requires a response hook so the session can stream bytes back.
|
||||||
MissingHook,
|
MissingHook,
|
||||||
}
|
}
|
||||||
|
|
||||||
+10
-18
@@ -1,13 +1,9 @@
|
|||||||
//! Per-hook remote shell session lifecycle.
|
//! Per-hook remote shell session lifecycle.
|
||||||
//!
|
//!
|
||||||
//! A session opens one PTY-backed shell process and then translates protocol hook
|
//! A session opens one PTY-backed shell process and translates protocol hook
|
||||||
//! traffic into stdin writes and stdout/stderr chunks. The close model is
|
//! traffic into stdin writes and stdout or stderr chunks. Close is intentionally
|
||||||
//! intentionally two-sided:
|
//! two-sided: the peer signals input completion with `end_hook`, while the local
|
||||||
//! - peer end: the caller sets `end_hook`, so no more stdin is accepted
|
//! side closes only after the child exits and the PTY reader drains.
|
||||||
//! - local end: the shell process exits and the PTY reader drains completely
|
|
||||||
//!
|
|
||||||
//! Only after both conditions are observed does the session emit its final empty
|
|
||||||
//! `end_hook` packet back through the protocol runtime.
|
|
||||||
|
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -15,18 +11,18 @@ use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
|
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
|
||||||
|
use unshell::Procedure;
|
||||||
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
|
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
|
||||||
|
|
||||||
use unshell::Procedure;
|
use super::RemoteShellEndpoint;
|
||||||
|
|
||||||
use super::errors::ShellLeafError;
|
use super::errors::ShellLeafError;
|
||||||
|
|
||||||
/// Per-hook shell session created by the `open` procedure.
|
/// Per-hook shell session created by the `open` procedure.
|
||||||
///
|
///
|
||||||
/// The procedure type is also the stored session type. This keeps the mapping
|
/// The procedure type is also the stored session type so the mapping between
|
||||||
/// between protocol procedure and hook state direct and easy to inspect.
|
/// one opening procedure and one live hook remains direct and visible.
|
||||||
#[derive(Procedure)]
|
#[derive(Procedure)]
|
||||||
#[procedure(leaf = RemoteShellLeaf, name = "open")]
|
#[procedure(leaf = RemoteShellEndpoint, name = "open")]
|
||||||
pub struct ProcedureOpen {
|
pub struct ProcedureOpen {
|
||||||
/// Spawned PTY child process.
|
/// Spawned PTY child process.
|
||||||
pub(super) child: Box<dyn portable_pty::Child + Send>,
|
pub(super) child: Box<dyn portable_pty::Child + Send>,
|
||||||
@@ -56,8 +52,6 @@ enum OutputEvent {
|
|||||||
ReaderClosed,
|
ReaderClosed,
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::RemoteShellLeaf;
|
|
||||||
|
|
||||||
impl ProcedureOpen {
|
impl ProcedureOpen {
|
||||||
pub(super) fn spawn(
|
pub(super) fn spawn(
|
||||||
return_path: Vec<String>,
|
return_path: Vec<String>,
|
||||||
@@ -173,9 +167,7 @@ impl ProcedureOpen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Peer end means no more stdin from the caller. Keep the process alive so
|
// Peer end means no more stdin from the caller. Keep the process alive so
|
||||||
// any buffered PTY output can drain through the normal poll path. On Unix
|
// buffered PTY output can drain through the normal poll path.
|
||||||
// we also send SIGHUP so an interactive shell treats this like terminal
|
|
||||||
// hangup instead of waiting forever on the still-open PTY master.
|
|
||||||
self.stdin_tx.take();
|
self.stdin_tx.take();
|
||||||
self.signal_process_group("-HUP");
|
self.signal_process_group("-HUP");
|
||||||
Ok(ProcedureEffect::default())
|
Ok(ProcedureEffect::default())
|
||||||
+5
-5
@@ -6,10 +6,11 @@ use std::thread;
|
|||||||
use unshell::protocol::FrameBytes;
|
use unshell::protocol::FrameBytes;
|
||||||
use unshell::protocol::tree::EndpointOutcome;
|
use unshell::protocol::tree::EndpointOutcome;
|
||||||
|
|
||||||
|
/// TCP listen address used by the remote shell examples.
|
||||||
pub const LISTEN_ADDR: &str = "127.0.0.1:4444";
|
pub const LISTEN_ADDR: &str = "127.0.0.1:4444";
|
||||||
const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Writes the forwarded frame produced by one endpoint outcome.
|
||||||
pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Result<()> {
|
pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Result<()> {
|
||||||
match outcome {
|
match outcome {
|
||||||
EndpointOutcome::Forward { frame, .. } => write_frames(stream, &[frame]),
|
EndpointOutcome::Forward { frame, .. } => write_frames(stream, &[frame]),
|
||||||
@@ -17,6 +18,7 @@ pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes one or more framed packets onto the example TCP stream.
|
||||||
pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result<()> {
|
pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result<()> {
|
||||||
for frame in frames {
|
for frame in frames {
|
||||||
let frame_len = u32::try_from(frame.len()).map_err(|_| {
|
let frame_len = u32::try_from(frame.len()).map_err(|_| {
|
||||||
@@ -29,6 +31,7 @@ pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the example frame reader that lifts prefixed frames off the TCP stream.
|
||||||
pub fn spawn_frame_reader(mut stream: TcpStream) -> Receiver<io::Result<FrameBytes>> {
|
pub fn spawn_frame_reader(mut stream: TcpStream) -> Receiver<io::Result<FrameBytes>> {
|
||||||
let (tx, rx) = mpsc::sync_channel(64);
|
let (tx, rx) = mpsc::sync_channel(64);
|
||||||
|
|
||||||
@@ -65,10 +68,7 @@ fn read_frame(stream: &mut TcpStream) -> io::Result<Option<FrameBytes>> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut bytes = vec![0u8; frame_len];
|
let mut bytes = vec![0u8; frame_len];
|
||||||
match stream.read_exact(&mut bytes) {
|
stream.read_exact(&mut bytes)?;
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut frame = FrameBytes::with_capacity(bytes.len());
|
let mut frame = FrameBytes::with_capacity(bytes.len());
|
||||||
frame.extend_from_slice(&bytes);
|
frame.extend_from_slice(&bytes);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Remote shell leaf and its user-facing surfaces.
|
||||||
|
//!
|
||||||
|
//! The module always exports the protocol contract for the leaf. Role-specific
|
||||||
|
//! implementations live behind crate-wide features:
|
||||||
|
//! - `endpoint` builds the PTY-backed runtime leaf
|
||||||
|
//! - `tui` builds a placeholder client-side TUI surface
|
||||||
|
|
||||||
|
use rkyv::{Archive, Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(feature = "endpoint")]
|
||||||
|
mod endpoint;
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
#[cfg(feature = "endpoint")]
|
||||||
|
pub use endpoint::{
|
||||||
|
LISTEN_ADDR, RemoteShellEndpoint, ShellLeafError, build_agent_runtime,
|
||||||
|
build_controller_endpoint, send_forward, spawn_frame_reader, write_frames,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
pub use tui::RemoteShellTui;
|
||||||
|
|
||||||
|
use unshell::protocol::tree::encode_call_reply;
|
||||||
|
|
||||||
|
/// Open-request payload for the remote shell leaf.
|
||||||
|
///
|
||||||
|
/// The shell currently needs no structured arguments, but a named payload type is
|
||||||
|
/// easier for downstream code to discover than a bare `()`.
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct OpenRequest;
|
||||||
|
|
||||||
|
crate::role_leaf! {
|
||||||
|
/// Feature-selected remote shell surface.
|
||||||
|
pub type RemoteShell {
|
||||||
|
endpoint => endpoint::RemoteShellEndpoint,
|
||||||
|
tui => tui::RemoteShellTui,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the example endpoint path used by the remote shell samples.
|
||||||
|
pub fn agent_path() -> Vec<String> {
|
||||||
|
path(&["agent"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical leaf id used by endpoint and TUI code.
|
||||||
|
#[cfg(feature = "endpoint")]
|
||||||
|
pub fn shell_leaf_name() -> String {
|
||||||
|
RemoteShellEndpoint::protocol_leaf_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical opening `procedure_id` for the shell leaf.
|
||||||
|
#[cfg(feature = "endpoint")]
|
||||||
|
pub fn shell_open_procedure() -> String {
|
||||||
|
endpoint::ProcedureOpen::protocol_procedure_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the empty open-request payload used by the shell example.
|
||||||
|
#[cfg(all(not(feature = "endpoint"), feature = "tui"))]
|
||||||
|
pub fn shell_leaf_name() -> String {
|
||||||
|
RemoteShellTui::protocol_leaf_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical opening `procedure_id` for the shell leaf.
|
||||||
|
#[cfg(all(not(feature = "endpoint"), feature = "tui"))]
|
||||||
|
pub fn shell_open_procedure() -> String {
|
||||||
|
let mut procedure_id = shell_leaf_name();
|
||||||
|
procedure_id.push_str(".open");
|
||||||
|
procedure_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the empty open-request payload used by the shell example.
|
||||||
|
#[cfg(not(any(feature = "endpoint", feature = "tui")))]
|
||||||
|
pub fn shell_leaf_name() -> String {
|
||||||
|
String::from("remote_shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical opening `procedure_id` for the shell leaf.
|
||||||
|
#[cfg(not(any(feature = "endpoint", feature = "tui")))]
|
||||||
|
pub fn shell_open_procedure() -> String {
|
||||||
|
let mut procedure_id = shell_leaf_name();
|
||||||
|
procedure_id.push_str(".open");
|
||||||
|
procedure_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the empty open-request payload used by the shell example.
|
||||||
|
pub fn shell_open_payload() -> Vec<u8> {
|
||||||
|
encode_call_reply(&OpenRequest).expect("remote shell open payload should encode")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(parts: &[&str]) -> Vec<String> {
|
||||||
|
parts.iter().map(|part| (*part).to_owned()).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Placeholder client-side TUI surface for the remote shell leaf.
|
||||||
|
//!
|
||||||
|
//! The first application-layer consumer will be a CLI and later a full GUI. This
|
||||||
|
//! stub keeps the leaf-specific interpretation point in place without forcing a
|
||||||
|
//! rendering-library decision yet.
|
||||||
|
|
||||||
|
use std::string::String;
|
||||||
|
use std::vec::Vec;
|
||||||
|
|
||||||
|
use unshell::Leaf;
|
||||||
|
use unshell::protocol::DataMessage;
|
||||||
|
|
||||||
|
use crate::{LeafTui, TuiError};
|
||||||
|
|
||||||
|
/// Stub TUI surface for the remote shell leaf.
|
||||||
|
#[derive(Default, Leaf)]
|
||||||
|
#[leaf(leaf_name = "remote_shell")]
|
||||||
|
pub struct RemoteShellTui {
|
||||||
|
transcript: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteShellTui {
|
||||||
|
/// Returns a short explanation of the current stub status.
|
||||||
|
pub fn status_line(&self) -> &'static str {
|
||||||
|
"remote shell TUI stub: rendering is placeholder-only for now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeafTui for RemoteShellTui {
|
||||||
|
fn leaf_name(&self) -> String {
|
||||||
|
Self::protocol_leaf_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError> {
|
||||||
|
self.transcript.extend_from_slice(&message.data);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self) -> String {
|
||||||
|
let body = String::from_utf8_lossy(&self.transcript);
|
||||||
|
format!("{}\n\n{}", self.status_line(), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "unshell-protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Wire protocol, framing, validation, and endpoint runtime for UnShell"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
doctest = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rkyv = { workspace = true }
|
||||||
|
unshell-macros = { path = "../unshell-macros" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
elided_lifetimes_in_paths = "warn"
|
||||||
|
future_incompatible = { level = "warn", priority = -1 }
|
||||||
|
nonstandard_style = { level = "warn", priority = -1 }
|
||||||
|
rust_2018_idioms = { level = "warn", priority = -1 }
|
||||||
|
rust_2021_prelude_collisions = "warn"
|
||||||
|
semicolon_in_expressions_from_macros = "warn"
|
||||||
|
unsafe_op_in_unsafe_fn = "warn"
|
||||||
|
unused_import_braces = "warn"
|
||||||
|
unused_lifetimes = "warn"
|
||||||
|
trivial_casts = "allow"
|
||||||
|
missing_docs = "warn"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! # UnShell Protocol
|
||||||
|
//!
|
||||||
|
//! The protocol crate owns the wire types, framing, validation helpers, and the
|
||||||
|
//! small tree runtime used by endpoint implementations.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
pub extern crate alloc;
|
||||||
|
#[allow(unused_extern_crates)]
|
||||||
|
extern crate self as unshell;
|
||||||
|
|
||||||
|
/// Keep the historical nested path so existing imports and proc-macro output can
|
||||||
|
/// continue to target `unshell::protocol::...` while the implementation lives in
|
||||||
|
/// its own crate.
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
|
pub use protocol::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub use unshell_macros::{Leaf, Procedure, procedures};
|
||||||
Reference in New Issue
Block a user