Files
alktty/CHANGELOG.md

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/ (per docs/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 via fuzz/rust-toolchain.toml:
    • chunk_frame — the 5-byte chunk codec (ChunkReader/ChunkWriter), including the peek/disambiguation seam (peek_stream_type vs read_chunk_after_peek in both call orders must yield exactly one chunk).
    • negotiation_frame — the 4-byte BE length-prefixed negotiation framing + NegotiateRequest JSON parse + error_response_bytes, and the cross-codec invariant that an error-response frame's length-prefix high byte is 0x00 so a ChunkReader peek distinguishes it from a chunk (ADR-001 §5).
    • control_json — ControlMessage JSON 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-Drop per 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; the session_opseq seeds are hand-encoded against the arbitrary 1.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.toml replays 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) — alkcall bumped 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.toml gains a [workspace] table (members = ["."], exclude = ["fuzz"]) so the fuzz subtree's nightly dev-deps never join the root workspace (an MSRV footgun), and fuzz/ 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_opseq at 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) — alkcall bumped 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 new open_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 name channels/tty/sub, so the new flavor-form channel_open_alpn discovery 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.
  • alkcall requirement updated to 0.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-version 1.85 → 1.88. Verified on a real 1.88 toolchain (full test suite, clippy). alktty's own code is 1.85-clean — cargo check under a 1.85 toolchain passes with the pre-bump lockfile — so the raise is dependency-driven only and needed no clippy fixes. Behavior-identical.
  • alkcall requirement updated to 0.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) — alkcall bumped to 0.7.0. alkcall 0.7.0 changed no type that alktty touches (OpenEstablisher/OpenHandler/Establishment/ChannelPlan are identical to 0.6.0; alktty never constructs ServingConfig), 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_openable no longer captures the install-time identity and overrides the establisher/handler auth.identity with 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_handler are 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 — alkcall bumped to 0.6.0; channels-path allocation failure changes shape (ADR-010 §2A). alkcall 0.6.0 filled the reserved Establishment field (plan: Option<ChannelPlan>, review 007 R-01), so backend.allocate moved from the pump handler into the establisher — the allocated TtyHandle crosses to the handler via the plan payload (a private per-open one-shot slot; the handle is not Sync). Allocation failure now fails the open op itself: TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed, reason: "dial_failed" }) instead of the post-open in-band NegotiationRejected{ "allocate_failed" }. The SSH contract now covers every failure class: no channel ever exists opener-side, no channel_id, no 0x00-prefixed in-band frame. The direct-ALPN path's in-band vocabulary (including allocate_failed) is unchanged.
  • tty_open_spec()'s channel:open_failed ErrorDefinition declares dial_failed in the details.reason enum (four reachable reasons: dial_failed / unknown_resource / handler_error / timeout).
  • Handler closures in make_tty_open_handler gain the Option<ChannelPlan> parameter (alkcall 0.6 OpenHandler shape); a None plan (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-end dial_failed open-failure surface (flipped from the pinned in-band test), and the handler's plan: None fallback 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_openable now registers channels/tty/sub with an establisher (ChannelCore::register_openable_with_establisher): the semantic validation that used to be post-open in-band error frames — the full NegotiateRequest parse of schema-valid input, carriage == "raw", non-empty cmd, backend lookup, and the ADR-050 ownership check — runs before the open reply. Rejections resolve the open op with channel:open_failed carrying details: { reason, message } (unknown_resource for an unknown backend, handler_error for malformed-negotiation/ownership-denial, timeout on 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_failed ErrorDefinition on tty_open_spec() (ADR-016). The establishment-failure contract (the reachable details.reason enum) is disclosed via services/schema; the spec also gains a description (review 006 E-02) disclosed via services/list.
  • New tests: the establisher unit gates (parse/carriage/cmd/backend/ ownership mappings), the end-to-end channel:open_failed surfaces (the review-006 verification gate), and a pinned test guarding the one failure class that stays in-band (below).

Changed

  • BREAKING — TtySessionError::ChannelsOpen carries alkcall's typed ChannelOpenError (#[from]) instead of a flattened String (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at alktty's layer). Consumers branch on ChannelOpenError::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-band 0x00-prefixed error frame on a channel the open op had already reported as succeeding); they now fail the open op itself as TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed }) (ADR-010). The one failure class that still arrives in-band is allocate_failed (still NegotiationRejected): the establisher cannot carry the allocated TtyHandle across to the pump handler (Establishment is payloadless), and re-allocating would violate ADR-005's kill-on-Drop contract — revisit when alkcall gives Establishment a 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/env plus opaque serde(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 byte 0x00, 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's EventEnvelope framing. The control channel is split into STREAM_CTRL_IN/STREAM_CTRL_OUT so 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's StreamType enum against drift from the wire constants.
  • TtyBackend trait (ADR-002). The inversion point between the wire-format adapter and backend crates. allocate(&TtyParams) -> TtyHandle (stdin AsyncWrite, stdout/stderr Stream<Item = Bytes>, exit_code: BoxFuture, TtyControlHandle for resize/signal); resource_id(&TtyParams) -> Option<(kind, id)> feeds the ADR-050 ownership check. TtyError is #[non_exhaustive]. The trait shape is a one-way door once backends exist.
  • Local backend behind the local feature (ADR-003). The alknet-tty-local crate folded in as a feature-gated module: PTY mode via portable_pty (real terminal semantics — resize, process-group signal forwarding, merged stdout/stderr) and pipe mode via tokio::process::Command (the runner case — separate stdout/stderr, no-op resize, pid-only signal), selected by TtyParams::terminal. The blocking→async bridge uses three dedicated std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; the default crate (no features) stays wasm32-unknown-unknown-clean.
  • TtyAdapter producer (direct alk/tty ALPN). A ProtocolHandler holding a HashMap<String, Arc<dyn TtyBackend>> keyed by the negotiation frame's backend string. Accepts a connection, loops accept_bi, and dispatches each bidi stream to a session. Per-stream flow (ADR-001/002/005): parse + validate the negotiation frame (carriage == "raw", non-empty cmd), scope-gate via tty:open, optional OwnershipProvider resource 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} (-1 on wait failure, ADR-004; negative = signal-terminated) is enqueued only after both stream pumps complete and exit_code resolves. 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 the TtyHandle drops without driving exit_code to completion, which triggers the backend's kill-on-Drop guard — the adapter has no separate kill path (the session-cancel contract, ADR-005: dropping the future kills the session target).
  • channels integration (ADR-008, ADR-009). register_openable + tty_open_spec register the channels/tty/sub open op on a per-connection OperationRegistry (per ADR-047's per-connection amendment): the registry's AccessControl carries the tty:open scope gate, the op's input schema validates the shared NegotiateRequest fields (carriage/backend/cmd required; alkcall 0.4 enforces at dispatch), and the OpenHandler spawns 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.
  • TtySession consumer. The typed client handle with two constructors — connect_direct (direct alk/tty connection: writes the negotiation frame, then typed methods) and open_via_channels (opens channels/tty/sub on a ChannelClient, 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, and wait (awaits the Exit control chunk; watch-based, no lost wakeup). recv_stdout ends ON the zero-length drained sentinel (it is not yielded as an item). Both constructors disambiguate the first response frame (0x00 prefix = negotiation error frame → NegotiationRejected with the server's error code; raw chunk = session proceeds); a refused session open surfaces as TtySessionError::Open carrying the upstream error type. TtySessionError is #[non_exhaustive]. Dropping the session aborts the read pump and closes the write half.
  • Control messages. ControlMessage (Resize/Signal/Eof on ctrl_in; Exit on ctrl_out) — JSON, tagged by "type", unknown types ignored by policy. signal_from_name maps 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, and LocalTtyBackend under local) are re-exported at the crate root; the module paths remain the full surface.
  • Wire write-path validation. All ChunkWriter methods validate before touching the transport — a stream_type > 4 or a payload over MAX_CHUNK_LEN fails locally instead of writing a header the peer cannot frame (the length is validated before the u32 cast, so an oversized payload cannot truncate past the check and corrupt the peer's framing). Empty-payload writes are one shape: length == 0 with no payload bytes.
  • Peek-safe ChunkReader. The reader tracks peeked state: read_chunk() after peek_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_peek remains 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 (local feature). 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 adopt into_inner()), and thread-spawn failure maps to TtyError::AllocFailed instead of panicking.
  • Producer hardening. The tty:open scope gate runs before the backend lookup (an unscoped identity gets forbidden regardless 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.