feat: channels path carries the negotiation in the open op (L1, L3)

The channels path no longer carries a second negotiation frame on the
channel's data stream (ADR-009). The open op's registry-validated
input IS the negotiation:

- producer: make_tty_open_handler parses the open op's input into a
  NegotiateRequest and drives the new drive_session_pre_negotiated
  (same three-pump driver as drive_session, minus the wire-frame
  negotiation phase; validate/allocate factored into
  validate_and_allocate, shared by both paths). Post-open failures
  (unknown backend, allocate_failed, ownership denial) still go to the
  client as a 0x00-prefixed negotiation error frame, so the consumer's
  M1 disambiguation read applies unchanged. The tty:open scope gate is
  enforced by the registry's AccessControl (not re-checked in the
  handler).
- consumer: open_via_channels parses params locally (fail-fast before
  a channel is allocated), opens the channel, and starts raw-chunk
  mode directly (from_halves_raw — no negotiation write; the
  0x00-error-frame peek retained).
- tty_open_spec's input schema is now the partial NegotiateRequest
  shape (carriage/backend/cmd required; backend params stay free-form
  — raw JSON Schema is permissive on unknown keys).

Prerequisites landed upstream: alkcall 0.4.0 enforces
OperationSpec.input_schema at dispatch (the registry check this design
leans on never existed before); alkcall 0.4.1 parks early-arrival
chunks for un-adopted channels instead of dropping them — the open
response / producer's-first-write race was silently losing the first
chunks (found by L3's test; the session never resolved).

L3: open_via_channels + from_bidi_stream_via now covered end-to-end
(5 session tests + pre-negotiated adapter test + shared-harness tests
in the new crate::testing module; the channels harness moved there so
session tests share it).

Verification: cargo test 93 lib (default), 116 lib + 19 integration
(--all-features); clippy -D warnings native + wasm clean; fmt clean;
doc 0 warnings; wasm check clean.
This commit is contained in:
2026-09-05 07:08:17 +00:00
parent 9327a73496
commit 96692d3b6a
8 changed files with 781 additions and 243 deletions
+190 -62
View File
@@ -192,7 +192,10 @@ async fn send_negotiation_error<W: AsyncWrite + Unpin>(
/// when the stream is reset (cancel-cleanup path — no exit chunk sent).
///
/// This is the per-stream session driver — generalized from the POC's
/// `session::drive_session` to the [`TtyBackend`] trait.
/// `session::drive_session` to the [`TtyBackend`] trait. The negotiation
/// frame is read from the wire (the direct-ALPN path — ADR-052); the
/// channels path (open-op `input` is the negotiation) uses
/// [`drive_session_pre_negotiated`] instead.
pub async fn drive_session(
client_send: impl AsyncWrite + Send + Unpin + 'static,
client_recv: impl AsyncRead + Send + Unpin + 'static,
@@ -207,6 +210,131 @@ pub async fn drive_session(
}
}
/// Drive a `alk/tty` session whose negotiation already happened out of
/// band — the channels path (ADR-009). The open op's registry-validated
/// `input` was parsed into `req` by the channels wrapper; the raw-chunk
/// data plane starts immediately. No negotiation frame is read from the
/// wire, and none is written by the client.
///
/// Validation still runs here (`carriage`/`cmd`/backend lookup) plus the
/// ADR-050 ownership check — failures go to the client as a negotiation
/// error frame on the channel stream (the same framing the direct path
/// uses, so the consumer's M1 disambiguation read applies unchanged).
/// The `tty:open` scope gate is NOT re-checked: on the channels path the
/// registry's `AccessControl` enforced it at open time (the identity view
/// the registry checked — resolved from the connection — is the
/// authoritative one; the handler-side identity is only the
/// ownership-check subject).
pub async fn drive_session_pre_negotiated(
client_send: impl AsyncWrite + Send + Unpin + 'static,
client_recv: impl AsyncRead + Send + Unpin + 'static,
req: NegotiateRequest,
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<Identity>,
) {
if let Err(e) = drive_session_pre_negotiated_inner(
client_send,
client_recv,
req,
&backends,
&ownership,
&identity,
)
.await
{
debug!("tty: session ended with error: {e}");
}
}
/// Validate a parsed `NegotiateRequest`, select the backend, run the
/// ADR-050 ownership check, and allocate. Shared by the direct
/// (wire-negotiated) and channels (pre-negotiated) paths. Failures are
/// reported to the client as a negotiation error frame on `neg_writer`
/// (which is consumed and shut down); `Err(())` means "session over, the
/// client has been told". On success the unwrapped write half comes back
/// with the allocated handle.
///
/// `enforce_scope` gates the `tty:open` scope check: `true` on the direct
/// path (the adapter is the only gate), `false` on the channels path (the
/// registry's `AccessControl` enforced the scope at open time — see
/// [`drive_session_pre_negotiated`]).
#[allow(clippy::type_complexity)]
async fn validate_and_allocate<W>(
neg_writer: NegotiationWriter<W>,
req: NegotiateRequest,
backends: &HashMap<String, Arc<dyn TtyBackend>>,
ownership: &Option<Arc<dyn OwnershipProvider>>,
identity: &Option<Identity>,
enforce_scope: bool,
) -> Result<(TtyHandle, W), ()>
where
W: AsyncWrite + Send + Unpin + 'static,
{
if req.carriage != "raw" {
send_negotiation_error(
neg_writer,
"malformed_negotiation",
&[("message", "carriage must be 'raw'")],
)
.await;
return Err(());
}
if req.cmd.is_empty() {
send_negotiation_error(
neg_writer,
"malformed_negotiation",
&[("message", "cmd must be non-empty")],
)
.await;
return Err(());
}
let backend = match backends.get(&req.backend) {
Some(b) => b.clone(),
None => {
send_negotiation_error(neg_writer, "unknown_backend", &[("backend", &req.backend)])
.await;
return Err(());
}
};
if enforce_scope && !has_scope(identity, TTY_OPEN_SCOPE) {
send_negotiation_error(neg_writer, "forbidden", &[]).await;
return Err(());
}
let params = crate::backend::TtyParams::from(req);
if let Some(provider) = ownership {
if let Some((kind, id)) = backend.resource_id(&params) {
let owns = identity
.as_ref()
.map(|id_ref| provider.owns(id_ref, kind, &id, "tty"))
.unwrap_or(false);
if !owns {
send_negotiation_error(neg_writer, "forbidden", &[]).await;
return Err(());
}
}
}
let handle = match backend.allocate(&params).await {
Ok(h) => h,
Err(e) => {
send_negotiation_error(
neg_writer,
"allocate_failed",
&[("message", &e.to_string())],
)
.await;
return Err(());
}
};
Ok((handle, neg_writer.into_inner()))
}
async fn drive_session_inner<W, R>(
client_send: W,
client_recv: R,
@@ -253,72 +381,38 @@ where
}
};
if req.carriage != "raw" {
send_negotiation_error(
neg_writer,
"malformed_negotiation",
&[("message", "carriage must be 'raw'")],
)
.await;
return Ok(());
}
if req.cmd.is_empty() {
send_negotiation_error(
neg_writer,
"malformed_negotiation",
&[("message", "cmd must be non-empty")],
)
.await;
return Ok(());
}
let backend = match backends.get(&req.backend) {
Some(b) => b.clone(),
None => {
send_negotiation_error(neg_writer, "unknown_backend", &[("backend", &req.backend)])
.await;
return Ok(());
}
};
if !has_scope(identity, TTY_OPEN_SCOPE) {
send_negotiation_error(neg_writer, "forbidden", &[]).await;
return Ok(());
}
let params = crate::backend::TtyParams::from(req);
if let Some(provider) = ownership {
if let Some((kind, id)) = backend.resource_id(&params) {
let owns = identity
.as_ref()
.map(|id_ref| provider.owns(id_ref, kind, &id, "tty"))
.unwrap_or(false);
if !owns {
send_negotiation_error(neg_writer, "forbidden", &[]).await;
return Ok(());
}
}
}
let handle = match backend.allocate(&params).await {
Ok(h) => h,
Err(e) => {
send_negotiation_error(
neg_writer,
"allocate_failed",
&[("message", &e.to_string())],
)
.await;
return Ok(());
}
};
let (handle, client_write) =
match validate_and_allocate(neg_writer, req, backends, ownership, identity, true).await {
Ok(ok) => ok,
Err(()) => return Ok(()),
};
let client_read = neg_reader.into_inner();
let client_write = neg_writer.into_inner();
pump_session(client_write, client_read, handle).await
}
async fn drive_session_pre_negotiated_inner<W, R>(
client_send: W,
client_recv: R,
req: NegotiateRequest,
backends: &HashMap<String, Arc<dyn TtyBackend>>,
ownership: &Option<Arc<dyn OwnershipProvider>>,
identity: &Option<Identity>,
) -> Result<(), std::io::Error>
where
W: AsyncWrite + Send + Unpin + 'static,
R: AsyncRead + Send + Unpin + 'static,
{
let neg_writer = NegotiationWriter::new(client_send);
let (handle, client_write) =
match validate_and_allocate(neg_writer, req, backends, ownership, identity, false).await {
Ok(ok) => ok,
Err(()) => return Ok(()),
};
pump_session(client_write, client_recv, handle).await
}
/// Phase 3: the bidirectional pump. Three concurrent tasks plus a drainer.
///
/// Enforces the exit-chunk-is-last invariant (ADR-055): the adapter waits for
@@ -1519,4 +1613,38 @@ mod tests {
exit_tx.send(Ok(0)).unwrap();
let _ = session.await;
}
#[tokio::test]
async fn pre_negotiated_happy_path_over_plain_duplex() {
use crate::backend::MockBackend;
use tokio::io::AsyncReadExt;
let backend: Arc<dyn TtyBackend> = Arc::new(MockBackend::with_exit_code(0));
let mut backends: StdHashMap<String, Arc<dyn TtyBackend>> = StdHashMap::new();
backends.insert("mock".to_string(), backend);
let backends = Arc::new(backends);
let (mut client, server) = duplex(8 * 1024);
let (server_read, server_write) = tokio::io::split(server);
let identity = identity_with_scope(TTY_OPEN_SCOPE);
let req: NegotiateRequest =
serde_json::from_slice(TEST_NEG.as_bytes()).expect("parse TEST_NEG");
let session = tokio::spawn(async move {
drive_session_pre_negotiated(server_write, server_read, req, backends, None, identity)
.await;
});
let mut buf = [0u8; 5];
tokio::time::timeout(
std::time::Duration::from_secs(5),
client.read_exact(&mut buf),
)
.await
.expect("no first chunk from pre-negotiated driver")
.expect("read");
assert_eq!(buf[0], STREAM_STDOUT, "stdout sentinel comes first");
let _ = session.await;
}
}