use std::collections::HashMap;
use std::sync::Arc;
use alkcall::channels::client::ChannelClient;
use alkcall::channels::operations::ChannelCore;
use alkcall::channels::policy::default_policy;
use alkcall::core::auth::{AuthContext, Identity};
use alkcall::core::types::Connection as CoreConnection;
use alkcall::registry::registration::OperationRegistry;
use crate::backend::TtyBackend;
use crate::channels::register_openable;
pub(crate) async fn wire_client_and_server(
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
ownership: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
identity: Option<Identity>,
) -> ChannelClient {
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
use alkcall::channels::policy::NoCap;
use alkcall::core::auth::IdentityProvider;
use alkcall::protocol::connection::split_single_stream;
use alkcall::protocol::dispatch::Dispatcher;
struct NoopIdProvider;
impl IdentityProvider for NoopIdProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _: &alkcall::core::auth::AuthToken) -> Option<Identity> {
None
}
}
let policy = default_policy();
let policy_for_hook = Arc::clone(&policy);
let identity_for_conn = identity.clone();
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, auth| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let _identity = identity.clone();
let policy = Arc::clone(&policy_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
if let Some(id) = _identity {
let _ = channel0_conn.set_identity(id);
}
let core = ChannelCore::new(manager, policy);
let mut registry = OperationRegistry::new();
register_openable(
&core,
Arc::clone(&backends),
ownership.clone(),
&mut registry,
auth.clone(),
)
.expect("register_openable");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(
alkcall::protocol::connection::CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
),
);
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = CoreConnection::from_bidi(client_end, b"alk/channels".to_vec(), None);
let server_conn = CoreConnection::from_bidi(server_end, b"alk/channels".to_vec(), None);
if let Some(id) = &identity_for_conn {
let _ = server_conn.set_identity(id.clone());
}
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = alkcall::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
ChannelClient::from_connection(client_conn)
.await
.expect("channel client init")
}
pub(crate) fn tty_identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
resources: HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MockBackend;
use crate::channels::OP_TTY_OPEN;
#[test]
fn tty_identity_has_tty_open_scope() {
let id = tty_identity("carol");
assert_eq!(id.id, "carol");
assert!(id
.scopes
.iter()
.any(|s| s == crate::adapter::TTY_OPEN_SCOPE));
}
#[tokio::test]
async fn harness_open_op_returns_channel_id() {
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.call_open_op(
OP_TTY_OPEN,
serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
),
)
.await
.expect("open op timed out");
assert!(
response.result.is_ok(),
"open op failed: {:?}",
response.result
);
}
#[tokio::test]
async fn harness_handler_to_client_data_flows() {
use tokio::io::AsyncReadExt;
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let (channel_id, send, mut recv) = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.open_channel(
OP_TTY_OPEN,
serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
crate::channels::TTY_ALPN,
),
)
.await
.expect("open_channel timed out")
.expect("open_channel");
assert!(channel_id > 0);
let mut first = [0u8; 5];
tokio::time::timeout(
std::time::Duration::from_secs(5),
recv.read_exact(&mut first),
)
.await
.expect("no first chunk from producer (handler→client direction)")
.expect("read first chunk header");
assert_eq!(first[0], crate::wire::STREAM_STDOUT);
drop(send);
}
#[tokio::test]
async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() {
use alkcall::channels::client::ChannelOpenError;
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.open_channel(
OP_TTY_OPEN,
serde_json::json!({
"carriage": "raw",
"backend": "mock",
"cmd": ["true"],
"cwd": 42
}),
crate::channels::TTY_ALPN,
),
)
.await
.expect("open_channel timed out");
match result {
Err(ChannelOpenError::CallFailed { error }) => {
assert_eq!(error.code, "channel:open_failed");
let details = error.details.expect("details carry the reason");
assert_eq!(details["reason"], "handler_error");
assert!(
details["message"]
.as_str()
.is_some_and(|m| m.contains("malformed negotiation")),
"message carries the malformed-negotiation detail, got {details}"
);
}
Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"),
Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"),
}
assert!(
client.manager().channel_ids().iter().all(|&id| id == 0),
"no channel survives a failed establishment"
);
}
#[tokio::test]
async fn allocate_failure_fails_open_as_dial_failed() {
use alkcall::channels::client::ChannelOpenError;
struct AllocFailBackend;
#[async_trait::async_trait]
impl TtyBackend for AllocFailBackend {
async fn allocate(
&self,
_params: &crate::backend::TtyParams,
) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
Err(crate::backend::TtyError::AllocFailed {
message: "out of ptys".to_string(),
})
}
}
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(AllocFailBackend));
let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.open_channel(
OP_TTY_OPEN,
serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
crate::channels::TTY_ALPN,
),
)
.await
.expect("open_channel timed out");
match result {
Err(ChannelOpenError::CallFailed { error }) => {
assert_eq!(error.code, "channel:open_failed");
let details = error.details.expect("details carry the reason");
assert_eq!(details["reason"], "dial_failed");
assert!(
details["message"]
.as_str()
.is_some_and(|m| m.contains("out of ptys")),
"message carries the backend allocation failure detail, got {details}"
);
}
Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"),
Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"),
}
assert!(
client.manager().channel_ids().iter().all(|&id| id == 0),
"no channel survives a failed allocation"
);
}
}