fix: channels parse-failure path writes the negotiation error frame (R4)

Review #002 R4 — a NegotiateRequest parse failure of the open op's
schema-validated input died silently (log + return, channel teardown,
consumer observed NoExitChunk — indistinguishable from a crashed
producer), while the other post-open failure classes (unknown backend,
allocate_failed, ownership denial) wrote the 0x00-prefixed error frame.

- make_tty_open_handler now accepts the channel's BiStream and writes
  a malformed_negotiation frame via the shared
  crate::adapter::send_negotiation_error (now pub(crate)) before
  returning; the consumer's M1 peek surfaces NegotiationRejected
  unchanged
- the frame type and layout are unchanged (ADR-001 wire-stable
  contract); no new frame type, no wire change
- tests: make_tty_open_handler seam test with a hand-built
  schema-bypassing input (cwd: 42) + a real-registry end-to-end test
  via ChannelClient::open_channel (bypasses open_via_channels's local
  fail-fast parse — R5's path — so it exercises the producer handler)
- docs: ADR-009 amended (Parse-failure error frame section);
  tty-adapter.md malformed_negotiation row covers both paths;
  session.rs post-open failure lists updated; review #002 R4 resolved

Note: the review's "unreachable end-to-end" premise was refined —
open_via_channels parses params locally (fail-fast) so a TtySession
consumer never hits the producer-side parse failure, but direct
ChannelClient callers do; the schema is deliberately partial so a
schema-valid value (cwd typed as a number) reaches the handler.

Verification: cargo test 95 lib (default) / 138 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
This commit is contained in:
glm-5.3-flash committed 2026-09-05 08:37:02 +00:00
1 parent 5b608117ff
commit 8ee9216a07
7 files changed
+263 -25

No files matched your search

+121 -7
View File
@@ -43,7 +43,7 @@ use tracing::debug;
use crate::adapter::{drive_session_pre_negotiated, TTY_OPEN_SCOPE};
use crate::backend::TtyBackend;
use crate::negotiation::NegotiateRequest;
use crate::negotiation::{NegotiateRequest, NegotiationWriter};
/// The per-ALPN open operation name (`channels/<alpn>/sub` convention,
/// ADR-047). TTY is consumer-opens (the client requests a shell), so
@@ -184,12 +184,27 @@ pub fn tty_open_spec() -> OperationSpec {
/// three-pump session driver as the direct-ALPN path, minus the
/// wire-frame negotiation phase.
///
/// Parse or validation failures (unknown backend, `allocate_failed`,
/// ADR-050 ownership denial) are reported to the client as a
/// negotiation error frame on the channel stream (the same
/// `0x00`-prefixed framing the direct path uses), so a consumer's
/// negotiation-error disambiguation read applies unchanged. The
/// `tty:open` scope gate is enforced by the registry's `AccessControl`
/// All failure classes write a negotiation error frame on the channel
/// stream (the same `0x00`-prefixed framing the direct path uses), so a
/// consumer's negotiation-error disambiguation read applies unchanged:
///
/// - The registry rejects schema-invalid `input` at dispatch (alkcall
/// 0.4) — the open op fails, no channel is ever allocated, and the
/// failure is a `CallError` on the open op, not a channel-stream
/// error frame. But the schema is deliberately partial (the opaque
/// ADR-053 backend params pass through), so a schema-valid value can
/// still fail the full `NegotiateRequest` parse — e.g. `cwd` typed
/// as a number — and that failure is post-open: the handler writes a
/// `malformed_negotiation` error frame (the same
/// [`crate::adapter::send_negotiation_error`] the other failure
/// classes use) and the consumer's peek surfaces
/// `NegotiationRejected`.
/// - Semantic validation failures (unknown backend, `carriage != "raw"`,
/// empty `cmd`, `allocate_failed`, ADR-050 ownership denial) are
/// post-open error frames written by `validate_and_allocate` inside
/// [`crate::adapter::drive_session_pre_negotiated`].
///
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
/// before this handler runs — the handler does not re-check it.
///
/// The handler's `JoinHandle` is recorded by the channels wrapper for
@@ -214,6 +229,20 @@ fn make_tty_open_handler(
Ok(r) => r,
Err(e) => {
debug!("tty: channels open: invalid negotiate params: {e}");
let stream = match channel_conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
debug!("tty: channels open: accept_bi failed: {e}");
return;
}
};
let (_, mut client_write) = tokio::io::split(stream);
crate::adapter::send_negotiation_error(
NegotiationWriter::new(&mut client_write),
"malformed_negotiation",
&[("message", &e.to_string())],
)
.await;
return;
}
};
@@ -250,6 +279,91 @@ mod tests {
use std::collections::HashMap as StdHashMap;
use tokio::io::duplex;
/// The R4 unit-testable seam: feed `make_tty_open_handler` a
/// hand-built `input` the (partial) schema would accept but the full
/// `NegotiateRequest` parse rejects (`cwd` typed as a number). The
/// handler must write a `0x00`-prefixed `malformed_negotiation` error
/// frame on the channel stream — the same failure class as the
/// post-open semantic failures, not a silent teardown. The read side
/// is the consumer's exact post-open sequence (`from_halves_raw`):
/// peek the first byte, see `0x00`, read the length-prefixed error
/// frame.
#[tokio::test]
async fn open_handler_writes_error_frame_on_schema_bypassing_input() {
use alkcall::core::types::{BiStream, BidiStreamSource, StreamError};
use tokio::io::AsyncReadExt;
struct YieldOnce {
stream: tokio::sync::Mutex<Option<BiStream>>,
}
#[async_trait::async_trait]
impl BidiStreamSource for YieldOnce {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
self.stream
.lock()
.await
.take()
.ok_or(StreamError::ConnectionClosed)
}
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
fn remote_addr(&self) -> Option<std::net::SocketAddr> {
None
}
fn close(&self, _code: u32, _reason: &str) {
// The take-on-drop of the wrapped stream is not needed
// for this test; the connection is never closed.
}
}
let (client_end, server_end) = duplex(64 * 1024);
let (server_read, server_write) = tokio::io::split(server_end);
let bidi = BiStream::from_joined(server_read, server_write);
let channel_conn = Connection::from_source(
YieldOnce {
stream: tokio::sync::Mutex::new(Some(bidi)),
},
TTY_ALPN.as_bytes().to_vec(),
);
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let handler = make_tty_open_handler(Arc::new(backends), None, None);
let task = handler(
serde_json::json!({
"carriage": "raw",
"backend": "mock",
"cmd": ["true"],
"cwd": 42
}),
channel_conn,
AuthContext::anonymous(b"test"),
);
task.await.expect("handler task");
let mut read = client_end;
let mut first = [0u8; 1];
tokio::time::timeout(
std::time::Duration::from_secs(5),
read.read_exact(&mut first),
)
.await
.expect("no byte from handler")
.expect("read first byte");
assert_eq!(first[0], 0x00, "error frame length prefix starts with 0x00");
let mut len_rest = [0u8; 3];
read.read_exact(&mut len_rest).await.expect("read len rest");
let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]) as usize;
let mut body = vec![0u8; len];
read.read_exact(&mut body).await.expect("read error body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
assert_eq!(v["error"], "malformed_negotiation");
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
}
#[test]
fn op_tty_open_is_channels_tty_sub() {
assert_eq!(OP_TTY_OPEN, "channels/tty/sub");