22 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.6.0 - 2026-09-28
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
parse surfaces, with zero findings in the crate — every crash the
campaigns produced was a harness-model gap, and every fix landed in the
harness, not the crate. The alkcall requirement also updates to
0.8.1. No API, wire-format, or behavior change; this release ships no
source change in src/ at all — the deltas are the fuzz workspace,
the dependency bump, and this changelog. Minor bump per the 0.x
ecosystem-coordination rule (alktty's public signatures reference
alkcall types, so downstream crates must unify on alkcall 0.8.1).
Added
- cargo-fuzz workspace under
fuzz/(perdocs/plans/fuzzing.md, adopted from the alkcall fuzzing playbook): four targets over the wire-facing surfaces, with the invariant logic in a stable-toolchain shared crate (fuzz/shared/) so the corpus replays on stable — the nightly pin is scoped to the subtree viafuzz/rust-toolchain.toml:chunk_frame— the 5-byte chunk codec (ChunkReader/ChunkWriter), including the peek/disambiguation seam (peek_stream_typevsread_chunk_after_peekin both call orders must yield exactly one chunk).negotiation_frame— the 4-byte BE length-prefixed negotiation framing +NegotiateRequestJSON parse +error_response_bytes, and the cross-codec invariant that an error-response frame's length-prefix high byte is0x00so aChunkReaderpeek distinguishes it from a chunk (ADR-001 §5).control_json—ControlMessageJSON parse +signal_from_name.session_opseq— the stateful target: a#[derive(Arbitrary)]12-op sequence drives the real producer session pump (drive_session_pre_negotiated) over a duplex pair against a fuzz-local mock backend, asserting the wire-contract invariants (kill-on-Dropper ADR-005, exit-chunk-is-last per ADR-004, lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness, control dispatch bounds, take-once allocation, teardown termination).
- Committed corpus: 340 hand-made seeds across the four targets
(deterministic generator
fuzz/gen_fuzz_seeds.py; thesession_opseqseeds are hand-encoded against thearbitrary1.4.x derive layout with the encoding pinned by a decode test). Grown corpus entries are gitignored and excised after campaigns. - The standing fuzz gate:
cargo test --manifest-path fuzz/shared/Cargo.tomlreplays every committed seed through the same invariant functions the targets run, on stable. Added to the verification checklist. - Detached-runner rule: campaigns run via
fuzz/run-detached.sh(setsid/nohup, fork mode, bounded RSS) — never in the foreground of an agent session.
Changed
- BREAKING (ecosystem-coordination) —
alkcallbumped to 0.8.1. alkcall 0.8.1 is a patch release fixing the duplicate adopt/open channel-state destruction found by alkcall's own fuzz campaign; it changed no type alktty touches. Downstream crates must unify on alkcall 0.8.x per the caret requirement. Minor bump per the 0.x ecosystem-coordination rule. - Root
Cargo.tomlgains a[workspace]table (members = ["."],exclude = ["fuzz"]) so the fuzz subtree's nightly dev-deps never join the root workspace (an MSRV footgun), andfuzz/joins the publish exclude. Verified: the packaged file list is unchanged except for the exclusion (43 files; the fuzz workspace, seeds, plans, reviews, and.opencode/all stay out of the crate).
Verification
- 0 findings across all four targets: detached campaigns (10 min each
on the three wire targets; 10 min on
session_opseqat 42.3k execs, cov 3934 → 4075 and still growing at budget end) plus a 60s smoke run on target 4 — no crash/OOM/timeout/leak. - Corpus replay 9 tests green on stable (340 seeds); crate tests 113 default / 156 all-features; clippy stable + wasm; fmt; doc; wasm check — all pass.
- The three crashes the harness-model smoke runs surfaced were harness gaps, each fixed in the fuzz harness (the alkcall §7.7 pattern): a failed client write can mean the session completed and dropped the server duplex half mid-write — the harness drains and requires the exit chunk on any write/shutdown error (a premature close is a finding). No adapter code changed.
0.5.0 - 2026-09-18
The alkcall 0.8.0 adoption: a dependency bump only — no source change
in alktty. alkcall 0.8.0 landed review 008's remediation (the
establisher reply projection, flavor-form open-op ids in discovery, the
in-tree ChannelRelay + HubLegImports/HubLegTemplate) plus a
post-landing hardening sweep; all new surfaces are additive on the
call-plane JSON and the registry seams, and the channels data plane is
untouched. The wire format, the direct-ALPN path, the TtyBackend
trait, and alktty's public API surface are unchanged.
Changed
- BREAKING (ecosystem-coordination) —
alkcallbumped to 0.8.0. alkcall 0.8.0 changed no type that alktty touches: the open-op machinery alktty registers through (ChannelCore/register_openable_with_establisher/OpenEstablisher/OpenHandler/Establishment/ChannelPlan,ChannelClient::open_channel,ChannelOpenError::CallFailed) is shape-identical to 0.7.1 — the newopen_channel_with_reply/ reply-field / relay / hub-leg surfaces are additive and alktty exercises none of them. alktty's own open op is the standard-shape namechannels/tty/sub, so the new flavor-formchannel_open_alpndiscovery path stays on the byte-stable standard derivation, and with no establisher reply-fields configured the open reply is byte-identical to pre-0.8.0. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.8.0. Minor bump per the 0.x ecosystem-coordination rule. alkcallrequirement updated to0.8.0; lockfile bumped from 0.7.1 to 0.8.0. Verification counts are unchanged from the 0.7.1 baseline (113 default / 156 all-features) — the adoption required no source change in alktty.
0.4.1 - 2026-09-10
The MSRV floor becomes honest: rust-version raises from 1.85 to
1.88. The 1.85 claim was already false at the dependency level — the
lockfile pins alkcall 0.7.x, and alkcall 0.7.1 raised its MSRV floor
to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path;
iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0
regardless of what Cargo.toml declares. The raise breaks no downstream
that could build the crate before. No API or wire-format change;
downstreams on caret requirements pick this up on their next cargo update.
Changed
rust-version1.85 → 1.88. Verified on a real 1.88 toolchain (full test suite, clippy). alktty's own code is 1.85-clean —cargo checkunder a 1.85 toolchain passes with the pre-bump lockfile — so the raise is dependency-driven only and needed no clippy fixes. Behavior-identical.alkcallrequirement updated to0.7.1; lockfile bumped from 0.7.0 to 0.7.1. Version-requirement-only (0.7.x is already the resolved family); alkcall 0.7.1 changed no type alktty touches.
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.