Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a #[derive(Arbitrary)] op sequence drives the producer session pump (drive_session_pre_negotiated) over a duplex pair against a fuzz-local mock backend. - fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl. invalid-type/oversize-length barrier probes, backend production/EOF, exit resolve/fail/drop, control messages, bounded reads, yields), kill-guard exit future (ADR-005 observability), delivery model, and the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit chunk is last with first-issued code -1 on wait-failure (ADR-004), lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness (post-shutdown chunks never delivered), control dispatch bounds, take-once allocation, teardown termination. - Harness-model correction caught by a 60s smoke run before the campaign (the alkcall §7.7 pattern, third time): a failed client write can mean the session completed and dropped the server duplex half while the write was in flight - the harness now drains and requires the exit chunk on any write/shutdown error (premature close without it is a finding). No adapter code changed. - 12 committed seeds via the deterministic generator, hand-encoded against the arbitrary 1.4.x derive layout; the encoding is pinned by a seed-decode test. Existing chunk_frame/negotiation_frame/ control_json seed corpora are byte-identical to before. - fuzz/.gitignore: the per-target seed negations never matched (corpus/* excludes the parent dir; git cannot re-include beneath an excluded dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-* recipe - new seed files were silently ignored until now. - Campaign: 10 min detached via run-detached.sh, exited 0, artifact dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout, cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy. Verification: corpus replay green on stable (9 tests), cargo test 113 + --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc, publish dry-run, wasm check - all pass.
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 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
for your execution environment, and the adapter handles the wire.
Quick start
Producer — register backends and run the adapter
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
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
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
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 as a ProtocolHandler — negotiation frame → access control → backend allocation → three-pump session driver |
register_openable — 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 — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods |
TtySession::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).
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'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
reference implementation over portable_pty + tokio::process) needs a
real OS and is non-wasm by design.
Documentation
- Architecture docs — the authoritative spec: ADRs (001–009), the wire format, the backend trait contract, and the local backend's cancel-cleanup semantics.
- API docs — full crate documentation on docs.rs.
License
MIT OR Apache-2.0