Files
alktty/README.md
T
glm-5.3-flash 3baa993edb 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.
2026-09-05 16:47:45 +00:00

146 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# alktty
Terminal session protocol for the `alk/tty` ALPN: a JSON negotiation
frame followed by raw chunks (`[stream_type: u8][length: u32
be][payload]`), a backend-agnostic `TtyBackend` trait, a
backend-agnostic producer adapter, and a typed consumer client.
A producer/consumer protocol crate on top of
[alkcall](https://crates.io/crates/alkcall) channels — no networking, no
transport dependencies. Downstream crates (a `alknet-docker` or
`alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of
it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html)
for your execution environment, and the adapter handles the wire.
## Quick start
### Producer — register backends and run the adapter
```rust
use std::collections::HashMap;
use std::sync::Arc;
// `LocalTtyBackend` needs the `local` feature; swap in any `TtyBackend`
// implementation (docker, ssh, ...) compiled for your target.
let mut backends: HashMap<String, Arc<dyn alktty::TtyBackend>> = HashMap::new();
backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new()));
let tty_adapter = alktty::TtyAdapter::new(backends);
// tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty`
// ALPN — hand it to your transport accept loop:
// tty_adapter.handle(connection, &auth).await
```
### Producer — via `alk/channels` instead of a dedicated ALPN
```rust
use std::collections::HashMap;
use std::sync::Arc;
let backends: HashMap<String, Arc<dyn alktty::TtyBackend>> =
std::iter::once(("local".to_string(), Arc::new(alktty::LocalTtyBackend::new()) as _))
.collect();
// In your channels acceptor's `install_channel_zero` hook (per-connection):
// alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth)
// Consumers open `channels/tty/sub`; the open op's params ARE the
// negotiation (ADR-009) — no second frame on the channel stream.
```
### Consumer — open a session over the direct ALPN
```rust
use alkcall::core::Connection;
use alktty::{NegotiateRequest, TtySession};
let connection: Connection = /* dial the transport, negotiate `alk/tty` */;
let session = TtySession::connect_direct(
connection,
NegotiateRequest {
carriage: "raw".into(),
backend: "local".into(),
tty: None,
cmd: vec!["bash".into()],
cwd: None,
env: Default::default(),
backend_params: Default::default(),
},
).await?;
session.resize(120, 40, 0, 0).await?;
session.send_stdin("ls -l\n".into()).await?;
session.close_stdin().await?;
let mut stdout = session.recv_stdout().await;
while let Some(bytes) = futures::StreamExt::next(&mut stdout).await {
// The stream ends on the zero-length stdout sentinel ("drained") —
// it is never yielded as an item.
// ... render bytes ...
}
let code = session.wait().await?;
```
### Consumer — open a session via `alk/channels`
```rust
use alkcall::channels::client::ChannelClient;
use alktty::TtySession;
let client: ChannelClient = /* from_connection on an `alk/channels` connection */;
let session = TtySession::open_via_channels(
&client,
serde_json::json!({
"carriage": "raw",
"backend": "local",
"cmd": ["bash"],
"tty": {"term": "xterm-256color", "cols": 120, "rows": 40,
"pixel_width": 0, "pixel_height": 0}
}),
).await?;
```
## Architecture
Two roles, symmetric per the alk convention (avoid "server"/"client":
both sides of a connection can be either):
| Role | Direct `alk/tty` | Via `alk/channels` |
|------|------------------|--------------------|
| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated |
| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation |
One connection hosts many sessions (one per bidi stream on the direct
path, one per channel on the channels path); sessions are independent.
The wire format (ADR-001) is two carriages: a 4-byte
length-prefixed JSON negotiation frame, then raw chunks —
`[stream_type: u8][length: u32 be][payload]` with five stream types
(`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)).
Zero-length data chunks are sentinels (stdin EOF from the client,
stdout drained from the server); the exit code rides as the final
`ctrl_out` control chunk (ADR-005). The codec is
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)'s
`ChunkReader`/`ChunkWriter`; the machine-readable contract is the BAST
document at `docs/architecture/tty-bast.md`.
### WASM target
The default crate (no features) compiles to `wasm32-unknown-unknown`
this is what makes the downstream TS/Python adapter story work: a
wasm-compiled alktty is the protocol layer for a sandboxed adapter. The
`local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html)
reference implementation over `portable_pty` + `tokio::process`) needs a
real OS and is non-wasm by design.
## Documentation
- [Architecture docs](docs/architecture/README.md) — the authoritative
spec: ADRs (001009), the wire format, the backend trait contract,
and the local backend's cancel-cleanup semantics.
- [API docs](https://docs.rs/alktty) — full crate documentation on
docs.rs.
## License
MIT OR Apache-2.0