fix: local-bridge robustness, scope-gate order, sentinel contract; packaging/docs closeout (P8-P11, P2/P3/P15)

Closes review #003 (prepublish review for v0.1.0).

- P8: StdinSink::poll_shutdown parks an inflight reserve+send on a
  full channel (waker registered) — a stdin blast followed by EOF
  delivers the EOF instead of stranding it
- P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner)
- P10: three thread-spawn .expect() sites -> TtyError::AllocFailed
- P5: tty:open scope gate runs before carriage/cmd/backend-lookup
  checks (no backend-name enumeration differential for unscoped ids)
- P11: recv_stdout terminates on the zero-length drained sentinel;
  the sentinel is no longer yielded as an item (doc was already the
  contract); stderr has no sentinel (doc noted)
- P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and
  docs/research/ entries (package list: 42 files, 659.2KiB)
- P3: AGENTS.md phase status (all five landed), ADR range 001..009
  (+ alktty-native ADR-009 in the mapping), alkcall guidance
  corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row
  + landed-phase status
- P15: backend.rs doc typo; redundant tokio-stream dev-dep removed;
  NegotiationError::Io arm logs; set_identity failure logs;
  input_pump.abort() at session end; TtySessionError::Open carries
  the accept_bi StreamError (no io::Error flattening); borrowing
  deserialize in open_via_channels (no params.clone());
  error_response_bytes guards an "error" key in fields; trivial
  inline comments promoted/removed; plan-doc test counts + doc
  front-matter refreshed; session tests that raced session teardown
  under the abort change use a GatedBackend (exit held until
  released)

Verification: cargo test 104 lib / --all-features 147; clippy
(all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings;
publish dry-run OK.
This commit is contained in:
glm-5.3-flash committed 2026-09-05 16:47:45 +00:00
1 parent 7b0bac0671
commit 3baa993edb
14 files changed
+372 -105

No files matched your search

+24 -14
View File
@@ -130,7 +130,11 @@ impl ProtocolHandler for TtyAdapter {
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
if let Some(identity) = auth.identity.clone() {
let _ = connection.set_identity(identity);
// "Already set" is benign (one identity per connection by
// construction); anything else is a real wiring bug.
if let Err(e) = connection.set_identity(identity) {
debug!("tty: set_identity: {e}");
}
}
loop {
let stream = match connection.accept_bi().await {
@@ -143,11 +147,6 @@ impl ProtocolHandler for TtyAdapter {
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite
// + Send + Unpin`. Split into halves for `drive_session`
// (which takes separate `AsyncWrite` + `AsyncRead` args). The
// split is the stdlib idiom for `TcpStream`-style duplex
// streams; no per-handler wrapper.
let (client_read, client_write) = tokio::io::split(stream);
let _ =
drive_session(client_write, client_read, backends, ownership, identity).await;
@@ -265,7 +264,12 @@ pub async fn drive_session_pre_negotiated(
/// `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`]).
/// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review
/// #003 P5): checking it after the backend lookup would let an
/// authenticated-but-unscoped identity distinguish registered backend
/// names (`unknown_backend`) from unregistered ones (`forbidden`) — a
/// name-enumeration differential. Gated first, every unscoped request
/// gets the same `forbidden` regardless of the request body.
#[allow(clippy::type_complexity)]
async fn validate_and_allocate<W>(
neg_writer: NegotiationWriter<W>,
@@ -278,6 +282,11 @@ async fn validate_and_allocate<W>(
where
W: AsyncWrite + Send + Unpin + 'static,
{
if enforce_scope && !has_scope(identity, TTY_OPEN_SCOPE) {
send_negotiation_error(neg_writer, "forbidden", &[]).await;
return Err(());
}
if req.carriage != "raw" {
send_negotiation_error(
neg_writer,
@@ -306,11 +315,6 @@ where
}
};
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 {
@@ -359,7 +363,10 @@ where
let frame = match neg_reader.read_frame().await {
Ok(f) => f,
Err(NegotiationError::ConnectionClosed) => return Ok(()),
Err(NegotiationError::Io(_)) => return Ok(()),
Err(NegotiationError::Io(e)) => {
debug!("tty: negotiation read io error: {e}");
return Ok(());
}
Err(NegotiationError::FrameTooLarge(_)) => {
send_negotiation_error(
neg_writer,
@@ -485,7 +492,10 @@ where
}
drop(writer_tx);
drop(input_pump);
// The client→backend pump outlives the session data plane only until
// the client disconnects; aborting it at session end is tighter — no
// lingering task holding the backend stdin half after the exit chunk.
input_pump.abort();
let _ = drainer.await;
debug!("tty: session complete");