- BREAKING (ecosystem-coordination): alkcall bumped to 0.7.0. No type alktty touches changed shape (OpenEstablisher/OpenHandler/ Establishment/ChannelPlan identical to 0.6.0); the minor bump is for downstream lockstep, since alktty's public signatures reference alkcall types. - CF-006 alignment: the channels establisher and pump handler now take their identity from the per-call auth (alkcall 0.7 derives the dispatch-resolved opener — the same identity view the registry's ACL gate checked) instead of the captured install-time override. Behavior-identical in the per-connection-registry deployment; the ownership check and the ACL gate see the same subject in every deployment. make_tty_establisher / make_tty_open_handler drop their captured-identity parameter (both private). Verification: - cargo test: 113 passed (default), 156 passed (--all-features) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean - cargo check/clippy --target wasm32-unknown-unknown -D warnings: clean - cargo publish --dry-run --allow-dirty: clean
15 KiB
Changelog
All notable changes to this crate are documented here. The format is based on Keep a Changelog, and this crate adheres to Semantic Versioning.
[0.4.0] - 2026-09-07
The alkcall 0.7.0 adoption (the connect-side caller-identity seam,
CF-005/006/007): a dependency bump plus one behavior-alignment change
on the channels path. The wire format, the direct-ALPN path, the
TtyBackend trait, and the public API surface are unchanged.
Changed
- BREAKING (ecosystem-coordination) —
alkcallbumped to 0.7.0. alkcall 0.7.0 changed no type that alktty touches (OpenEstablisher/OpenHandler/Establishment/ChannelPlanare identical to 0.6.0; alktty never constructsServingConfig), but alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.7.0. Minor bump per the 0.x ecosystem-coordination rule. - The channels establisher and pump handler take their identity
from the per-call
auth(CF-006 alignment).register_openableno longer captures the install-time identity and overrides the establisher/handlerauth.identitywith it — post-alkcall-0.7 the per-call context already carries the dispatch-resolved opener (the same identity view the registry's ACL gate checked; the end client, not the hub, on hub-forwarded opens). Behavior-identical in the per-connection-registry deployment (install-time identity was the caller's); the ownership check and the ACL gate now see the same subject in every deployment. Two factory parameters dropped (make_tty_establisher/make_tty_open_handlerare private; no public-surface change).
[0.3.0] - 2026-09-07
The alkcall 0.6.0 adoption (review 007 R-01 — the Establishment
plan payload): backend allocation moves into the channels establisher.
Breaking on the channels path's allocation-failure shape; the wire
format, the direct-ALPN path, and the TtyBackend trait are
unchanged.
Changed
- BREAKING —
alkcallbumped to 0.6.0; channels-path allocation failure changes shape (ADR-010 §2A). alkcall 0.6.0 filled the reservedEstablishmentfield (plan: Option<ChannelPlan>, review 007 R-01), sobackend.allocatemoved from the pump handler into the establisher — the allocatedTtyHandlecrosses to the handler via the plan payload (a private per-open one-shot slot; the handle is notSync). Allocation failure now fails the open op itself:TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed, reason: "dial_failed" })instead of the post-open in-bandNegotiationRejected{ "allocate_failed" }. The SSH contract now covers every failure class: no channel ever exists opener-side, nochannel_id, no0x00-prefixed in-band frame. The direct-ALPN path's in-band vocabulary (includingallocate_failed) is unchanged. tty_open_spec()'schannel:open_failedErrorDefinitiondeclaresdial_failedin thedetails.reasonenum (four reachable reasons:dial_failed/unknown_resource/handler_error/timeout).- Handler closures in
make_tty_open_handlergain theOption<ChannelPlan>parameter (alkcall 0.6OpenHandlershape); aNoneplan (no-establisher registration) falls back to the pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping the in-band error-frame vocabulary for that registration shape. - New tests: establisher unit gate for allocate-in-establisher (plan
slot carries the live handle; failure maps to
dial_failed), the end-to-enddial_failedopen-failure surface (flipped from the pinned in-band test), and the handler'splan: Nonefallback arm.
[0.2.0] - 2026-09-06
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
channels-path establishment phase. Breaking on the channels path's
error surface; the wire format, the direct-ALPN path, and the
TtyBackend trait are unchanged.
Added
- Channels establishment phase (ADR-010; alkcall 0.5.0 ADR-049 /
review 006 E-01).
register_openablenow registerschannels/tty/subwith an establisher (ChannelCore::register_openable_with_establisher): the semantic validation that used to be post-open in-band error frames — the fullNegotiateRequestparse of schema-validinput,carriage == "raw", non-emptycmd, backend lookup, and the ADR-050 ownership check — runs before the open reply. Rejections resolve the open op withchannel:open_failedcarryingdetails: { reason, message }(unknown_resourcefor an unknown backend,handler_errorfor malformed-negotiation/ownership-denial,timeouton the establishment deadline) and no channel ever exists opener-side — the SSH contract. The phantom-channel workaround (allocate → succeed → in-band-fail) is retired. channel:open_failedErrorDefinition ontty_open_spec()(ADR-016). The establishment-failure contract (the reachabledetails.reasonenum) is disclosed viaservices/schema; the spec also gains adescription(review 006 E-02) disclosed viaservices/list.- New tests: the establisher unit gates (parse/carriage/cmd/backend/
ownership mappings), the end-to-end
channel:open_failedsurfaces (the review-006 verification gate), and a pinned test guarding the one failure class that stays in-band (below).
Changed
- BREAKING —
TtySessionError::ChannelsOpencarries alkcall's typedChannelOpenError(#[from]) instead of a flattenedString(alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at alktty's layer). Consumers branch onChannelOpenError::establishment_reason()instead of parsing a debug string. - BREAKING — channels-path semantic failures change shape. An
unknown backend (and schema-valid-but-unparseable params,
ownership denial) previously surfaced as
TtySessionError::NegotiationRejected(an in-band0x00-prefixed error frame on a channel the open op had already reported as succeeding); they now fail the open op itself asTtySessionError::ChannelsOpen(CallFailed{ channel:open_failed })(ADR-010). The one failure class that still arrives in-band isallocate_failed(stillNegotiationRejected): the establisher cannot carry the allocatedTtyHandleacross to the pump handler (Establishmentis payloadless), and re-allocating would violate ADR-005's kill-on-Dropcontract — revisit when alkcall givesEstablishmenta payload. The direct-ALPN path's failure surface is unchanged (two transports, two contracts). alkcall = "0.5.0".
Fixed
- No more phantom channels on the channels path. Ledger, policy count, and manager state balance on every establishment rejection (the wrapper's teardown runs before the reply), instead of allocating a channel that immediately EOFs with an error frame.
0.1.0 - 2026-09-05
Initial crates.io release: the alk/tty terminal-session protocol —
producer/consumer protocol crate on top of alkcall channels, ported and
consolidated from the alknet-tty + alknet-tty-local crates of the
alknet mono-repo.
Added
- Wire format (ADR-001). Two-carriage protocol: a 4-byte
big-endian length-prefixed JSON negotiation frame (
NegotiateRequest—carriage/backend/tty/cmd/cwd/envplus opaqueserde(flatten)backend params), then raw chunks ([stream_type: u8][length: u32 be][payload]) with five stream types (STREAM_STDIN=0,STREAM_STDOUT=1,STREAM_STDERR=2,STREAM_CTRL_IN=3,STREAM_CTRL_OUT=4). Zero-length data chunks are sentinels (zero-length stdin = client EOF; zero-length stdout = server drained); control chunks are never zero-length. Payloads are capped at 16 MiB (MAX_CHUNK_LEN) — this keeps the length prefix's high byte0x00, which is the disambiguation invariant between an error frame and a raw chunk (ADR-001 §5). The framing is self-contained (ADR-006): not reused from alkcall'sEventEnvelopeframing. The control channel is split intoSTREAM_CTRL_IN/STREAM_CTRL_OUTso it is genuinely bidirectional on the wire (the Phase 7 amendment inside ADR-001). TTY always uses its own 5-byte chunk format inside channels — the channels layer carries it transparently in the channel payload (ADR-008, reversing ADR-007). The machine-readable contract is the BAST document (docs/architecture/tty-bast.md); a unit test guards the BAST'sStreamTypeenum against drift from the wire constants. TtyBackendtrait (ADR-002). The inversion point between the wire-format adapter and backend crates.allocate(&TtyParams) -> TtyHandle(stdinAsyncWrite, stdout/stderrStream<Item = Bytes>,exit_code: BoxFuture,TtyControlHandlefor resize/signal);resource_id(&TtyParams) -> Option<(kind, id)>feeds the ADR-050 ownership check.TtyErroris#[non_exhaustive]. The trait shape is a one-way door once backends exist.- Local backend behind the
localfeature (ADR-003). Thealknet-tty-localcrate folded in as a feature-gated module: PTY mode viaportable_pty(real terminal semantics — resize, process-group signal forwarding, merged stdout/stderr) and pipe mode viatokio::process::Command(the runner case — separate stdout/stderr, no-op resize, pid-only signal), selected byTtyParams::terminal. The blocking→async bridge uses three dedicated std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; the default crate (no features) stayswasm32-unknown-unknown-clean. TtyAdapterproducer (directalk/ttyALPN). AProtocolHandlerholding aHashMap<String, Arc<dyn TtyBackend>>keyed by the negotiation frame'sbackendstring. Accepts a connection, loopsaccept_bi, and dispatches each bidi stream to a session. Per-stream flow (ADR-001/002/005): parse + validate the negotiation frame (carriage == "raw", non-emptycmd), scope-gate viatty:open, optionalOwnershipProviderresource check,backend.allocate(), then three concurrent pumps (stdin→backend, stdout/stderr→client, exit→exit chunk) with the exit-chunk-is-last invariant —{"type":"exit","code":N}(-1on wait failure, ADR-004; negative = signal-terminated) is enqueued only after both stream pumps complete andexit_coderesolves. A concurrent drainer task owns the client write half, so a burst of >64 chunks from the backend cannot deadlock the session (the drainer starts before the pumps join; the single FIFO preserves exit-chunk-last). On session cancel theTtyHandledrops without drivingexit_codeto completion, which triggers the backend's kill-on-Dropguard — the adapter has no separate kill path (the session-cancel contract, ADR-005: dropping the future kills the session target).channelsintegration (ADR-008, ADR-009).register_openable+tty_open_specregister thechannels/tty/subopen op on a per-connectionOperationRegistry(per ADR-047's per-connection amendment): the registry'sAccessControlcarries thetty:openscope gate, the op's input schema validates the sharedNegotiateRequestfields (carriage/backend/cmdrequired; alkcall 0.4 enforces at dispatch), and theOpenHandlerspawns the session driver pre-negotiated — the open op's params ARE the negotiation (ADR-009), no second frame on the channel stream. Access control is enforced by the registry before the handler runs; the handler validates the backend exists and drives the session.TtySessionconsumer. The typed client handle with two constructors —connect_direct(directalk/ttyconnection: writes the negotiation frame, then typed methods) andopen_via_channels(openschannels/tty/subon aChannelClient, adopts the channel, starts in raw-chunk mode). Typed methods:send_stdin/close_stdin(stdin chunks / EOF sentinel),recv_stdout/recv_stderr(Stream<Item = Bytes>),resize,signal, andwait(awaits theExitcontrol chunk; watch-based, no lost wakeup).recv_stdoutends ON the zero-length drained sentinel (it is not yielded as an item). Both constructors disambiguate the first response frame (0x00prefix = negotiation error frame →NegotiationRejectedwith the server's error code; raw chunk = session proceeds); a refused session open surfaces asTtySessionError::Opencarrying the upstream error type.TtySessionErroris#[non_exhaustive]. Dropping the session aborts the read pump and closes the write half.- Control messages.
ControlMessage(Resize/Signal/Eofonctrl_in;Exitonctrl_out) — JSON, tagged by"type", unknown types ignored by policy.signal_from_namemaps the common signal set (HUP/INT/QUIT/TERM/KILL/USR1/USR2/TSTP/CONT) to libc numbers (unix). - Crate-root re-exports. The primary types (
TtyAdapter,TtySession,TtyBackend,TtyHandle,TtyParams,Chunk,NegotiateRequest,ControlMessage, the wire constants, andLocalTtyBackendunderlocal) are re-exported at the crate root; the module paths remain the full surface. - Wire write-path validation. All
ChunkWritermethods validate before touching the transport — astream_type> 4 or a payload overMAX_CHUNK_LENfails locally instead of writing a header the peer cannot frame (the length is validated before theu32cast, so an oversized payload cannot truncate past the check and corrupt the peer's framing). Empty-payload writes are one shape:length == 0with no payload bytes. - Peek-safe
ChunkReader. The reader tracks peeked state:read_chunk()afterpeek_stream_type()completes the peeked chunk instead of consuming a second header byte (which silently desynchronized framing), and a second peek returns the peeked byte without reading.read_chunk_after_peekremains the explicit form. - Backpressure regression test. >64-chunk sessions are driven
through the real adapter with a per-read timeout so a deadlock
regression fails the test instead of hanging it, plus exact-boundary
(
MAX_CHUNK_LEN) round-trip and server-sends-client-direction- stream-types discard tests. - PTY bridge robustness (
localfeature). The stdin sink's EOF (poll_shutdown) parks an in-flight send on a full channel so the waker is registered — a stdin blast followed by EOF always delivers the EOF to the child. Lock-poisoning no longer cascades (the five lock sites adoptinto_inner()), and thread-spawn failure maps toTtyError::AllocFailedinstead of panicking. - Producer hardening. The
tty:openscope gate runs before the backend lookup (an unscoped identity getsforbiddenregardless of the request body — no backend-name enumeration differential), and the client→backend pump is aborted at session end instead of lingering until the client disconnects.