241 lines
12 KiB
Markdown
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(¶ms)`. 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(¶ms)`. 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 |