glm-5.3-flash e2fa32b3c7 feat: channels-path establisher migration (alkcall 0.5.0 / ADR-049) — bump to 0.2.0
Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 —
review 006 E-01 + N-1) and migrate the channels-path semantic failures
per its §5 sequencing (alktty ADR-010).

- `register_openable` registers `channels/tty/sub` with an establisher
  (`register_openable_with_establisher`): full `NegotiateRequest`
  parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`,
  backend lookup, and the ADR-050 ownership check run before the open
  reply; rejections are `channel:open_failed` with `details.reason`
  (`unknown_resource` / `handler_error` / `timeout`) — no phantom
  channel (the SSH contract holds consumer-visibly)
- `backend.allocate` deliberately stays in the pump handler:
  `Establishment` is payloadless so the `TtyHandle` cannot cross the
  establisher→handler boundary, and re-allocating would violate
  ADR-005's kill-on-Drop contract — `allocate_failed` remains the one
  in-band failure class on the channels path (pinned by test)
- `TtySessionError::ChannelsOpen` carries alkcall's typed
  `ChannelOpenError` (`#[from]`) instead of a flattened `String` —
  the N-1 fix at alktty's layer (breaking)
- channels-path semantic failures change shape from
  `NegotiationRejected` in-band frames to `channel:open_failed` call
  errors (breaking); the direct-ALPN path is unchanged
- `tty_open_spec()` gains a `description` (review 006 E-02) and an
  ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via
  services/schema)
- alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment +
  tty-adapter.md + CHANGELOG

Verification: cargo test (112 lib + integration), cargo test
--all-features (136), clippy --all-targets -D warnings (host + wasm),
fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check
confirms the default crate stays wasm-clean.
2026-09-06 20:25:39 +00:00
2026-08-14 12:41:38 +00:00
2026-08-14 12:41:38 +00:00

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

S
Description
No description provided
Readme
2.3 MiB
0 Stars 6 Watchers 0 Forks
Languages
Rust 96.3%
Python 3%
Shell 0.7%