Files
alknet/tasks/tty/adapter.md
T

241 lines
12 KiB
Markdown

---
id: tty/adapter
name: Implement TtyAdapter (ProtocolHandler) and three-pump session driver
status: completed
depends_on: [tty/wire-codec, tty/control-messages, tty/negotiation, tty/backend-trait]
scope: broad
risk: high
impact: phase
level: implementation
---
## Description
Implement the `TtyAdapter` (`ProtocolHandler` on `alknet/tty`) and the
`drive_session` three-pump bidirectional driver in `src/adapter.rs`. This is
where the wire format (ADR-052), the backend trait (ADR-053), and the
exit-chunk ordering (ADR-055) come together. The adapter is backend-agnostic;
the backends are wire-format-agnostic. The inversion is the `TtyBackend` trait.
This is a generalization of the POC's `/workspace/alknet-tty-poc/src/session.rs`,
which hardcoded the local PTY backend; the adapter dispatches to any
`TtyBackend`.
### TtyAdapter struct
```rust
pub struct TtyAdapter {
/// Backends keyed by the negotiation frame's `backend` string
/// ("local", "docker", "ssh"). Populated at construction.
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
/// Optional ownership provider (ADR-050) for terminal sessions as
/// runtime-spawned resources. None = no resource-level ACL (scope-
/// gate only). Wired by the assembly layer.
ownership: Option<Arc<dyn OwnershipProvider>>,
}
impl TtyAdapter {
pub fn new(backends: HashMap<String, Arc<dyn TtyBackend>>) -> Self;
pub fn with_ownership(backends: HashMap<String, Arc<dyn TtyBackend>>, ownership: Arc<dyn OwnershipProvider>) -> Self;
}
#[async_trait]
impl ProtocolHandler for TtyAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/tty" }
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
// One connection → many sessions (one bidi stream each).
while let Ok((send, recv)) = connection.accept_bi().await {
let backends = self.backends.clone();
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
let _ = drive_session(send, recv, backends, ownership, identity).await;
});
}
Ok(())
}
}
```
### Session lifecycle (drive_session)
A `alknet/tty` session on one bidi stream proceeds in three phases:
1. **Negotiation.** Read the single length-prefixed JSON negotiation frame
(task `tty/negotiation`), parse into `NegotiateRequest`, extract the
`backend` string, look up the `TtyBackend`, construct `TtyParams`. If the
backend is not registered → `unknown_backend` error. If the frame is
malformed or `carriage != "raw"` or `cmd` is empty → `malformed_negotiation`
error. Send the JSON error response in negotiation framing and close the
stream (do NOT enter raw mode).
2. **Allocation.** Call `backend.allocate(&params)`. If it fails →
`allocate_failed` error response, close. Before allocating, run the access
control checks (see below).
3. **Raw carriage — the bidirectional pump.** Switch to the raw chunk format
and pump three concurrent tasks:
- **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout
chunks (stream_type 1). If `TtyHandle.stderr` is `Some`, a concurrent
stderr pump emits stderr chunks (stream_type 2). On backend stdout EOF,
emit a zero-length stdout sentinel.
- **B. client → backend**: client chunks → backend. stdin chunks
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control chunks
(stream_type 3) → `ControlMessage` dispatch: `Resize` →
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` → close
stdin. `Exit` from the client is ignored (server→client only). On client
read-half close or a zero-length stdin chunk, signal EOF to the backend's
stdin. Unknown control `type` values are ignored (logged at debug), not
errors.
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, enqueue
`{"type":"exit","code":N}` as a control chunk (stream_type 3). On
`TtyError`, send `{"type":"exit","code":-1}` (ADR-055 §4).
A drainer task writes chunks to the client in arrival order. After the exit
chunk is written (task C resolves AND stdout/stderr pumps complete AND the
exit chunk drains), the adapter closes the write half — the session ends.
### Exit-chunk ordering (ADR-055)
The "exit chunk is last" invariant is enforced here, in the adapter's session
driver, not in the backend:
1. The stdout pump (task A) drains the backend's stdout to EOF.
2. The exit task (task C) awaits `TtyHandle.exit_code`.
3. **The adapter waits for *both* the stdout pump to complete (EOF) *and*
`exit_code` to resolve** before enqueueing the exit chunk. If `stderr` is
`Some`, it also drains before the exit chunk. (ADR-055 assumption 2.)
4. After both resolve, the exit chunk is enqueued on the writer channel.
5. The drainer writes the exit chunk to the client.
6. The adapter closes the write half — the session ends.
Use a coordination primitive (e.g., `tokio::join!` on the stdout pump and the
exit future, or a barrier) to enforce the "both done before exit chunk" rule.
The POC's `session.rs` uses an mpsc writer channel where the exit task sends
the exit chunk and the drainer writes it last; the adapter must additionally
ensure the stdout pump has finished before the exit chunk is sent. A clean
pattern: `join!(stdout_pump, exit_future)` then send the exit chunk.
### Access control
Terminal sessions are runtime-spawned resources per ADR-050:
- **Scope-gate at negotiation.** Check the caller's `identity.scopes` for the
`tty:open` scope (or a deployment-configured scope) before allocating. A
caller without the scope gets `{"error":"forbidden"}` and the stream closes.
The scope name is a two-way-door choice (reversible, not a wire-format
constant).
- **Resource ownership for backend-specific resources.** Call
`backend.resource_id(&params)`. If `Some((kind, id))` and an ownership
provider is wired, check `OwnershipProvider::owns(identity, kind, id, "tty")`.
If the caller doesn't own it → `{"error":"forbidden"}`. If `None` (the
session creates its own resource — local process, SSH channel), no
ownership check.
- **`forwarded_for`** for proxied sessions (ADR-032) is the hub's concern, not
the adapter's — the worker authorizes the hub (its direct caller).
### Negotiation errors
Send the JSON error response in negotiation framing (task `tty/negotiation`),
then close the write half. The framing-disambiguation trick (first byte `0x00`
= error frame, first byte `1`/`2`/`3` = raw chunk) is sound because error frames
are under 16 MiB.
### Connection and stream lifecycle
- **Connection drop**: all in-flight sessions are cancelled. Pump tasks drop;
`TtyHandle` drops; `exit_code` future drops without completion → backend's
cancel-cleanup kills the session target (ADR-056).
- **Stream reset**: `ChunkReader` returns `RawError` (ConnectionClosed or Io).
Pump tasks exit; `TtyHandle` drops; cancel-cleanup runs. No exit chunk sent.
- **Client cancel (write-half close / eof / zero-length stdin)**: signal EOF
to backend stdin, keep pumping stdout until exit resolves. The session
completes normally (exit chunk sent). This is NOT a cancel — ADR-056 is not
triggered. Cancel-cleanup is triggered only when the *adapter* drops the
handle (connection drop, stream reset, panic).
### Tests
Use the `MockBackend` from task `tty/backend-trait` (in-memory tokio mpsc
channels for stdin/stdout, a oneshot for exit_code, a mock `TtyControl`).
Drive `drive_session` over a `tokio::io::duplex`:
- **Happy path**: send a negotiation frame, send stdin chunks, mock backend
echoes stdout, resolve exit_code 0, assert the client receives stdout
chunks then the exit chunk last, then stream close.
- **Exit-chunk-is-last**: assert no stdout chunk arrives after the exit chunk.
- **Stdin EOF**: send a zero-length stdin chunk (or `eof` control), assert the
backend's stdin closes, stdout continues, exit chunk still sent.
- **Resize/signal control**: send resize and signal control chunks, assert
the mock `TtyControl` receives them.
- **Unknown control type**: send `{"type":"unknown"}`, assert it's ignored
(no error, session continues).
- **unknown_backend error**: negotiation frame with unregistered backend →
error response, stream closes, no raw mode.
- **malformed_negotiation error**: bad JSON or `carriage != "raw"` or empty
`cmd` → error response, stream closes.
- **allocate_failed error**: mock backend returns `TtyError::AllocFailed` →
error response, stream closes.
- **Exit error**: mock backend's `exit_code` resolves with `TtyError` →
`{"type":"exit","code":-1}`.
- **Cancel cleanup**: drop the connection mid-session, assert the mock
backend's `exit_code` future is dropped (the kill-on-Drop guard fires).
- **Scope gate**: identity without `tty:open` scope → `forbidden` error.
- **Ownership check**: backend returns `resource_id Some`, ownership provider
returns false → `forbidden`; returns true → session proceeds.
## Acceptance Criteria
- [ ] `TtyAdapter` struct with `backends` and `ownership` fields
- [ ] `TtyAdapter::new` and `with_ownership` constructors
- [ ] `impl ProtocolHandler for TtyAdapter` with `alpn()` returning `b"alknet/tty"`
- [ ] `handle()` loops `accept_bi`, spawns `drive_session` per stream
- [ ] `drive_session` reads negotiation frame, parses, selects backend, constructs `TtyParams`
- [ ] Negotiation errors (`unknown_backend`, `malformed_negotiation`, `allocate_failed`) sent as JSON in negotiation framing, stream closed
- [ ] Three-pump driver: stdout→client, client→backend (stdin + control dispatch), exit→exit-chunk
- [ ] stderr pump concurrent with stdout when `TtyHandle.stderr` is `Some`
- [ ] Exit-chunk-is-last invariant: stdout/stderr pumps complete AND exit_code resolves before exit chunk enqueued
- [ ] Exit error → `{"type":"exit","code":-1}`
- [ ] Unknown control `type` ignored (not an error)
- [ ] `Exit` control from client ignored (server→client only)
- [ ] Zero-length stdin chunk and `eof` control both close backend stdin
- [ ] Scope-gate at negotiation (`tty:open` scope, or deployment-configured)
- [ ] Ownership check via `backend.resource_id()` + `OwnershipProvider::owns()` when wired
- [ ] Connection drop / stream reset drops `TtyHandle` (triggers ADR-056 cancel-cleanup)
- [ ] Client write-half close does NOT trigger cancel-cleanup (session runs to completion)
- [ ] Integration tests with `MockBackend` over `tokio::io::duplex` for all the scenarios above
- [ ] `cargo test -p alknet-tty` succeeds
- [ ] `cargo clippy -p alknet-tty` succeeds with no warnings
## References
- docs/architecture/crates/tty/tty-adapter.md — the authoritative session lifecycle spec
- docs/architecture/crates/tty/tty-wire.md — the wire format the adapter pumps
- docs/architecture/crates/tty/tty-backend.md — the backend trait the adapter dispatches to
- docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md — ADR-052
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053
- docs/architecture/decisions/055-exit-code-on-control-chunk.md — ADR-055 (exit-chunk ordering)
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (cancel-cleanup)
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 (access control)
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for)
- /workspace/alknet-tty-poc/src/session.rs — the reference three-pump driver (hardcoded to local PTY; generalize to the trait)
## Notes
> This is the integration task where all the invariants come together. The
> exit-chunk-is-last ordering (ADR-055) is the subtle part: the adapter must
> wait for BOTH the stdout pump to complete AND exit_code to resolve before
> sending the exit chunk. The POC's `session.rs` is the reference but it does
> not enforce this ordering strictly (the exit task sends the chunk
> independently); the crate's adapter must add the coordination. The
> `MockBackend` from `tty/backend-trait` is the test fixture. The scope name
> `tty:open` is a two-way-door choice — make it a constant or configurable,
> not a wire-format constant.
## Summary
> To be filled on completion