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:
+190
-62
@@ -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(¶ms) {
|
||||
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(¶ms).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(¶ms) {
|
||||
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(¶ms).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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user