- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049 amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path allocation-failure shape) - make_tty_establisher runs backend.allocate; failure maps to EstablishmentError::DialFailed (channel:open_failed reason dial_failed); the TtyHandle crosses to the pump handler via a private per-open AllocatedHandle one-shot slot (the handle is not Sync — it cannot be the ChannelPlan directly) - make_tty_open_handler gains the Option<ChannelPlan> parameter; a plan drives the new drive_session_pre_allocated (pumps only), a None plan falls back to drive_session_pre_negotiated (inline validate-and-allocate — defense-in-depth for no-establisher registrations) - tty_open_spec's channel:open_failed ErrorDefinition declares dial_failed (four reachable reasons) - Pinned in-band allocate test flipped: allocate_failure_fails_open_as_dial_failed (end-to-end, no channel survives); establisher unit gate for the plan slot + failure mapping - ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md, session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin updated Verification: cargo test (113) + --all-features (137), clippy -D warnings (native + wasm32, default + all-features), fmt, doc, wasm32 check — all clean
332 lines
13 KiB
Rust
332 lines
13 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|