alktty 0.5.0

Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels.
Documentation
//! Shared test wiring for the channels-based tests (the producer
//! harness in `channels.rs::tests` and the consumer end-to-end tests
//! in `session.rs::tests` both need the same `ChannelClient` ↔
//! server wiring). `#[cfg(test)]`-only — never part of the public
//! API.
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;

/// Build a `ChannelClient` and a server-side `ChannelCore` + registry
/// with `channels/tty/sub` registered, wired over a `tokio::io::duplex`
/// carrying the channels 8-byte chunk header wire format. Returns the
/// client. The server-side dispatch loop is spawned and runs until the
/// client drops or the test ends.
///
/// This mirrors the alkcall `channel_0_end_to_end_register_openable`
/// test's wiring pattern but uses alktty's `register_openable` helper
/// so the registration is the code under test.
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);

            // Propagate the identity to the channel-0
            // connection so the call dispatch's
            // `resolve_identity` sees it. The
            // `ChannelsAdapter` builds `channel0_conn` fresh
            // from `channel_source` and does NOT inherit the
            // outer connection's identity — we set it here
            // so the `AccessControl` scope-gate can check it.
            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);
    // Set the identity on the server connection so the call
    // dispatch sees it (the ACL check runs against the
    // connection's identity, not the `install_channel_zero`
    // hook's `auth`).
    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")
}

/// An identity with the `tty:open` scope (the gate
/// `channels/tty/sub` requires).
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);

        // The producer writes a 5-byte TTY header (stdout sentinel)
        // almost immediately after the open (MockBackend resolves
        // exit right away) — a push-first producer whose first write
        // races the consumer's adopt. Read it: alkcall 0.4.1's
        // early-arrival park guarantees the first chunks are not lost.
        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);
    }

    /// End-to-end (R4, post-ADR-010): `input` that passes the registry's
    /// (partial) schema but fails the establisher's full
    /// `NegotiateRequest` parse — `cwd` typed as a number. The
    /// establisher rejects it before the reply: the open op fails with
    /// `channel:open_failed` + `details.reason == "handler_error"` (the
    /// malformed-negotiation mapping), the channel is torn down, and no
    /// `channel_id` is ever returned. Uses `ChannelClient::open_channel`
    /// directly (the consumer's local fail-fast parse in
    /// `open_via_channels` would reject these params before the open op
    /// runs — R5's fail-fast path).
    #[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:?}"),
        }
        // The SSH contract: no data channel survives a failed
        // establishment (channel 0 is the call channel).
        assert!(
            client.manager().channel_ids().iter().all(|&id| id == 0),
            "no channel survives a failed establishment"
        );
    }

    /// Post-alkcall-0.6 (ADR-010 as amended): allocation failure no
    /// longer arrives in-band. `backend.allocate` runs in the
    /// establisher (the allocated `TtyHandle` crosses to the pump
    /// handler via the `Establishment` plan payload), so its failure
    /// fails the open op itself — `channel:open_failed` with
    /// `details.reason == "dial_failed"` — and no channel survives
    /// (the SSH contract: no `channel_id`, no `0x00`-prefixed
    /// in-band frame on a channel that was reported as succeeding).
    #[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:?}"),
        }
        // The SSH contract: no data channel survives a failed
        // establishment (channel 0 is the call channel), and no
        // in-band frame was ever written (the channel never existed).
        assert!(
            client.manager().channel_ids().iter().all(|&id| id == 0),
            "no channel survives a failed allocation"
        );
    }
}